etcdとKubernetes APIサーバーのセキュリティ強化:保存時の暗号化、mTLS、認証情報漏洩検出

Security tutorial - IT technology blog
Security tutorial - IT technology blog

etcdに潜む見えないリスク

深夜にサーバーがSSHブルートフォース攻撃を受けた経験から、セキュリティへの適切な警戒心が身についた。本番のKubernetesクラスタを管理し始めたとき、最初にしたことはワークロードのデプロイではなく、コントロールプレーンに直行して問いかけることだった:誰かがetcdに侵入したら何が起きるのか?

その答えは不快なものだ。etcdはKubernetesの頭脳であり、すべてのSecret、ConfigMap、ServiceAccountトークン、クラスタ状態変更が記録されている。デフォルトでは、kubeadmのデフォルト設定を含む多くのクラスタ構築手順において、Secretはbase64エンコードされたテキストとして保存される。暗号化ではなく、ただエンコードされているだけだ。etcdにアクセスできる者なら誰でも、etcdctl getコマンド一つでデータベースパスワード、TLS秘密鍵、APIトークンを読み取ることができる。

公開されたetcdエンドポイントは、実際のセキュリティ侵害の事後報告に初期侵入経路として登場している。仮定の話ではなく、記録された実際のインシデントだ。これを正しく設定するには意図的な構成が必要だが、多くのチームは動かすことを急ぐあまりこのステップを省略する。私がすべてのクラスタで実施していること:保存時の暗号化、コントロールプレーンコンポーネント間のmTLS検証、認証情報漏洩スキャン。この3つを毎回必ず実施する。

設定を変更する前に理解しておくべき基本概念

保存時の暗号化と転送時の暗号化の違い

これらは2つの別々の問題を解決する。転送時とは、コンポーネント間でデータが移動する際に暗号化されることを指す。kube-apiserverがTLSでetcdと通信したり、kubeletがHTTPSでノードのステータスを報告する場合がこれにあたる。正しくブートストラップされていれば、Kubernetesはこれをデフォルトで適切に処理する。

保存時とは、etcd内でディスク上に保存されているデータが暗号化されていることを指す。これはデフォルトでは無効だ。誰かがetcdのディスクボリュームを取得した場合——スナップショットの流出、VMの侵害、不正なクラウドスナップショット——Secretを平文で読み取ることができる。保存時暗号化を有効にするには、etcdへの書き込み前に特定のリソースタイプを暗号化するようkube-apiserverに指示するEncryptionConfigurationリソースを使用する。

コントロールプレーンにおける相互TLS

通常のTLSはサーバーのIDをクライアントに証明するものだ。相互TLS(mTLS)は双方向で機能し、両側が証明書を提示して互いを検証する。Kubernetesコントロールプレーン内では:

  • kube-apiserverはクライアント証明書を使用してetcdに接続する
  • etcdは信頼されたCAによって署名された証明書を提示するクライアントからの接続のみを受け入れる
  • kubeletはすべてのAPIコールでノード証明書をkube-apiserverに提示する

kubeadmでブートストラップされたクラスタはこれを正しく設定する。ただし、カスタム環境やマネージド環境では、フラグの欠落、不正なCAチェーン、静かに期限切れになった証明書に行き着きやすい。正常に動作しているように見えるが、実際はそうではない。

認証情報が実際に漏洩する経路

認証情報がコントロールプレーンから流出する主な経路は3つある:etcd内の平文データ、必要のないPodにマウントされたJWTサービスアカウントトークン、そしてマシン間でコピーされた長期有効な管理者認証情報を含むkubeconfigファイル。この3つすべてに積極的な監視が必要だ。

実践:コントロールプレーンのセキュリティ強化

ステップ1 — Secretの保存時暗号化を有効にする

コントロールプレーンノードに暗号化設定ファイルを作成する。これにより、どのリソースをどのプロバイダーで暗号化するかを定義する:

# /etc/kubernetes/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
      - configmaps
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <BASE64_ENCODED_32_BYTE_KEY>
      - identity: {}

32バイトのキーを生成する:

head -c 32 /dev/urandom | base64

kubeadmクラスタでは、/etc/kubernetes/manifests/kube-apiserver.yamlにあるkube-apiserverのスタティックPodマニフェストを編集して、設定ファイルを参照するようにする:

spec:
  containers:
  - command:
    - kube-apiserver
    - --encryption-provider-config=/etc/kubernetes/encryption-config.yaml
    volumeMounts:
    - mountPath: /etc/kubernetes/encryption-config.yaml
      name: encryption-config
      readOnly: true
  volumes:
  - hostPath:
      path: /etc/kubernetes/encryption-config.yaml
      type: File
    name: encryption-config

kube-apiserverが再起動すると、新しいSecretは暗号化される。既存のSecretは自動的には再暗号化されないため、強制的に書き直す:

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

etcdからSecretを直接読み取ることで、暗号化が実際に機能していることを確認する:

ETCDCTL_API=3 etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/my-secret | hexdump -C | head -5

