はじめに

前回の投稿で Google Domains で購入したドメインを Azure DNS に委任しました。

今回は cert-manager というツールを使って、自宅ラボの Kubernetes クラスタで LAN に公開している Ingress 用の証明書を発行します。

証明書は Let’s Encrypt に署名してもらうのですが、Let’s Encrypt がドメインの所有をチェックするために Azure DNS を使って「DNS01 チャレンジ」を突破します。 ドメインの所有のチェック方法として「HTTP01 チャレンジ」もありますが、こちらはインターネットからアクセス可能なエンドポイントを用意する必要があるため、インターネットに非公開の自宅ラボでは使えません。

今までも cert-manager を使って nnstt1.home という独自ドメインの自己署名証明書を発行していたのですが、ブラウザの警告を回避するためのルート証明書の設定が面倒でした。 そこで、nnstt1.dev のサブドメイン home.nnstt1.dev に対して証明書を発行(署名)してもらって、自宅ラボでも警告なしで使えるようにします。

手順の流れは以下です。

  1. Azure にサービスプリンシパル作成する
  2. Kubernetes クラスタに Issuer / Certificate リソースを作成して証明書を発行する
  3. Ingress で証明書を使ってサービスを公開する

なお、前提として Kubernetes クラスタには既に cert-manager がインストールされているものとして手順を記載しています。

サービスプリンシパル作成

AKS では ID 管理がより楽な「マネージド ID」を使うことができますが、自宅クラスタなので「サービスプリンシパル」を使用します。

はじめに、サービスプリンシパル名などの変数を設定します。

$ AZURE_CERT_MANAGER_NEW_SP_NAME=home-lab-cert-manager
$ AZURE_DNS_ZONE_RESOURCE_GROUP=home-lab
$ AZURE_DNS_ZONE=nnstt1.dev

サービスプリンシパルを作成して、アプリケーション ID などの情報を変数に格納します。

$ DNS_SP=$(az ad sp create-for-rbac --name $AZURE_CERT_MANAGER_NEW_SP_NAME --output json)
$ AZURE_CERT_MANAGER_SP_APP_ID=$(echo $DNS_SP | jq -r '.appId')
$ AZURE_CERT_MANAGER_SP_PASSWORD=$(echo $DNS_SP | jq -r '.password')
$ AZURE_TENANT_ID=$(echo $DNS_SP | jq -r '.tenant')
$ AZURE_SUBSCRIPTION_ID=$(az account show --output json | jq -r '.id')

作成したサービスプリンシパルは「共同作成者」のロールが割り当てられているので、セキュリティ向上のために「共同作成者」は削除して、代わりに「DNS ゾーン共同作成者」を割り当てます。

$ az role assignment delete --assignee $AZURE_CERT_MANAGER_SP_APP_ID --role Contributor

$ DNS_ID=$(az network dns zone show --name $AZURE_DNS_ZONE --resource-group $AZURE_DNS_ZONE_RESOURCE_GROUP --query "id" --output tsv)
$ az role assignment create --assignee $AZURE_CERT_MANAGER_SP_APP_ID --role "DNS Zone Contributor" --scope $DNS_ID

サービスプリンシパルに割り当てられているロールを確認します。

$ az role assignment list --all --assignee $AZURE_CERT_MANAGER_SP_APP_ID

最後に、Kubernetes からサービスプリンシパルを使うためにシークレットを作成します。

$ kubectl create secret generic azuredns-config --from-literal=client-secret=$AZURE_CERT_MANAGER_SP_PASSWORD

これでサービスプリンシパルの設定は完了です。

Issuer 作成

cert-manager には、namespace ごとに証明書を発行する Issuer と、クラスタ全体で発行する ClusterIssuer という認証局を表す 2 種類のリソースがあります。 今回は ClusterIssuer を使って、Let’s Encrypt に証明書を署名してもらいます。

ClusterIssuer には、Automated Certificate Management Environment (ACME) プロトコルを使って Let’s Encrypt に署名してもらうための情報と、署名時にどの Azure DNS を使ってドメインの所有をチェックするかの情報を記載します。

Azuer DNS を使った cert-manager の「DNS01 チャレンジ」の方法は公式ドキュメントがあります。

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    email: hoge@example.com
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: issuer-account-key
    solvers:
      - dns01:
          azureDNS:
            clientID: 00000000-0000-0000-0000-000000000000
            clientSecretSecretRef:
              name: azuredns-config
              key: client-secret
            subscriptionID: 00000000-0000-0000-0000-000000000000
            tenantID: 00000000-0000-0000-0000-000000000000
            resourceGroupName: home-lab
            hostedZoneName: nnstt1.dev
            environment: AzurePublicCloud

Let’s Encrypt の本番環境/ステージング環境

Issuer もしくは ClusterIssuerspec.acme.server には ACME に対応した認証局の URL を指定します。 Let’s Encrypt の場合、本番環境ではレート制限が厳しいため、ステージング環境を利用することも検討したほうがよいです。

Staging Environment - Let’s Encrypt

例えば「登録済みドメインごとの証明書」は、本番環境では週あたり 50 個しか証明書を発行できませんが、ステージング環境では週あたり 30,000 個の証明書を発行することができます。

私の環境では本番環境/ステージング環境それぞれの ClusterIssuer を作成しておいて、後続の Certificate リソースではじめに認証局のステージング環境を指定して、ステージング環境で発行された証明書に問題がなければ本番環境に切り替えています。

Certificate 作成

ClusterIssuer を作成できたら、次は X.509 証明書を表す Certificate リソースを作成します。

