ぽよメモ

レガシーシステム考古学専攻

trust-managerを利用してk8sクラスタ内のオレオレ証明書をちゃんと検証する

背景

近年、自社データセンター内の通信であっても盗聴を防げるように通信を暗号化することが求められており、うちの家のKubernetesクラスタでもなんとなく通信を暗号化しようかなという気持ちになった。一方、サービスメッシュによるmTLSのような比較的重厚な仕組みを導入するのはあまり前向きになれず、単にクラスタ内で使える証明書をcert-managerから発行して各サービスが扱うことで達成できないか考えた。

cert-managerでself-signedな証明書を作っても、その証明書のsecretにあるca.crtなどを読めなければ他のサービスからは検証できない。よって、クラスタ内の通信でちゃんとTLSを使うためにはsecretの参照権限をあちこちに渡して参照させるか、クライアント側で検証のスキップなどが必要であった。

検証をスキップするということは、せっかく暗号化しているのに偽の通信先に繋いでしまったときその情報を漏洩させてしまう。これはやや喉に骨が刺さったような気持ちになる。ただ、そのためにクラスタ内で使う全ての証明書をLet’s Encryptなどから発行することはできないため、どうにか信用できるオレオレ証明書を見分けられるようにしたい。

cert-managerと同じ開発元が開発しているtrust-managerは、簡単に言うとクラスタ内にCA証明書をばらまく役割を持つ。クラスタ内で共通して利用する単一のCAと、そのCAから証明書を発行するIssuerを使って各サービスがTLSを有効化するようにする。それらに接続する別のサービスは、trust-managerが各namespaceに用意したCA証明書を使ってサーバから提示される証明書を検証できるようになる。

インストールする

サンプルとしてkindで作成したKubernetesクラスタ上で行う。

kind create cluster --name tls-example

helmでのインストールが推奨されているのでhelmを使い、cert-manager namespaceにtrust-managerをインストールする。

cert-manager.io

先にcert-managerをインストールする。

helm repo add jetstack https://charts.jetstack.io --force-update
helm update
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

そしてtrust-managerをインストールする。

helm upgrade \
    --install \
    --namespace cert-manager \
    --wait \
    trust-manager jetstack/trust-manager 

CAを作る

cert-managerを使ってクラスタ内で使うためのCAを用意する。CAの有効期限をどれくらいにするかは悩ましいが、現状CAの更新はそこそこ面倒(後述)なので、比較的長めに取る。

---
# 自己署名証明書を作るためのissuerを作る
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsign
spec:
  selfSigned: {}
---
# 上記のissuerを使ってCA証明書を発行する
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-ca-2024
  namespace: cert-manager
spec:
  # selfsignなissuerを指す
  issuerRef:
    name: selfsign
    kind: ClusterIssuer
    group: cert-manager.io
  # これが必須
  isCA: true
  commonName: internal-ca-2024
  secretName: internal-ca-2024-secret
  # 有効期限を10年とする
  duration: 87600h  # 24h * 365d * 10y
  # 実は使わないが適当な更新日時を設定しておく
  renewBefore: 336h # 24h * 14d
  # 好みでアルゴリズム設定する
  privateKey:
    algorithm: ECDSA
    size: 256

最後に、このCAを使って証明書を発行するissuerを作る。このクラスタ内のサービス間通信でTLSを有効化するときはこのissuerから証明書を発行して使う。

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal
spec:
  ca:
      # 先ほど作成したCA証明書のsecretを指す
    secretName: internal-ca-2024-secret

trust-managerで配布する

trust-managerは Bundle というカスタムリソースを使う。ここで配布されるファイルなどを変更できる。

---
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: internal-ca-bundle
  namespace: cert-manager
