WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

cert-managerで管理する証明書を強制的に更新する

こんにちは。基盤チームのakaimoです。
現在リネットではTLS証明書にLet's Encryptを使っていて、その管理にcert-managerを導入しています。

先日、Let's Encryptのインシデントにより証明書の強制失効が行われました。

community.letsencrypt.org

幸いリネットは対象外でしたが、cert-managerで管理している証明書をcert-managerが設定した更新のスケジュールが来る前に、ダウンタイムを発生させずに任意のタイミングで更新する方法を知らないで運用するのはリスクだと感じたため、やり方を調べました。

Let's Encryptのインシデントの対象者チェックツール

公式のドキュメントを読んだところ、手動で更新をかけられるような記載は見当たりませんでした。

しかし、今回のインシデントの対象者かチェックするツールをcert-managerの開発者が公開しており、このツールを使って対象者であることが判明した場合は更新をかけることができるようです。

github.com

このツール自体の使い方は簡単で、cert-managerにアクセスする権限のあるkubectlが存在するサーバーに、バイナリとインシデントの対象者一覧をダウンロードして実行するだけです。

$ wget -O letsencrypt-caa-bug-checker https://github.com/jetstack/letsencrypt-caa-bug-checker/releases/download/v0.0.2/letsencrypt-caa-bug-checker-linux-amd64
$ chmod 755 letsencrypt-caa-bug-checker
$ wget -c https://d4twhgtvn0ff5.cloudfront.net/caa-rechecking-incident-affected-serials.txt.gz
$ zcat < caa-rechecking-incident-affected-serials.txt.gz > serials.txt
$ ./letsencrypt-caa-bug-checker --affected-serials-file serials.txt

renewオプション

--renewオプションをつけて実行すれば更新も行うとドキュメントに記載されていたため、その処理を再現すれば任意のタイミングで更新をかけられそうなので調べてみます。

チェックツール自体はmain.goだけのシンプルな構成で、--renewオプションによるフラグがあり、そのフラグのチェック後に実行される部分にrenewCertificateという関数があります。
そこで更新の処理を行っているようです。

renewCertificate関数

renewCertificateはk8sのクライアントとcert-managerが管理する証明書の構造体を引数として受け取っています。

func renewCertificate(ctx context.Context, cl client.Client, cert capi.Certificate) error

https://github.com/jetstack/letsencrypt-caa-bug-checker/blob/v0.0.2/main.go#L148

まず、更新する証明書のCertificateRequestを取得し、処理中でなければ削除しています。

   var requests capi.CertificateRequestList
    if err := cl.List(ctx, &requests, client.InNamespace(cert.Namespace)); err != nil {
        return err
    }
    for _, req := range requests.Items {
        // If any existing CertificateRequest resources exist and are complete,
        // we delete them to avoid a re-issuance of the same certificate.
        if !metav1.IsControlledBy(&req, &cert) {
            continue
        }

        // This indicates an issuance is currently in progress
        if len(req.Status.Certificate) == 0 {
            log.Printf("Found existing CertificateRequest %s/%s for Certificate - skipping triggering a renewal...", req.Namespace, req.Name)
            return nil
        }

        if err := cl.Delete(ctx, &req); err != nil {
            log.Printf("Failed to delete old CertificateRequest %s/%s for Certificate", req.Namespace, req.Name)
            return err
        }

        log.Printf("Deleted old CertificateRequest %s/%s for Certificate", req.Namespace, req.Name)
    }

https://github.com/jetstack/letsencrypt-caa-bug-checker/blob/v0.0.2/main.go#L150

次に証明書が入ったSecretを取得しIssuerNameAnnotationKeyというannotationをアップデートしています。
コメントを読む限り、これで証明書の更新が行われるようです。

   // Fetch an up to date copy of the Secret resource for this Certificate
    var secret core.Secret
    if err := cl.Get(ctx, client.ObjectKey{Namespace: cert.Namespace, Name: cert.Spec.SecretName}, &secret); err != nil {
        log.Printf("Failed to retrieve up-to-date copy of existing Secret resource for Certificate: %v", err)
        return err
    }

    // Manually override/set the IssuerNameAnnotationKey - this will cause cert-manager
    // to assume that we have changed the 'issuerRef' specified on the Certificate and
    // trigger a one-time renewal.
    if secret.Annotations == nil {
        secret.Annotations = make(map[string]string)
    }
    secret.Annotations[capi.IssuerNameAnnotationKey] = "force-renewal-triggered"
    if err := cl.Update(ctx, &secret); err != nil {
        log.Printf("Failed to update Secret resource for Certificate: %v", err)
        return err
    }

https://github.com/jetstack/letsencrypt-caa-bug-checker/blob/v0.0.2/main.go#L176

更新処理が行われると、はじめに消したCertificateRequestが作成され、これをもって更新処理が始まったとしているようです。

   log.Printf("Triggered renewal of Certificate - waiting for new CertificateRequest resource to be created...")
    // Wait for a CertificateRequest resource to be created
    err := wait.Poll(time.Second, time.Minute, func() (bool, error) {
        var requests capi.CertificateRequestList
        if err := cl.List(ctx, &requests, client.InNamespace(cert.Namespace)); err != nil {
            return false, err
        }
        // Wait for a CertificateRequest owned by this Certificate to exist
        for _, req := range requests.Items {
            if metav1.IsControlledBy(&req, &cert) {
                log.Printf("CertificateRequest %s/%s found, renewal in progress!", req.Namespace, req.Name)
                return true, nil
            }
        }
        return false, nil
    })

https://github.com/jetstack/letsencrypt-caa-bug-checker/blob/v0.0.2/main.go#L197

実際にやってみる

開発用のAPIに向けて実際に手動でやってみたいと思います。

手順としては以下のようになります。

  1. 更新したい証明書のCertificateRequestを削除
  2. 更新したい証明書のSecretのAnnotationcert-manager.io/issuer-nameforce-renewal-triggeredに書き換え

実行すると多数のログが出力されますが、以下のようなログがでていれば更新が行われています。

I0310 09:44:42.874306       1 sync.go:367] cert-manager/controller/certificates "msg"="no existing CertificateRequest resource exists, creating new request..."
I0310 09:44:42.920794       1 sync.go:379] cert-manager/controller/certificates "msg"="created certificate request"
I0310 09:44:46.881652       1 logger.go:75] Calling GetAuthorization
I0310 09:44:47.673297       1 logger.go:100] Calling DNS01ChallengeRecord

更新されたSecretの中にある証明書の期限を見てみると以下のようになっていました。

f:id:akaimo3:20200310191023p:plain

無事に更新されています。

終わりに

無事に手動で更新ができ、似たような事態に遭遇した場合でもダウンタイムなしに対応できそうです。

また、今回のやり方を開発者はhacksと言っており、今後のリリースで正式な手段で再取得する方法をリリースすると言っています。

as part of #2402 we intend to introduce a mechanism that allows users or external controllers to trigger a renewal/reissuance in a well reasoned/sane way (unlike some of the recent 'hacks' we've had to resort to up until now).

参考