certbotの後付けドメインだけが静かに期限切れする話

certbotの後付けドメインだけが静かに期限切れする話

全2回の前編。後編 → ベンダー提供の自動化に自分の層を足す

久しぶりに自己ホストのGitサーバーを開いたら、ブラウザに警告が出た。

Your connection is not private
NET::ERR_CERT_DATE_INVALID

証明書の期限切れ。Let's Encryptは90日なので自動更新が止まったのだろう——と思ったら、少し違った。

自動更新は毎日きちんと動いていた。ただし1つのドメインだけを見ていた。

同じ罠にハマる人がいそうなので記録しておく。


前提の構成

「用意されたものをベースに、少しずつ足していく」という、よくあるパターン。


切り分け:アプリ本体を疑わない

エラーが出たサービスのコンテナを確認したら、数週間連続で稼働していた。

docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
# app-container   Up 4 weeks

アプリコンテナは証明書を持っていない。TLSを終端しているのは前段のnginxなので、壊れているのは前段だけと即座に確定できる。

ブラウザ ── https://example.com ──▶ [nginx] ──▶ 127.0.0.1:3000 (app)
                                        ↑ ここだけが壊れている

この切り分けを最初にやると、以降の調査範囲が一気に狭まる。


証明書はホストではなくvolumeにあった

/etc/letsencrypt/ を見ても何もない。この手のDocker構成では、証明書はプロジェクト配下のvolumeに置かれることが多い。

sudo ls -la ./volumes/certbot/conf/live/

ここで最初の手がかりが出た。ディレクトリの更新日に差がある。

ドメイン live/ の更新日
メインドメイン 直近1ヶ月以内
後から追加したA 3ヶ月以上前
後から追加したB 3ヶ月以上前

メインドメインだけが更新されている。ここで仮説が立つ。


決定的な証拠

certbotコンテナで全証明書の状態を確認する。

docker exec <certbot-container> certbot certificates
ドメイン 状態
メインドメイン VALID(残62日)
後から追加したA EXPIRED
後から追加したB EXPIRED

さらにログを見ると、これが効いた。

sudo tail -60 ./volumes/certbot/logs/letsencrypt.log
# certbot._internal.main:Arguments: []

renewの失敗記録が一切ない。

ここが核心だった。「失敗している」ならログが残るはずで、ネットワークやチャレンジの問題を疑う。しかしログが空ということは、そもそも更新が試行されていないということだ。

エラーが出ていないことが、逆にエラーの証拠になる。


更新の実体を探す:crontab -l に騙されない

では毎日何が動いているのか。

crontab -l
# no crontab for root

ここで「cronは無い」と結論づけると詰む。crontab コマンドはユーザー個人のcrontabしか見ない。

システム側のcronは別の場所にある。

ls -la /etc/cron.d/
cat /etc/cron.d/*

見つかった。

0 2 * * * root bash /root/ssl_update.sh

ファイル名からは中身が推測できず、発見が遅れた。cronの実体を探すときは、以下を全部見る必要がある。

sudo ls -la /var/spool/cron/crontabs/     # 全ユーザーのcrontab
cat /etc/crontab                          # システムcrontab
ls -la /etc/cron.d/ && cat /etc/cron.d/*  # ドロップイン
ls -la /etc/cron.daily/ /etc/cron.weekly/
systemctl list-timers --all               # systemd timer

根本原因

スクリプトを追うと、チェーンはこうなっていた。

/etc/cron.d/xxx  (毎日2:00)
    └─▶ /root/ssl_update.sh        ← 事業者提供
            └─▶ /update-cert.sh     ← 環境変数の単一ドメインのみ
                    └─▶ certbot certonly -d ${DOMAIN}

certbot renew ではなく certonly で特定ドメインを名指ししていた。

後からサブドメインを追加するとき、多くの人はこうする。

docker exec <certbot-container> certbot certonly -d new.example.com ...

これで証明書は取得できるし、nginxに設定を書けば動く。動いてしまうので、そこで完了だと思ってしまう。

しかし取得と更新は別物だ。毎日走るスクリプトは元の環境変数しか見ていないので、後付けドメインは対象外のまま。3ヶ月後に静かに期限が来る。


復旧手順

1. ACME疎通を先に確認する

いきなりrenewを叩くのは悪手。Let's Encryptには失敗回数の制限があるので、疎通確認を先に済ませる。

mkdir -p ./volumes/certbot/www/.well-known/acme-challenge
echo ok > ./volumes/certbot/www/.well-known/acme-challenge/test

curl -sI http://new.example.com/.well-known/acme-challenge/test | head -3

200 が返ればOK。301(httpsへリダイレクト)や 404 なら、nginxの80番serverブロックにACME除外が入っていない。

server {
    listen 80;
    server_name new.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

2. ドライラン

docker exec <certbot-container> certbot renew \
  --cert-name new.example.com --webroot -w /var/www/html --dry-run

このとき Account registered. と出た。ACMEアカウントすら登録されていなかったわけで、一度も renew が走っていなかったことの裏付けになった。

3. 本実行

docker exec <certbot-container> certbot renew \
  --cert-name new.example.com --webroot -w /var/www/html --force-renewal

docker compose exec nginx nginx -s reload

reloadを忘れると、ファイルは新しいのに配信されるのは古い証明書のまま、という状態になる。

4. renewal設定への焼き込みを確認(ここが本丸)

docker exec <certbot-container> grep -E 'authenticator|webroot_path' \
  /etc/letsencrypt/renewal/new.example.com.conf

期待する出力:

authenticator = webroot
webroot_path = /var/www/html,

本実行時に --webroot -w を明示すると、この内容がrenewal confに記録される。以降は引数なしの certbot renew で全ドメインが回るようになる。

5. 合格判定

docker exec <certbot-container> certbot renew --dry-run

引数なしのドライラン。全ドメインがsuccessになれば根治完了の証明になる。

6. 外部からの最終確認

for d in main.example.com new.example.com other.example.com; do
  echo "--- $d"
  echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
    | openssl x509 -noout -enddate
done

サーバー内部の状態ではなく、ブラウザが受け取るものを見るのが本当の合格判定。これが通ればreloadも効いていることになる。


教訓

「取得できた」と「更新され続ける」は別物。

用意された構成に自分でドメインを足すとき、足した瞬間は動く。動くから完了だと思う。しかし自動化の仕組みに載っているかどうかは、別途確認しないと分からない。

そして厄介なのは、失敗ログすら出ないことだ。エラー監視では拾えない。何も起きていないのだから。3ヶ月後にブラウザで開いて初めて気づく。

対策は2つ。

  1. ドメインを追加したら、必ず certbot certificates で全体を見て、他のドメインと設定が揃っているか確認する
  2. 証明書の残日数を監視対象に入れる。エラーの有無ではなく、期限そのものを見る

今回、同時期に追加したもう1つのサブドメインも一緒に切れていた。そちらはブラウザで開く機会がなく、まったく気づいていなかった。

では具体的にどう組むか。提供物のスクリプトを直接書き換えるべきか。監視はどう設計するか。

→ 後編: ベンダー提供の自動化に自分の層を足す


診断コマンド集

# 外部から証明書の期限を確認(サーバーに入らずに済む)
echo | openssl s_client -connect <domain>:443 -servername <domain> 2>/dev/null \
  | openssl x509 -noout -dates -issuer

# certbotコンテナで全証明書の状態
docker exec <certbot-container> certbot certificates

# 証明書volumeを持つコンテナを横断で探す
docker ps -a -q | xargs -I{} sh -c \
  'docker inspect {} --format "{{.Name}} {{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}"' \
  2>/dev/null | grep -i letsencrypt

# cronの実体を全部見る
sudo ls -la /var/spool/cron/crontabs/
cat /etc/crontab
ls -la /etc/cron.d/ && cat /etc/cron.d/*
systemctl list-timers --all | grep -iE 'cert|renew'

3つ目は「証明書がどこにあるか分からない」ときに効く。どのコンテナがletsencryptのパスをマウントしているかを一発で洗い出せる。