発行はできるのに更新でだけ壊れる設定
一言でいうと
ACMEは、「このドメインを自分が制御している」をHTTPやDNSで証明するとCAが証明書を発行するプロトコルで、cert-managerは、その証明と更新をクラスターの中で代行します。発行はうまくいくのに、更新でだけ壊れる設定ミスがあり、ホームラボで実際に経験しました。
なぜ必要なのか
証明書を人が更新していた時代には、期限切れの事故が多発しました。短い有効期間と自動更新が答えで、そのためには、CAが人手なしでドメインの制御権を確認する方法が必要でした。RFC 8555(ACME)が、その仕様です。問題は、自動化が成功したように見える失敗を生むという点です。最初の発行はうまくいきました。そのあと、ゲートウェイの設定を1つ変えました。何の症状もありません。証明書はまだ2か月残っているからです。更新の時点になって初めてチャレンジが失敗し、そのときには、なぜ失敗するのかを、誰もその設定変更と結びつけて思い出せません。
どう動くのか
ACMEクライアントは、アカウント鍵でCAに注文(order)を出し、CAはドメインごとにチャレンジを与えます。HTTP-01では、http://<도메인>/.well-known/acme-challenge/<token>をGETしたとき(プレースホルダーはドメインです)、本文がキー認可(key authorization)、すなわちtoken || '.' || base64url(Thumbprint(accountKey))と同じである必要があります。RFCは、このリクエストをTCP 80に送るよう定めており、検証サーバーはリダイレクトを追いかけてもよい(SHOULD)と書いています。Let's Encryptの説明によると、実際の実装は最大10段階までリダイレクトを追いかけますが、http:・https:の80・443ポートにしか行かず、HTTPSにリダイレクトされたときは、証明書を検証しません。DNS-01は、_acme-challenge.<도메인>のTXTレコードに(プレースホルダーはドメインです)、アカウント鍵から導出した値を入れる方式で、ワイルドカード証明書は、DNS-01でしか受け取れません。
GET /.well-known/acme-challenge/zk3r9Qm... Host: www.lab.internal
200 OK
zk3r9Qm....Q7pVw2x... ← token.thumbprint, 이것이 본문 전체
cert-managerは、この手順をコントローラーとして作ったものです。Issuer/ClusterIssuerがCAとの契約(アカウント鍵・ソルバー)を持ち、Certificateリソースが、欲しい証明書を宣言します。secretName(結果が保存されるSecret)、dnsNames、issuerRef(ほかのネームスペースからも使うにはkind: ClusterIssuer)、duration(既定は90日)、renewBeforeです。ドキュメントのとおり、durationとrenewBeforeはGoの時間文字列なので、h・m・sしか使えず、d(日)は使えません。また、durationはrenewBeforeより大きい必要があります。renewBeforeを書かないと、発行された証明書の有効期間の3分の2の時点で更新しますが、実際の有効期間が要求より短く出ることがあるため、絶対値よりrenewBeforePercentageが推奨されます。HTTP-01ソルバーは、チャレンジの間、一時的なIngressやHTTPRouteを作って、チャレンジのパスをソルバーPodに送ります。ドキュメントは、Gateway APIを使う場合、そのルートが80ポートのリスナーを持つGatewayに付く必要があると書いています。
現場での姿
ホームラボで経験した事例です。ゲートウェイ(Cilium Gateway)に、HTTPからHTTPSへのリダイレクトを有効にしました。サイトは問題なく動きました。ところが、このリダイレクトは/.well-known/acme-challenge/へのリクエストまでHTTPSに飛ばし、HTTPSリスナーにはソルバーのルートがないので、その行き着く先は404でした。発行はすでに終わっていたので症状はなく、期限切れの30日前の更新で初めて、静かに失敗する状態でした。「ソルバーのルートのほうがパスが具体的なので、勝つのではないか」を、実際に測ってみました。ソルバーを模したExactパスのHTTPRouteを立ち上げて、そのパスを叩いたところ、リダイレクトが勝ちました(Cilium 1.20.1での実測)。パスの具体性は優先されませんでした。直し方は、リダイレクトをゲートウェイではなくアプリで行い、チャレンジのパスは例外にすることでした。常時の確認は1行です。curl -sI http://<도메인>/.well-known/acme-challenge/x | head -1が301なら、更新が塞がれているということで、404や200なら正常です(プレースホルダーはドメインです)。ファイルがなくて404になるのは問題ありません。リダイレクトでさえなければよいのです。
もう1つあります。この設定で更新を一度も経験していない状態だったので、期限切れを待たずに、CertificateのstatusにIssuing条件を立てて、更新を強制的に起こしました。数秒後に新しいCertificateRequestが作られ、判定は、openssl s_clientで受け取った証明書の日付が変わったことで行いました。自動化は、実際の発行履歴があって初めて信頼できます。
次のラボですること
Podの中に、HTTPとHTTPSのリスナーを持つエッジ(/opt/app/edge.py)とHTTP-01検証サーバー(/opt/app/acme_va.py)を立ち上げます。キー認可のファイルを置いて検証をVALIDで通したあと、リダイレクトを有効にして、301 → 404でINVALIDになる事故を再現し、チャレンジのパスだけを例外にして、リダイレクトを維持したまま再びVALIDを得ます。更新が塞がれているかを見るprobe.shを作り、採点ツールのサーバーで双方向の検査を受け、cert-manager Certificateマニフェストを、duration・renewBefore・ClusterIssuerで作成します。