以下は Grafana 用の Ingress で使っている Certificate の例です。

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: grafana-certificate
  namespace: monitoring
spec:
  secretName: grafana-tls
  duration: 2160h
  renewBefore: 360h
  subject:
    organizations:
      - nnstt1-home-lab
  isCA: false
  privateKey:
    algorithm: RSA
    encoding: PKCS1
    size: 2048
  usages:
    - server auth
    - client auth
  dnsNames:
    - "grafana.home.nnstt1.dev"
  issuerRef:
    name: letsencrypt
    kind: ClusterIssuer
    group: cert-manager.io

Certificate リソースが作成されると、cert-manager が Let’s Encrypt に対して証明書発行を依頼し、DNS01 チャレンジがおこなわれます。 DNS01 チャレンジでは、サービスプリンシパルを使って Azure DNS にチャレンジ用の TXT レコードが作成されます。

チャレンジが成功して証明書が発行されると、Certificatespec.secretName で指定した名前で署名済みの証明書が含まれるシークレットが作成されます。 シークレットに格納された証明書は以下のコマンドで確認できます。

$ kubectl get secret grafana-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -noout

どのような流れで証明書が発行されるか詳しく知りたい場合は、cert-manager のドキュメント「Certificate Lifecycle」を参照してください。

余談)SAN に .home ドメインを設定してみた

最初は nnstt1.home ドメインを証明書の「Subject Alternative Name (SAN)」に設定すれば使えるんじゃないかと思ってました。

Certificate リソースでいうと spec.dnsNames という項目で、cert-manager のドキュメントにも記載されていました。

The dnsNames field specifies a list of Subject Alternative Names to be associated with the certificate.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: grafana-certificate
  namespace: monitoring
spec:
  (snip)
  dnsNames:
    - "grafana.home.nnstt1.dev"
    - "grafana.nnstt1.home"       # プライベートドメイン
  issuerRef:
    name: letsencrypt
    kind: ClusterIssuer
    group: cert-manager.io

このマニフェストをデプロイしたところ、以下のようなエラーになりました

Warning  Failed     15s   cert-manager  The certificate request has failed to complete and will be retried: 
Failed to wait for order resource "nnstt1-dev-9qsdx-1123654891" to become ready: 
order is in "errored" state: Failed to create Order: 
400 urn:ietf:params:acme:error:rejectedIdentifier: 
Error creating new order :: Cannot issue for "nnstt1.home": 
Domain name does not end with a valid public suffix (TLD)

ということで、SAN に設定するドメインも TLD じゃないとだめらしいです。

Ingress で証明書利用

Let’s Encrypt で署名された証明書が手に入ったので、Ingress で証明書を利用します。 今回の環境では NGINX Ingress Controller を使っています。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
  name: grafana
  namespace: monitoring
spec:
  tls:
    - hosts:
        - grafana.home.nnstt1.dev
      secretName: grafana-tls
  rules:
    - host: grafana.home.nnstt1.dev
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: grafana
                port:
                  number: 3000

spec.tls にホスト名と cert-manager で作成したシークレット名を指定することで、Ingress が証明書を使って TLS 終端してくれます。

これにより、自宅ラボの Ingress で公開したサービス (今回の例では https://grafana.home.nnstt1.dev) に対してアクセスしてもブラウザで警告が表示されなくなりました。

証明書

(追記)

大事なことの記載が抜けていました…。 自宅ラボでは DNS サーバ (PowerDNS) を構築していて、nnstt1.home はローカルの DNS サーバで応答し、それ以外はパブリック DNS にフォワーディングしています。

従来の設定では nnstt1.dev ドメインは Azure DNS で名前解決されるため、今回使った home.nnstt1.dev サブドメインも Azure DNS に問い合わせる形になります。 Azure DNS 側でローカル IP アドレスを使ったレコードも登録できますが、ローカル IP アドレスの情報を誰でも参照できる状態はあまり気持ちがよいものではないため、使用したくありません。

そこで、今回はローカル DNS サーバで nnstt1.home に加えて home.nnstt1.dev も自分自身が名前解決するように設定しました。 これで、ルートドメインは Azure DNS で管理しつつ、サブドメインはローカルのみ使用できる状態となりました。

おわりに

今回は cert-manager を使って、自宅ラボでも HTTPS の警告が出ないようにできました。 Let’s Encrypt に証明書を発行してもらう際の DNS01 チャレンジに対応するため、前回 Azure DNS にドメインを移管したというわけですね。

ここで、シークレット管理について改めて考えていこうと思います。 今回の記事の中では 2 種類のシークレットが登場しました。 1 つ目は「サービスプリンシパルのパスワード」、2 つ目は「Ingress で利用する証明書」です。

このうち、「Ingress で利用する証明書」は cert-manager によって作成されているため、Certificate リソースのマニフェストだけ Git 管理すればセキュリティ的な問題ありません。

一方、「サービスプリンシパルのパスワード」は自らシークレットを作成しました。 当然、シークレットのマニフェストを作成して Git で管理することは危険ですし、かといってシークレット情報を手作業で管理することは運用的にも避けたいところです。 (自宅ラボは温もりのある手作業ですが……)

そこで、クラスタ外のシークレット管理ソリューションを利用することで、運用効率を維持したままセキュリティを担保することが考えられます。 既にいくつか外部ソリューションを使ったシークレット管理の方法が公開されています。

そこに加えて、最近 HashiCorp から Vault Secrets Operator なるものが公開されました。

というわけで、自宅ラボのシークレット管理方法を検討するため、Vault Secrets Operator を試してみようと思います。