certbotの公式のdockerイメージを使ってlet's encryptする。
ウェブで調べるとnginxコンテナにcertbotをインストールしている例が多いが、1つのコンテナに複数の役割を持たせるのはGoodとは言えないので別にした。あとDocker buildとかしたくない。
docker-compose.yml
--- version: "3" services: nginx: image: nginx:1.19.2-alpine restart: always volumes: - /etc/nginx/conf.d:/etc/nginx/conf.d - /etc/letsencrypt:/etc/letsencrypt - /var/www/html:/var/www/html network_mode: host certbot: image: certbot/certbot:v1.7.0 volumes: - /etc/letsencrypt:/etc/letsencrypt - /var/www/html:/var/www/html command: ["--version"]
certbotは間違って起動した場合にバージョンを表示して終わるようにしている。
2つのディレクトリをvolumeで共有する。
- /etc/letsencrypt - 証明書が作られるディレクトリ。
- /var/www/html - ウェブのドキュメントルート。
nginxの起動
証明書を取得するまではSSL無効で起動する、バーチャルホストのconfigは以下のようになる
# /etc/nginx/conf.d/vhost-example.com.conf
server {
server_name example.com;
listen 80;
listen [::]:80;
root /var/www/html;
}
起動
docker-compose up -d nginx
証明書の作成
docker-compose run --rm certbot certonly --webroot -w /var/www/html -d example.com
SSLを有効にする
証明書を取得できたらconfigを修正し、SSLを有効にしてnginxをリロードする
# /etc/nginx/conf.d/vhost-example.com.conf server { server_name example.com; listen 80; listen [::]:80; # 全てのリクエストをSSLサイトにリダイレクト location / { return 301 https://$host$request_uri; } # 例外的に証明書更新時のlet's encryptからのリクエストは80番で受ける(443に飛ばしても実は問題ない) location /.well-known/acme-challenge/ { root /var/www/html; } } server { server_name example.com; listen 443 ssl http2; listen [::]:443 ssl http2; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; ssl_session_tickets off; ssl_protocols TLSv1.3 TLSv1.2; ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256'; ssl_prefer_server_ciphers off; add_header Strict-Transport-Security "max-age=2592000" always; root /var/www/html; }
リロード
docker-compose exec nginx nginx -s reload
証明書の更新とnginxのリロード
証明書更新の定期処理はホスト側のcronで行う。さすがにcronまでdockerでやるのは面倒くさい。
ホストOSに /etc/cron.weekly/renew-cert を設置する。cronの頻度は週1。月1ではインターバル長すぎてエラーが発生した場合に証明書が失効する恐れがある。毎日ではやりすぎなので週1程度がベストだろう。
#!/bin/bash { echo "---- start renew-cert $(date '+%Y-%m-%d %H:%M:%S')" cd /opt/apps || exit 1 nyan=$(docker-compose run --rm certbot renew --post-hook='echo nyan') if echo "${nyan}" | grep -q "nyan"; then docker-compose exec nginx nginx -s reload fi } >> /var/log/renew-cert.log 2>&1
--post-hookは証明書が更新された場合に実行されるコマンドを記述する。本来は renew --post-hook='nginx -s reload'
としてnginxをリロードするのだが、コンテナが別なのでリロードできない。そこで証明書更新のログには絶対に出てこないような文字列を--post-hookで出力させ、その文字列が含まれていたらnginxをreloadするようにした。
追記
- コマンド置換の結果で取得できるのは標準出力のみ、標準エラー出力はキャプチャされない
- certbotコマンドの出力は標準エラーに出るのでpost-hookを拾うには標準エラーを拾わないとだめ
- cronのようなttyが割り当てられない環境でdocker-compose execを実行する場合は擬似端末をオフにする(-Tオプションをつける)必要がある
the input device is not a TTY
と言うエラーメッセージが出て実行されない- docker-composeは、dockerコマンドとは逆にデフォルトで擬似端末を作成する点に注意
よって以下が正解
#!/bin/bash { echo "---- start renew-cert $(date '+%Y-%m-%d %H:%M:%S')" cd /opt/apps || exit 1 nyan=$(docker-compose run --rm certbot renew --post-hook='echo nyan' 2>&1) if echo "${nyan}" | grep -q "nyan"; then docker-compose exec -T nginx nginx -s reload fi } >> /var/log/renew-cert.log 2>&1