メインコンテンツまでスキップ

certbot で docker nginx の https を設定する

· 約8分

環境:Docker-1.13.1、nginx-1.15.12、certbot

サイトを HTTPS 化すること自体は、物理マシン上で nginx を直接設定するだけなら難しくなく、チュートリアルも検索すればいくらでも出てきます。しかし nginx が docker コンテナ内で動いている場合、証明書ファイルは volumes でコンテナにマウントする必要があり、そこにファイルシステムの間接層が一枚挟まります。折悪しく certbot が生成する証明書ファイルはシンボリックリンクであり、この 2 つが重なると、ドキュメントを調べないとなかなか理解できない落とし穴になります。この記事では当時の調査過程を記録しておきます。

昨今は https でないと安全でないサイトとして表示されてしまうため、設定することにしました。無料の Let's Encrypt 証明書を申請するだけなので大した手間ではないはずでしたが、docker の nginx コンテナに ssl 証明書を設定するところにまさかの罠が潜んでいるとは思いもしませんでした。私は独学の叩き上げで docker のドキュメントを一度も読んだことがなく、ここで見事にハマりました。機会があれば公式ドキュメントにはやはりじっくり目を通しておくべきですね。

証明書はどこから来るのか

まず私の状況を説明しておくと、certbot で無料のドメイン ssl 証明書を申請した結果、得られた

fullchain.pem、privkey.pem ファイルはシンボリックリンクファイルでした。

これは偶然ではなく、certbot の意図的な設計です。実際の証明書ファイルは /etc/letsencrypt/archive/<ドメイン>/ 配下に保存され、更新のたびに新しいファイル一式が生成されます。一方、/etc/letsencrypt/live/<ドメイン>/ 配下の fullchain.pem、privkey.pem は archive 内の最新版を指すシンボリックリンクにすぎません。こうすることで nginx の設定には live のパスを固定で書いておけば、更新後も設定を変更する必要がありません。物理マシン上ではこの仕組みは実に快適なのですが、コンテナに持ち込むと問題が起きます。

コンテナへのマウント

次に docker volumes で ssl 設定フォルダを nginx コンテナ内部にマウントし、あわせて nginx.conf を設定します(nginx コンテナの nginx.conf 設定ファイルには少し癖があり、nginx.conf は /etc/nginx/conf.d/ 配下の *.conf ファイルを読み込みます。この細かい点には注意が必要です。またコンテナの 443 ポートのマッピングも忘れないでください)。

サイトの設定はだいたい次のようになります。

# /etc/nginx/conf.d/ 配下に置くと、メイン設定の include で読み込まれる
server {
listen 443 ssl;
server_name example.com;

# ここに書くのはコンテナ内のパス。マウント後の実際の位置と一致していなければならない
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}

落とし穴はどこにあるのか

ここに落とし穴があります。証明書ファイルはシンボリックリンクファイルであるため、コンテナ内にマウントしても使えないのです。

nginx はエラーを出し、エラーメッセージは「証明書ファイルが見つからない」というものになります。しかしコンテナに入ってみると、証明書ファイルは確かにコンテナ内にあるのです。ところが!この証明書ファイルは読み取ることができません。権限の問題ではなく、docker volumes 自体がハードリンクの情報しかマッピングできないことが原因です。シンボリックリンクはマッピングできず、コンテナに残っているのはシンボリックリンクの抜け殻にすぎません。

この種の問題を調査する簡単な方法があります。コンテナに入って ls -l で確認し、証明書ファイルがどこかのパスを指すリンクとして表示されていたら、続けて cat で読めるか試してみます。読み出せなければ、リンクが指す先がコンテナ内に存在しないのがほぼ確実です。

なぜこうなるのか

実は理解するのは難しくありません。シンボリックリンクは単なるショートカットにすぎないからです。もし volumes の中にあるホストマシンのショートカットからホストのハードリンクにアクセスできてしまったら、そのほうが問題です。シンボリックリンクは /home ボリューム配下にあり、そのシンボリックリンクが対応する実ファイルは実際には /usr ボリューム配下にあります。しかし私が設定した volumes は /home ボリュームしかマッピングしておらず、/usr ボリュームのデータにはアクセスできません。ここでは対応するリンクファイルの実体パスまで正確にマッピングしなければならず、リンクファイルのディレクトリだけではだめで、リンクファイルを直接マウントするのもだめです。

別の角度から言えば、シンボリックリンク自体はターゲットのパスを 1 本記録しているだけです。docker はリンクをそのままコンテナに置き、コンテナはそのパスに従って自分自身のファイルシステム内でターゲットを探します。ターゲットのパスが一緒にマウントされていなければ、このリンクはデッドリンクになります。これはコンテナの隔離として当然あるべき姿でもあります。ショートカットを 1 つマウントするだけで、それを辿ってホストマシン上の任意の場所のファイルを読めてしまったら、隔離は有名無実になってしまいます。

マウントパスが証明書のリンクファイルの完全なパス構造を指していて初めて有効になります。docker 内の nginx は起動時に、リンクファイルが対応するファイルアドレスから証明書ファイルを取得できるようになります。

つまり鍵となるのは、シンボリックリンクが指すターゲットのパスがコンテナ内に実在するようにすることです。certbot の場合、live と archive の 2 つのディレクトリを一緒にコンテナに入れて初めて、リンクが解決できるようになります。

# コンテナ起動時に letsencrypt ディレクトリ全体をマウントする。
# live 配下のシンボリックリンクと archive 配下の実ファイルが両方あって初めてリンクが正常に解決される
docker run -d \
-p 80:80 -p 443:443 \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /data/nginx/conf.d:/etc/nginx/conf.d \
nginx

ハマりどころと注意点

  1. nginx 公式イメージのメイン設定はグローバル設定のみを行い、サイト設定は include /etc/nginx/conf.d/*.conf に頼っています。自分の server ブロックは正しい場所に置かないと、変更しても反映されません。

  2. 443 ポートは忘れがちです。コンテナ内の nginx が 443 をリッスンしていても、ホストマシン側でマッピングしていなければブラウザは接続できず、しかも nginx のログには何の異常も現れません。

  3. Let's Encrypt の証明書の有効期間は 90 日しかありません。更新後は archive ディレクトリ配下に新しい版のファイルが追加され、live 配下のシンボリックリンクもそれに合わせて更新されます。もし当初、手間を惜しんで解決後の実ファイルパスを設定にハードコードしていた場合、更新後にパスが合わなくなります。これがディレクトリ全体をマウントし、設定では live のパスを参照する方式が推奨されるゆえんでもあります。更新後はコンテナ内の nginx に設定をリロードさせることを忘れないでください。

  4. コンテナ内でファイルが「存在して見える」ことは、読めることを意味しません。シンボリックリンクに対して ls しても問題は見えません。cat してみるか、nginx のエラーメッセージを直接見るほうが手っ取り早いです。

まとめ

この落とし穴の本質は、それぞれ単体では合理的な 2 つの設計が衝突したことにあります。certbot はシンボリックリンクで「固定パス」と「変化する証明書ファイル」を分離し、docker volumes はリンクそのものを忠実に運ぶだけでリンクの先までは追いません。シンボリックリンクは「パスを記録しているだけ」という点を理解すれば、問題はもう不思議ではありません。リンクとその指し先を一緒にコンテナにマウントし、パスがコンテナ内で閉じるようにすれば、証明書は自然に読み取れるようになります。docker のドキュメントを読んでこなかった授業料を、一度のハマりで払ったということにしておきましょう。

COMMENTS