spec:
  sources:
  # Debianのデフォルトのca-certificatesを含めて配布する。
  # そのコンテナにca-certificatesをインストールしなくても、trust-managerで配布できるようになる
  # Ref: https://cert-manager.io/docs/trust/trust-manager/#securely-maintaining-a-trust-manager-installation
  - useDefaultCAs: true
  - secret:
      # 今回作成したCA証明書を配布するためにsecretの名前を指定する
      name: "internal-ca-2024-secret"
      # 該当するsecret内のどれを配布するか
      key: "tls.crt"
  target:
    # 配布するroot証明書を保存する先を決める。
    # ここではconfig mapとして保存し、そのkeyを `trust-bundle.pem` とする。
    configMap:
      key: "trust-bundle.pem"
    # namespaceSelectorを特に設定しない場合、全てのnamespaceに配布する。
    # ただし、この挙動は将来的に変更されると書かれている。
    # Ref: https://cert-manager.io/docs/trust/trust-manager/#namespace-selector
    # この挙動が変更されるときは、おそらくマイグレーションガイドが書かれるので従わなければならない
    # 例えば以下のラベルを持つ場合にconfig mapを作るようにするなどの設定が出来る。
    # namespaceSelector:
    #   matchLabels:
    #     your.label.io: "inject-ca"

これにより全てのnamespaceに internal-ca-bundle というConfigMapが作成される。

❯ kubectl get cm internal-ca-bundle -n cert-manager
NAME                 DATA   AGE
internal-ca-bundle   1      1m

useDefaultCAs: true

この設定を入れることで、trust-managerが配布するファイルにDebianca-certificates パッケージ相当の内容を含めることが出来る。scratchベースのコンテナ内からインターネット上のhttpsなエンドポイントに接続しようとして、証明書の検証に失敗した経験のある人も多いだろう。そのためにdistrolessイメージをベースにビルドし直して使っている人も多いと思う。trust-managerを使うことで、scratchベースのイメージでもca-certificatesをインストールすることなく外部の証明書を検証できる。

なお、勝手に最新に追従してくれるわけではないのでメンテが必要である。

https://cert-manager.io/docs/trust/trust-manager/#securely-maintaining-a-trust-manager-installation

実際にTLSを有効化して通信する

サンプルの実装をリポジトリに用意してある。

github.com

簡単なgRPCのヘルスチェックを実装したAPIサーバのコンテナイメージと、grpcurlをインストールしただけのUbuntuのイメージをビルドする。

git clone https://github.com/pddg/example-trust-manager
cd example-trust-manager
docker build . -f Dockerfile -t tls-example-server
docker build . -f Dockerfile.bastion -t tls-example-bastion

このイメージをkindで作ったクラスタ内にロードする。

kind load docker-image tls-example-server:latest --name tls-example
kind load docker-image tls-example-bastion:latest --name tls-example

bastion Podをデプロイする。このPodにはtrust-managerで配布したConfigMapがマウントされている。

example-trust-manager/manifests/04-bastion.yaml at ffc32b9c85b8442273885905e1275647d8262090 · pddg/example-trust-manager · GitHub

      volumeMounts:
        - mountPath: /internal-ca-bundle
          name: internal-ca-bundle
          readOnly: true
  volumes:
    - configMap:
        name: internal-ca-bundle
      name: internal-ca-bundle
kubectl apply -f manifests/04-bastion.yaml

TLSを有効化したAPIサーバをデプロイする

まずは利用する証明書を作る。

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: server
  namespace: secure
spec:
  secretName: server-tls
  commonName: api
  # アクセスする名前を書く
  dnsNames:
    - api
    - api.secure.svc
    - api.secure.svc.cluster.local
  # internal issuerを使って証明書を発行する
  issuerRef:
    name: internal
    kind: ClusterIssuer
    group: cert-manager.io
kubectl apply -f manifests/06-cert.yaml

証明書と、対応するSecretが作成されているはずだ。

❯ kubectl get cert server -n secure
NAME     READY   SECRET       AGE
server   True    server-tls   4h20m

❯ kubectl get secret server-tls -n secure
NAME         TYPE                DATA   AGE
server-tls   kubernetes.io/tls   3      4h20m

