Let’s Encryptを使用しDocker+nginxのアプリをSSL化した手順

この記事は「[つながる勉強会 Advent Calendar 2022](https://adventar.org/calendars/7752)」の19日目の記事です。

https://adventar.org/calendars/7752

18日目の記事は@daishimanさんの以下の記事でした！
（勉強になりました…！）

https://qiita.com/daishiman/items/a81029ea6682c09fb67f



今回は、既存のWebアプリをSSL化する機会があったので、その時の手順をまとめました。
Let’s Encryptを使用し、SSL証明書の取得、自動更新処理の実装まで行います。

# 目次
[・前提](#前提)
[・Let’s Encryptとは](#lets-encryptとは)
[・手順](#手順)
　[1. SSL証明書を取得](#1-ssl証明書を取得)
　　1-1. SSL証明書取得の準備（docker-compose.ymlを編集）
　　1-2. SSL証明書を取得
　　1-3. SSL証明書を確認
　[2. httpsで接続](#2-httpsで接続)
　　2-1. nginxの設定ファイルにhttpsの設定を追加
　　2-2. httpsで接続できるか確認
　[3. 自動更新処理を実装](#3-自動更新処理を実装)
　　3-1. 更新コマンドの動作確認
　　3-2. cronを設定
[・参考](#参考)

# 前提
- 既存のwebアプリをSSL化する
- webアプリ、webサーバ（nginx）は Docker コンテナ上で動いている
- Docker と docker-compose はインストール済
- ドメイン（仮）：example.com

# Let’s Encryptとは

Let’s Encryptの公式サイト

https://letsencrypt.org/ja/

[こちらのサイト](https://free-ssl.jp/)から引用すると
> Let's Encrypt は、認証局（CA）として「SSL/TLSサーバ証明書」を無料で発行するとともに、証明書の発行・インストール・更新のプロセスを自動化することにより、TLS や HTTPS（TLSプロトコルによって提供されるセキュアな接続の上でのHTTP通信）を普及させることを目的としているプロジェクトです。2016年4月12日 に正式サービスが開始されました。


Let’s Encrypt が発行するSSL証明書の有効期間は90日間と短いです。
ですが、自動更新機能を使えば更新の手間はなくなります。

証明書の更新処理は、有効期限が30日以上ある場合はスキップされます。
証明書の取得には回数の制限があるので注意が必要です。（[詳細](https://letsencrypt.org/docs/rate-limits/)）


# 手順

## 1. SSL証明書を取得
### 1-1. SSL証明書取得の準備（docker-compose.ymlを編集）

Let’s Encryptとのやりとりをするにあたり、ACMEクライアントの１つである[certbot](https://certbot.eff.org/)を使用します。

certbotの公式サイト

https://certbot.eff.org/

[こちらのサイト](https://avinton.com/academy/creating-ssl-certificate-by-certbot/)から引用すると

>Certbotは無料かつ自動でSSL証明書を発行できるツールです。
CSRとKEYファイルの作成からWebサーバーの設定まで自動で行ってくれます。
さらにCronと組み合わせることで、証明書の更新作業までも完全に自動化することが可能です。


まず、Dockerで以下を行います。

- nginxに証明書保存用のボリュームを追加
- certbotのコンテナを作成（[公式のイメージ](https://hub.docker.com/r/certbot/certbot)を使用）


docker-compose.ymlに追記します。

```yml:docker-compose.yml
version: '3'
// 略
### NGINX Server #########################################
services:
    nginx:
       # 略
      volumes:
        # 略
        # - /etc/nginx/conf.d:/etc/nginx/conf.d # 参考ページには記載があったがが正常に動作せずコメントアウト
        - /etc/letsencrypt:/etc/letsencrypt # 追記
        - /var/www/html:/var/www/html # 追記
      # 略

# 以下を追記

### certbot ################################################
    certbot:
        image: certbot/certbot:v1.7.0
        volumes:
          - /etc/letsencrypt:/etc/letsencrypt
          - /var/www/html:/var/www/html
        command: ["--version"]

```
※/etc/letsencryptディレクトリ以下に証明書などを格納するディレクトリ群が作成されます。

次にnginxの設定について。

現状httpで接続しているので、confファイルはhttp（80番ポート）のserverコンテキストのみ。
（証明書を取得後にhttps（443番ポート）のserverコンテキストを追加します）

```conf:nginx.conf
server {
    server_name  example.com;
    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    root         /var/www/html;

    # 以下はSSL化のチャレンジという工程で必要
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }
　　# 略
}
```
※ 「# 略」の部分にはプロジェクトごとの設定の記述があります。

`location /.well-known/acme-challenge/…`の部分は[チャレンジ](https://letsencrypt.org/ja/docs/challenge-types/)という工程で使用されるようです。

[参考にしたサイト](https://paulownia.hatenablog.com/entry/2020/09/12/150658)では、`location /.well-known/acme-challenge/…`の部分は
証明書の取得の際は記述せず、更新の際に80番ポートのserverコンテキストに追記していたのですが、今回はデフォルトで記述があり、そのままで証明書の取得も正常に動作しました。


では、コンテナを再起動してdocker-compose.ymlの変更を反映します。
```sh:
$ docker-compose down nginx certbot
$ docker-compose up -d nginx certbot
```
この状態で確認してみると…

```sh:
$ docker-compose ps
 Name                   Command                       State            Ports
--------------------------------------------------------------------------------
certbot             certbot --version                Exit 0
nginx               # 略                             Up              # 略
# 略
```
certbotのコンテナのStateがExit0になっていますが、このままで証明書を取得できます。

### 1-2. SSL証明書を取得

取得するコマンドは以下です。
```sh:
$ docker-compose run --rm certbot certonly --webroot -w /var/www/html -d example.com
```

オプションの意味は以下。
-–webroot：サーバーを起動したまま認証する
-w ：認証時のドキュメントルートを指定
-d ：ドメインを指定

メールアドレスの入力と利用規約への同意を求められるので入力します。
成功すると、以下のように表示されます。

```
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator webroot, Installer None
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for example.com
Using the webroot path /var/www/html for all unmatched domains.
Waiting for verification...
Cleaning up challenges
Subscribe to the EFF mailing list (email: 登録したメールアドレス).

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/example.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/example.com/privkey.pem
   Your cert will expire on 2023-01-31. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https:/
```

`Congratulations! Your certificate and chain have been saved at…`とあり、成功しているようです。


### 1-3. SSL証明書を確認

/etc/letsencrypt を確認してみると…
```sh:
$ ls /etc/letsencrypt
accounts  archive  csr  keys  live  renewal  renewal-hooks
```
必要なディレクトリができているのが確認できます。

/etc/letsencrypt/archiveには証明書、/etc/letsencrypt/keysに秘密鍵が保存され、/etc/letsencrypt/live/example.com/には最新のものへのシンボリックリンクが保存されるので確認します。

証明書の内容の確認は、後から知ったのですがコマンドで行えるようです（→[確認コマンド](https://jp.globalsign.com/support/ssl/confinfo/openssl.html)）
が、私はhttpsで接続したあとブラウザのURLの左の錠マークから確認しました。

## 2. httpsで接続

### 2-1. nginxの設定ファイルにhttpsの設定を追加

nginxの設定ファイルにhttpsのserverコンテキストを追加します。

```conf:nginx.conf
server {
    server_name  example.com;
    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    root         /var/www/html;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }
    # 以下を追記
    location / {
        return 301 https://$host$request_uri; # http接続をhttpsにリダイレクト
    }
　　 # 以上を追記

　　# 略
}
# 以下を追記
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;

    # 略
}
```
反映させるため、nginxをリロードします。
```sh:
$ docker-compose exec nginx nginx -s reload
```

### 2-2. httpsで接続できるか確認

ブラウザでURLのhttpをhttpsに変更し、正常に接続できるか確認します。

証明書の内容については、ブラウザのURLの左側の錠マークをクリック
→「この接続は保護されています」→「証明書は有効です」をクリックで詳細を確認できます。


## 3. 自動更新処理を実装

あとは、SSL証明書を自動更新する処理の実装です。

### 3-1. 更新コマンドの動作確認

更新コマンドは以下。

```sh:
$ docker-compose run --rm certbot renew --dry-run
```
※--dry-runオプションをつけると、実際には証明書の更新を行わず、シミュレーションだけできます。
（証明書の取得には制限があるので（[詳細](https://letsencrypt.org/docs/rate-limits/)）シミュレーションだけに留めます）

反映させるため、nginxのリロードも行います。
```sh:
$ docker-compose exec nginx nginx -s reload
```
更新処理をすると、コンソールに以下が表示されました。
```sh:
You have mail in /var/spool/mail/{ユーザー名}
```

中身を確認してみます。
```sh:
$ less /var/spool/mail/{ユーザー名}
```

```sh:一部抜粋
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/example.com
.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// 略
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
new certificate deployed without reload, fullchain is
/etc/letsencrypt/live/example.com/fullchain.pem
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test certificates below have not been saved.)
Congratulations, all renewals succeeded. The following certs have been renewed:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test certificates above have not been saved.)
```
`Congratulations, all renewals succeeded.`とあり、更新コマンドはきちんと動作しているのが確認できました！

### 3-2. cronを設定

ホストでcronを設定し、更新を自動化します。
毎月1日、15日の3:30に更新処理をすることにしました。
有効期限が30日以上ある場合はスキップされるので、月1回だとスキップされる可能性あり、毎週だとやりすぎかなぁとと思い…

```sh:crontab
30 3 1,15 * * bash /path/renew_ssl_certificate.sh
```
```sh:/path/renew_ssl_certificate.sh
cd /path2/
docker-compose run --rm certbot renew
docker-compose exec -T nginx nginx -s reload
```
（crontabに直接書いてもいいかもしれませんね）

指定の日時に更新処理が走ったあと、以下のファイルを確認してみるとログが確認できます。
```sh:
$ less /var/spool/mail/{ユーザー名}
```

```sh:期限が30日以上あり処理がスキップされた場合（抜粋）
Saving debug log to /var/log/letsencrypt/letsencrypt.log

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/example.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Cert not yet due for renewal

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

The following certs are not due for renewal yet:
  /etc/letsencrypt/live/example.com/fullchain.pem expires on 2023-01-31 (skipped)
No renewals were attempted.
```

```sh:更新処理が行われた場合（抜粋）
Saving debug log to /var/log/letsencrypt/letsencrypt.log

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/example.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Non-interactive renewal: random delay of 7.574000498832259 seconds
Plugins selected: Authenticator webroot, Installer None
Renewing an existing certificate

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
new certificate deployed without reload, fullchain is
/etc/letsencrypt/live/example.com/fullchain.pem
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Congratulations, all renewals succeeded. The following certs have been renewed:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
```

# 参考

https://letsencrypt.org/ja/

https://certbot.eff.org/

https://hub.docker.com/r/certbot/certbot

https://free-ssl.jp/

https://avinton.com/academy/creating-ssl-certificate-by-certbot/

https://paulownia.hatenablog.com/entry/2020/09/12/150658

https://jp.globalsign.com/support/ssl/confinfo/openssl.html