cert-managerとLet’s EncryptでKubernetesのSSL/TLS証明書を自動化:ClusterIssuerとIngressの自動更新

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

誰も対応したくない午前2時15分の障害

数ヶ月前、オンコール担当エンジニアの元に午前2時15分にアラートが届きました。本番環境の決済ゲートウェイで突然すべての購入処理が ERR_CERT_DATE_INVALID により失敗し始めたのです。わずか90秒の間に、毎分140件あった注文数はゼロに落ち込みました。Webブラウザには全画面でセキュリティ警告が表示され、モバイルネイティブアプリは証明書ピンニング(Certificate Pinning)によってAPI接続を即座に遮断しました。

エッジプロキシのクラッシュやルーティング設定のデプロイミスを疑い、私たちは急遽ブリッジコールを開いて調査に追われました。しかし、真の原因を突き止めるまでに20分もの貴重な時間を費やすことになります。原因は、メインのIngress Controllerで稼働していたSSL証明書が深夜0時にひっそりと期限切れを迎えていたことでした。そのワイルドカード証明書を発行・ローテーションしていたプラットフォームエンジニアは4ヶ月前に別チームへ異動しており、定期設定されていたカレンダーの通知は未読メールの山に埋もれてしまっていたのです。

動的に変化するクラスタにおいて、SSL/TLSのライフサイクルを手動チェックリストに依存していると、いつか必ずダウンタイムが発生します。それは「起こるかどうか」ではなく、「いつ起こるか」の問題にすぎません。

根本原因:なぜKubernetesで手動TLS運用は破綻するのか

ハードウェアアプライアンス配下にある固定的な3台のVM環境であれば、手動ローテーションでも問題ないかもしれません。しかし、Kubernetesはまったくの別物です。変化の速いマイクロサービス、分離されたNamespace、一時的なプレビュー環境などが混在する環境では、手動によるTLS運用はたちまち崩壊します。

  • 証明書の有効期間の短縮化: 主要なルート証明書プログラムは、有効期間の上限を従来の5年から398日、そして90日へと短縮しました。現在CA/Browser Forumで検討されている提案では、45日への短縮が目指されています。年に何度も手動で更新作業を行うことは、ヒューマンエラーのリスクを跳ね上がらせます。
  • Namespace間でのSecretの乱立: KubernetesではSecretがNamespaceごとに隔離されます。エンジニアが単一のワイルドカード Secret を15個もの個別Namespaceに手動コピーすると、すぐに構成のドリフト(乖離)が生じます。バージョンの不一致やコピー先の更新漏れが発生し、ローテーション時に原因の特定しづらい障害を引き起こします。
  • 属人化による運用の脆弱性: カレンダーの通知、管理スプレッドシート、外部の死活監視ツールなどは対症療法にすぎません。アラートが検知されてからも、エンジニアが秘密鍵を作成し、CSRを生成し、チャレンジレコードをパスさせ、Kubernetes向けにBase64エンコードして適用するという手順を手作業で行う必要があります。
  • 目に見えにくいIngressのエラー: 不完全な証明書チェーンを貼り付けてしまったり、中間証明書バンドルを入れ忘れたりすると、Podのステータスチェックでは何のエラーも表示されないまま、古いモバイル端末でのみTLSハンドシェイクが静かに失敗するという厄介な不具合につながります。

KubernetesにおけるTLS管理の3つのアプローチ

クラスタインフラを標準化する前に、Ingressの暗号化管理としてよく使われる3つのアプローチを比較検討しました。

1. クラウドロードバランサーでのエッジオフロード(AWS ALB / Cloudflare / GCP HTTPS Proxy)

トラフィックがワーカーノードに到達する前に、クラウド事業者の外部ロードバランサーでTLSを終端(Terminate)させる方法です。

  • メリット: Kubernetes内部に証明書のマニフェストを持つ必要がなく、パブリックCAの管理はすべてクラウド側に委託できます。
  • デメリット: VPC内のトラフィックが暗号化されていないHTTPとして流れること、ハイブリッド環境やベアメタルクラスタ間でのポータビリティが低いこと、動的なマルチテナントサブドメインへの自動化が難しい点が挙げられます。

2. Certbotを実行するスケジュール設定されたCronJob

クラスタ内でCertbotを実行するコンテナを定期実行し、HTTP-01チャレンジの解決、鍵のエクスポート、そして kubectl create secret tls ... --dry-run=client -o yaml | kubectl apply -f - を実行する方法です。

  • メリット: カスタムリソース定義(CRD)をクラスタに導入する必要がありません。
  • デメリット: シェルスクリプトのエラーが検知されにくく、複数のNamespaceにまたがる広範なRBAC権限をジョブに付与しなければならない上、誰かが誤ってSecretを削除してしまった場合の自己修復(Reconciliation)ループが存在しません。

3. cert-managerを用いたIngressネイティブな自動化

