深夜3時の証明書失効インシデント
深夜にSSHブルートフォース攻撃を受けてからというもの、新しいプロジェクトに関わるたびにセキュリティが最優先事項になった。Kubernetesを本番環境で運用しているチームに加わったとき、最初に聞いたのは「SSL証明書の管理は誰がやってるの?」という質問だった。答えは、有効期限を記録した共有Google SheetとSlackのリマインダーbotだった。3週間後、週末に証明書が失効して決済ゲートウェイが6時間ダウンした。
6時間のダウンタイム。全員が失効することを知っていた証明書のせいで。そのとき気づいた——大規模な手動証明書管理はプロセスじゃない。それは確認するのを忘れたカウントダウンタイマーだ。
手動証明書管理が破綻する理由
問題は怠慢じゃない。複雑さがどんなスプレッドシートも追いつけないスピードで増大するのが問題だ。
小規模なクラスターならTLSを使うサービスが10個程度かもしれない。中規模の本番環境になると、複数のnamespace、複数のドメイン、内部サービス、ワイルドカード証明書を合わせると50〜100枚の証明書が簡単に存在する。それぞれに独自の有効期限、更新ウィンドウ、CAの発行者、Secretの場所がある。人間がこれを確実に管理するのは無理だ——ミスなしには到底無理で、長い週末にはなおさら無理だ。
同じ障害パターンが繰り返し起きる:
- 更新漏れ — リマインダーが発火してもSlackに埋もれ、金曜の夕方までに誰も対応しない。
- Secret名の不一致 — 誰かが手動で証明書を更新したが、KubernetesのSecret名がIngressコントローラーの期待するものと一致しない。トラフィックがサイレントに壊れる。
- プライベートCAの漂流 — 内部サービスが自己署名CAを使っているが、CA証明書自体が2年後に失効する。誰もそれを追跡していなかった。
これらすべて、自動化で防げる。
どんな選択肢があるか?
cert-managerだけが選択肢ではない。代替案を理解することで、選択を正当化しやすくなる。
オプション1:各ノードでCertbot
定番のアプローチだ。すべてのVMでcertbot renewをcronジョブとして実行する。静的サーバーには問題なく動くが、Kubernetesには向いていない。証明書はクラスター外に存在し、手動でSecretに同期する必要がある。Podが再起動して古いSecretをマウントすると、最悪のタイミングでTLSエラーをデバッグするはめになる。
オプション2:外部シークレット + Vault
HashiCorp VaultはPKIシークレットエンジンを通じて証明書を発行・更新できる。複雑なCA階層を持つ大企業には優れた選択だ。しかし小規模チームにとっては、Vault自体のデプロイ、堅牢化、アンシール、維持管理が必要で——実際のメリットを得る前に相当なオーバーヘッドがある。
オプション3:cert-manager
cert-managerはKubernetesネイティブなコントローラーとしてクラスター内で動作する。カスタムリソース(Certificate、Issuer、ClusterIssuer)を監視し、リクエスト、発行、Kubernetes Secretとしての保存、自動更新まで、ライフサイクル全体を処理する。手動作業ゼロ。Let’s Encrypt(ACME)、プライベートCA、Vault、Venafiをすぐに使えるようサポートしている。
Kubernetesを使うほとんどのチームにとって、cert-managerは自明な選択だ。Ingressアノテーションと自然に統合され、一度動かせばほぼメンテナンス不要だ。
cert-managerのセットアップ:ステップバイステップ
ステップ1:cert-managerのインストール
公式Helmチャートを使う。Kubernetes 1.22以上とHelm 3が必要だ。
# Jetstack Helmリポジトリを追加
helm repo add jetstack https://charts.jetstack.io
helm repo update
# CRDを含めてcert-managerをインストール
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
次に進む前に、Podが正常に動いているか確認しよう:
kubectl get pods -n cert-manager
cert-manager、cert-manager-cainjector、cert-manager-webhookの3つのPodがすべてRunning状態で表示されるはずだ。webhookのPodが止まっている場合は60秒待とう——最後に初期化されるからだ。
ステップ2:Let’s Encrypt ClusterIssuerの設定
ClusterIssuerはクラスター全体に適用される。通常のIssuerはnamespaceスコープだ。公開サービスには、HTTP-01チャレンジを使ったLet’s Encryptが最もシンプルな出発点だ。
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: [email protected]
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
ingressClassName: nginx
適用してステータスを確認しよう:
kubectl apply -f clusterissuer-letsencrypt.yaml
kubectl get clusterissuer letsencrypt-prod -o yaml
statusのconditionsにReady: Trueが表示されるか確認しよう。止まっている場合はコントローラーのログを確認しよう:kubectl logs -n cert-manager deploy/cert-manager。ほとんどの場合、メールアドレスかサーバーURLのタイポが原因だ。
ステップ3:IngressアノテーションによるはじめてのSSL証明書の発行
Ingressにアノテーションを1つ追加するだけだ。cert-managerがそれを検知してCertificateリソースを自動的に作成する——追加のマニフェストは不要だ。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- app.example.com
secretName: my-app-tls
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app
port:
number: 80
60〜90秒以内に、cert-managerはproduction namespaceにSecret my-app-tlsを作成する。リアルタイムで確認してみよう:
kubectl get certificate -n production -w
kubectl describe certificate my-app-tls -n production
更新は証明書の有効期限30日前に自動的に開始される(Let’s Encryptの90日証明書の場合)。以降は何もしなくていい。
ステップ4:内部サービス向けプライベートCA
mTLS上の内部マイクロサービスも証明書が必要だ。Let’s Encryptは使うべきではない——あれは公開ドメイン向けだ。代わりにプライベートCAの発行者を使おう。
まずルートCAを生成しよう:
# CA秘密鍵を生成
openssl genrsa -out ca.key 4096
# CA証明書を生成(有効期限10年)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/CN=Internal Cluster CA/O=MyOrg"
Kubernetes Secretとして保存しよう:
kubectl create secret tls internal-ca-secret \
--cert=ca.crt \
--key=ca.key \
-n cert-manager
そのCAをバックエンドにしたClusterIssuerを作成しよう:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca
spec:
ca:
secretName: internal-ca-secret
内部サービスは同じCertificateリソースのパターンを使ってプライベートCAに証明書をリクエストできるようになった——発行者としてinternal-caを指定するだけだ。
ステップ5:非Ingressワークロード向け明示的なCertificateリソース
Ingressを一切使わないサービスもある——gRPCバックエンド、TLS付きPostgreSQL、内部APIなどだ。そういったサービスにはCertificateリソースを直接作成しよう:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: postgres-tls
namespace: database
spec:
secretName: postgres-tls-secret
issuerRef:
name: internal-ca
kind: ClusterIssuer
dnsNames:
- postgres.database.svc.cluster.local
duration: 720h # 30日
renewBefore: 168h # 有効期限7日前に更新
証明書ヘルスの監視
自動化が更新を処理してくれるが、可視性は依然として必要だ。cert-managerはデフォルトでPrometheusメトリクスを公開している。まず全namespaceの簡単な監査から始めよう:
# すべての証明書を一覧表示
kubectl get certificates --all-namespaces
# Readyでないものにフラグを立てる
kubectl get certificates --all-namespaces | grep -v True
アラートには、証明書の有効期限まで14日未満でまだ更新されていない場合に発火するPrometheusルールを追加しよう:
- alert: CertificateExpirationWarning
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 1209600
for: 1h
labels:
severity: warning
annotations:
summary: "証明書の有効期限まで14日未満"
14日あれば、火事場の消火活動なしに2営業週間じっくり調査できる。
よくあるミスと回避策
- 本番環境でLet’s Encryptのステージング環境を使う — ステージングACMEエンドポイント(
https://acme-staging-v02.api.letsencrypt.org/directory)が発行する証明書はブラウザに信頼されない。テストに使い、本番公開前にサーバーURLを切り替えよう。 - ファイアウォール越しのHTTP-01 — HTTP-01はドメインがポート80で公開アクセス可能であることを要求する。プライベートクラスターやエアギャップ環境はDNS-01チャレンジが必要だ——cert-managerはRoute53、Cloudflare、その他をサポートしている。
- IssuerとClusterIssuerのスコープを混同する — 通常の
Issuerはそのnamespace内でしか機能しない。別のnamespaceから参照すると謎めいた「issuer not found」エラーが出る。迷ったらClusterIssuerを使おう。 - プライベートCAの有効期限を忘れる — cert-managerは自分が発行したリーフ証明書を管理する。CA証明書自体が失効してもcert-managerは警告しない。CAの有効期限にカレンダーリマインダーを設定するか、上記のPrometheusアラートを活用して早期に検知しよう。
結果:ゼロタッチの証明書管理
クラスター全体に展開してから、証明書管理は繰り返しのタスクから消えた。スプレッドシートなし。Slackのリマインダーなし。週末の呼び出しなし。
Prometheusアラートは8か月で2回発火した。どちらも一時的にcert-managerの管理外に移したサービスだった。どちらも10日以上の余裕を持って検知し、業務時間中に落ち着いて対応できた。
セットアップはシンプルだ:cert-managerをインストールし、公開ドメイン向けと内部サービス向けにそれぞれ1つのClusterIssuerを作成し、IngressリソースにアノテーションをT1つ追加するだけ。更新、Secretの更新、Ingressのリロードは自動で行われる。あなたの仕事は有効期限を追いかける代わりに、たまに発生するアラートを確認するだけになる。
深夜3時に置かれる状況として、これははるかにましだ。