gRPCでは、cert-managerで作成した証明書のsecret内にあるtls.crtとtls.keyを以下の様に起動時に渡すことで、TLSを有効化できる。

example-trust-manager/main.go at ffc32b9c85b8442273885905e1275647d8262090 · pddg/example-trust-manager · GitHub

   var serverOptions []grpc.ServerOption
    if tlsKeyPath != "" || tlsCertPath != "" {
        cred, err := credentials.NewServerTLSFromFile(tlsCertPath, tlsKeyPath)
        if err != nil {
            return fmt.Errorf("failed to create transport credential: %w", err)
        }
        serverOptions = append(serverOptions, grpc.Creds(cred))
    }
    server := grpc.NewServer(serverOptions...)

Pod内に作成したSecretをマウントし、起動時のオプションでこれらへのパスを渡して起動する*1

kubectl apply -f manifests/07-tls-server.yaml

サーバと通信する

bastion PodからこのAPIに対してgrpcurlでリクエストを投げてみる。まずは特に何も設定せず投げる。

❯ kubectl exec bastion -- grpcurl api.secure.svc:443 grpc.health.v1.Health/Check
Failed to dial target host "api.secure.svc:443": tls: failed to verify certificate: x509: certificate signed by unknown authority
command terminated with exit code 1

この場合、 certificate signed by unknown authority エラーで失敗する。このbastion上にはca-certificatesはインストールされておらず、インストールされていたとしてもオレオレ認証局から発行されたオレオレ証明書であるため同様のエラーで失敗する。

一応この状態でも、 -insecure オプションを使って証明書の検証をスキップすることで暗号化した通信自体は行える。一方、背景で説明したとおりこれは脆弱な状態である。

❯ kubectl exec bastion -- grpcurl -insecure api.secure.svc:443 grpc.health.v1.Health/Check   
{
  "status": "SERVING"
}

ここで、grpcurlに信頼できるCA証明書を指定して同様にアクセスしてみる。trust-managerによって各namesaceに配布されており、bastion Podには /internal-ca-bundle/trust-bundle.pem というパスにマウントされている。

example-trust-manager/manifests/04-bastion.yaml at ffc32b9c85b8442273885905e1275647d8262090 · pddg/example-trust-manager · GitHub

      volumeMounts:
        - mountPath: /internal-ca-bundle
          name: internal-ca-bundle
          readOnly: true
  volumes:
    - configMap:
        name: internal-ca-bundle
      name: internal-ca-bundle
❯ kubectl exec bastion -- grpcurl -cacert /internal-ca-bundle/trust-bundle.pem api.secure.svc:443 grpc.health.v1.Health/Check
{
  "status": "SERVING"
}

通信先のサービスはinternal-ca-bundleに内包されているCA証明書で検証可能な証明書を利用しているため、この通信は検証をスキップしなくても通る。ここでselfsignなissuerから発行した全く別のオレオレ証明書に差し替えると、通信が通らないことがわかるだろう。

CAの更新

もちろん実質無期限のようなCA証明書を発行することは可能だが、いくつかの理由により更新が推奨されている。例えば利用するアルゴリズムが時代の変化と共に脆弱と見なされるようになったりすることなどが上げられる。cert-managerで発行するCAもデフォルトでは90日の期限しかなく、さすがに90日ごとに(後述するような面倒な手順により)手動で更新するのは現実的ではない。今回は特に根拠無く10年としているが、これをどの程度にするかは人によるだろう。

cert-managerとtrust-managerは発行されたCAの更新に対して自動化された方法をほとんど提供しておらず、現状はある程度手動の作業が求められる。

github.com

cert-managerは設定された期限 .spec.duration から猶予分 .spec.beforeRenew 引いた日時を超えると、更新を開始する*2。ここで古い内容は破棄され、以降でこのCAを使って発行される証明書は新しいCAのものになる。trust-managerはこのCAが更新されたときにBundleにそれが含まれていれば、更新して配布する。よってこの時点以降に新しく起動したPodは、まだ古い証明書を使ってサーブされているサービスへアクセスすると検証に失敗して接続できなくなってしまう。