クラスタ内コントローラーとして cert-manager を運用する方法です。Issuer、ClusterIssuer、Certificate などのリソースでKubernetes APIを拡張します。

  • メリット: 宣言的でGitOpsとの親和性が高く、IngressのAnnotationと直接連動し、有効期限の30日前に自動更新を実施します。HTTP-01およびDNS-01チャレンジソルバーの両方をサポートしています。
  • デメリット: 3つの軽量なコントローラーPodとCRDをクラスタに追加でデプロイする必要があります。

完全実装ガイド:cert-manager + Let’s Encrypt

cert-managerとクラスタ全体で有効な ClusterIssuer を組み合わせるのが最もスマートな構成です。コントローラーがIngressリソースを監視し、TLSホストのルールを検出すると証明書を自動的にプロビジョニングします。

ステップ 1: Helmでcert-managerをインストールする

公式のHelmチャートを使用してcert-managerをデプロイします。フラグでCRDのインストールを直接有効化しておくことで、カスタムリソースとチャートのバージョン同期を保ちやすくなります。

# JetstackのHelmリポジトリを追加
helm repo add jetstack https://charts.jetstack.io
helm repo update

# cert-managerとCRDをインストール
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

# すべてのコントローラーPodが実行中であることを確認
kubectl get pods -n cert-manager

30秒ほど待つと、cert-manager、cert-manager-cainjector、cert-manager-webhook の3つのPodがRunningステータスになります。

ステップ 2: ステージング用と本番用のClusterIssuerを設定する

通常の Issuer は単一のNamespace内でのみ有効ですが、ClusterIssuer はクラスタ全体で機能し、どのNamespaceのIngressオブジェクトにも対応できます。Let’s Encryptには厳しい本番レート制限(登録ドメインあたり週50件、重複証明書は週5件)が存在するため、ACMEソルバーのパイプラインは必ずステージング環境のエンドポイントで最初にテストしてください。

cluster-issuers.yaml という名前でファイルを作成します:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx

このIssuer設定をクラスタに適用します:

kubectl apply -f cluster-issuers.yaml

# 登録ステータスを確認
kubectl get clusterissuer

両方のIssuerのステータスカラムに READY: True と表示されていることを確認します。

ステップ 3: IngressリソースにAnnotationを付与する

個別の Certificate マニフェストを手動で記述する必要はほとんどありません。単にIngressに cert-manager.io/cluster-issuer アノテーションを付与するだけです。コントローラーがTLSブロックを読み取り、チャレンジを実行して、生成された鍵ペアを指定されたSecretに格納してくれます。

以下はIngressマニフェストの例(payment-ingress.yaml)です:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payment-service-ingress
  namespace: production
  annotations:
    # Let's Encrypt本番環境経由でプロビジョニングするようcert-managerに指示
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    # HTTPからHTTPSへのリダイレクトを強制
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - api.example.com
    secretName: payment-service-tls
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: payment-service
            port:
              number: 8080

マニフェストを適用します:

kubectl apply -f payment-ingress.yaml

ステップ 4: ACMEチャレンジとSecretの生成を確認する

cert-managerはアノテーションを検知すると、一時的なチャレンジPodを起動し、/.well-known/acme-challenge/<token> という一時的なIngressパスを作成します。Let’s Encryptはそのエンドポイントにアクセスしてドメインの所有権を確認し、チャレンジを検証した上で署名済み証明書バンドルを返送します。

リコンサイルの各ステージに沿って発行の進捗を追跡します:

# 1. 発行ステータスを確認
kubectl get certificate -n production

# 2. ACME CertificateRequestを確認
kubectl get certificaterequest -n production

# 3. チャレンジの状態を確認(通常15〜45秒以内に完了)
kubectl get challenges -n production

# 4. チャレンジがPendingのまま進まない場合のデバッグ
kubectl describe order -n production

発行が完了すると、kubectl get certificate -n production の出力に READY: True と表示されます。新しく作成されたTLS Secretを確認してみましょう:

kubectl get secret payment-service-tls -n production -o yaml

このSecretには tls.crt と tls.key が保持されています。Ingress-NGINXはInformerキャッシュを通じて更新されたSecretを即座に検知し、接続断を発生させることなくSSLコンテキストをリロードします。

ステップ 5: 自動更新のテスト

Let’s Encryptの証明書の有効期限は90日です。cert-managerは有効期限が残り30日になった時点(60日目)で、バックグラウンドでの自動更新を開始します。CronJobの設定も手作業の介入も一切不要です。

2ヶ月待つことなく、今すぐ更新の動作を検証することも可能です。CLIツール cmctl を使用して即時更新をトリガーしてみましょう:

# 対象の証明書で更新をトリガー
cmctl renew payment-service-tls -n production

コントローラーのログをストリーミングして、更新ループが完了する様子を確認します:

kubectl logs -n cert-manager -l app.kubernetes.io/name=cert-manager -f

宣言的な ClusterIssuer、IngressのAnnotation、そしてKubernetesのリコンサイラを導入することで、証明書ローテーション作業から人間を完全に解放できます。一度正しくセットアップしてしまえば、深夜のアラートに怯えることなくぐっすり眠れるようになります。

Share: