certbotの後付けドメインだけが静かに期限切れする話
certbotの後付けドメインだけが静かに期限切れする話
全2回の前編。後編 → ベンダー提供の自動化に自分の層を足す
久しぶりに自己ホストのGitサーバーを開いたら、ブラウザに警告が出た。
Your connection is not private
NET::ERR_CERT_DATE_INVALID
証明書の期限切れ。Let's Encryptは90日なので自動更新が止まったのだろう——と思ったら、少し違った。
自動更新は毎日きちんと動いていた。ただし1つのドメインだけを見ていた。
同じ罠にハマる人がいそうなので記録しておく。
前提の構成
- VPS上でDockerベースのアプリケーション基盤を運用
- ホスティング事業者が提供する「アプリケーションイメージ」で構築(nginxコンテナ + certbotコンテナが同梱)
- そこに後から別サービスを追加し、サブドメインを増やしていった
- TLS終端はすべて同じnginxコンテナが担当
「用意されたものをベースに、少しずつ足していく」という、よくあるパターン。
切り分け:アプリ本体を疑わない
エラーが出たサービスのコンテナを確認したら、数週間連続で稼働していた。
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つ。
- ドメインを追加したら、必ず
certbot certificatesで全体を見て、他のドメインと設定が揃っているか確認する - 証明書の残日数を監視対象に入れる。エラーの有無ではなく、期限そのものを見る
今回、同時期に追加したもう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のパスをマウントしているかを一発で洗い出せる。