出力にk8s:enc:aescbc:v1:key1がプレフィックスとして表示されれば、暗号化は有効だ。読み取り可能なテキストが表示される場合は有効ではない。

ステップ2 — etcdとkube-apiserver間のmTLSを検証する

実行中のkube-apiserverフラグを確認して、etcdにクライアント証明書を提示していることを確認する:

ps aux | grep kube-apiserver | tr ' ' '\n' | grep '\-\-etcd-'

--etcd-cafile--etcd-certfile--etcd-keyfileすべてが実際のファイルを指している必要がある。いずれかが欠落している場合、etcdはAPIサーバーからの認証されていない接続を受け入れている。

証明書の有効期限は、チームが気づかないうちに問題を引き起こすものだ:

# すべてのコントロールプレーン証明書の有効期限を確認する
kubeadm certs check-expiration

# etcd証明書を個別に手動確認する
openssl x509 -in /etc/kubernetes/pki/etcd/server.crt -noout -dates
openssl x509 -in /etc/kubernetes/pki/apiserver-etcd-client.crt -noout -dates

次に、etcdが誤ってすべてのインターフェースでリッスンしていないことを確認する:

ss -tlnp | grep 2379

0.0.0.0:2379が表示される場合、etcdはネットワークに公開されている。etcdの設定にある--listen-client-urlsフラグは、127.0.0.1またはクラスタの内部IPのみにバインドされている必要がある。

ステップ3 — Secretからの認証情報漏洩をスキャンする

保存時暗号化が有効であっても、認証情報は他の経路から漏洩する可能性がある。まず、クラスタ全体のSecretに実際に何が保存されているかを把握することから始める:

# 全ネームスペースのSecretとそのキーを一覧表示する
kubectl get secrets --all-namespaces -o json | \
  jq -r '.items[] | .metadata.namespace + "/" + .metadata.name + ": " + \
  (.data | if . then (keys | join(", ")) else "(no data)" end)'

手動で確認したいSecretに対しては:

kubectl get secret my-secret -n default \
  -o jsonpath='{.data.password}' | base64 -d

インシデント対応中は、素早い手動確認にToolCraftのBase64エンコーダー/デコーダーを使用している。ブラウザ上で完全に動作するため、データが外部に送信されることはない。機密性の高いエンコードされた値を扱う際にこれは重要だ。入力を記録するオンラインツールにSecretを貼り付けることは、まさに自分が防ごうとしている露出そのものになる。

Secretそのもの以外にも、必要ないかもしれないサービスアカウントトークンを受け取っているPodを確認する:

# automountが明示的に無効化されていないPodを見つける
kubectl get pods --all-namespaces -o json | \
  jq -r '.items[] | select(.spec.automountServiceAccountToken != false) | \
  .metadata.namespace + "/" + .metadata.name'

実行中のPodからJWTトークンを取り出して確認する場合:

kubectl exec -n default my-pod -- \
  cat /var/run/secrets/kubernetes.io/serviceaccount/token

ToolCraftのJWTデコーダーを使えば、トークンを外部に送信せずに、クレーム、有効期限、発行者をブラウザ上で直接確認できる。トークンのスコープが適切に制限されているか、有効期限がいつかを確認するのに役立つ。

ステップ4 — 認証情報の影響範囲把握のためのRBAC監査

漏洩した認証情報の危険性は、それに付与された権限に比例する。cluster-adminを持つサービスアカウントを確認する:

kubectl get clusterrolebindings -o json | \
  jq -r '.items[] | select(.roleRef.name == "cluster-admin") | \
  .metadata.name + ": " + ([.subjects[]?.name] | join(", "))'

明示的に文書化され正当化されていないcluster-adminを持つサービスアカウントは、今この瞬間もクラスタに潜むリスクだ。

持続可能な運用体制を構築する

クラスタは時間とともに劣化する。証明書は期限切れになり、新しいSecretが深く考えずに追加され、RBACのバインディングが数ヶ月かけて静かに蓄積されていく。私はクラスタの運用手順書に3つの定期チェックを組み込んでいる:

  1. 毎月kubeadm certs check-expirationを実行し、90日以内に期限切れになる証明書にフラグを立てる
  2. 新しいデプロイの後:ワークロードのServiceAccountが不要なトークンをマウントしていないことを確認する(PodスペックにautomountServiceAccountToken: falseを設定する)
  3. 四半期ごと:cluster-adminバインディングを再スキャンし、etcdの暗号化がまだ有効であることを監査する

これはすべてのLinuxホストでSSHキーのローテーションやFail2Banのしきい値に適用しているのと同じ規律だ——ただし対象がKubernetesコントロールプレーンになっただけだ。

公開されたetcdは実際の本番インシデントの侵入経路となってきた。緩和策はKubernetes自体に組み込まれており、検証手順は数分で完了し、上記のコマンドで全体の対象範囲をカバーできる。まず保存時暗号化から始めよう——これが実行中のクラスタへのリスクを最小限に抑えつつ、最も大きな効果をもたらす。

Share: