dockerでlet's encryptを使ってSSLを有効にする

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