家庭ラボでコンテナを増やしていくと、http://ホスト:8080、:9000、:3000 …とポート番号で入口が散らかってきます。そこで多くの方が、入口をひとつにまとめてHTTPS化するために Caddy のリバースプロキシを検討します。
ただし入口を一本化するということは、その一本を壊すと全サービスが同時に見えなくなるということでもあります。今回想定した読者は「入口を増やしすぎた家庭ラボ運用者」です。設定ミスで全部の入口を落としたくない、証明書やproxy設定のエラー原因をログから追えるか不安、reloadで壊した後すぐ戻せるか分からない——この3点が主な不安だと考えました。
そこでこの記事では、Caddyを「便利なHTTPS化ツール」として紹介するのではなく、公開・常用の前に わざと壊して、どこで検出でき、reload失敗時に既存設定がどう扱われ、どう戻せるか を使い捨てのDocker環境で観測した記録としてまとめます。
正直に先へ書いておきます。今回は「502を作る」「reload失敗を再現する」という当初の狙いがほとんど空振りしました。理由は後半で説明しますが、その空振りそのものが、検証手順の組み方で気をつけるべき点を示していました。結果と原因の推定まで含めて共有します。
入口の選択肢を整理する
読者が実際に迷うのは、おおむね次の選択肢の間です。
| 選択肢 | 設定検証の分かりやすさ | 障害時ログの追いやすさ | ロールバックのしやすさ | 小規模での運用負荷 |
|---|---|---|---|---|
| 直接ポート公開 | 検証する設定がほぼ無い | サービス個別のログのみ | 該当ポートを止めるだけ | 低いが入口が散らかる |
| Caddyリバースプロキシ | caddy validate で事前確認できる |
Caddyのログに集約される | 設定ファイルを戻して reload |
中。入口が一本化される |
| validateありで変更する運用 | 文法・スキーマ崩れを事前に弾ける | 検出ポイントが明確 | バックアップから戻しやすい | 手順が一段増える |
| validateなしでreloadする運用 | 反映後に初めて気づく | 後追いになりがち | 戻す判断が遅れる | 一見軽いが事故時に重い |
この記事の関心は「validateありの運用」が、家庭ラボの最小構成でどこまで失敗を事前検出できるかにあります。
検証環境
- 某所ラボの使い捨て(ephemeral)VM上に用意したDocker環境。検証後に破棄しています。
- 一次観測として、
docker compose・caddy validate・curl・docker compose logsの出力をそのまま記録しました。
まずバージョンを確認します。
$ docker version && docker compose version
Client: Docker Engine - Community Version: 29.6.0 API version: 1.55 ... Server: Docker Engine - Community Engine: Version: 29.6.0 ... Docker Compose version v5.2.0
最小構成のファイル
固定の作業ディレクトリを作り、CaddyfileとCompose定義を置きます。変更で壊す前に、Caddyfileのバックアップを先に取っておく のが今回いちばん効いた準備でした。最小構成では、退避すべき対象は実質 caddy/Caddyfile の1ファイルです。
caddy/Caddyfile:
localhost:8080 {
reverse_proxy upstream:80
}
compose.yaml:
services:
caddy:
image: caddy:2
command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
ports:
- "8080:8080"
upstream:
image: hashicorp/http-echo
command: ["-text=hello-from-upstream", "-listen=:80"]
変更前にバックアップを取ります。
$ cp caddy/Caddyfile caddy/Caddyfile.bak
起動と正常確認
$ docker compose up -d
$ docker compose ps
設定項目 設定項目 設定項目 設定項目 設定項目 issue-caddy-caddy-1 caddy:2 caddy Up 8 seconds 80/tcp, 443/tcp, 2019/tcp, 0.0.0.0:8080->8080/tcp issue-caddy-upstream-1 hashicorp/http-echo upstream Up 8 seconds 5678/tcp
2コンテナとも起動しました。続いて設定の検証です。
$ docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
Valid configuration
(このとき Caddyfile input is not formatted; run 'caddy fmt --overwrite' ... という整形に関する warn も出ましたが、Valid configuration でexitは0でした。)
ここまでは順調です。では正常到達を確認します。
$ curl -sv http://localhost:8080/
* Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET / 設定項目/1.1 > Host: localhost:8080 > User-Agent: curl/7.88.1 > Accept: */* > * 設定項目 1.0, assume close after body < 設定項目/1.0 400 Bad Request < * Closing connection 0 Client sent an 設定項目 request to an 設定項目 server.
ここで最初の想定外でした。正常構成のはずなのに、素のHTTPリクエストが 400 Bad Request で弾かれ、Client sent an 設定項目 request to an 設定項目 server. と返ってきました。upstreamの hello-from-upstream には到達していません。この時点で「あれ?」と思いつつ、当初の計画どおり、わざと壊す手順を続けました。
わざと壊す(1)upstream名の誤り
reverse_proxy の宛先を、存在しないサービス名 no-such-upstream:80 に書き換えて reload しました。狙いは「upstreamに届かない → 502 Bad Gateway」を観測することです。
$ docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
$ curl -s -o /dev/null -w 'http_status=%{http_code}\n' http://localhost:8080/
reload_exit=0 http_status=400
reloadは成功(exit 0)し、curlは502ではなく、また 400 でした。docker compose logs caddy を見ても、自動HTTP→HTTPSリダイレクトを有効化したという enabling automatic 設定項目->設定項目 redirects の情報ログが目立ち、upstream解決失敗のエラーは観測できていません。プレーンHTTPの段階で弾かれているため、リバースプロキシのロジックまで到達していないのです。
わざと壊す(2)証明書パスの誤り
次に、存在しない証明書ファイルを tls で指定しました。
caddy/Caddyfile:
localhost:8443 {
tls /etc/caddy/missing.crt /etc/caddy/missing.key
reverse_proxy upstream:80
}
$ docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
$ docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
Valid configuration validate_exit=0 reload_exit=0
ここも想定外でした。存在しない証明書パスを指していても、caddy validate は Valid configuration を返し、reload もexit 0でした。validate は設定の文法・スキーマを見るもので、証明書ファイルの実在まではこの観測では弾いていません。証明書ロードの失敗は、実際にTLSハンドシェイクが起きる経路でないと表面化しないと推測されます。
わざと壊す(3)文法崩し
いったんバックアップから戻したうえで、明らかに不正なトークンを混ぜたCaddyfileで reload を試しました。狙いは「adaptに失敗してreloadがエラー終了する」ことです。
caddy/Caddyfile:
localhost:8080 {
reverse_proxy upstream:80
this_is_not_valid }
$ docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
$ curl -s -o /dev/null -w 'http_status=%{http_code}\n' http://localhost:8080/
reload_exit=0 http_status=400
これも空振りでした。今回書いた「壊し方」では adapter がエラーにせず、reload はexit 0で通り、curlは引き続き 400 です。つまり「確実にreloadを失敗させられる崩し方」を用意できていなかった、ということです。
ロールバック
最後に、バックアップから元の設定へ戻します。家庭ラボで一番大事な操作です。
$ cp caddy/Caddyfile.bak caddy/Caddyfile
$ docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
$ docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
$ curl -s -o /dev/null -w 'http_status=%{http_code}\n' http://localhost:8080/
Valid configuration http_status=400
バックアップを戻して validate → reload の順で反映する流れは問題なく動きました。Valid configuration を確認してから reload する手順は、戻すときに気持ちが少し落ち着きます。ただし応答は 400 のまま、つまり起動直後から一貫して同じ状態でした。
検証後はクリーンアップしています。
$ docker compose down -v
症状の共通点
ここまでの観測を並べると、共通点がはっきりします。
- 正常構成・upstream名誤り・証明書パス誤り・文法崩し・ロールバック後のすべてで、curlの応答は
400 Bad Request(Client sent an 設定項目 request to an 設定項目 server.)。 - 狙っていた 502 Bad Gateway は一度も観測できなかった。
caddy reloadは、不正なupstream名でも、存在しない証明書パスでも、今回の文法崩しでも、いずれも exit 0 で、reload失敗そのものを観測できなかった。
つまり、壊し方を変えても、すべて入口の手前(プレーンHTTPの拒否)で止まっていたわけです。
推定原因
localhost:8080指定が自動HTTPSを誘発した。 Caddyはサイトアドレスにホスト名(ここではlocalhost)を含めると、自動HTTPSを有効化します。ログにもenabling automatic 設定項目->設定項目 redirectsが出ていました。その結果、http://localhost:8080/への素のHTTPリクエストは「HTTPSサーバへの平文リクエスト」とみなされ400で弾かれ、リバースプロキシのロジック(upstream解決)まで到達しません。だから502が出ません。caddy validateは証明書ファイルの実在を確認しない。 文法とスキーマの妥当性を見るため、存在しない証明書パスでもValid configurationになりました。証明書ロード失敗はTLS経路で初めて表面化すると考えられます。- 今回の文法崩しが adapter に弾かれなかった。 「確実にadaptが失敗する不正設定」になっておらず、reload失敗を観測する条件を満たせていませんでした。
まとめると、失敗が起きていないのではなく、失敗を観測できる経路に到達する前で止まる検証設計だった、というのが結論です。
次はこう設計し直す
同じ目的(502やreload失敗を実際に観測する)を達成するなら、次の検証に作り替えます。
- 502を見たいなら、サイトアドレスを
http://localhost:8080もしくは:8080のように プレーンHTTPを明示 し、自動HTTPSを避ける。これでupstream解決の段階まで到達でき、宛先が無いときに502が観測できるはずです。 - 証明書まわりは
validateだけに頼らず、実際の起動経路(TLSハンドシェイクが発生する経路) で検証ログを確認する。 - reload失敗を観測したいなら、adapterが確実に拒否する不正設定(明確な未対応ディレクティブや閉じ括弧の不整合など)を使い、
reloadのexit codeとエラーメッセージを記録する。
失敗・制約・再現条件
- 本検証は hands_on_limited、使い捨てVM上で 1回のみ 実行した記録です。再検証は未実施です。
- 観測されたバージョン(Docker Engine 29.6.0 / Docker Compose v5.2.0 / caddy:2 イメージ)に依存します。
- 「reload失敗時に既存設定が保持されるか」は、本来
caddy reloadがadaptに失敗したときに既存の稼働設定を維持する挙動を確認したい論点でしたが、今回は確実に失敗するreloadを作れなかったため、この論点は未確認のまま残っています。 - 再現する場合は、同等の使い捨てDocker環境で同じファイル構成・同じ手順を辿ってください。
結論:導入前に確認しておく条件
読者の問いは「Caddyを導入する前に、どの失敗条件を使い捨て環境で確認しておくべきか」でした。今回の遠回りから言えることは、次のとおりです。
- 入口を一本化する前に、わざと壊して『どこで検出できるか』を確かめる姿勢自体は正しい。 ただし検証手順を誤ると、失敗そのものを見逃します。今回がまさにそれでした。
- まず確認すべきは、自分のサイトアドレス指定がHTTPなのかHTTPSなのかです。
localhost:8080のようなホスト名付き指定は自動HTTPSを誘発し、curlでの平文確認が空振りします。検証ではhttp://か:ポートを意識して使い分けてください。 caddy validateで止められるのは文法・スキーマの崩れで、証明書ファイルの実在やupstreamへの到達可否は止められません。validateは「変更前に通すべき安価なゲート」として有効ですが、それだけで安心はできません。- 戻す手順は、変更前に対象ファイル(最小構成なら
caddy/Caddyfile)を退避 → 戻したらvalidate→reloadの順が落ち着きます。
すぐに常用へ進める段階かと言われれば、今回の私の検証では「reload失敗時の挙動」をまだ確認しきれていません。少なくともその一点を上記の設計し直しで観測できるまでは、本番の入口に据えるのは見送り、使い捨て環境での再検証を先に置くのが妥当だと考えています。
この検証を回している環境
この検証は、自宅の常設ラボ(使い捨てVM/LXCを回す母艦+GPU+VLAN分離ネットワーク)で動かしています。使っている機材と選定理由、全体構成は1本にまとめています。
ラボ構成のまとめを見る →