- はじめに
- 1. 証明書ライフサイクル管理
- 2. 無停止更新戦略
- 3. Let's Encrypt自動更新運用
- 4. 証明書期限切れ監視
- 5. インシデント対応プレイブック
- 6. マルチ環境の証明書管理
- 7. 実戦チェックリスト
- 8. まとめ
- クイズ

はじめに
SSL/TLS証明書の基本概念、発行方法、Nginx設定についてはSSL/TLS証明書完全ガイドで解説した。本記事はその延長線上で運用の観点に集中する。証明書を一度発行するのは簡単だ。問題は、数十のドメインを運用しながら、一件の期限切れ事故もなく証明書を管理することにある。
実際の証明書期限切れ事故は大手サービスでも頻繁に発生している。2020年にはMicrosoft Teamsが証明書の期限切れにより数時間の障害を経験し、SpotifyやLinkedInも同様の問題に見舞われた。これらの事故の共通点は、自動化の欠如ではなく、運用プロセスの欠如だった。
本プレイブックは以下の問いに答える。
- 証明書更新時にサービスを停止させずに済むにはどうすればよいか?
- 期限切れの30日前に自動通知を受けるには何を構築する必要があるか?
- 深夜3時に証明書の期限切れ障害が発生した場合、どの順序で対応すべきか?
- dev/staging/prod環境ごとに証明書をどう分離管理するか?
1. 証明書ライフサイクル管理
証明書運用は単に「発行して更新する」だけではない。体系的なライフサイクル管理が必要だ。
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 発行 │ → │ デプロイ │ → │ 監視 │ → │ 更新 │ → │ 失効 │
│ Issuance │ │ Deploy │ │ Monitor │ │ Renewal │ │ Revoke │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
↑ │
└──────────────────────────────────────────────┘
(自動更新サイクル)
1.1 発行 (Issuance)
発行段階で決定すべき事項は以下の通り。
| 決定項目 | 選択肢 | 推奨 |
|---|---|---|
| CA選択 | Let's Encrypt / DigiCert / ACM | 環境に応じて(下記参照) |
| 鍵アルゴリズム | RSA 2048 / RSA 4096 / ECDSA P-256 | ECDSA P-256(性能+セキュリティ) |
| 証明書範囲 | 単一ドメイン / ワイルドカード / SAN | ワイルドカード+apex SAN |
| 検証方式 | HTTP-01 / DNS-01 | DNS-01(ワイルドカード必須) |
ECDSAを推奨する理由は、RSA 2048と比較して鍵サイズが小さく(256bit vs 2048bit)、TLSハンドシェイク性能が約2〜5倍高速で、同等のセキュリティ強度でCPU負荷が低いためだ。
# ECDSA鍵でLet's Encrypt証明書を発行
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
--key-type ecdsa \
--elliptic-curve secp256r1 \
-d "*.example.com" \
-d "example.com"
1.2 デプロイ (Deployment)
証明書を発行した後、実際のサービスに適用するプロセスだ。単一サーバーなら簡単だが、複数サーバーにまたがる場合はデプロイ戦略が必要になる。
#!/bin/bash
# /usr/local/bin/deploy-cert.sh
# 証明書デプロイスクリプト(マルチサーバー)
CERT_DIR="/etc/letsencrypt/live/example.com"
SERVERS=("web01" "web02" "web03")
REMOTE_CERT_DIR="/etc/nginx/ssl"
DEPLOY_LOG="/var/log/cert-deploy.log"
deploy_cert() {
local server=$1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $server" >> "$DEPLOY_LOG"
# 証明書ファイルの転送
scp -q "$CERT_DIR/fullchain.pem" "$server:$REMOTE_CERT_DIR/fullchain.pem.new"
scp -q "$CERT_DIR/privkey.pem" "$server:$REMOTE_CERT_DIR/privkey.pem.new"
# アトミックな置換(mvは同一ファイルシステム上でatomic)
ssh "$server" "
mv $REMOTE_CERT_DIR/fullchain.pem.new $REMOTE_CERT_DIR/fullchain.pem
mv $REMOTE_CERT_DIR/privkey.pem.new $REMOTE_CERT_DIR/privkey.pem
nginx -t && systemctl reload nginx
"
if [ $? -eq 0 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $server: OK" >> "$DEPLOY_LOG"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $server: FAILED" >> "$DEPLOY_LOG"
return 1
fi
}
for server in "${SERVERS[@]}"; do
deploy_cert "$server"
done
1.3 監視 (Monitoring)
第4章で詳しく解説するが、核心原則は以下の通り。
- 期限切れ30日前からwarningアラート
- 期限切れ7日前からcriticalアラート
- 期限切れ1日前にエスカレーション(PagerDuty/電話)
- 更新成功/失敗イベントを必ずロギング
1.4 更新 (Renewal)
第2章で無停止更新戦略を詳しく解説する。
1.5 失効 (Revocation)
証明書の失効が必要な状況は以下の通り。
- 秘密鍵の漏洩が疑われる場合
- ドメインの所有権を失った場合
- 組織情報が変更された場合
# Let's Encrypt証明書の失効
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/cert.pem \
--reason keycompromise
# 失効後に証明書ファイルを削除
sudo certbot delete --cert-name example.com
# 直ちに新しい証明書を発行
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "*.example.com" -d "example.com"
2. 無停止更新戦略
証明書更新時にサービス中断が発生する主な原因は大きく3つある。
- 更新過程でWebサーバーを再起動(restart)する場合
- 新しい証明書のデプロイとロードバランサーへの反映の間のタイムラグ
- クライアントのTLSセッションキャッシュが以前の証明書を参照する場合
2.1 Nginx reload方式(単一サーバー)
最も基本的な無停止更新だ。Nginxのreloadでは、既存のワーカープロセスが現在処理中のリクエストを完了してから終了し、新しいワーカープロセスが新しい設定(新しい証明書)で起動する。
# restart vs reload の違い
# restart: プロセスを停止後に再起動 → リクエスト喪失の可能性
# reload: 新ワーカー生成 → 既存ワーカーのgraceful shutdown → 無停止
# certbot deploy hookでreloadを自動化
sudo certbot renew --deploy-hook "systemctl reload nginx"
注意: systemctl restart nginxは絶対に使用しないこと。既存の接続が即座に切断される。
2.2 ローリング更新(マルチサーバー)
ロードバランサーの後ろに複数のサーバーがある場合、1台ずつ順番に更新する。
#!/bin/bash
# /usr/local/bin/rolling-cert-renewal.sh
SERVERS=("web01" "web02" "web03")
LB_API="http://lb-admin.internal:8080/api"
HEALTH_CHECK_URL="https://example.com/healthz"
WAIT_SECONDS=30
for server in "${SERVERS[@]}"; do
echo "=== Processing $server ==="
# 1. ロードバランサーからサーバーを除外
curl -s -X POST "$LB_API/drain" -d "server=$server"
echo "Draining $server from load balancer..."
sleep $WAIT_SECONDS # 既存接続の完了を待機
# 2. 証明書のデプロイと適用
scp /etc/letsencrypt/live/example.com/fullchain.pem "$server:/etc/nginx/ssl/"
scp /etc/letsencrypt/live/example.com/privkey.pem "$server:/etc/nginx/ssl/"
ssh "$server" "nginx -t && systemctl reload nginx"
# 3. ヘルスチェック
for i in $(seq 1 10); do
status=$(curl -s -o /dev/null -w "%{http_code}" "https://$server/healthz" --resolve "example.com:443:$(dig +short $server)")
if [ "$status" = "200" ]; then
echo "$server health check passed"
break
fi
sleep 2
done
# 4. ロードバランサーにサーバーを復帰
curl -s -X POST "$LB_API/enable" -d "server=$server"
echo "$server re-enabled in load balancer"
sleep 5
done
echo "=== Rolling renewal complete ==="
2.3 Blue-Green証明書交換
2セットの証明書を運用し、切り替え時点で即座にスイッチングする方式だ。主に大規模インフラで使用する。
# /etc/nginx/conf.d/ssl-blue-green.conf
# シンボリックリンクを活用したBlue-Green証明書切り替え
# 現在アクティブな証明書(シンボリックリンク)
# /etc/nginx/ssl/active/ -> /etc/nginx/ssl/blue/ または /etc/nginx/ssl/green/
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/ssl/active/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/active/privkey.pem;
# ... その他の設定
}
#!/bin/bash
# /usr/local/bin/blue-green-cert-switch.sh
ACTIVE_LINK="/etc/nginx/ssl/active"
BLUE_DIR="/etc/nginx/ssl/blue"
GREEN_DIR="/etc/nginx/ssl/green"
# 現在のアクティブスロットを確認
current=$(readlink "$ACTIVE_LINK")
if [ "$current" = "$BLUE_DIR" ]; then
target="$GREEN_DIR"
target_name="green"
else
target="$BLUE_DIR"
target_name="blue"
fi
echo "Current: $current"
echo "Deploying new cert to: $target ($target_name)"
# 非アクティブスロットに新しい証明書をデプロイ
cp /etc/letsencrypt/live/example.com/fullchain.pem "$target/fullchain.pem"
cp /etc/letsencrypt/live/example.com/privkey.pem "$target/privkey.pem"
# 証明書の有効性を検証
openssl x509 -in "$target/fullchain.pem" -noout -checkend 86400
if [ $? -ne 0 ]; then
echo "ERROR: New certificate expires within 24 hours. Aborting."
exit 1
fi
# 鍵の一致を検証
CERT_MD5=$(openssl x509 -noout -modulus -in "$target/fullchain.pem" | openssl md5)
KEY_MD5=$(openssl rsa -noout -modulus -in "$target/privkey.pem" 2>/dev/null | openssl md5)
if [ "$CERT_MD5" != "$KEY_MD5" ]; then
echo "ERROR: Certificate and key do not match. Aborting."
exit 1
fi
# シンボリックリンクのアトミックな切り替え
ln -sfn "$target" "${ACTIVE_LINK}.new"
mv -T "${ACTIVE_LINK}.new" "$ACTIVE_LINK"
# Nginx reload
nginx -t && systemctl reload nginx
echo "Switched to $target_name slot. Reload complete."
2.4 デュアル証明書 (Dual-Certificate)
Nginx 1.11.0以降では、RSAとECDSAの証明書を同時にロードできる。これを活用すれば、一方の証明書を更新している間、もう一方がサービスを維持する。
server {
listen 443 ssl http2;
server_name example.com;
# RSA証明書
ssl_certificate /etc/nginx/ssl/rsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;
# ECDSA証明書
ssl_certificate /etc/nginx/ssl/ecdsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;
# Nginxがクライアントのサポート状況に応じて自動選択
# ECDSA優先、非対応クライアントはRSAにフォールバック
}
3. Let's Encrypt自動更新運用
3.1 systemd timerベースの更新(推奨)
cronよりsystemd timerを推奨する理由は以下の通り。
RandomizedDelaySecでCAサーバーの負荷を分散Persistent=trueで起動時に逃したスケジュールを補償systemctl list-timersで次回実行時刻を確認可能- journalctlでログを統合管理
# /etc/systemd/system/certbot-renewal.service
[Unit]
Description=Certbot SSL Certificate Renewal
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet \
--pre-hook "/usr/local/bin/cert-pre-hook.sh" \
--deploy-hook "/usr/local/bin/cert-deploy-hook.sh"
ExecStartPost=/usr/local/bin/cert-renewal-notify.sh
TimeoutStartSec=300
# /etc/systemd/system/certbot-renewal.timer
[Unit]
Description=Run certbot renewal twice daily
[Timer]
OnCalendar=*-*-* 02,14:00:00
RandomizedDelaySec=3600
Persistent=true
AccuracySec=1s
[Install]
WantedBy=timers.target
# timerの有効化と状態確認
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer
sudo systemctl list-timers certbot-renewal.timer
# 手動テスト(dry-run)
sudo certbot renew --dry-run
# 手動トリガー
sudo systemctl start certbot-renewal.service
3.2 pre-hook / deploy-hookの活用
hookは更新前後に必要な作業を自動化する。certbotは証明書が実際に更新された場合にのみhookを実行する。
#!/bin/bash
# /usr/local/bin/cert-pre-hook.sh
# 更新前に実行されるhook
LOG="/var/log/cert-hooks.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] PRE-HOOK: Starting renewal process" >> "$LOG"
# 現在の証明書情報をバックアップ
for domain_dir in /etc/letsencrypt/live/*/; do
domain=$(basename "$domain_dir")
expiry=$(openssl x509 -in "${domain_dir}fullchain.pem" -noout -enddate 2>/dev/null | cut -d= -f2)
echo "[PRE] $domain expires: $expiry" >> "$LOG"
done
#!/bin/bash
# /usr/local/bin/cert-deploy-hook.sh
# 更新成功後に実行されるhook
# 環境変数 $RENEWED_DOMAINS, $RENEWED_LINEAGE が利用可能
LOG="/var/log/cert-hooks.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEPLOY-HOOK: Certificate renewed" >> "$LOG"
echo " Domains: $RENEWED_DOMAINS" >> "$LOG"
echo " Lineage: $RENEWED_LINEAGE" >> "$LOG"
# 1. Nginx設定を検証してreload
if nginx -t 2>/dev/null; then
systemctl reload nginx
echo " Nginx reloaded successfully" >> "$LOG"
else
echo " ERROR: Nginx config test failed!" >> "$LOG"
# 設定エラー時に緊急通知
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"CRITICAL: Nginx config test failed after cert renewal!"}'
exit 1
fi
# 2. HAProxyがある場合、証明書を結合してreload
if systemctl is-active haproxy > /dev/null 2>&1; then
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" \
> /etc/haproxy/certs/$(basename "$RENEWED_LINEAGE").pem
systemctl reload haproxy
echo " HAProxy reloaded" >> "$LOG"
fi
# 3. 他のサーバーに証明書を同期(必要に応じて)
# /usr/local/bin/sync-certs-to-peers.sh "$RENEWED_LINEAGE"
#!/bin/bash
# /usr/local/bin/cert-renewal-notify.sh
# 更新結果の通知
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
LOG="/var/log/cert-hooks.log"
# 直近の更新結果を確認
last_renewal=$(journalctl -u certbot-renewal.service --since "5 minutes ago" --no-pager 2>/dev/null)
if echo "$last_renewal" | grep -q "Congratulations"; then
# 更新成功
renewed_domains=$(echo "$last_renewal" | grep "renewed" | head -5)
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"SSL証明書の更新が成功しました\\n${renewed_domains}\"}"
elif echo "$last_renewal" | grep -q "No renewals were attempted"; then
# 更新対象なし(正常)
echo "[$(date)] No certificates due for renewal" >> "$LOG"
else
# 更新失敗
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"WARNING: certbot renewalの実行結果を確認してください!"}'
fi
3.3 更新失敗時の自動リトライ
certbot自体にはリトライロジックがない。systemdの機能を活用して実装する。
# /etc/systemd/system/certbot-renewal.service に追加
[Service]
# 失敗時に5分後にリトライ、最大3回
Restart=on-failure
RestartSec=300
StartLimitBurst=3
StartLimitIntervalSec=3600
4. 証明書期限切れ監視
4.1 Prometheus + ssl_exporter
ssl_exporterはTLS証明書の期限切れ時間をPrometheusメトリクスとして公開する。
# ssl_exporterのインストール
wget https://github.com/ribbybibby/ssl_exporter/releases/download/v2.4.3/ssl_exporter-2.4.3.linux-amd64.tar.gz
tar xzf ssl_exporter-2.4.3.linux-amd64.tar.gz
sudo mv ssl_exporter-2.4.3.linux-amd64/ssl_exporter /usr/local/bin/
# systemd service
sudo tee /etc/systemd/system/ssl-exporter.service << 'EOF'
[Unit]
Description=SSL Certificate Exporter
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ssl_exporter
Restart=on-failure
User=ssl-exporter
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now ssl-exporter
# Prometheus scrape設定
# /etc/prometheus/prometheus.yml
scrape_configs:
- job_name: 'ssl'
metrics_path: /probe
static_configs:
- targets:
- example.com:443
- api.example.com:443
- admin.example.com:443
- staging.example.com:443
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: ssl-exporter:9219 # ssl_exporterのアドレス
主要メトリクスは以下の通り。
ssl_cert_not_after: 証明書の期限切れ時刻(Unixタイムスタンプ)ssl_cert_not_before: 証明書の発行時刻ssl_tls_version_info: TLSバージョン情報ssl_ocsp_response_status: OCSPレスポンスのステータス
# 証明書の期限切れまでの残り日数を計算
(ssl_cert_not_after - time()) / 86400
# 30日以内に期限切れの証明書を検索
(ssl_cert_not_after - time()) / 86400 < 30
# 7日以内に期限切れの証明書を検索
(ssl_cert_not_after - time()) / 86400 < 7
4.2 Alertmanagerアラートルール
# /etc/prometheus/rules/ssl-alerts.yml
groups:
- name: ssl_certificate_alerts
rules:
# 30日以内に期限切れ - Warning
- alert: SSLCertExpiringSoon
expr: (ssl_cert_not_after - time()) / 86400 < 30
for: 1h
labels:
severity: warning
annotations:
summary: 'SSL証明書の期限切れが迫っています ({{ $labels.instance }})'
description: '{{ $labels.instance }} の証明書が {{ $value | printf "%.0f" }}日後に期限切れになります。'
runbook_url: 'https://wiki.internal/runbooks/ssl-renewal'
# 7日以内に期限切れ - Critical
- alert: SSLCertExpiryCritical
expr: (ssl_cert_not_after - time()) / 86400 < 7
for: 10m
labels:
severity: critical
team: platform
annotations:
summary: 'SSL証明書の期限切れ緊急 ({{ $labels.instance }})'
description: '{{ $labels.instance }} の証明書が {{ $value | printf "%.0f" }}日後に期限切れになります。即時対応が必要です。'
runbook_url: 'https://wiki.internal/runbooks/ssl-emergency-renewal'
# すでに期限切れの証明書
- alert: SSLCertExpired
expr: (ssl_cert_not_after - time()) < 0
for: 0m
labels:
severity: critical
escalation: pagerduty
annotations:
summary: 'SSL証明書が期限切れです ({{ $labels.instance }})'
description: '{{ $labels.instance }} の証明書が期限切れになりました!サービス障害が発生する可能性があります。'
# プローブ失敗(接続不可)
- alert: SSLProbeFailure
expr: ssl_probe_success == 0
for: 5m
labels:
severity: warning
annotations:
summary: 'SSLプローブ失敗 ({{ $labels.instance }})'
description: '{{ $labels.instance }} にTLS接続を確立できません。'
# Alertmanagerルーティング設定
# /etc/alertmanager/alertmanager.yml
route:
group_by: ['alertname', 'instance']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'slack-warning'
routes:
- match:
severity: critical
escalation: pagerduty
receiver: 'pagerduty-critical'
repeat_interval: 30m
- match:
severity: critical
receiver: 'slack-critical'
repeat_interval: 1h
receivers:
- name: 'slack-warning'
slack_configs:
- api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
channel: '#ssl-alerts'
title: '{{ .CommonAnnotations.summary }}'
text: '{{ .CommonAnnotations.description }}'
- name: 'slack-critical'
slack_configs:
- api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
channel: '#incident'
title: '{{ .CommonAnnotations.summary }}'
text: '{{ .CommonAnnotations.description }}'
- name: 'pagerduty-critical'
pagerduty_configs:
- service_key: 'YOUR_PAGERDUTY_SERVICE_KEY'
severity: critical
4.3 Grafanaダッシュボード
{
"title": "SSL Certificate Dashboard",
"panels": [
{
"title": "証明書の期限切れまでの残り日数",
"type": "table",
"targets": [
{
"expr": "sort_desc((ssl_cert_not_after - time()) / 86400)",
"legendFormat": "{{ instance }}"
}
]
},
{
"title": "期限切れ間近の証明書(30日以内)",
"type": "stat",
"targets": [
{
"expr": "count((ssl_cert_not_after - time()) / 86400 < 30)"
}
],
"thresholds": [
{ "value": 0, "color": "green" },
{ "value": 1, "color": "orange" },
{ "value": 3, "color": "red" }
]
}
]
}
4.4 自前の監視スクリプト(Prometheusなしの場合)
Prometheusインフラがない環境では、シェルスクリプトで代替できる。
#!/bin/bash
# /usr/local/bin/ssl-expiry-check.sh
# 証明書期限切れ監視 + Slack/Emailアラート
set -euo pipefail
DOMAINS=(
"example.com"
"api.example.com"
"admin.example.com"
"staging.example.com"
)
WARNING_DAYS=30
CRITICAL_DAYS=7
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
ALERT_EMAIL="ops-team@example.com"
check_cert() {
local domain=$1
local port=${2:-443}
# 証明書の期限切れ日を取得
local expiry_date
expiry_date=$(echo | timeout 10 openssl s_client \
-servername "$domain" \
-connect "${domain}:${port}" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
if [ -z "$expiry_date" ]; then
echo "UNKNOWN|${domain}|Connection failed"
return
fi
# 残り日数の計算(Linux/macOS互換)
local expiry_epoch days_left
if date --version >/dev/null 2>&1; then
# GNU date (Linux)
expiry_epoch=$(date -d "$expiry_date" +%s)
else
# BSD date (macOS)
expiry_epoch=$(date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s)
fi
days_left=$(( (expiry_epoch - $(date +%s)) / 86400 ))
if [ "$days_left" -lt 0 ]; then
echo "EXPIRED|${domain}|${days_left}|${expiry_date}"
elif [ "$days_left" -lt "$CRITICAL_DAYS" ]; then
echo "CRITICAL|${domain}|${days_left}|${expiry_date}"
elif [ "$days_left" -lt "$WARNING_DAYS" ]; then
echo "WARNING|${domain}|${days_left}|${expiry_date}"
else
echo "OK|${domain}|${days_left}|${expiry_date}"
fi
}
# 全ドメインをチェック
alerts=""
for domain in "${DOMAINS[@]}"; do
result=$(check_cert "$domain")
status=$(echo "$result" | cut -d'|' -f1)
days=$(echo "$result" | cut -d'|' -f3)
case $status in
OK)
printf "%-30s %-10s %s days\n" "$domain" "[OK]" "$days"
;;
WARNING)
printf "%-30s %-10s %s days\n" "$domain" "[WARNING]" "$days"
alerts="${alerts}WARNING: ${domain} - 残り${days}日\n"
;;
CRITICAL|EXPIRED)
printf "%-30s %-10s %s days\n" "$domain" "[$status]" "$days"
alerts="${alerts}${status}: ${domain} - 残り${days}日\n"
;;
UNKNOWN)
printf "%-30s %-10s\n" "$domain" "[UNKNOWN]"
alerts="${alerts}UNKNOWN: ${domain} - 接続失敗\n"
;;
esac
done
# アラート送信
if [ -n "$alerts" ]; then
if [ -n "$SLACK_WEBHOOK" ]; then
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"SSL証明書チェック結果:\\n${alerts}\"}"
fi
# メールアラート(mailutils必要)
echo -e "$alerts" | mail -s "[SSL Alert] 証明書期限切れ警告" "$ALERT_EMAIL" 2>/dev/null || true
fi
# cronに登録(毎日午前9時)
echo "0 9 * * * /usr/local/bin/ssl-expiry-check.sh >> /var/log/ssl-check.log 2>&1" | sudo crontab -
5. インシデント対応プレイブック
5.1 証明書期限切れ事故発生時の対応順序
┌─────────────────────────────────────────────────────────────┐
│ 証明書期限切れインシデント対応フロー │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 検知・確認 (0〜5分) │
│ └→ 障害範囲の把握、影響ドメインのリスト化 │
│ │
│ 2. 即時緩和 (5〜15分) │
│ └→ 一時証明書の適用またはトラフィックの迂回 │
│ │
│ 3. 正式な証明書更新 (15〜30分) │
│ └→ certbot更新または緊急発行 │
│ │
│ 4. サービス検証 (30〜45分) │
│ └→ すべてのエンドポイントのTLS接続確認 │
│ │
│ 5. ポストモーテム (48時間以内) │
│ └→ 原因分析、再発防止策 │
│ │
└─────────────────────────────────────────────────────────────┘
5.2 段階別対応の詳細
ステップ1: 検知・確認 (0〜5分)
# 1-1. 期限切れ状態を即座に確認
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates
# 1-2. 複数ドメインを一括確認
for domain in example.com api.example.com admin.example.com; do
echo -n "$domain: "
echo | openssl s_client -servername "$domain" -connect "${domain}:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null || echo "CONNECTION FAILED"
done
# 1-3. ローカルの証明書ファイルを確認
for cert in /etc/letsencrypt/live/*/fullchain.pem; do
domain=$(basename $(dirname "$cert"))
expiry=$(openssl x509 -in "$cert" -noout -enddate | cut -d= -f2)
echo "$domain: $expiry"
done
# 1-4. インシデントチャンネルで状況を共有
# "SSL証明書の期限切れを確認。影響範囲: example.com, api.example.com。対応開始。"
ステップ2: 即時緩和 (5〜15分)
# 2-1. Let's Encrypt証明書の強制更新
sudo certbot renew --force-renewal --cert-name example.com
sudo systemctl reload nginx
# 2-2. 更新失敗時 - standalone方式で緊急発行
sudo systemctl stop nginx
sudo certbot certonly --standalone -d example.com -d "*.example.com"
sudo systemctl start nginx
# 2-3. Let's Encryptのrate limitに達した場合 - 自己署名証明書の一時適用
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout /tmp/emergency.key \
-out /tmp/emergency.crt \
-subj "/CN=example.com"
# 注意: 自己署名証明書はブラウザ警告が表示されるが、
# APIサーバーなど内部通信では一時的な代替手段になりうる
# 2-4. AWS環境でACM証明書を使用中の場合
# ACMは自動更新のため、大半はALB/CloudFront側の問題
aws elbv2 describe-listeners --load-balancer-arn $ALB_ARN \
--query 'Listeners[].Certificates[].CertificateArn'
# ACM証明書のステータス確認
aws acm describe-certificate --certificate-arn $CERT_ARN \
--query 'Certificate.{Status:Status,NotAfter:NotAfter}'
ステップ3: 正式な証明書更新 (15〜30分)
# 3-1. 更新された証明書チェーンの検証
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
/etc/letsencrypt/live/example.com/fullchain.pem
# 3-2. 証明書と鍵の一致を確認
diff <(openssl x509 -noout -modulus -in /etc/letsencrypt/live/example.com/fullchain.pem | openssl md5) \
<(openssl rsa -noout -modulus -in /etc/letsencrypt/live/example.com/privkey.pem | openssl md5)
# 3-3. 全サーバーにデプロイ
/usr/local/bin/deploy-cert.sh
ステップ4: サービス検証 (30〜45分)
# 4-1. TLS接続テスト
curl -vI https://example.com 2>&1 | grep -E "SSL|expire|subject"
# 4-2. 全エンドポイントのチェック
for url in https://example.com https://api.example.com/healthz https://admin.example.com; do
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
echo "$url: HTTP $status"
done
# 4-3. 外部からの確認(SSL Labs)
echo "Check: https://www.ssllabs.com/ssltest/analyze.html?d=example.com"
ステップ5: ポストモーテム (48時間以内)
ポストモーテムに含めるべき項目は以下の通り。
## インシデント ポストモーテム: SSL証明書の期限切れ
### タイムライン
- HH:MM - 初回アラート受信(ソース: Prometheus/ユーザー報告)
- HH:MM - インシデント確認、対応開始
- HH:MM - 証明書更新完了
- HH:MM - サービス正常確認
### 影響範囲
- 影響を受けたドメイン: example.com, api.example.com
- 影響時間: XX分
- 影響を受けたユーザー数: 約N名
### 根本原因
- (例)certbot timerが無効化されており、自動更新が動作していなかった
- (例)DNS検証の失敗が繰り返されていたが、アラートが未設定だった
### 再発防止策
- [ ] 自動更新timerの状態監視を追加
- [ ] 更新失敗時の即時アラート設定
- [ ] 証明書期限切れ30日前のwarningアラート設定を確認
6. マルチ環境の証明書管理
6.1 環境別戦略
| 環境 | 証明書タイプ | CA | 更新周期 | 備考 |
|---|---|---|---|---|
| dev | 自己署名またはmkcert | 自前 | N/A | ブラウザ警告を許容 |
| staging | Let's Encrypt (staging) | LE Staging | 90日 | rate limitなし |
| prod | Let's EncryptまたはACM | 公認CA | 90日 / 自動 | 無停止必須 |
6.2 開発環境: mkcertの活用
ローカル開発環境ではmkcertでローカル信頼証明書を生成する。
# mkcertのインストール(macOS)
brew install mkcert
mkcert -install # ローカルCAをシステム証明書ストアに追加
# 開発用証明書の生成
mkcert "*.dev.example.com" localhost 127.0.0.1 ::1
# 出力ファイル
# _wildcard.dev.example.com+3.pem(証明書)
# _wildcard.dev.example.com+3-key.pem(鍵)
6.3 ステージング環境: Let's Encrypt Stagingの利用
ステージングではLet's EncryptのstagingサーバーでRate Limit問題を回避する。
# stagingサーバーから証明書を発行(rate limitなし、ブラウザ非信頼)
sudo certbot certonly --staging \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "*.staging.example.com"
# APIテスト時は -k (insecure) フラグを使用
curl -k https://staging.example.com/api/healthz
6.4 ワイルドカード戦略
example.com → 単一ドメイン + ワイルドカード
├── www.example.com → *.example.com でカバー
├── api.example.com → *.example.com でカバー
├── admin.example.com → *.example.com でカバー
├── staging.example.com → 別証明書(ステージング環境)
│ ├── api.staging.example.com → *.staging.example.com でカバー
│ └── admin.staging.example.com → *.staging.example.com でカバー
└── internal.example.com → 内部専用(mTLS検討)
# プロダクション ワイルドカード証明書(apex + wildcard)
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "example.com" \
-d "*.example.com" \
--cert-name prod-wildcard
# ステージング ワイルドカード証明書(別管理)
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "staging.example.com" \
-d "*.staging.example.com" \
--cert-name staging-wildcard
6.5 Kubernetes環境: cert-manager
Kubernetes環境ではcert-managerで証明書を宣言的に管理する。
# cert-manager ClusterIssuer (Let's Encrypt)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
email: admin@example.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
# Certificateリソース
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: istio-system
spec:
secretName: example-com-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- example.com
- '*.example.com'
# 自動更新: 期限切れの30日前
renewBefore: 720h # 30日
# Ingressで自動証明書発行
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-ingress
annotations:
cert-manager.io/cluster-issuer: 'letsencrypt-prod'
spec:
tls:
- hosts:
- example.com
- api.example.com
secretName: example-com-tls
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
cert-managerの状態監視コマンドも覚えておくとよい。
# 証明書の状態確認
kubectl get certificates -A
kubectl describe certificate example-com-tls -n istio-system
# 証明書イベントの確認
kubectl get events --field-selector reason=IssueError -A
# cert-managerのログ
kubectl logs -n cert-manager deploy/cert-manager -f
7. 実戦チェックリスト
証明書発行時のチェックリスト
- 鍵アルゴリズムの選択(ECDSA P-256推奨)
- 証明書範囲の決定(単一 / ワイルドカード / SAN)
- DNS-01検証用のDNS APIクレデンシャルの準備
- 証明書ファイルの権限設定(
chmod 600 privkey.pem) - fullchain.pemの使用を確認(cert.pemのみではチェーン不完全)
自動更新チェックリスト
- systemd timerのアクティブ状態確認(
systemctl is-active certbot-renewal.timer) -
certbot renew --dry-runの成功確認 - deploy-hookでWebサーバーのreloadを設定
- 更新失敗時のアラート設定
- 更新失敗時のリトライロジックの実装
監視チェックリスト
- ssl_exporterまたは自前スクリプトで期限切れ日を監視
- 30日前にwarning、7日前にcriticalアラートを設定
- Slack/PagerDutyアラートチャンネルの接続
- Grafanaダッシュボードに証明書現況パネルを追加
- 週次証明書チェックレポートの自動化
インシデント対策チェックリスト
- 証明書期限切れ時の対応ランブック作成完了
- 緊急連絡チャンネル(オンコール)の指定
- 以前の証明書のバックアップ保管
- certbot以外の代替発行手段の確保(acme.shなど)
- rate limit超過時の代替案(staging CA、別のCA)
マルチ環境チェックリスト
- dev: mkcertローカル証明書を使用
- staging: Let's Encrypt staging CAを使用
- prod: 公認CA + 自動更新 + 監視
- Kubernetes: cert-managerのインストールとClusterIssuerの設定
- 証明書ごとの更新スケジュールの文書化(証明書インベントリ)
8. まとめ
証明書運用の核心原則を整理する。
1. 手動更新は必ず失敗する。 Let's Encryptが90日の有効期間を採用した理由は、自動化を強制するためだ。certbot + systemd timer、cert-managerなどで更新を完全に自動化しなければならない。
2. 自動化だけでは不十分だ。 自動更新は失敗しうる。DNS APIトークンの期限切れ、ディスク容量不足、CAサーバーの障害など、失敗原因は多様だ。必ず監視も併せて構築する必要がある。
3. インシデントは必ず来る。 期限切れ事故が発生したときにパニックにならないためには、事前にランブックを作成し、定期的に訓練する必要がある。復旧時間は準備度に比例する。
4. 証明書はインベントリとして管理する。 ドメインが増えるほど、「どこにどの証明書があり、いつ期限切れになるか」の把握が難しくなる。証明書リストを文書化し、監視対象に漏れなく登録しなければならない。
このプレイブックのチェックリストとスクリプトを基に、自身の環境に合った証明書運用体制を構築することをお勧めする。一度しっかり作り上げれば、証明書の期限切れ事故から解放される。
クイズ
Q1: 「SSL証明書運用プレイブック:無停止更新と期限切れ事故の予防」の主なトピックは何ですか?
証明書ライフサイクル全体(発行・デプロイ・監視・更新・失効)を体系的に管理する運用プレイブック。無停止更新戦略、Prometheusベースの期限切れ監視、インシデント対応ランブック、マルチ環境ワイルドカード戦略まで、実戦運用に必要なすべてを解説する。
Q2: 証明書ライフサイクル管理とは何ですか?
証明書運用は単に「発行して更新する」だけではない。体系的なライフサイクル管理が必要だ。 1.1 発行
(Issuance) 発行段階で決定すべき事項は以下の通り。 ECDSAを推奨する理由は、RSA
2048と比較して鍵サイズが小さく(256bit vs
2048bit)、TLSハンドシェイク性能が約2〜5倍高速で、同等のセキュリティ強度でCPU負荷が低いためだ。
1.2 デプロイ (Deployment)
証明書を発行した後、実際のサービスに適用するプロセスだ。単一サーバーなら簡単だが、複数サーバーにまたがる場合はデプロイ戦略が必要になる。
Q3: 無停止更新戦略の核心的な概念を説明してください。
証明書更新時にサービス中断が発生する主な原因は大きく3つある。
更新過程でWebサーバーを再起動(restart)する場合
新しい証明書のデプロイとロードバランサーへの反映の間のタイムラグ
クライアントのTLSセッションキャッシュが以前の証明書を参照する場合 2.1 Nginx
reload方式(単一サーバー)
最も基本的な無停止更新だ。Nginxのreloadでは、既存のワーカープロセスが現在処理中のリクエストを完了してから終了し、新しいワーカープロセスが新しい設定(新しい証明書)で起動する。
Q4: Let's Encrypt自動更新運用の主な特徴は何ですか?
3.1 systemd timerベースの更新(推奨) cronよりsystemd timerを推奨する理由は以下の通り。
RandomizedDelaySecでCAサーバーの負荷を分散 Persistent=trueで起動時に逃したスケジュールを補償
systemctl list-timersで次回実行時刻を確認可能 journalctlでログを統合管理 3.2 pre-hook /
deploy-hookの活用
hookは更新前後に必要な作業を自動化する。certbotは証明書が実際に更新された場合にのみhookを実行する。
Q5: 証明書期限切れ監視はどのように機能しますか?
4.1 Prometheus + ssl_exporter
ssl_exporterはTLS証明書の期限切れ時間をPrometheusメトリクスとして公開する。
主要メトリクスは以下の通り。 ssl_cert_not_after: 証明書の期限切れ時刻(Unixタイムスタンプ)
ssl_cert_not_before: 証明書の発行時刻 ssl_tls_version_info: TLSバージョン情報
ssl_ocsp_response_status: OCSPレスポンスのステータス 4.2 Alertmanagerアラートルール 4.3 Grafana...