ダウンタイムを避けるためには、おそらく以下の様な手順で更新する必要がある。

  1. 古いCAの有効期限が切れるより十分な余裕を持って新しい長めの期限を持つCAを作る。
  2. Bundleに1のCA証明書を含めて配布する。
    1. これ以降に読み込んだPodでは新しいCAの証明書を正しく検証できる。
    2. 確実にするなら、この時点で全てのプロセスがこれを読み込むようにローリングアップデートする。
  3. 既存のClusterIssuerで使われるCAを1に変更する。
    1. 以降に発行された証明書は古いCA証明書では検証できない。2で更新された内容を読み込んでいないサービスは新しい証明書を使うサービスにアクセス出来なくなる。
    2. これを切り替えただけでは既存の証明書は更新されない。
  4. 既存の証明書が全て1のCAから発行されたものに切り替わり、全てのプロセスで読み込まれるまで待つ。
    1. もしくはcert-managerのクライアントコマンドである cmctl コマンドで更新をトリガーし、更新された後にローリングアップデートする。
  5. 古いCAを破棄する

間を繋ぐためだけの短い期限のCAを使うこともできるが、単にこの手順を二回実行することになるだけでうれしさがあまりないため、単純に新しいものを作った方が楽に思われる。このように結構複雑な手順を踏んで更新しなければダウンタイムが発生してしまうため、ある程度長い期限を設定せざるを得ないだろう。

CAの保管場所

このように長期間にわたって生存するものであり、悪用されると容易にその組織内で信頼できる証明書を作られてしまうため、作成したCAのsecretへアクセスできる人は限定的にしておくべきである。この場合cert-managerのnamespaceに入れており、このnamespaceへの(特にsecretへの)アクセスは厳重に管理されるべきだろう。

また、今回はClusterIssuerで誰でも証明書を発行できるようにしているが、実際にそれで良いかはやや疑問の残る所である。

クラスタ外との通信の暗号化

当然発行されたCA証明書を外部の環境に持ち出すことで、クラスタ外ともこの証明書を使って通信することは可能である。一方、それらの管理や更新方法の検討、自前のものとはいえプリインされた信頼できるルート証明書とは異なるものを持ち込むというリスクを考えると、少なくとも一般家庭のレベルではあまりやりたいものではない。

現代では、ACMEDNS-01チャレンジにより外部からアクセス可能なサーバのないクローズドな環境でも証明書の自動発行・更新がやりやすくなっている。KubernetesIngressコントローラ、例えばnginx-ingress-controllerやcontourなどで外部からの通信におけるTLS終端を担い、そこではLet’s EncryptやZero SSLを使って発行した証明書を使うのが良いだろう。そこからクラスタ内向けの通信は今回のようなオレオレ証明書で暗号化するという方式を採用できる。

まとめ

trust-managerとcert-managerを使うことで、クラスタ内で使われるオレオレ証明書の正しさを比較的簡単に検証できるようになる。また、scratchベースのイメージのような信頼できる証明書を検証する術を持たないコンテナイメージにも、後からそのためのファイルを追加できる。

ただしcert-managerおよびtrust-managerは作成したオレオレCAの更新に関して、自動化された方法を提供していない。更新方法は比較的複雑かつ失敗するとダウンタイムを伴うため、計画的に行わなければならない。

ここで作った証明書はあくまでクラスタ内通信に留め、クラスタ外との通信はDNS-01チャレンジなどを活用してLet’s Encryptなどから発行したちゃんとした証明書を使う方が良い。

クラスタ内通信の暗号化、サービスメッシュに任せずにやる方法もあるよという話でした。


*1:なお、KubernetesのgRPCを使ったliveness/readiness probeはTLSに対応していないため、別のエンドポイントを使う必要がある(ref)。今回は検証用なので単にliveness probeを無効化している。

*2:実際にはdurationの2/3とbeforeRenewのどちらか遅い方