LabHub

ブログ

SSL証明書運用プレイブック:無停止更新と期限切れ事故の予防

한국어English日本語

SSL証明書運用プレイブック:無停止更新と期限切れ事故の予防

はじめに

SSL/TLS証明書の基本概念、発行方法、Nginx設定についてはSSL/TLS証明書完全ガイドで解説した。本記事はその延長線上で運用の観点に集中する。証明書を一度発行するのは簡単だ。問題は、数十のドメインを運用しながら、一件の期限切れ事故もなく証明書を管理することにある。

実際の証明書期限切れ事故は大手サービスでも頻繁に発生している。2020年にはMicrosoft Teamsが証明書の期限切れにより数時間の障害を経験し、SpotifyやLinkedInも同様の問題に見舞われた。これらの事故の共通点は、自動化の欠如ではなく、運用プロセスの欠如だった。

本プレイブックは以下の問いに答える。


1. 証明書ライフサイクル管理

証明書運用は単に「発行して更新する」だけではない。体系的なライフサイクル管理が必要だ。

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│  発行     │ → │ デプロイ  │ → │  監視     │ → │  更新     │ → │  失効     │
Issuance │    │  Deploy  │    │ Monitor  │    │ Renewal  │    │  Revoke└──────────┘    └──────────┘    └──────────┘    └──────────┘    └──────────┘
      ↑                                              │
      └──────────────────────────────────────────────┘
                    (自動更新サイクル)

1.1 発行 (Issuance)

発行段階で決定すべき事項は以下の通り。

決定項目選択肢推奨
CA選択Let's Encrypt / DigiCert / ACM環境に応じて(下記参照)
鍵アルゴリズムRSA 2048 / RSA 4096 / ECDSA P-256ECDSA P-256(性能+セキュリティ)
証明書範囲単一ドメイン / ワイルドカード / SANワイルドカード+apex SAN
検証方式HTTP-01 / DNS-01DNS-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章で詳しく解説するが、核心原則は以下の通り。

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つある。

  1. 更新過程でWebサーバーを再起動(restart)する場合
  2. 新しい証明書のデプロイとロードバランサーへの反映の間のタイムラグ
  3. クライアントの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を推奨する理由は以下の通り。

# /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 - 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. 検知・確認 (05)│     └→ 障害範囲の把握、影響ドメインのリスト化                  │
│                                                             │
2. 即時緩和 (515)│     └→ 一時証明書の適用またはトラフィックの迂回                 │
│                                                             │
3. 正式な証明書更新 (1530)│     └→ certbot更新または緊急発行                              │
│                                                             │
4. サービス検証 (3045)│     └→ すべてのエンドポイントの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ブラウザ警告を許容
stagingLet's Encrypt (staging)LE Staging90日rate limitなし
prodLet's EncryptまたはACM公認CA90日 / 自動無停止必須

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. 実戦チェックリスト

証明書発行時のチェックリスト

自動更新チェックリスト

監視チェックリスト

インシデント対策チェックリスト

マルチ環境チェックリスト


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...

コメント

まだコメントはありません。

ログインするとコメントできます