新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第9回です。第8回は「危険な Pod を作らせない」入口の話でした。本回は向きが逆になります。すでにクラスタの中にあるもの——etcd に平文で保存されている Secret を扱います。
本回の主題は 1 文で書けます。Secret の base64 は暗号化ではなく、etcd を直接読めば DB のパスワードがそのまま出てくる。 そして 設定ファイルを 1 枚置いただけでは、既存の Secret は 1 件も暗号化されません。
扱うのは etcd の Encryption at Rest(保存時暗号化)、暗号化キーのローテーション、SealedSecrets(Git に置ける暗号文)の 3 つです。ExternalSecrets Operator は概念のみで、ラボには導入しません。実機作業は 3 台の Control Plane Node(/etc/kubernetes/enc/ と kube-apiserver.yaml)と k8s-ops からの kubectl / helm / kubeseal / git、それに alma-proxy の whitelist 1 行です。
- ラボ Kubernetes v1.36.3
- containerd v2.2.6
- AlmaLinux 10.2
- etcd サーバ 3.6.8-0
- etcdctl / etcdutl v3.6.6(API version 3.6)
- sealed-secrets Helm chart 2.19.1(コントローラ / kubeseal とも 0.38.4)
- Helm v4.1.4
- ArgoCD v3.4 系
- CKS 試験環境 v1.35
- 確認日 2026-07-28
目次
- 第9回のスコープ・今ここマップ
- この回のゴール
- base64 は暗号化ではない —— 3 段階で確かめる
- やってみよう①:base64 をデコードし、etcd の生データで平文を確認する
- EncryptionConfiguration の構造と、プロバイダをどう選ぶか
- プロバイダ配列の順序が決めるもの —— 先頭・末尾・identity
- 3 点セットと kubeadm-config への恒久化 —— HA では 3 台すべてに配る
- やってみよう②:etcd Encryption at Rest を 3 台の Control Plane Node に適用する
- ステップ1:鍵を 1 つ生成する
- ステップ2:設定ファイルを 3 台すべてに置く(同じ鍵を 3 回貼る)
- ステップ3:kube-apiserver.yaml をバックアップする
- ステップ4:3 点セットを書き込む(1 台ずつ)
- ステップ5:API Server が起動したことを確認する
- ステップ6:ステップ4〜5 を k8s-cp-02 / k8s-cp-03 で繰り返す
- ステップ7:既存の Secret は平文のままであることを確認する
- ステップ8:新規に作った Secret は暗号化されることを確認する
- ステップ9:既存の Secret を全件再暗号化する
- ステップ10:網羅確認のループを回す
- ステップ11:kubeadm-config に恒久化する
- ステップ12:kube-bench で宿題を回収し、回帰確認する
- 鍵をローテーションする —— ファイルを直しても反映されない
- やってみよう③:暗号化キーをローテーションする
- ステップ1:key2 を生成して 3 台の enc.yaml の先頭に足す
- ステップ2:再起動せずに新規 Secret を作って確認する
- ステップ3:3 台の API Server を 1 台ずつ再起動する
- ステップ4:key2 が使われることを確認する
- ステップ5:全件を再暗号化して key2 にそろえる
- ステップ6:key1 を削除して 3 台を再起動する
- ステップ7:自動リロードを導入する(3 台 + kubeadm-config へ追記)
- ステップ8:key3 を足して、再起動なしで効くことを確認する
- ステップ9:わざと順序を破る —— 再暗号化せずに key2 を消す
- ステップ9 の観察①:反映は 3 台バラバラに進む —— 打った直後は再現しない
- ステップ9 の観察②:一覧(LIST)は成功し、単体取得(GET)だけが失敗する
- ステップ9 の観察③:稼働中の Pod は落ちず、新規 Secret は作れる
- ステップ9 の観察④:3 台のローカル API Server に直接聞いても同じ
- ステップ9 の補足:SealedSecrets コントローラには ErrUnsealFailed が出る
- ステップ10:復旧する —— key2 を書き戻して再暗号化する
- ステップ11:key2 を削除して最終形にそろえる
- etcd 暗号化が守らないもの —— Git に置いた平文
- SealedSecrets の仕組み —— 封印鍵は etcd の Secret である
- やってみよう④:fanclub の DB 認証情報を SealedSecret 化する
- Secret の正本を Helm から Git へ移す
- やってみよう⑤:SealedSecret を GitOps に載せる
- kubeseal の主要フラグと、封印鍵のバックアップ
- ExternalSecrets Operator(概念のみ)と SealedSecrets との使い分け
- 暗記必須コマンド(etcd 暗号化 / etcdctl / kubeseal)
- まとめ
- 理解度チェック(○×形式・全 9 問)
- 次回予告
第9回のスコープ・今ここマップ
本回も CKS ドメイン D4: Minimize Microservice Vulnerabilities(配点 20%) です。D4 は 4 つのコンピテンシーからなり、本回が担当するのは 2 つ目です。1 つ目は第8回で終えており、残る 2 つは第10回・第11回で埋めます。
| 公式コンピテンシー(D4 Minimize Microservice Vulnerabilities・20%) | 担当 | 対応セクション |
|---|---|---|
Use appropriate pod security standards | 第8回 | — |
Manage Kubernetes secrets | 本回(第9回) | 本回の全セクション + やってみよう①〜④(「Secret の正本を Helm から Git へ移す」節と やってみよう⑤ は試験範囲外の実務パート) |
Understand and implement isolation techniques (multi-tenancy, sandboxed containers, etc.) | 第10回 | — |
Implement Pod-to-Pod encryption using Cilium, Istio | 第11回 | — |
本回はドメインをまたぎます。
etcd の Encryption at Rest そのものは D4 の Manage Kubernetes secrets として扱います。 一方で、その設定を検査する CIS ベンチマークの 1.2.27 / 1.2.28 は D1(Cluster Setup)の道具であり、第2回で WARN のまま保留した項目です。本回はその宿題を D4 の作業として片付けます。 「etcd 暗号化は D1 なのか D4 なのか」で迷わないよう、この関係を先に置いておきます。設定を書くのが D4、書いた設定を検査するのが D1、と分けて覚えてください。
既習範囲 / 本回で上書きする観点
| 既習 | どこで | 本回で上書きする観点 |
|---|---|---|
ConfigMap / Secret の外部化(DB_HOST / DB_NAME / DB_USER / DB_PASSWORD / JAVA_OPTS)。「base64 は暗号化ではない」「保存時暗号化は第3巻」と読者に明示済み | 第1巻第10回 | 本回で約束を果たします。 「値を渡す手段」だった Secret を、保存時に暗号化し、Git に置ける形へ変換するという防御の対象として扱い直します |
| ExternalSecrets Operator の名前紹介 | 第1巻第10回 | 概念(SecretStore / ClusterSecretStore / ExternalSecret)と SealedSecrets との使い分けを表で果たします。ラボには導入しません |
| kube-bench の 1.2.27 / 1.2.28 が WARN で残っていること | 第2回 | 暗号化を有効化して 2 項目とも PASS へ回収します(43 → 45 PASS / 11 → 9 WARN)。あわせて、第5回完了時の 45 PASS から 2 件が FAIL へ戻っていたことも起点で判明します(やってみよう② ステップ12 で扱います) |
apiServer.extraArgs / extraVolumes と、kubeadm クラスタではノード上のファイルは結果であって正本ではないという作法 | 第5回 | 同じ枠組みに 2 フラグ + 1 extraVolume を足します(本回が 2 度目。手が覚える段階へ) |
| ファイル権限と最小権限の設計 | 第7回 | 暗号化鍵のファイルは 600・ディレクトリは 700。読める人が増えれば暗号化の意味が消えるという形で回収します |
| 3 台の Control Plane Node すべてに設定ファイルを配らないと「たまに効く」状態になる(AdmissionConfiguration) | 第8回 | 同じ構造が暗号化でも起きます。しかも今度は「たまに暗号化されない」という、後から気づけない形で残ります |
fanclub namespace が enforce=restricted | 第8回 | 本回で足すのは Secret / SealedSecret(Pod ではない)なので PSS の影響を受けません。sealed-secrets コントローラは kube-system に入ります(ラベル無し=制限なし) |
先に 1 つ、本回で覆る前提を予告しておきます。
「--encryption-provider-config を書いて API Server を再起動すれば暗号化は終わり」——という手順書をよく見かけます。実機で確かめると、その時点で暗号化されている Secret は 1 件もありませんでした。 暗号化されるのは、それ以降に書き込まれたものだけです。既存の Secret は、誰かが書き直すまで平文のまま etcd に残り続けます。
第3巻 19 回のうち、現在位置は次のとおりです。
第1部 第3巻オリエンテーション
第1回: 第3巻スコープ + CKA 境界 + 4C/脅威モデル + Kubestronaut
第2部 クラスタ堅牢化(D1/D2)
第2回: kube-bench + CIS ベンチマーク修復 + バイナリ検証(D1)
第3回: NetworkPolicy 完全設計 + ノードメタデータ保護 + TLS Ingress(D1)
第4回: RBAC 監査 + ServiceAccount トークン管理(D2)
第5回: API Server 堅牢化 + kubeadm アップグレード(D2)
第3部 OS / ノード堅牢化(D3)
第6回: カーネル層の強制アクセス制御(SELinux/seccomp/capabilities/AppArmor)(D3)
第7回: ホスト OS の攻撃面最小化と権限管理(D3)
第4部 ワークロード防御(D4)
第8回: Pod Security Standards + Admission(D4)
★ 第9回: Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)(D4) ← 今ここ
第10回: マルチテナンシー分離 + サンドボックス(gVisor/RuntimeClass)(D4)
本回の起点
起点は第8回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働している前提で進めます。
| 項目 | 値 |
|---|---|
| ノード | 5/5 Ready・Kubernetes v1.36.3・containerd 2.2.6・AlmaLinux 10.2 |
| Pod | 102 個・異常 0 |
| fanclub | https://fanclub.local/ が 200(/api/members が JSON を返す) |
| Longhorn | 4 ボリューム attached healthy |
| ArgoCD | fanclub-api-prod が Synced / Healthy |
| Helm | fanclub revision 7 deployed(chart fanclub-api-1.0.0 / appVersion 0.3.2) |
| etcd | RAFT TERM 14 で 3 台一致・leader 1 台・RAFT INDEX の差は 1 以内・DB 124〜125 MB |
| 全 namespace の Secret | 43 個 |
fanclub の PSA ラベル | enforce=restricted(第8回で付与) |
--encryption-provider-config | 無し(本回で初めて足します) |
| alma-proxy の whitelist | 41 行(本回で bitnami.github.io を 1 行足して 42 行になります) |
スナップショットから復元した直後の見え方について。
順序起動と dnsmasq の再起動を終えた直後は、calico-node / coredns / fluent-bit が Unknown、metallb-speaker が CrashLoopBackOff に見えます。これは復元直後の既知の挙動で、5 分待つと 102 個すべて Running / Completed に戻ります。 backend は DB Pod の作り直しで一時的に 1/2 になります。Pod 数が 102 に揃うまで、本回の作業は始めないでください。
curl https://fanclub.local/ が 000 を返す場合。
原因は 2 つあります。①k8s-ops の no_proxy(/etc/profile.d/proxy.sh)に fanclub.local が入っていないため、Squid 経由になって exit 56 / HTTP コード 000 が返る。②fanclub.local の証明書は cert-manager の自己署名 CA が発行しているため、検証に失敗して exit 60 / HTTP コード 000 が返る。 本書の回帰確認コマンドは -k --noproxy "*" を付けた形で統一します。 ①を恒久的に直すなら /etc/profile.d/proxy.sh の no_proxy に fanclub.local を足してください。
演習の進め方
本回は「やってみよう」が 5 本あり、通しで実施すると約 155 分(15 / 40 / 45 / 25 / 30 分)かかります。
やってみよう②と③は続けて 85 分を確保してから始めてください。 この 2 本は 3 台の Control Plane Node の API Server 設定を触り、しかも②の終わりと③の始まりが地続きです。途中で止めると 3 台のうち一部だけ設定が入った状態になり、「たまに暗号化されない Secret ができる」という、後から気づけない形の不整合が残ります。やってみよう③ のステップ9 では、意図的に読めない状態を作って復旧させます。 ここで止めると Secret が読めないままのクラスタが残ります。
①は読み取りだけなので単独で実施できます(15 分)。④と⑤は連続しており、④で作った SealedSecret を⑤で Git に載せます(合わせて 55 分)。
CKS 対策としての優先度は ② → ③ → ① の順です。 この 3 本で Manage Kubernetes secrets の実技部分はひととおり手が動くようになります。④と⑤は実務パートで、④のうち試験に効くのは --dry-run=client -o yaml の使い方だけです。まとまった時間が取れないときは、②と③を先に通してください。
本文のコマンドは読みやすさのため kubectl と書いていますが、試験では alias k=kubectl が最初から設定されています(bash 補完も入っています)。本回は第3巻でいちばん kubectl を打つ回——網羅確認のループも、全件の再暗号化も kubectl です——なので、手元でも alias k=kubectl を設定しておくと写経が速くなります。あわせて export do="--dry-run=client -o yaml" も入れておいてください。 やってみよう④ でそのまま使います。
この回のゴール
本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。
- etcd の生データを直接読んで、Secret が平文で保存されていることを確認できる
EncryptionConfigurationを書き、プロバイダを理由づけて選べる(CIS の許容リストと公式の推奨の交差点)- 3 台の Control Plane Node に配り、
kubeadm-configに恒久化し、既存 Secret を再暗号化できる - 暗号化キーを安全な順序でローテーションできる(新鍵を先頭に足す → 反映 → 全件再暗号化 → そのあとで旧鍵を消す)。順序を破ったときのエラーと復旧手順も説明できる
kubesealで Secret を封印し、既存 Secret の所有権を SealedSecret へ移せる- 封印済み Secret を Git に置き、ArgoCD から復元できる(スコープと GC の挙動を含めて)
base64 は暗号化ではない —— 3 段階で確かめる
Kubernetes の Secret は、data フィールドの値が base64 エンコードされています。base64 は可逆変換であり、鍵を必要としません。 つまり暗号化ではありません。ここまでは第1巻第10回で扱いました。そこで「保存時の暗号化は第3巻で扱う」と書いています。本回がその回です。
ここで Encryption at Rest(保存時暗号化)という語を使います。データがディスクに置かれている状態での暗号化のことで、通信路の暗号化(TLS)とは守る対象が違います。TLS は「運んでいる途中を守る」もの、Encryption at Rest は「置いてある間を守る」ものです。
本節では、同じ 1 つの Secret を 3 つの深さで見ます。
| 段階 | 見るもの | コマンド |
|---|---|---|
| ① | Kubernetes API 越しの data | kubectl get secret ... -o jsonpath='{.data.DB_PASSWORD}' |
| ② | base64 をデコードした値 | 上に | base64 -d を足す |
| ③ | etcd に実際に書かれているバイト列 | etcdctl get /registry/secrets/fanclub/fanclub-db-secret | hexdump -C |
③まで降りるのが本回の出発点です。 ①②は「API を叩ける人には見える」という話ですが、③は「etcd のファイルやバックアップを手に入れた人には見える」という別の話になります。守る対象が違います。
本回の主役 —— fanclub-db-secret
実行コマンド(k8s-ops 上・developer):
$ kubectl describe secret fanclub-db-secret -n fanclub
実行結果:
Name: fanclub-db-secret
Namespace: fanclub
Labels: app.kubernetes.io/managed-by=Helm
velero.io/backup-name=fanclub-backup
velero.io/restore-name=fanclub-backup-20260726030303
Annotations: meta.helm.sh/release-name: fanclub
meta.helm.sh/release-namespace: fanclub
Type: Opaque
Data
====
DB_PASSWORD: 11 bytes
DB_USER: 7 bytes
この出力から読めることを 3 点に分解します。
DB_PASSWORD: 11 bytes——describeは値を表示しません。バイト数だけを出します。これを「安全」と読むと誤ります。kubectl get -o yamlなら値(base64)がそのまま出ますapp.kubernetes.io/managed-by=Helmとmeta.helm.sh/release-name: fanclub—— この Secret の正本は Helm chart です。誰かがkubectl editしても、次のhelm upgradeで戻ります(本回の後半で扱います)velero.io/backup-name/velero.io/restore-name—— 第2巻でバックアップ・リストアした痕跡です。バックアップにも同じ値が入っているという事実が、後で効いてきます
chart のテンプレートに平文でベタ書きされている
本回の題材はラボの中に最初から存在します。 ~/fanclub-chart/templates/secret.yaml の全量です。
apiVersion: v1
kind: Secret
metadata:
name: fanclub-db-secret
namespace: {{ .Values.namespace }}
type: Opaque
stringData:
DB_USER: appuser
DB_PASSWORD: apppassword
stringData は base64 エンコードすら要らないフィールドです。
API Server が受け取った時点で data へ base64 変換されます。書く側から見れば楽ですが、このファイルを Git に置いた瞬間、apppassword という文字列が履歴に永久に残ります。 後から消しても、Git の履歴からは消えません(git filter-repo などで履歴そのものを書き換えない限り)。
backend はこの Secret を envFrom.secretRef で丸ごと環境変数に流し込んでいます(~/fanclub-chart/templates/backend-deployment.yaml)。つまり DB_USER / DB_PASSWORD は backend コンテナの環境変数に平文で入ります。
~/fanclub-chart/templates/backend-deployment.yaml の該当箇所(51〜61 行):
containers:
- name: backend
image: {{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: fanclub-config
- secretRef:
name: fanclub-db-secret
etcd に何が書かれているか
etcdctl get /registry/secrets/fanclub/fanclub-db-secret | hexdump -C の出力は 45 行あります。
- 先頭は
k8s\x00—— 平文で保存されている印です - 平文の
apppassword/appuserは 45 行目付近(末尾)に出ます
実行結果(末尾 3 行の抜粋):
00000280 42 5f 50 41 53 53 57 4f 52 44 12 0b 61 70 70 70 |B_PASSWORD..appp|
00000290 61 73 73 77 6f 72 64 12 12 0a 07 44 42 5f 55 53 |assword....DB_US|
000002a0 45 52 12 07 61 70 70 75 73 65 72 1a 06 4f 70 61 |ER..appuser..Opa|
右端の ASCII 表示に apppassword と appuser が並んでいます。鍵は 1 つも使っていません。 etcd のデータファイルを手に入れた人には、この文字列がそのまま見えます。
hexdump -C | head では出ません。
先頭 12 行は managedFields の JSON で埋まっており、そこに平文はありません。| hexdump -C | tail -8 を使ってください。| strings | grep -A1 DB_PASSWORD でも同じものが見えます。「head で見えるはず」と書いてある手順書は、Secret のサイズが小さい場合にしか当てはまりません。
managedFields とは何か。 どのフィールドを誰がいつ書き換えたかを API Server が記録するメタデータです。Server-Side Apply が所有権を判定するために使っており、Secret のように本体が小さいリソースでは、この記録のほうが本体より長くなることがあります。 本回の fanclub-db-secret がその状態で、45 行のうち先頭 12 行を managedFields が占めています。
etcdctl の作法(本回で使うので先に整理する)
| 項目 | 本書ラボの実測 |
|---|---|
etcdctl version | 3.6.6 / API version 3.6 |
ETCDCTL_API=3 | 不要。 付けると {"level":"warn",...,"msg":"unrecognized environment variable","environment-variable":"ETCDCTL_API=3"} が出る |
which | AlmaLinux 10 に存在しない。 command -v を使う |
| 証明書 | --cacert=/etc/kubernetes/pki/etcd/ca.crt / --cert=/etc/kubernetes/pki/etcd/server.crt / --key=/etc/kubernetes/pki/etcd/server.key |
ETCDCTL_API=3 は etcd v2 API が現役だった時代の名残で、多くの手順書に残っています。etcd 3.4 以降は v3 が既定であり、3.6 では「知らない環境変数だ」という警告が返ります。 動作はしますが、警告が混ざると実行結果が読みにくくなるので本書では付けません。試験環境の etcd がどのバージョンかは開いてみないと分かりませんが、付けても付けなくても v3 で動きます。

やってみよう①:base64 をデコードし、etcd の生データで平文を確認する
本回の 5 つの演習には、所要時間と「試験相当の粒度はどこか」を併記しています。
LF は出題数・各問の配点・試験クラスタのノード構成を公表していません。本書が書く「試験ではここが 1 問(何分)」は試験時間 120 分から逆算した本書独自の目安であり、公式情報ではありません。数字そのものではなく、ラボで手を動かす範囲と試験で問われる範囲の差を読み取ってください。
所要 15 分(うち試験相当の粒度は ステップ4 の 5 分・読み取りのみで環境は 1 バイトも変わりません)。
公式コンピテンシー Manage Kubernetes secrets に照らすと、試験で「etcd の中身を直接読め」という設問が出る可能性は低いです。ただし 「暗号化されているかどうかを確認せよ」という形は出題されえます。そのときに使うのが本演習のステップ4 のコマンドです。ステップ1〜3・5 は本番作法(現状把握)です。
対象は k8s-ops 上の kubectl と、k8s-cp-01 上の etcdctl(root)です。
ステップ1:Secret の総数と種類を把握する
実行コマンド(k8s-ops 上・developer):
$ kubectl get secrets --all-namespaces
実行結果は 43 行(ヘッダを除く)でした。namespace 別の内訳は次のとおりです。
| namespace | Secret の数 |
|---|---|
argocd | 5 |
cert-manager | 3 |
fanclub | 9 |
gatekeeper-system | 2 |
longhorn-system | 3 |
metallb-system | 3 |
monitoring | 14 |
traefik | 2 |
velero | 2 |
| 合計 | 43 |
kube-system には Secret が 1 つもありません。 本回の後半で sealed-secrets を導入すると、ここに封印鍵の Secret が 1 個できます。
続けて型ごとの件数を数えます。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secrets --all-namespaces -o custom-columns=TYPE:.type --no-headers | sort | uniq -c
実行結果:
22 Opaque
17 helm.sh/release.v1
4 kubernetes.io/tls
この 3 行から、次の 3 点が読み取れます。
helm.sh/release.v1型(sh.helm.release.v1.*)が 17 個で最多です(fanclubだけで v1〜v7 の 7 個)。これらも再暗号化の対象になりますkubernetes.io/tls型が 4 個(fanclub-tls/fanclub-ca-key-pair/longhorn-webhook-*)kubernetes.io/service-account-token型が 1 つもありません。 ServiceAccount トークン Secret は v1.24 以降の既定で作られず、第4回で扱ったTokenRequestに置き換わっています
-A という短縮形もありますが、本書は公式ドキュメントの表記に合わせて --all-namespaces で統一します。動作は同じです。
ステップ2:base64 をデコードする
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub -o jsonpath='{.data.DB_PASSWORD}'
実行結果:
YXBwcGFzc3dvcmQ=
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
実行結果:
apppassword
base64 -d に鍵は要りません。 これが「base64 は暗号化ではない」の実物です。Secret を read できる権限があれば、値は読めます。 権限側で絞る話は第4回(RBAC)で扱いました。本回が扱うのは、API を経由せずに読まれる経路です。
ステップ3:etcd に降りる準備をする
実行コマンド(k8s-cp-01 上・root):
# etcdctl version
実行結果:
etcdctl version: 3.6.6
API version: 3.6
ステップ4:etcd の生データを読む
実行コマンド(k8s-cp-01 上・root):
# etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/fanclub/fanclub-db-secret | hexdump -C | head -3
実行結果:
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 66 61 6e 63 6c 75 62 2f 66 61 6e 63 6c 75 |s/fanclub/fanclu|
00000020 62 2d 64 62 2d 73 65 63 72 65 74 0a 6b 38 73 00 |b-db-secret.k8s.|
3 行目の末尾に 6b 38 73 00(k8s\x00)が見えます。 これが平文で保存されている印です。4 行目から先は managedFields の JSON が続くので、ここに平文の値は出てきません。
実行コマンド(k8s-cp-01 上・root):
# etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/fanclub/fanclub-db-secret | hexdump -C | tail -8
実行結果:
00000250 50 41 53 53 57 4f 52 44 22 3a 7b 7d 2c 22 66 3a |PASSWORD":{},"f:|
00000260 44 42 5f 55 53 45 52 22 3a 7b 7d 7d 2c 22 66 3a |DB_USER":{}},"f:|
00000270 74 79 70 65 22 3a 7b 7d 7d 42 00 12 1a 0a 0b 44 |type":{}}B.....D|
00000280 42 5f 50 41 53 53 57 4f 52 44 12 0b 61 70 70 70 |B_PASSWORD..appp|
00000290 61 73 73 77 6f 72 64 12 12 0a 07 44 42 5f 55 53 |assword....DB_US|
000002a0 45 52 12 07 61 70 70 75 73 65 72 1a 06 4f 70 61 |ER..appuser..Opa|
000002b0 71 75 65 1a 00 22 00 0a |que.."..|
000002b8
上から 3 行は managedFields の JSON の末尾です。4 行目以降に DB_PASSWORD / apppassword / DB_USER / appuser / Opaque が順に並んでいます。 最終行の 000002b8 は、このデータ全体のバイト数(0x2b8 = 696 バイト)を示す hexdump の終端表示です。
head と tail で結果が変わる理由。
この出力は全部で 45 行あります。先頭の 12 行は managedFields——「どのフィールドを誰がいつ書いたか」の記録——の JSON で埋まっており、平文は出ません。 値は末尾にあります。head で見て「暗号化されている」と早合点しないでください。
ステップ5:行数を数えて構造を掴む
実行コマンド(k8s-cp-01 上・root):
# etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/fanclub/fanclub-db-secret | hexdump -C | wc -l
実行結果:
45
45 行のうち、先頭 12 行が managedFields、末尾の数行に本体の値が入っている——という構造が見えていれば十分です。
この演習の合格条件
tail -8 の出力に apppassword と appuser が読める形で見えていること。 ここが本回の出発点であり、やってみよう②の完了後に同じコマンドを打つと、これが読めなくなることが到達点になります。
EncryptionConfiguration の構造と、プロバイダをどう選ぶか
etcd の Encryption at Rest は、API Server が etcd へ書き込む直前に値を暗号化し、読み出した直後に復号する仕組みです。etcd 側は何も知りません。暗号化しているのは API Server です——この一点を押さえると、後の挙動がすべて説明できます。
設定は EncryptionConfiguration という 1 枚の YAML で行います。API Server が起動時に読む設定ファイルで、どのリソースを・どのプロバイダで・どの鍵で暗号化するかを書きます。--encryption-provider-config フラグでそのパスを API Server に渡します。
プロバイダ 6 種と公式の評価
ここで プロバイダ(provider) という語を使います。暗号化アルゴリズムと鍵の持ち方の組み合わせのことで、identity / aescbc / aesgcm / secretbox / kms があります。公式ドキュメント(https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/)のプロバイダ表を要約します。
| プロバイダ | 暗号方式 | 公式の記述(要旨) |
|---|---|---|
identity | 暗号化しない | 平文で書く。先頭に置くと新規書き込みが平文になるが、既存の暗号文が自動で平文へ書き戻されるわけではない |
aescbc | AES-CBC + PKCS#7 | CBC のパディングオラクル攻撃に対する脆弱性があるため推奨されない |
aesgcm | AES-GCM | 20 万回の書き込みごとに鍵のローテーションが必要。自動鍵ローテーションの仕組みがある場合を除き推奨されない |
secretbox | XSalsa20 + Poly1305 | 比較的新しい暗号技術であり、厳格なレビューが要る環境では敬遠されうる |
kms v1 | エンベロープ暗号化 | v1.28 で非推奨 |
kms v2 | エンベロープ暗号化 | v1.29 で stable。本番の第一候補だが、外部の KMS プラグインが要る |
表に出た用語を 3 つ補っておきます。secretbox は NaCl 系の XSalsa20 + Poly1305 を使うプロバイダで、認証付き暗号なので暗号文の改ざんが検出できます。KMS プラグインは外部の鍵管理サービスに 鍵そのものを預ける方式で、API Server は「データ暗号鍵を暗号化してもらう」だけを外部に頼みます。これを エンベロープ暗号化 と呼び、データを暗号化する鍵(DEK)をさらに別の鍵(KEK)で暗号化して保管する二段構えのことです。KEK だけを外部の金庫に置けるのが利点です。
aesgcm の「20 万回」が何を意味するかも押さえておきます。GCM は nonce(使い捨ての数値)を鍵ごとに再利用してはいけない方式です。書き込み回数が増えると nonce が衝突する確率が無視できなくなるため、回数を基準に鍵を替える運用が前提になります。その仕組みを自分で用意しないなら選ぶべきではない、というのが公式の立場です。Kubernetes は書き込み回数を数えてくれません。
CIS ベンチマークが許すのは 3 つだけ
kube-bench の CIS 1.12 プロファイルには、プロバイダを名指しで検査する項目があります。実機のファイル(/etc/kube-bench/cfg/cis-1.12/master.yaml の 742〜757 行)を引用します。
- id: 1.2.28
text: "Ensure that encryption providers are appropriately configured (Manual)"
audit: |
ENCRYPTION_PROVIDER_CONFIG=$(ps -ef | grep $apiserverbin | grep -- --encryption-provider-config | sed 's%.*encryption-provider-config[= ]\([^ ]*\).*%\1%')
if test -e $ENCRYPTION_PROVIDER_CONFIG; then grep -A1 'providers:' $ENCRYPTION_PROVIDER_CONFIG | tail -n1 | grep -o "[A-Za-z]*" | sed 's/^/provider=/'; fi
tests:
test_items:
- flag: "provider"
compare:
op: valid_elements
value: "aescbc,kms,secretbox"
この 11 行から 3 つのことが読めます。
- 判定対象は「設定ファイルの
providers:の次の 1 行」= 先頭プロバイダだけです(grep -A1 'providers:' | tail -n1)。2 番目以降は見ていません - 許容値は
aescbc/kms/secretboxの 3 つ。aesgcmは含まれていません auditはps -efから--encryption-provider-configのパスを抜き出しています。つまり フラグが無ければこの項目は評価すらされません(それが 1.2.28 が WARN のまま残っていた理由です)
同一クラスタでの対照実験
著者が実機で確かめました。やってみよう② を終えて --encryption-provider-config が有効になっている状態で、API Server は再起動せず、設定ファイルの先頭プロバイダ名だけを差し替えて kube-bench を実行しています。1.2.28 は ps -ef でフラグの値を拾い、そのファイルの中身を grep するだけなので、API Server がメモリに持っている設定とは無関係に判定が変わります。
| 設定ファイルの先頭プロバイダ | 1.2.28 の判定 |
|---|---|
secretbox | [PASS] |
aesgcm | [WARN](据え置き) |
実行コマンド(k8s-cp-01 上・root):
# export KUBECONFIG=/etc/kubernetes/admin.conf
# kube-bench run --targets master | grep 1.2.28
実行結果(先頭プロバイダが secretbox のとき):
[PASS] 1.2.28 Ensure that encryption providers are appropriately configured (Manual)
実行結果(先頭プロバイダを aesgcm に差し替えたとき):
[WARN] 1.2.28 Ensure that encryption providers are appropriately configured (Manual)
この対照実験は、著者が別枠で実施した記録です。読者は再現しないでください。
理由は 2 つあります。①本節の時点では /etc/kubernetes/enc/enc.yaml がまだ存在せず、--encryption-provider-config も設定されていないので、1.2.28 は評価されず [WARN] のままになります(上の 3 点目のとおりです)。②やってみよう③ を終えたあとなら評価はされますが、そのときは自動リロードが有効です。 先頭プロバイダ名を aesgcm に書き換えた瞬間、k8s:enc:secretbox:v1:key3: を復号できる設定がクラスタから消えます。 やってみよう③ のステップ9 で作るのと同じ「Secret が読めない」状態が、意図しない形で発生します。判定の違いは、上に載せた 2 本の実出力で確認してください。
この対照実験を載せる理由。
「推奨されているから」ではなく、「自分のクラスタで検査ツールがどう判定するかを確かめてから決める」という手順そのものを教材にしています。CKS は設定を書く試験であると同時に、書いた設定を検査する試験でもあります。 本回は 第2回 で導入した kube-bench を、設計判断の道具として使い直す回でもあります。
本書が secretbox を選ぶ理由
3 つの制約の交差点として決まります。
| 制約 | 帰結 |
|---|---|
CIS 1.2.28 が許すのは aescbc / kms / secretbox | aesgcm は落ちる |
公式が aescbc を非推奨(パディングオラクル) | aescbc も落ちる |
kms v2 が本番の第一候補だが、外部 KMS プラグインが要る | ラボに KMS は無い |
残るのは secretbox ひとつ。 これが本書ラボの選択です。
本番でどう選ぶか。
クラウド上のマネージド Kubernetes を使うなら kms v2 を選んでください。 鍵そのものをクラスタの外(KMS)に置けるので、etcd のバックアップを盗まれても、KMS へのアクセス権が無ければ復号できません。 本書ラボが secretbox を選ぶのは 外部 KMS が無いからであって、secretbox が kms より優れているからではありません。「静的鍵をノード上のファイルに置いている」という限界を自覚したうえで使ってください。
暗号化する対象を決める
resources にはリソース種別を複数書けます。本書は secrets のみを対象にします。
configmaps も暗号化できます。 ただし ConfigMap は本来「秘密でない設定」を置く場所であり、暗号化すると kubectl get cm -o yaml の読み出しに復号処理が挟まるぶんだけ API Server の負荷が増えます。*.*(全リソース)を指定することもできますが、本番で推奨される形ではありません。 何を秘密として扱うかを決めてから書いてください。
鍵の生成は公式ドキュメントの値をそのまま使います。
実行コマンド(k8s-cp-01 上・root):
# head -c 32 /dev/urandom | base64
32 バイトの乱数を base64 にすると 44 文字になります(末尾は = のパディング)。この 44 文字が鍵そのものです。 画面共有中に実行しないでください。
プロバイダ配列の順序が決めるもの —— 先頭・末尾・identity
providers は配列です。順序には明確な意味があります。
| 位置 | 書き込み時 | 読み取り時 |
|---|---|---|
| 先頭 | 必ずこれが使われる | 最初に試される |
| 2 番目以降 | 使われない | 上から順に試される |
書き込みは先頭ひとつ、読み取りは上から全部。 この非対称性が、あらゆる移行手順の土台になります。
identity を末尾に置く理由
本回で最初に置く設定は次の形になります。
providers:
- secretbox:
keys:
- name: key1
secret: <44 文字の鍵>
- identity: {}
- 書き込みは
secretbox—— 以降に作られる Secret はすべて暗号化されます - 読み取りは
secretbox→identity—— すでに etcd にある平文の Secret も読めます
identity が末尾に無いとどうなるか。 平文の Secret を読もうとして復号に失敗し、その Secret を参照する Pod が起動できなくなります。 移行の途中で identity を外すのは、まだ暗号化していない Secret を捨てるのと同じです。
| やりたいこと | 配列の書き方 |
|---|---|
| これから暗号化を始める(本回の状態) | secretbox → identity |
全件の再暗号化が完了した(identity を外せる) | secretbox のみ |
| 鍵をローテーションする | secretbox(key2, key1) —— 同じプロバイダの keys 配列の中で先頭を入れ替える |
暗号化をやめる(kubernetes.io/docs の Decrypt ページ) | identity → secretbox にしてから、全件を kubectl replace で書き戻す |
「暗号化をやめる」ときも再暗号化が要ります。 identity を先頭に置いただけでは、すでに暗号化されている Secret が平文へ戻るわけではありません。 読み取りは 2 番目の secretbox が引き受けるので動き続けますが、etcd の中身は暗号文のままです。 平文へ戻すには、やはり 全件を読み直して書き戻す必要があります。入るときも出るときも同じ一手間が要る、と覚えてください。
keys 配列の中の順序
keys も配列であり、同じ規則が適用されます。
| 位置 | 意味 |
|---|---|
keys の先頭 | 新規書き込みの暗号化に使われる鍵 |
keys の 2 番目以降 | 復号にだけ使われる(古い鍵を残しておく場所) |
鍵の name(key1 / key2 …)は etcd に書かれるプレフィックスに現れます。 k8s:enc:secretbox:v1:key1: の key1 がそれです。どの鍵で暗号化されたかを、暗号文自体が名乗っています。 これがローテーションの進捗を数える手がかりになります。
この「名乗り」は、読み取りのときに照合されます。 API Server は暗号文の先頭のプレフィックスを読み、設定側の同じプロバイダ名・同じ鍵名を探しにいきます。 見つからなければ復号は始まりません。だから「鍵の中身は同じままで name: だけを key2 から key9 に変える」という編集をしても、key2 で暗号化された Secret は読めなくなります。 鍵の名前は、単なるラベルではありません。
CKS 試験での使いどころ。
kubernetes.io/docs の「Encrypting Confidential Data at Rest」は試験中に参照できます。 そのページには EncryptionConfiguration の完全な YAML と、鍵生成のコマンド(head -c 32 /dev/urandom | base64)が載っています。丸暗記する必要はありません。 暗記すべきは 「providers の先頭が書き込み」「identity を末尾に置く」「設定を入れただけでは既存は暗号化されない」という 3 つの原則のほうです。ページを開いても、この 3 つは表になっていません。

3 点セットと kubeadm-config への恒久化 —— HA では 3 台すべてに配る
この節は読み取りの解説です。
ここに出てくるコマンドは、この節では打ちません。 実際に手を動かすのは次の やってみよう②です。
--encryption-provider-config はファイル参照フラグです。API Server は static Pod として動いているので、コンテナの中からそのファイルが見えなければなりません。 したがって次の 3 つをセットで書きます。
| # | 場所 | 書く内容 |
|---|---|---|
| ① | spec.containers[0].command | - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml |
| ② | spec.containers[0].volumeMounts | /etc/kubernetes/enc を readOnly: true でマウント |
| ③ | spec.volumes | hostPath で /etc/kubernetes/enc(type: DirectoryOrCreate) |
①だけ書くと API Server は起動に失敗します。 ファイルが見えないからです。第5回の auth-config とまったく同じ形なので、本回は 2 度目になります。
ファイル単体ではなくディレクトリをマウントする
volumes:
- hostPath:
path: /etc/kubernetes/enc
type: DirectoryOrCreate
name: enc-config
type: File でファイル 1 枚をマウントする書き方も動きます。
ただし本書は採りません。 理由は 2 つです。①公式ドキュメントと第5回の auth-config がディレクトリ方式なので、形を揃えたほうが迷いません。②鍵をローテーションするとき、エディタによってはファイルを書き換えるのではなく新しいファイルを作って置き換えます(inode が変わります)。ファイル単体マウントだと、コンテナの中からは古い中身が見え続けることがあります。 ディレクトリをマウントしていれば、この問題は起きません。
パスは /etc/kubernetes/enc/enc.yaml に揃える
公式ドキュメントが使っているパスをそのまま採ります。 試験中に kubernetes.io/docs を開いて手を動かすとき、パスが一致していると迷いません。手順書を読みながら別のパスに読み替える作業は、時間を食うだけでなく、volumeMounts と volumes のどちらかを直し忘れる原因になります。第8回の /etc/kubernetes/admission/ とは同居させません。 用途が違うディレクトリを分けておくと、権限(700 / 600)も別々に管理できます。
ファイル権限は 第7回の最小権限の続きです。
| 対象 | 権限 |
|---|---|
/etc/kubernetes/enc/ | 700(root のみ) |
/etc/kubernetes/enc/enc.yaml | 600(root のみ読み書き) |
この 2 つを緩めると暗号化の意味が消えます。 ノードにログインできる人が鍵ファイルを読めるなら、その人は etcd の暗号文も復号できます。 「etcd を暗号化した」と言えるのは、鍵ファイルへのアクセスが etcd データへのアクセスより厳しく管理されているときだけです。
kubeadm-config にも書く —— ノード上のファイルは結果であって正本ではない
第5回で確立した原則をそのまま適用します。/etc/kubernetes/manifests/kube-apiserver.yaml を手で書き換えても、kubeadm upgrade を実行すると kubeadm が ClusterConfiguration から生成し直すので消えます。 正本は kube-system の kubeadm-config ConfigMap です。
現在の apiServer: ブロック(第5回で確立した内容):
apiServer:
extraArgs:
- name: authentication-config
value: /etc/kubernetes/auth/anonymous.yaml
- name: kubelet-certificate-authority
value: /etc/kubernetes/pki/ca.crt
- name: profiling
value: "false"
- name: service-account-extend-token-expiration
value: "false"
- name: service-account-max-token-expiration
value: 24h
extraVolumes:
- name: auth-config
hostPath: /etc/kubernetes/auth
mountPath: /etc/kubernetes/auth
readOnly: true
pathType: DirectoryOrCreate
本回で足すのは 2 つの extraArgs と 1 つの extraVolumes です。足すタイミングは 2 回に分かれます。
| いつ | 足すもの |
|---|---|
| やってみよう② ステップ11 | extraArgs に encryption-provider-config の 1 件 + extraVolumes に enc-config の 1 件 |
| やってみよう③ ステップ7 | extraArgs に encryption-provider-config-automatic-reload の 1 件(追記のみ) |
なぜ 2 回に分けるのか。 やってみよう② だけを実施して次回へ進む読者を想定しているからです。②で恒久化を済ませておけば、そこで止めても kubeadm upgrade で設定が消えることはありません。 ③で足す automatic-reload は、③を実施した読者だけが必要とするフラグです。
やってみよう② ステップ11 を終えた時点の apiServer: ブロックは次のようになります(全量)。
apiServer:
extraArgs:
- name: encryption-provider-config
value: /etc/kubernetes/enc/enc.yaml
- name: authentication-config
value: /etc/kubernetes/auth/anonymous.yaml
- name: kubelet-certificate-authority
value: /etc/kubernetes/pki/ca.crt
- name: profiling
value: 'false'
- name: service-account-extend-token-expiration
value: 'false'
- name: service-account-max-token-expiration
value: 24h
extraVolumes:
- hostPath: /etc/kubernetes/enc
mountPath: /etc/kubernetes/enc
name: enc-config
pathType: DirectoryOrCreate
readOnly: true
- hostPath: /etc/kubernetes/auth
mountPath: /etc/kubernetes/auth
name: auth-config
pathType: DirectoryOrCreate
readOnly: true
extraArgs の先頭に追加される点に注意してください。
実機で kubectl edit を通したところ、新しい encryption-provider-config は配列の先頭に入り、第5回で足した 5 件がその後ろに並びました。 また value: "false" のダブルクォートは、保存後 value: 'false' のシングルクォートに正規化されます。 上の「編集後」の全量はその形で載せています。「自分が書いた順に並ぶ」と思って読むと、差分の確認で混乱します。
やってみよう③ ステップ7 を終えた時点では、上記の extraArgs に次の 1 件が加わります。
- name: encryption-provider-config-automatic-reload
value: 'true'
この 1 件がどこに入るかは、編集の仕方で決まります。 本書の実機検証では kubectl patch で追記したため extraArgs の先頭に入りました。kubectl edit で手編集する場合は、配列のどこに置いても動作は同じです。並び順に意味はありません(providers や keys の配列とは違います)。
HA では 3 台すべてに配る —— 今度は「たまに暗号化されない」
第8回で、AdmissionConfiguration を 1 台だけに置くと「たまに効く」状態になることを扱いました。暗号化でも同じことが起きます。ただし症状はもっと悪くなります。
| AdmissionConfiguration(第8回) | EncryptionConfiguration(本回) | |
|---|---|---|
| 1 台だけに置いた場合 | 同じコマンドが通ったり拒否されたりする | 同じ Secret 作成が、暗号化されたりされなかったりする |
| 気づけるか | kubectl の出力が変わるので、繰り返せば気づける | kubectl の出力は毎回同じ。 気づけない |
| 発覚するタイミング | すぐ | etcd を直接読んだとき。または漏洩したとき |
本回で最も重い注意点です。
k8s-lb の HAProxy は roundrobin で 3 台の API Server に振り分けます。設定を 1 台にしか入れていないと、その 1 台を経由した書き込みだけが暗号化されます。 kubectl get secret の出力は どちらでも同じです。復号は成功するからです。「暗号化したつもりで、3 分の 2 が平文」という状態は、etcd を直接読むまで誰も気づけません。 3 台に配ったことを、必ず ls -l で確認してください。
なお、この「3 台に配る」という手間は本書ラボ固有です。 CKS の試験クラスタは 1 台構成のことが多いので、設定ファイルの配置も静的 Pod マニフェストの編集も 1 回で終わります。本書が 3 回繰り返すのは、本番の HA クラスタで必ず必要になるからです。試験で 3 台構成が出たら、この節の内容がそのまま効きます。
やってみよう②:etcd Encryption at Rest を 3 台の Control Plane Node に適用する
所要 40 分(うち試験相当の粒度は ステップ1〜6 と ステップ9・本書ラボで 15 分。1 台構成なら 8〜10 分)。
公式コンピテンシー Manage Kubernetes secrets に照らすと、「指定のクラスタで Secret の保存時暗号化を有効にし、既存の Secret を暗号化せよ」という形の設問を想定できます。鍵を作り(ステップ1)、EncryptionConfiguration を書き(ステップ2)、3 点セットを入れて起動を確認し(ステップ4〜5)、全件を再暗号化する(ステップ9)までが、その 1 問の中身です。試験は 1 台構成のことが多いので、ステップ4〜6 は 1 回で済みます。本書ラボは 3 台なので 3 回繰り返します。 ステップ10・11・12(網羅確認・kubeadm-config への恒久化・kube-bench)は本番作法で、試験では求められない可能性が高い部分です。
この演習は必ず ステップ12 まで通してください。
3 台のうち一部にだけ設定が入った状態で止めると、「たまに暗号化されない Secret」ができます。 しかも kubectl の出力からは判別できません。途中で中断する場合は、kube-apiserver.yaml をバックアップから書き戻して、全台を元の状態へ戻してください。
続けて やってみよう③ に入ります。 本演習(40 分)と やってみよう③(45 分)は地続きです。合わせて 85 分を確保してから始めてください。
対象は k8s-cp-01 / k8s-cp-02 / k8s-cp-03(root)と k8s-ops(developer)です。前提は、スナップショットからの復元後に 102 Pod がすべて Running / Completed に戻っていることです。
ステップ1:鍵を 1 つ生成する
実行コマンド(k8s-cp-01 上・root):
# head -c 32 /dev/urandom | base64
実行結果:
4+FJTOvTwpmqpgyo+hEGjIuCgeYb+hHpbgl4cf4JuK0=
出力された 44 文字が鍵です。この値は読者ごとに異なります。記事の値をそのまま使わないでください。 上に載せた 44 文字は掲載用のダミーであり、本書ラボで実際に使った鍵ではありません。
3 台で別々に生成してはいけません。 3 台の enc.yaml は同一内容である必要があります。k8s-cp-01 で 1 回生成し、その値を 3 台に配ります。 鍵が違うと、cp-01 が暗号化した Secret を cp-02 が復号できません。
配り方について。
生成された 44 文字を、画面から手元に控えてください。 次のステップ2 では k8s-cp-01 / k8s-cp-02 / k8s-cp-03 の 3 台それぞれで heredoc を打ち、同じ 44 文字を 3 回貼り込みます。
scp で配る方法は本書では採りません。 Control Plane Node 間の root SSH が通るかは本書ラボの構成で保証しておらず、通す設定を入れること自体が第7回の最小権限の方針に反するからです。3 回貼るのが確実です。
貼り間違いは後で必ず表面化します。 ステップ8 で新規 Secret が暗号化されることを確認したあと、ステップ10 の網羅確認ループが 1 台分だけ失敗するという形で出ます。貼り終えたら 3 台で md5sum /etc/kubernetes/enc/enc.yaml を打ち、3 台のハッシュが一致することで機械的に照合してください。 cat の出力を目で見比べる方法もありますが、44 文字の base64 は 1 文字違っても目では気づけません。
ステップ2:設定ファイルを 3 台すべてに置く(同じ鍵を 3 回貼る)
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# mkdir -p /etc/kubernetes/enc
# chmod 700 /etc/kubernetes/enc
設定ファイル /etc/kubernetes/enc/enc.yaml の全量です。
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key1
secret: <ステップ1 で生成した 44 文字>
- identity: {}
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# cat > /etc/kubernetes/enc/enc.yaml <<'EOF'
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key1
secret: <ステップ1 で生成した 44 文字>
- identity: {}
EOF
<ステップ1 で生成した 44 文字> の部分だけを、手元に控えた値で置き換えてください。 山かっこは残しません。 EOF は行頭から書きます(前に空白を入れると heredoc が終わりません)。vi で開いて書いても同じです。
heredoc で書くときは cat > /etc/kubernetes/enc/enc.yaml <<'EOF' のようにクォートしてください。 head -c 32 /dev/urandom | base64 の出力は A-Z / a-z / 0-9 / + / / / = だけなので、この鍵に限れば展開されて困る文字はありません。それでもクォートを付ける形で覚えてください。 パスワードや証明書など、$ やバッククォートを含みうる値を heredoc で貼る場面は、この先いくらでも出てきます。 そのときだけ思い出す、では間に合いません。
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# chmod 600 /etc/kubernetes/enc/enc.yaml
# ls -l /etc/kubernetes/enc/
実行結果(3 台とも同じ):
合計 4
-rw-------. 1 root root 274 7月 28 04:39 enc.yaml
サイズが 274 バイトで、権限が -rw-------(600)であることを 3 台で確認します。続けて内容の一致を機械的に照合します。
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# md5sum /etc/kubernetes/enc/enc.yaml
3 台のハッシュ値が完全に一致すれば合格です。 ハッシュ値そのものは読者が生成した鍵によって変わるので、本書には載せません。比べるのは「3 台が同じ値かどうか」だけです。1 台でも違えば、そのノードの enc.yaml を貼り直してください。
ステップ3:kube-apiserver.yaml をバックアップする
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# cp -p /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-09
バックアップ先は /etc/kubernetes/manifests/ の外にしてください。 このディレクトリは kubelet が監視しており、.bak という拡張子でも YAML として読もうとして異常な static Pod を作りにいきます(第5回・第8回と同じ作法です)。
ステップ4:3 点セットを書き込む(1 台ずつ)
/etc/kubernetes/manifests/kube-apiserver.yaml に次の 3 箇所を足します。
① command に 1 行(- kube-apiserver の下、他のフラグと同じ並びに):
- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
② volumeMounts に 1 ブロック:
volumeMounts:
- mountPath: /etc/kubernetes/enc
name: enc-config
readOnly: true
③ volumes に 1 ブロック:
volumes:
- hostPath:
path: /etc/kubernetes/enc
type: DirectoryOrCreate
name: enc-config
どのインデントで、どこへ入るのかを、書き込んだあとのファイルで確認しておきます。まず command(21〜28 行)です。
- --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
- --etcd-certfile=/etc/kubernetes/pki/apiserver-etcd-client.crt
- --etcd-keyfile=/etc/kubernetes/pki/apiserver-etcd-client.key
- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
- --etcd-servers=https://127.0.0.1:2379
- --kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.crt
- --kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client.key
- --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname
次に volumeMounts(97〜107 行)です。第5回で入れた auth-config の上に並びました。
timeoutSeconds: 15
volumeMounts:
- mountPath: /etc/kubernetes/enc
name: enc-config
readOnly: true
- mountPath: /etc/kubernetes/auth
name: auth-config
readOnly: true
- mountPath: /etc/ssl/certs
name: ca-certs
readOnly: true
最後に volumes(123〜133 行)です。
seccompProfile:
type: RuntimeDefault
volumes:
- hostPath:
path: /etc/kubernetes/enc
type: DirectoryOrCreate
name: enc-config
- hostPath:
path: /etc/kubernetes/auth
type: DirectoryOrCreate
name: auth-config
並べる位置そのものに意味はありません。 大事なのは インデントを既存の項目とそろえることと、volumeMounts の name と volumes の name が同じ enc-config であることの 2 点です。片方だけ書くと API Server は起動しません。
保存した瞬間に何が起きるか。
/etc/kubernetes/manifests/ は kubelet が監視しています。 ファイルを保存した瞬間に kubelet が変更を検知し、API Server コンテナを作り直します。 第5回・第8回と同じ挙動です。
- 保存直後の 40〜46 秒、
kubectlが応答しなくなります。これは正常です。 ステップ5 の/livezがokを返すまで待ってください。「壊した」と思って別のコマンドを叩き始めると、状態が読めなくなります - 編集したら、1 台ずつ
/livezを確認してから次へ進んでください。 3 台を同時に触ると、書き間違えたときに API Server が 3 台とも落ちてkubectlが使えなくなります - 書き間違えた場合は、ステップ3 で取った
/root/kube-apiserver.yaml.bak-09を書き戻してください。cp -p /root/kube-apiserver.yaml.bak-09 /etc/kubernetes/manifests/kube-apiserver.yamlで元に戻り、同じく 1 分弱で復旧します。ただし、この書き戻しが安全なのは「まだ 1 件も暗号化されていない本ステップの間だけ」です。 ステップ9 で全件を再暗号化したあとにこのファイルを戻すと、暗号化設定ごと消えて Secret が読めなくなります
ステップ5:API Server が起動したことを確認する
実行コマンド(k8s-cp-01 上・root):
# crictl ps | grep kube-apiserver
実行結果:
01918a8c09af2 3400718b4dd57 2 minutes ago Running kube-apiserver 0 4d1cd5c99008b kube-apiserver-k8s-cp-01 kube-system
2 minutes ago と Running が見えれば、新しいコンテナに入れ替わっています。 コンテナ ID(左端)は作り直すたびに変わるので、編集前に控えておくと「本当に入れ替わったか」が判定できます。
実行コマンド(k8s-cp-01 上・root):
# curl -k https://localhost:6443/livez
実行結果:
ok
ファイルを保存してから ok が返るまでは、実測で 40〜46 秒でした。 旧コンテナが消えて新しいコンテナが起動し、そこから /livez が通るまでの合計です。この待ち時間を短いと感じるか長いと感じるかで、慌てて追加のコマンドを叩くかどうかが変わります。 やってみよう③ で使う crictl rm -f 経由(明示的にコンテナを落とす方法)は 3〜6 秒で、こちらのほうが速く終わります。
1 台ずつ確認してから次へ進んでください。 3 台同時に編集すると、書き間違えたときに API Server が 3 台とも落ちて kubectl が使えなくなります。 HA の意味がなくなる操作です。
なぜ認証なしで /livez を叩けるのか。
通常、API Server のエンドポイントは認証が要ります。/livez が匿名で通るのは、第5回で入れた authentication-config(/etc/kubernetes/auth/anonymous.yaml)が、匿名アクセスを /livez などのヘルスエンドポイントに限定して許可しているからです。匿名を全面禁止にすると、この確認手段が使えなくなります。 「健全性の確認だけは匿名で通す」という設計は、第5回で選んだ落としどころそのものです。
ステップ6:ステップ4〜5 を k8s-cp-02 / k8s-cp-03 で繰り返す
k8s-cp-02 で 3 点セットを書き込み、/livez が ok を返すことを確認してから、k8s-cp-03 に移ります。
実行コマンド(k8s-cp-02 / k8s-cp-03 上・root):
# curl -k https://localhost:6443/livez
実行結果(k8s-cp-02 / k8s-cp-03 とも):
ok
3 台とも ok になれば、この演習の山場は越えています。 実測の所要は k8s-cp-02 が 46 秒、k8s-cp-03 が 41 秒でした。
ステップ7:既存の Secret は平文のままであることを確認する
実行コマンド(k8s-cp-01 上・root):
# etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/fanclub/fanclub-db-secret | hexdump -C | tail -8
実行結果:
00000250 50 41 53 53 57 4f 52 44 22 3a 7b 7d 2c 22 66 3a |PASSWORD":{},"f:|
00000260 44 42 5f 55 53 45 52 22 3a 7b 7d 7d 2c 22 66 3a |DB_USER":{}},"f:|
00000270 74 79 70 65 22 3a 7b 7d 7d 42 00 12 1a 0a 0b 44 |type":{}}B.....D|
00000280 42 5f 50 41 53 53 57 4f 52 44 12 0b 61 70 70 70 |B_PASSWORD..appp|
00000290 61 73 73 77 6f 72 64 12 12 0a 07 44 42 5f 55 53 |assword....DB_US|
000002a0 45 52 12 07 61 70 70 75 73 65 72 1a 06 4f 70 61 |ER..appuser..Opa|
000002b0 71 75 65 1a 00 22 00 0a |que.."..|
000002b8
やってみよう① で採った出力とバイト単位で同一です。 apppassword も appuser もそのまま読めます。
ここが本回の山場です。
設定を入れて API Server を 3 台とも再起動したのに、Secret は 1 件も暗号化されていません。 暗号化が起きるのは 書き込みのときだけだからです。すでに etcd にあるものは、誰かが書き直すまで平文のまま残ります。
ステップ8:新規に作った Secret は暗号化されることを確認する
実行コマンド(k8s-ops 上・developer):
$ kubectl create secret generic enc-probe -n default --from-literal=probe=hello
実行コマンド(k8s-cp-01 上・root):
# etcdctl --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/enc-probe | hexdump -C | head -5
実行結果(4 行目までを抜粋):
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 66 61 75 6c 74 2f 65 6e 63 2d 70 72 |s/default/enc-pr|
00000020 6f 62 65 0a 6b 38 73 3a 65 6e 63 3a 73 65 63 72 |obe.k8s:enc:secr|
00000030 65 74 62 6f 78 3a 76 31 3a 6b 65 79 31 3a d8 86 |etbox:v1:key1:..|
head -3 ではなく head -5 にしている理由。
hexdump -C は 1 行 16 バイトです。3 行目までだと k8s:enc:secr で切れてしまい、プロバイダ名も鍵名も読めません。 Secret の名前が長いほど、プレフィックスは後ろの行へずれます。本書は、暗号化されているかどうかを確かめる場面では head -5 を使います。(やってみよう① の head -3 は、平文の印である k8s\x00 が 3 行目に収まるためです) 4 行目に etbox:v1:key1: が続いて、k8s:enc:secretbox:v1:key1: が完結していることが読み取れます。
ここで k8s:enc:<provider>:v1:<keyname>: というプレフィックスが登場します。etcd に書かれた値の先頭に付くもので、どのプロバイダのどの鍵で暗号化されたかが読み取れます。k8s:enc:secretbox:v1:key1: は「Kubernetes が / 暗号化して / secretbox で / スキーマ v1 の形式で / key1 という名前の鍵を使って」という意味です。この行が見えれば暗号化されています。 k8s\x00 で始まっていれば平文です。
ステップ9:既存の Secret を全件再暗号化する
ここで 再暗号化(re-encrypt) という作業をします。すべての Secret を読み直して書き戻すことで、読むときは古い鍵(または identity)、書くときは新しい鍵が使われるため、これで全件が新しい状態に揃います。
実行コマンド(k8s-ops 上・developer):
$ time kubectl get secrets --all-namespaces -o json | kubectl replace -f -
44 個の Secret すべてに secret/... replaced が出力され、エラーは 0 件でした(sh.helm.release.v1.* も kubernetes.io/tls 型もすべて成功しています)。末尾の所要時間は次のとおりです。
実行結果(末尾):
real 0m3.924s
44 という数字の内訳を確認しておきます。 やってみよう① のステップ1 で数えた Secret は 43 個でした。ステップ8 で作った enc-probe が 1 個増えて 44 個になっています。43 と 44 が本文に並ぶのはそのためです。
このコマンドが何をしているかを分解します。kubectl get secrets --all-namespaces -o json で全 Secret を読み出し(このとき identity が平文を、secretbox が暗号文を読みます)、kubectl replace -f - で同じ内容を書き戻します(このとき先頭の secretbox が暗号化します)。内容は 1 バイトも変わりませんが、etcd 上の表現だけが変わります。 Pod は再起動しません。envFrom は Pod の起動時にのみ読まれるので、動いているコンテナの環境変数にも影響はありません。
本番でこのコマンドを打つ前に。
ラボの 44 件なら 4 秒で終わりますが、本番では次の 3 点が問題になります。
①immutable: true の Secret は replace が拒否されます。 不変 Secret は仕様上あとから書き換えられません。該当があれば、削除して作り直す必要があります(参照している Pod が落ちるので、計画的に行ってください)。
②全 Secret を 1 度にメモリへ読み込みます。 数千件規模のクラスタでは、kubectl 側のメモリと API Server の負荷が跳ねます。-n <namespace> を付けて namespace 単位に分割するのが定石です。
③全 Secret の resourceVersion が更新されます。 Secret を watch しているすべてのコントローラ(cert-manager / ArgoCD / Operator 各種)にイベントが伝播します。業務時間帯は避けてください。
ステップ10:網羅確認のループを回す
実行コマンド(k8s-cp-01 上・root):
# KEY=key1
# for k in $(etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/ --prefix --keys-only); do etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get "$k" | head -c 200 | grep -q "k8s:enc:secretbox:v1:$KEY" || echo "NOT-$KEY: $k"; done
実行結果は 1 行もありません。 /registry/secrets/ 配下の 44 件すべてを走査して、NOT-key1: は 0 行でした。何も出ないのが正常です。
このループは本回で 3 回使います。 先頭の KEY= を差し替えるだけで、やってみよう③ でも同じ形が使えます(③ステップ5 で KEY=key2、③ステップ10 で KEY=key3)。演習ごとに違う形の grep を覚えなくて済むよう、本書はこの 1 つの型に統一します。
「1 件も出力されないこと」が合格条件です。 NOT-key1: の行が出たら、その Secret は暗号化されていません。3 台のうち 1 台に違う鍵を貼ってしまったか、kubectl replace が一部で失敗しています。 出力が空であることを、目で確認してください。
ステップ11:kubeadm-config に恒久化する
実行コマンド(k8s-ops 上・developer):
$ kubectl get cm kubeadm-config -n kube-system -o yaml > ~/kubeadm-config.bak-09.yaml
実行コマンド(k8s-ops 上・developer):
$ kubectl edit cm kubeadm-config -n kube-system
apiServer.extraArgs に encryption-provider-config の 1 件、apiServer.extraVolumes に enc-config の 1 件を足します。マージ後の全量は前節に掲載したものと一致します。
実行結果(kubectl get cm kubeadm-config -n kube-system -o yaml の apiServer: ブロック):
apiServer:
extraArgs:
- name: encryption-provider-config
value: /etc/kubernetes/enc/enc.yaml
- name: authentication-config
value: /etc/kubernetes/auth/anonymous.yaml
- name: kubelet-certificate-authority
value: /etc/kubernetes/pki/ca.crt
- name: profiling
value: 'false'
- name: service-account-extend-token-expiration
value: 'false'
- name: service-account-max-token-expiration
value: 24h
extraVolumes:
- hostPath: /etc/kubernetes/enc
mountPath: /etc/kubernetes/enc
name: enc-config
pathType: DirectoryOrCreate
readOnly: true
- hostPath: /etc/kubernetes/auth
mountPath: /etc/kubernetes/auth
name: auth-config
pathType: DirectoryOrCreate
readOnly: true
前節に載せた全量と一致しています。 encryption-provider-config が extraArgs の先頭に、enc-config が extraVolumes の先頭に入りました。
なぜここで書くのか。 kubeadm クラスタでは、ノード上の kube-apiserver.yaml は結果であって正本ではありません。 正本は kube-system の kubeadm-config ConfigMap です(第5回で確立した原則。本回が 2 度目)。ここに書かないと、次に kubeadm upgrade を実行した瞬間に暗号化設定が消え、以降の書き込みが平文に戻ります。 しかも kubectl の出力は何も変わらないので、気づけません。
--encryption-provider-config-automatic-reload は、やってみよう③ のステップ7 でここに追記します。本演習で止める場合でも、この時点で恒久化は完了しています。
ステップ12:kube-bench で宿題を回収し、回帰確認する
実行コマンド(k8s-cp-01 上・root):
# export KUBECONFIG=/etc/kubernetes/admin.conf
# kube-bench run --targets master
export KUBECONFIG を先に打つのは第2回と同じ作法です。 kubeconfig が無いと kube-bench はクラスタのバージョンを判定できず、古い CIS プロファイルで評価してしまいます。 そのときは 1.2.27 / 1.2.28 という ID 自体が出てこないか、下のサマリと数字が合いません。
実行結果(1.2.27 / 1.2.28 の該当行):
[PASS] 1.2.27 Ensure that the --encryption-provider-config argument is set as appropriate (Manual)
[PASS] 1.2.28 Ensure that encryption providers are appropriately configured (Manual)
実行結果(サマリ):
== Summary master ==
47 checks PASS
4 checks FAIL
9 checks WARN
0 checks INFO
== Summary total ==
47 checks PASS
4 checks FAIL
9 checks WARN
0 checks INFO
--targets master を指定すると、== Summary master == と == Summary total == の 2 つが出力されます。 検査したのが master ターゲットだけなので、両者の数値は同じになります。2 つ出るからといって、別々の検査結果が並んでいるわけではありません。
| 時点 | master の判定 |
|---|---|
| 第9回の開始時 | 45 PASS / 4 FAIL / 11 WARN(1.2.27 WARN / 1.2.28 WARN) |
| 暗号化適用後 | 47 PASS / 4 FAIL / 9 WARN(1.2.27 PASS / 1.2.28 PASS) |
動いたのは 2 件だけです。 第5回の完了時点が 45 PASS / 4 FAIL / 11 WARN で、本回はそこから 1.2.27 と 1.2.28 が WARN から PASS へ移った——それ以外は 1 件も変わっていません。FAIL の 4 件は第5回から据え置きです。
実行結果(FAIL と判定された 4 件):
[FAIL] 1.2.16 Ensure that the --audit-log-path argument is set (Automated)
[FAIL] 1.2.17 Ensure that the --audit-log-maxage argument is set to 30 or as appropriate (Automated)
[FAIL] 1.2.18 Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate (Automated)
[FAIL] 1.2.19 Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate (Automated)
4 件とも監査ログ(--audit-log-*)です。 第5回で「残る FAIL 4 件はすべて監査ログ」と書いたとおりの内訳が、本回でもそのまま維持されています。
1.3.2 / 1.4.1(controller-manager と scheduler の --profiling)が FAIL に戻っていないことを確認してください。
第2回ではこの 2 つをマニフェストの直接編集で PASS にしました。直接編集した設定は、kubeadm upgrade が静的 Pod を再生成した時点で消えます——本来なら第5回のアップグレード(v1.36.2 → v1.36.3)で退行しているはずの項目です。
退行していないのは、第5回で kubeadm-config の controllerManager.extraArgs と scheduler.extraArgs にも profiling: "false" を恒久化したからです。 API Server の分だけを apiServer.extraArgs に書いて満足していたら、ここで 2 件が FAIL へ戻っていました。「ノード上のマニフェストは結果であって正本ではない」——直前のステップ11 の主張が、スコアが動かなかったという形で裏づけられています。
確認は kubectl get cm kubeadm-config -n kube-system -o yaml | grep -A 3 -E 'controllerManager|scheduler' です。 ここに extraArgs が無いクラスタでは、次のアップグレードで静かに消えます。しかも kubectl の出力は何ひとつ変わりません。 作業のたびにスコアを取り直すのは、この種の退行が「直接いじっていない項目」で起きるからです。
残る FAIL 4 件(--audit-log-*)は本回では直しません。 本回のスコープは D4(Secret 管理)であり、監査ログの設計は 第16回の主題です。「見つけたが、いま直さないと決めた」ことを記録に残すのも運用の一部です。実際に 3 点セットで適用して 4 件とも PASS へ転じることは、第16回の やってみよう① で確認します。
第2回で保留した 2 項目を、ここで回収しました。1.2.27 は「--encryption-provider-config が設定されているか」、1.2.28 は「そのプロバイダが許容リストに入っているか」を見ています。フラグを足しただけでは 1.2.27 しか埋まりません。 1.2.28 は先頭プロバイダの名前を見ているので、aesgcm を選んでいたらここは WARN のままでした。
続けて後片付けと回帰確認を行います。
実行コマンド(k8s-ops 上・developer):
$ kubectl delete secret enc-probe -n default
実行コマンド(k8s-ops 上・developer):
$ kubectl get nodes
5 ノードすべてが Ready(v1.36.3)であることを確認します。
実行コマンド(k8s-ops 上・developer):
$ kubectl get pods --all-namespaces --no-headers | grep -cv 'Running\|Completed'
実行結果:
0
--no-headers を必ず付けてください。 付けないと NAMESPACE NAME READY STATUS ... のヘッダ行が Running にも Completed にも一致せず、異常が 0 件でも 1 と表示されます。
実行コマンド(k8s-ops 上・developer):
$ curl -k --noproxy "*" -s -o /dev/null -w '%{http_code}\n' https://fanclub.local/
実行結果:
200
-k も必須です。 fanclub.local の証明書は第3回で立てた cert-manager の自己署名 CA が発行したものなので、-k が無いと curl は検証に失敗して終了コード 60・HTTP コード 000 を返します。アプリは正常なのに落ちているように見えるので、回帰確認では必ず付けてください。
最後に etcd の 3 台を確認します。実測では 3 台とも RAFT TERM 15 で一致し、leader は 1 台、RAFT INDEX の差は 3 以内でした。API Server を 3 回作り直しても、etcd クラスタ自体は影響を受けません。
この演習の合格条件
- 3 台すべてで
/etc/kubernetes/enc/enc.yamlが600で存在し、内容が一致している - ステップ10 の網羅確認ループが 1 行も出力しない(
NOT-key1:が 0 行) kubeadm-configのapiServer.extraArgsにencryption-provider-configがあり、extraVolumesにenc-configがある- kube-bench の 1.2.27 / 1.2.28 が両方
[PASS](47 PASS / 4 FAIL / 9 WARN) https://fanclub.local/が 200 を返す
鍵をローテーションする —— ファイルを直しても反映されない
この節は読み取りの解説です。
ここに出てくるコマンドは、この節では打ちません。 実際に手を動かすのは次の やってみよう③です。
暗号化キーは永久に使い続けるものではありません。替えるべきタイミングは 3 つあります。
| タイミング | 理由 |
|---|---|
| 定期的に(年 1 回など) | 鍵の使用期間を区切ることで、漏洩したときの影響範囲を限定する |
| 鍵を知る人が組織を離れたとき | ファイルを読めた人は、鍵を覚えている可能性がある |
| 漏洩が疑われたとき | ノードへの侵入、バックアップの流出など |
ローテーションの手順そのものは短いものです。keys 配列の先頭に新しい鍵を足し、全件を再暗号化します。
providers:
- secretbox:
keys:
- name: key2
secret: <新しい 44 文字>
- name: key1
secret: <古い 44 文字>
- identity: {}
key1 を消してはいけません。 消すと、まだ key1 で暗号化されている Secret が読めなくなります。古い鍵は「復号専用」として残し、全件が key2 に移ってから消します。
ファイルを直しても反映されない
実測で分かったことです。
| 操作 | 新規 Secret のプレフィックス |
|---|---|
3 台の enc.yaml に key2 を先頭追加(API Server は再起動しない) | k8s:enc:secretbox:v1:key1:(変わらない) |
3 台の API Server を再起動(crictl rm -f → kubelet が再作成・3〜6 秒) | k8s:enc:secretbox:v1:key2: |
理由は 1 行で書けます。 API Server は起動時に EncryptionConfiguration をメモリへ読み込み、その後はファイルを見ません。 ディスク上のファイルを直しても、動いているプロセスには届きません。
この挙動が持つ意味は重いものです。 ローテーションのたびに 3 台の API Server を順番に再起動することになります。HA 構成なら無停止で回せますが、手順としては「1 台落として戻す」を 3 回やることになります。
--encryption-provider-config-automatic-reload=true
Kubernetes には、この再起動を不要にするフラグがあります。設定ファイルの中身が変わったら API Server を再起動せずに読み直すフラグです。kube-apiserver --help の記述(v1.36.3 で確認)は次のとおりです。
Determines if the file set by --encryption-provider-config should be automatically reloaded if the disk contents change. Setting this to true disables the ability to uniquely identify distinct KMS plugins via the API server healthz endpoints.
有効にすると、ファイルの中身が変わった時点で読み直します。 再起動は要りません。起動ログに証拠が出ます。
I0727 19:50:01.536414 1 controller.go:97] "Starting controller" name="encryption-provider-config-automatic-reload-controller"
| 自動リロード なし | 自動リロード あり | |
|---|---|---|
| 鍵の追加を反映させる方法 | 3 台の API Server を再起動 | ファイルを保存するだけ |
healthz での KMS プラグイン識別 | プラグインごとに個別のエンドポイントが出る | できなくなる(--help の記述) |
| 本書ラボへの影響 | — | KMS を使っていないので実害なし |
なぜ最初から付けないのか。 試験本番と同じ手順を先に通したいからです。kubernetes.io/docs の手順は自動リロード無しで書かれており、CKS の設問もその形で出ると考えるのが自然です。本回は ①まず自動リロード無しで暗号化を有効化し(やってみよう②)、②ローテーションで「ファイルを直したのに変わらない」を実測し、③そのうえで実務の定石として自動リロードを導入する、という順で進みます。先に便利なフラグを付けてしまうと、なぜそれが便利なのかが分かりません。
自動リロードは即時ではない —— HA では 3 台の反映時刻がずれる
「自動リロード=保存した瞬間に効く」ではありません。 実測では おおむね 1 分以内です。公式ドキュメントも「有効にすると、ファイルの変更を 1 分ごとにポーリングして検知する」と書いており、実測はこの記述どおりでした。 鍵を書き戻した直後に 20 秒間隔で観測した結果(1 回につき 8 リクエスト)は次のとおりでした(やってみよう③ のステップ10 で、消した鍵を書き戻したときの実測です)。
| 時刻 | 成功 / 失敗 |
|---|---|
| 04:58:40 | OK=0 / NG=8 |
| 04:59:01 | OK=3 / NG=5 |
| 04:59:21 | OK=5 / NG=3 |
| 04:59:41 | OK=8 / NG=0 |
3 台が同時に切り替わるのではなく、1 台ずつ順に反映されています。 8 リクエストのうち成功する数が 0 → 3 → 5 → 8 と増えていくのは、反映済みの API Server が 1 台ずつ増えているからです。
同じコマンドが成功したり失敗したりする窓が実在します。
k8s-lb の HAProxy が roundrobin で 3 台に振るため、先に反映された API Server に当たれば成功し、まだの API Server に当たれば失敗します。 第8回の AdmissionConfiguration で扱った「たまに効く」とまったく同じ診断構造です。
この窓は 1 分程度で閉じます。 慌てて設定を戻すと、かえって状況が読めなくなります。まず 1 分待ってから、もう一度同じコマンドを打ってください。
ラボ v1.36.3 と試験 v1.35 で何が変わるか
| 項目 | ラボ v1.36.3 | 試験 v1.35 |
|---|---|---|
EncryptionConfiguration の apiVersion | apiserver.config.k8s.io/v1 | 同じ(v1.13 から v1) |
--encryption-provider-config | あり | 同じ |
--encryption-provider-config-automatic-reload | あり(v1.36.3 の --help で確認) | あると考えられる(kubernetes.io/docs の該当ページにバージョンの但し書きなしで載っているため。著者は試験環境で確認していません) |
secretbox / aescbc / aesgcm / identity | すべて利用可 | 同じ |
kms v2 | stable | 同じ(v1.29 で stable) |
本書ラボと試験環境でバージョンが 1 マイナー違いますが、本回で扱う範囲に差はありません。 ただし 上表の「試験 v1.35」列は公式ドキュメントの導入バージョン記載から判断したものであり、著者は試験環境で確認していません。 試験当日は kube-apiserver --help | grep encryption を打てば、その環境で使えるフラグがその場で分かります。参照できるドキュメントを探すより速いので、覚えておいてください。
本番ガードレール —— 鍵を失うと復号できない
鍵ファイルを失うと、etcd の暗号文は永久に読めません。
Kubernetes には復旧手段がありません。Secret を全部作り直すことになります。
①identity を末尾に残している間だけ、平文の残骸も読めます。 全件の再暗号化が完了するまで identity を外さないでください。
②etcd のスナップショットにも暗号文が入ります。 第2巻でバックアップ・リストアを扱いましたが、その .db ファイルを別のクラスタでリストアしても、鍵が無ければ Secret は復号できません。 これはバックアップの弱点ではなく、設計どおりの動作です。
③だからこそ、鍵とバックアップは別々の場所に保管します。 同じディレクトリに置くと、片方を盗まれた時点で両方失われます。 バックアップの流出に備えるための暗号化なのに、鍵を同梱していては意味がありません。
④鍵をローテーションしたら、再暗号化が終わるまで古い鍵も保管し続けてください。 「新しい鍵に替えたから古いのは捨てた」は、まだ移行できていない Secret を捨てたのと同じです。やってみよう③ のステップ9 で、この順序を意図的に破って何が起きるかを実測します。
⑤enc.yaml をオフラインへ退避してください。 鍵はこのファイルにしか存在しません。ノードのディスクが飛べば終わりです。
実行コマンド(k8s-cp-01 上・root):
# install -m 600 /etc/kubernetes/enc/enc.yaml /root/enc.yaml.backup-09
本番では、これをクラスタの外へ持ち出してください。 install -m 600 を使うのは、cp だと退避先のパーミッションが umask 次第で緩くなるためです。本書ラボでは、9 VM のスナップショットに enc.yaml が含まれるので、それが実質のバックアップになります。ただしスナップショットは etcd データも一緒に含みます。 上の③(鍵とバックアップを別の場所へ)を厳密に満たしてはいない、という点は自覚しておいてください。
やってみよう③:暗号化キーをローテーションする
所要 45 分(うち試験相当の粒度は ステップ1〜6・本書ラボで 15 分)。
「鍵をローテーションせよ」という設問は CKS で出題されえます。 そのとき求められるのは ①新しい鍵を先頭に足す ②API Server を再起動する ③全件を再暗号化する ④旧鍵を消す の 4 手です。やってみよう② と同じく、試験は 1 台構成のことが多いので、3 台を回るステップ3 と ステップ6 は 1 回ずつで済みます。 1 台なら 8〜10 分の作業で、本書ラボの 15 分はそれを 3 台分繰り返した値です。ステップ7 以降(自動リロード・順序違反の実測・復旧)は実務の定石で、試験では求められない可能性が高いですが、現場では最初に身につけるべき手順です。
この演習は必ず ステップ11 まで通してください。
ステップ9 では、意図的に「読めない状態」を作ります。 そこで止めると、Secret が読めないままのクラスタが残ります。 ステップ10 で復旧し、ステップ11 で最終形にそろえるまでが 1 セットです。所要 45 分のうち、読めない状態が続くのは 3〜5 分程度です。
対象は k8s-cp-01 / k8s-cp-02 / k8s-cp-03(root)と k8s-ops(developer)です。前提は やってみよう② を完了済みであること(全 Secret が key1 で暗号化され、kubeadm-config に恒久化済み)です。
ステップ1:key2 を生成して 3 台の enc.yaml の先頭に足す
実行コマンド(k8s-cp-01 上・root):
# head -c 32 /dev/urandom | base64
この値は読者ごとに異なります。記事の値をそのまま使わないでください。 生成した 44 文字を手元に控え、3 台に同じ値を貼ります。
書き換えは やってみよう② ステップ2 と同じ形(cat > ... <<'EOF' で 3 台に上書き)で構いません。 vi で該当行だけを直しても同じです。どちらでも、最後に 3 台で md5sum を突き合わせてください。
/etc/kubernetes/enc/enc.yaml(全量・3 台とも同一内容):
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key2
secret: <このステップで生成した 44 文字>
- name: key1
secret: <やってみよう② で生成した 44 文字>
- identity: {}
key1 を消してはいけません。 いま etcd にあるすべての Secret は key1 で暗号化されています。消した瞬間に読めなくなります(ステップ9 で、その状態を実際に作って確認します)。古い鍵は「復号専用」として残します。
ステップ2:再起動せずに新規 Secret を作って確認する
実行コマンド(k8s-ops 上・developer):
$ kubectl create secret generic rotate-probe-1 -n default --from-literal=probe=before-restart
実行コマンド(k8s-cp-01 上・root):
# etcdctl --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/rotate-probe-1 | hexdump -C | head -5
実行結果:
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 66 61 75 6c 74 2f 72 6f 74 61 74 65 |s/default/rotate|
00000020 2d 70 72 6f 62 65 2d 31 0a 6b 38 73 3a 65 6e 63 |-probe-1.k8s:enc|
00000030 3a 73 65 63 72 65 74 62 6f 78 3a 76 31 3a 6b 65 |:secretbox:v1:ke|
00000040 79 31 3a 22 cc 0e 88 d2 e1 07 10 af 83 22 60 26 |y1:"........."`&|
右端の ASCII 表示を 3 行目から順に読むと、k8s:enc → :secretbox:v1:ke → y1: とつながります。 プレフィックスは k8s:enc:secretbox:v1:key1: のままで、key2 にはなりません。 5 行目の先頭にある数字が 1 か 2 かを確かめてください。 本ステップの観察点はこの 1 点だけです。
ファイルには key2 が先頭に書いてあるのに、暗号化には key1 が使われています。 API Server が読んでいるのは、起動したときのファイルの中身だからです。
ステップ3:3 台の API Server を 1 台ずつ再起動する
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# crictl rm -f $(crictl ps -q --name kube-apiserver)
crictl ps -q --name kube-apiserver は、実測でも 1 個だけを返しました。 停止済みのコンテナは crictl ps の対象外なので、rm -f に複数の ID が渡ることはありません。kubelet が再作成して /livez が ok を返すまでの実測は、k8s-cp-01 が 5 秒 / k8s-cp-02 が 3 秒 / k8s-cp-03 が 4 秒でした。
やってみよう② のマニフェスト編集経由(40〜46 秒)より、はるかに速く戻ります。 kubelet がファイルの変更を検知するまでの待ちが無いためです。
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# curl -k https://localhost:6443/livez
1 台ずつ、ok を確認してから次へ進んでください。 3 台同時に落とすと API Server が全滅します。
本番での作法。
crictl rm -f は、処理中のリクエストを道連れにする強制 kill です。 ラボでは手早く済むので使いますが、本番では次のどちらかを採ってください。
①k8s-lb の HAProxy のバックエンドから当該 Control Plane Node を外してから落とす。 新しいリクエストが振られなくなってから kill すれば、失敗するリクエストが出ません。
②kube-apiserver.yaml に無害な変更を加えて kubelet に作り直させる。 たとえば enc.yaml のパスを書き換えずに保存し直すだけでも kubelet は反応します。この方式なら kubelet が正規の手順で Pod を作り直します(ただし完了まで 40〜46 秒かかります)。
ステップ4:key2 が使われることを確認する
実行コマンド(k8s-ops 上・developer):
$ kubectl create secret generic rotate-probe-2 -n default --from-literal=probe=after-restart
実行コマンド(k8s-cp-01 上・root):
# etcdctl --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/rotate-probe-2 | hexdump -C | head -5
実行結果:
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 66 61 75 6c 74 2f 72 6f 74 61 74 65 |s/default/rotate|
00000020 2d 70 72 6f 62 65 2d 32 0a 6b 38 73 3a 65 6e 63 |-probe-2.k8s:enc|
00000030 3a 73 65 63 72 65 74 62 6f 78 3a 76 31 3a 6b 65 |:secretbox:v1:ke|
00000040 79 32 3a 65 37 87 93 b1 49 17 01 60 d4 bf db 55 |y2:e7...I..`...U|
今度はプレフィックスが k8s:enc:secretbox:v1:key2: になりました。 5 行目の先頭が y1: から y2: に変わっています。
ファイルは何も変えていません。変えたのは「API Server を再起動したかどうか」だけです。それでプレフィックスが key1 から key2 に変わりました。
ステップ5:全件を再暗号化して key2 にそろえる
実行コマンド(k8s-ops 上・developer):
$ time kubectl get secrets --all-namespaces -o json | kubectl replace -f -
この時点で etcd にある Secret を全件書き戻します。件数はやってみよう② のあとに増減しているので、手元の出力で数えてください。
本書ラボの実測では 45 件すべてが replaced となり、所要 4.092 秒でした(rotate-probe-1 と rotate-probe-2 が増え、enc-probe を消しているので 45 件です)。
Conflict が数件出ることがあります。異常ではありません。
別の検証では、同じコマンドで replaced 40 件・Conflict 5 件になりました(合計は同じ 45 件)。
Error from server (Conflict): ... "prometheus-kube-prometheus-stack-prometheus": the object has been modified; please apply your changes to the latest version and try again
Operator が管理している Secret を、再暗号化のために読んで書き戻している最中に Operator 自身が更新すると衝突します。 出たのは Prometheus / Alertmanager の 5 件で、いずれも kube-prometheus-stack が握っているものでした。対処は「もう一度同じコマンドを打つ」だけです。 ただし件数を追う必要はありません——判定はこの直後の網羅確認ループで行います(1 件でも古い鍵が残っていれば、そこで名前が出ます)。実測では Conflict が出たときも、Operator 自身の書き込みが新しい鍵で行われるため、ループは 0 行でした。この論点はステップ10 でもう一度扱います。
実行コマンド(k8s-cp-01 上・root):
# KEY=key2
# for k in $(etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/ --prefix --keys-only); do etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get "$k" | head -c 200 | grep -q "k8s:enc:secretbox:v1:$KEY" || echo "NOT-$KEY: $k"; done
実行結果は 1 行もありません。 45 件を走査して NOT-key2: は 0 行——全件が key2 に移りました。
やってみよう② ステップ10 と同じループです。 先頭の KEY= を差し替えただけです。1 件でも NOT-key2: が出るなら、次のステップへ進んではいけません。
ステップ6:key1 を削除して 3 台を再起動する
書き換えは やってみよう② ステップ2 と同じ形(cat > ... <<'EOF' で 3 台に上書き)で構いません。 vi で該当行だけを直しても同じです。どちらでも、最後に 3 台で md5sum を突き合わせてください。
/etc/kubernetes/enc/enc.yaml(全量・3 台とも同一内容):
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key2
secret: <ステップ1 で生成した 44 文字>
- identity: {}
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# crictl rm -f $(crictl ps -q --name kube-apiserver)
ここでもステップ3 と同じく 1 台ずつ落としてください。 curl -k https://localhost:6443/livez が ok を返してから次のノードへ進みます。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub
実行結果(6 回打った分を、ヘッダ行を除いて並べたもの):
fanclub-db-secret Opaque 2 2d1h
fanclub-db-secret Opaque 2 2d1h
fanclub-db-secret Opaque 2 2d1h
fanclub-db-secret Opaque 2 2d1h
fanclub-db-secret Opaque 2 2d1h
fanclub-db-secret Opaque 2 2d1h
実測では、3 台の再起動が 4〜6 秒で完了し、kubectl get secret は 6 回打って 6 回とも成功しました。 ステップ5 で全件を key2 に移してあるので、key1 を消しても読めます。6 回打つのは、3 台のうちどれに当たっても成功することを確かめるためです(ステップ9 では、ここが成功と失敗の混在に変わります)。
これが「正しい順序」です。 ①新しい鍵を先頭に足す → ②反映させる → ③全件を再暗号化する → ④そのあとで旧鍵を消す。 ステップ9 で、③を飛ばして④をやるとどうなるかを実際に確認します。順序を手順として身につけるのが本演習の目的です。
ステップ7:自動リロードを導入する(3 台 + kubeadm-config へ追記)
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# cp -p /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-09b
ここから先、/root/kube-apiserver.yaml.bak-09 は書き戻してはいけません。
あれは やってみよう② で暗号化設定を入れる前のマニフェストです。いま etcd の Secret はすべて secretbox で暗号化されているので、--encryption-provider-config の無い状態へ戻すと、API Server が Secret を 1 件も復号できなくなります。 本ステップ以降の復旧先は、いま取った .bak-09b です。
/etc/kubernetes/manifests/kube-apiserver.yaml の command に 1 行足します。
- --encryption-provider-config-automatic-reload=true
3 台とも /livez は ok を返しました。 マニフェストの編集経由なので、所要は やってみよう② と同じオーダー(実測 40 / 46 / 40 秒)です。1 台ずつ ok を確認してから次のノードへ進んでください。
実行コマンド(k8s-cp-01 上・root):
# crictl logs $(crictl ps -q --name kube-apiserver) 2>&1 | grep automatic-reload
実行結果:
I0727 19:50:01.536414 1 controller.go:97] "Starting controller" name="encryption-provider-config-automatic-reload-controller"
先頭のタイムスタンプは実行のたびに変わります。行の後半(encryption-provider-config-automatic-reload-controller)が出ていれば、フラグが効いています。
この 1 行が出れば、以降は enc.yaml を保存するだけで反映されます。 出ない場合は、command に足した行のインデントか綴りを確認してください。
続けて kubeadm-config にも追記します。
実行コマンド(k8s-ops 上・developer):
$ kubectl edit cm kubeadm-config -n kube-system
apiServer.extraArgs に 1 件足します。
- name: encryption-provider-config-automatic-reload
value: 'true'
配列のどこに置いても構いません。 本書の実機検証では kubectl patch で追記したため extraArgs の先頭に入りましたが、extraArgs は名前と値の対応が取れていればよく、並び順に意味はありません。
やってみよう② のステップ11 で encryption-provider-config と enc-config は既に恒久化済みです。 ここで足すのは automatic-reload の 1 件だけです。バックアップ(~/kubeadm-config.bak-09.yaml)は②で取ってあるので、取り直しは要りません。
ステップ8:key3 を足して、再起動なしで効くことを確認する
実行コマンド(k8s-cp-01 上・root):
# head -c 32 /dev/urandom | base64
この値は読者ごとに異なります。記事の値をそのまま使わないでください。
3 台の enc.yaml の keys 先頭に key3 を足します(API Server は再起動しません)。この時点の全量です。
書き換えは やってみよう② ステップ2 と同じ形(cat > ... <<'EOF' で 3 台に上書き)で構いません。 vi で該当行だけを直しても同じです。どちらでも、最後に 3 台で md5sum を突き合わせてください。
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key3
secret: <このステップで生成した 44 文字>
- name: key2
secret: <ステップ1 で生成した 44 文字>
- identity: {}
保存してから 1 分待ってください。 自動リロードは即時ではありません。保存直後に打つと、まだ key2 で暗号化されることがあります。
実行コマンド(k8s-ops 上・developer):
$ kubectl create secret generic rotate-probe-3 -n default --from-literal=probe=auto-reload
実行コマンド(k8s-cp-01 上・root):
# etcdctl --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/rotate-probe-3 | hexdump -C | head -5
実行結果:
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 66 61 75 6c 74 2f 72 6f 74 61 74 65 |s/default/rotate|
00000020 2d 70 72 6f 62 65 2d 33 0a 6b 38 73 3a 65 6e 63 |-probe-3.k8s:enc|
00000030 3a 73 65 63 72 65 74 62 6f 78 3a 76 31 3a 6b 65 |:secretbox:v1:ke|
00000040 79 33 3a 4a da 7c 39 f3 5f f4 64 54 c8 db 08 a6 |y3:J.|9._.dT....|
プレフィックスは k8s:enc:secretbox:v1:key3: になりました。 実測では保存から 70 秒待ってから Secret を作っています。API Server は 1 度も再起動していません。 これが自動リロードの効果です。
ステップ9:わざと順序を破る —— 再暗号化せずに key2 を消す
これは失敗を確認するためのステップです。
本番では絶対にやらないでください。 ここでは、やってはいけない順序を実際にやって、どういう症状が出るかを確認します。 ステップ10 で復旧します。
いま etcd の Secret は ほぼすべて key2 で暗号化されています(key3 になっているのは ステップ8 で作った rotate-probe-3 だけです)。再暗号化しないまま key2 を消します。
手を動かす前に、key2 の 44 文字が手元にあることを確認してください。
ステップ10 では、その 44 文字を enc.yaml へ書き戻して復旧します。 控えていなければ復旧できません。etcd にある Secret は 1 件も読めなくなり、作り直す以外の手段はありません。 いま画面に出ていないなら、3 台の enc.yaml を消す前に cat /etc/kubernetes/enc/enc.yaml で key2 の値を控えてから先へ進んでください。
書き換えは やってみよう② ステップ2 と同じ形(cat > ... <<'EOF' で 3 台に上書き)で構いません。 vi で該当行だけを直しても同じです。どちらでも、最後に 3 台で md5sum を突き合わせてください。
/etc/kubernetes/enc/enc.yaml(3 台とも・key2 の行を削除する):
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key3
secret: <ステップ8 で生成した 44 文字>
- identity: {}
1 分ほど待ってから、Secret を読みます。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub
実行結果:
Error from server (InternalError): Internal error occurred: no matching key was found for the provided Secretbox transformer
この状態の見え方を 4 点に分解します。どれも「壊れている」と判断しにくい形をしています。そのあとに、やってみよう④ 以降で同じ状態になったときに増える症状を 1 つ添えます。
ステップ9 の観察①:反映は 3 台バラバラに進む —— 打った直後は再現しない
鍵を消した直後は、まだ 1 台も新しい設定を読み込んでいません。 20 秒間隔で 4 ラウンド(1 ラウンドにつき 8 リクエスト)観測した実測が次のとおりです。
| 時刻 | 成功 / 失敗 |
|---|---|
| 04:53:35 | OK=8 / NG=0 |
| 04:53:55 | OK=5 / NG=3 |
| 04:54:15 | OK=3 / NG=5 |
| 04:54:36 | OK=0 / NG=8 |
打った直後は 8 回とも成功し、約 1 分後には 8 回とも失敗します。 その間は成功と失敗が混ざります——k8s-lb の HAProxy が roundrobin で 3 台に振り分けるので、まだ反映していない API Server に当たれば成功するからです。
「打った直後は再現しないのに、1 分後には必ず落ちる」——これが本ステップで最も厄介な出方です。作業直後に 1 回確認して「問題なし」と判断すると、あとから障害として上がってきます。
ステップ9 の観察②:一覧(LIST)は成功し、単体取得(GET)だけが失敗する
本回で最も紛らわしい症状です。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secrets -n fanclub
実行結果(先頭 3 行・実際は 9 件すべてが表示されます):
NAME TYPE DATA AGE
fanclub-db-secret Opaque 2 2d1h
fanclub-tls kubernetes.io/tls 3 2d1h
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub
実行結果:
Error from server (InternalError): Internal error occurred: no matching key was found for the provided Secretbox transformer
同じ Secret に対して、一覧なら 9 件すべてが表示され、名前を指定した瞬間に失敗します。 2 分後に繰り返しても同じでした。一覧が出るので「Secret はある」と判断してしまいます。
理由は、一覧と単体取得で読みにいく先が違うからです。 一覧(LIST)は API Server が持つキャッシュから返せますが、名前を指定した取得(GET)は etcd まで読みにいき、その場で復号が要ります。 復号できない鍵で保存された値に当たるのは、後者だけです。本書ラボの Kubernetes v1.36.3 では、ConsistentListFromCache が GA として有効になっていることを確認しています。 「一覧が出た」は「読めた」ではありません。 疑ったら必ず名前を指定して取得してください。
ステップ9 の観察③:稼働中の Pod は落ちず、新規 Secret は作れる
実行コマンド(k8s-ops 上・developer):
$ kubectl -n fanclub get pod
実測では、fanclub の 8 Pod すべてが Running のままでした。 envFrom は Pod の起動時にしか読まれないためです。しかし、次に Pod が作り直された瞬間に起動できなくなります。 これも時限式です。
さらに、新しい Secret の作成も読み取りもできます。 新規の書き込みは key3 で暗号化され、読み取りも key3 で成功するからです。helm list も通ります。「Secret 機能そのものは動いている」ように見えるのに、特定の既存 Secret だけが読めない——という切り分けにくい状態になります。
ステップ9 の観察④:3 台のローカル API Server に直接聞いても同じ
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# kubectl --kubeconfig /etc/kubernetes/admin.conf --server=https://127.0.0.1:6443 --insecure-skip-tls-verify get secret fanclub-db-secret -n fanclub
実行結果(3 台とも同じ):
Error from server (InternalError): Internal error occurred: no matching key was found for the provided Secretbox transformer
HAProxy を経由せず、各 Control Plane Node のローカル API Server に直接問い合わせています。3 台とも同じ結果になるので、「1 台だけ設定を配り忘れた」という別の原因は除外できます。 観察①(3 台バラバラに進む窓)と、この観察④(全台に反映済み)は、時間で切り分けます。
ステップ9 の補足:SealedSecrets コントローラには ErrUnsealFailed が出る
本演習の時点では sealed-secrets をまだ導入していないので、このイベントは出ません。やってみよう④以降で同じ状態になった場合、kubectl get events -n fanclub に ErrUnsealFailed の Warning が出ます(実機で確認済み)。「Secret が読めない」という 1 つの原因が、無関係に見えるコントローラのイベントとして表面化する——という形を覚えておいてください。
最も追いにくいのは「直後は再現しない」ことです。
旧鍵を消した直後は 8 回とも成功し、約 1 分後には 8 回とも失敗しました。 その間は成功と失敗が混ざります。「さっきは動いたのに」という報告が上がってきたときは、この可能性を疑ってください。 設定ファイルを触った時刻と、症状が出はじめた時刻の差が 1 分程度なら、まずこれです。
ステップ10:復旧する —— key2 を書き戻して再暗号化する
3 台の enc.yaml の keys に key2 を key3 の次に書き戻します(ステップ8 の状態に戻します)。
/etc/kubernetes/enc/enc.yaml(全量・3 台とも同一内容):
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key3
secret: <ステップ8 で生成した 44 文字>
- name: key2
secret: <ステップ1 で生成した 44 文字>
- identity: {}
3 台すべてに書き戻してください。 1 台でも key2 が欠けていると、そのノードを経由した読み取りだけが失敗し続けます。書き戻したら md5sum /etc/kubernetes/enc/enc.yaml で 3 台の一致を確かめてください。 API Server の再起動は要りません(ステップ7 で自動リロードを入れてあります)。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub
20 秒間隔で打つと、ステップ9 とちょうど逆向きに戻っていきます。 実測は次のとおりでした(1 ラウンドにつき 8 リクエスト)。
| 時刻 | 成功 / 失敗 |
|---|---|
| 04:58:40 | OK=0 / NG=8 |
| 04:59:01 | OK=3 / NG=5 |
| 04:59:21 | OK=5 / NG=3 |
| 04:59:41 | OK=8 / NG=0 |
約 1 分で全台が復旧しました。
書き戻した直後は、成功と失敗が混じります。
k8s-lb の HAProxy が roundrobin で 3 台に振るので、先に自動リロードが効いた API Server に当たれば成功し、まだの API Server に当たれば失敗します。 第8回で扱った「たまに効く」とまったく同じ診断構造です。1 分待てば揃います。
復旧を確認したら、今度は正しい順序で key3 へ移します。
実行コマンド(k8s-ops 上・developer):
$ time kubectl get secrets --all-namespaces -o json | kubectl replace -f -
実行結果(末尾の 2 行):
Error from server (Conflict): error when replacing "STDIN": Operation cannot be fulfilled on secrets "prometheus-kube-prometheus-stack-prometheus-tls-assets-0": the object has been modified; please apply your changes to the latest version and try again
Error from server (Conflict): error when replacing "STDIN": Operation cannot be fulfilled on secrets "prometheus-kube-prometheus-stack-prometheus-web-config": the object has been modified; please apply your changes to the latest version and try again
2 件が Conflict で失敗しました。 kubectl get してから kubectl replace するまでの短い間に、Prometheus Operator が同じ Secret を書き換えたためです。読み出した時点の resourceVersion が古くなり、API Server が上書きを拒否しました。
対処は「もう一度同じコマンドを打つ」だけです。 実測では 2 回目で 46 件すべてが成功し、エラー 0 件・所要 1.800 秒でした。2 回目は読み直した時点の値で書き戻すので、競合が解消しています。
ここが「網羅確認ループが安全網として要る」理由です。
再暗号化は、一部だけ静かに失敗することがあります。 しかも失敗した Secret は古い鍵のまま残るので、そのあとで旧鍵を消すと読めなくなります。
Operator が管理している Secret を持つクラスタでは、この競合は珍しくありません。 監視・証明書・GitOps のコントローラは、いずれも Secret を定期的に書き換えます。件数が多いほど、実行時間が長いほど、当たる確率は上がります。
だから kubectl replace の出力を目で追うのではなく、次のループで機械的に確認します。 「replace が失敗しても気づかない」ことを前提に手順を組む——これが本番作法です。
実行コマンド(k8s-cp-01 上・root):
# KEY=key3
# for k in $(etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/ --prefix --keys-only); do etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get "$k" | head -c 200 | grep -q "k8s:enc:secretbox:v1:$KEY" || echo "NOT-$KEY: $k"; done
実行結果は 1 行もありません(NOT-key3: が 0 行)。ここが 0 行になって初めて、次のステップで key2 を消せます。
なお、1 回目に Conflict で失敗した 2 件も、この時点では key3 になっていました。 Operator 自身の書き込みが新しい鍵で暗号化されたためです。結果として揃っていた、というだけの話であり、揃っている保証はどこにもありません。 ループを回して確かめる手順は省けません。
ステップ11:key2 を削除して最終形にそろえる
ループが 0 行になったので、key2 を消せます。 3 台の enc.yaml を次の内容にします。これが本回の最終形です。
書き換えは やってみよう② ステップ2 と同じ形(cat > ... <<'EOF' で 3 台に上書き)で構いません。 vi で該当行だけを直しても同じです。どちらでも、最後に 3 台で md5sum を突き合わせてください。
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key3
secret: <ステップ8 で生成した 44 文字>
- identity: {}
実行コマンド(k8s-ops 上・developer):
$ kubectl delete secret rotate-probe-1 rotate-probe-2 rotate-probe-3 -n default
実行コマンド(k8s-ops 上・developer):
$ kubectl get nodes
実行コマンド(k8s-ops 上・developer):
$ curl -k --noproxy "*" -s -o /dev/null -w '%{http_code}\n' https://fanclub.local/
実行結果:
200
5 ノードすべてが Ready、HTTPS は 200 でした。最後に、最終形の enc.yaml が 3 台で一致していることを照合します。
実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 上・root):
# md5sum /etc/kubernetes/enc/enc.yaml
3 台のハッシュ値が一致すれば合格です(値そのものは読者の鍵によって変わります)。ここが揃っていないまま第10回へ進むと、「たまに読めない Secret」が残ります。
identity を末尾に残す理由も書いておきます。全件が key3 に移ったので、理屈のうえでは identity を外せます。 それでも本書ラボが残すのは、今後の回で平文の Secret が etcd に入る可能性への保険です。たとえば kubeadm が新しい Secret を直接書き込む場面や、別の手段で etcd に書かれた値を読む必要が出たとき、identity が無いと読めません。本番で identity を外すのは、「このクラスタに平文の Secret は 1 件も無い」と言い切れる状態を維持できるときだけにしてください。なお、末尾の identity は暗号化の強度を下げません。 書き込みに使われるのは常に先頭の secretbox だけで、identity が働くのは「暗号化されていない値を読むとき」に限られるからです。危ないのは identity を先頭に置いたときだけです。
本回終了時のクラスタの状態は次のとおりです。第10回以降のラボはこの状態を前提にします。
| 項目 | 状態 |
|---|---|
| プロバイダ | secretbox |
keys | key3 のみ |
identity | 末尾に残す |
--encryption-provider-config | 3 台の Control Plane Node に設定済み(kubeadm-config にも恒久化) |
--encryption-provider-config-automatic-reload | true(3 台 + kubeadm-config) |
| etcd 上の全 Secret | k8s:enc:secretbox:v1:key3: |
この演習の合格条件
- ステップ2 では
key1、ステップ4 ではkey2になることを実出力で確認した(再起動が要ることを確認した) - ステップ5 のループが 0 行になってから
key1を消した - ステップ8 で、再起動なしに
key3が効いた - ステップ9 で
no matching key was found for the provided Secretbox transformerを確認し、ステップ10 で復旧させた - ステップ10 のループ(
KEY=key3)が 0 行 enc.yamlが最終形(key3のみ +identity)で 3 台とも一致しているhttps://fanclub.local/が 200 を返す
etcd 暗号化が守らないもの —— Git に置いた平文
ここまでで etcd の中身は暗号化されました。しかし守られていないものが残っています。
| 経路 | etcd 暗号化で守られるか | 何が要るか |
|---|---|---|
| etcd のデータファイルを直接読む | 守られる | — |
| etcd のスナップショットを持ち出す | 守られる(鍵が無ければ復号できない) | — |
| etcd をバックアップから別クラスタへリストアする | 守られる | — |
Kubernetes API で kubectl get secret -o yaml を叩く | 守られない(API は復号して返す) | RBAC(第4回) |
| Pod の中から環境変数を読む | 守られない | Pod のアクセス制御 |
| ★ chart や manifest を Git に置く | 守られない(etcd に入る前の話) | SealedSecrets(本節以降) |
本書ラボには、★を付けた「chart や manifest を Git に置く」経路の実例がそのまま残っています。 ~/fanclub-chart/templates/secret.yaml に apppassword が平文でベタ書きされていました。etcd を暗号化しても、このファイルの中身は 1 文字も変わりません。
GitOps を導入すると、この問題が必ず表面化する
第2巻で ArgoCD を導入し、fanclub の Deployment を Git 管理にしました。そのとき Secret だけは Git に置けませんでした。 実機の base/kustomization.yaml の resources は 5 本(backend-deployment / frontend-deployment / services / configmap / resourcequota)で、Secret は含まれていません。
| リソース | 正本 | Git に置けるか |
|---|---|---|
| Deployment / Service / ConfigMap / ResourceQuota | Git(ArgoCD が同期) | 置ける |
| Secret | Helm chart(手動 helm upgrade) | 置けない(平文だから) |
これが GitOps の穴です。 「すべてを Git で宣言する」と決めたのに、Secret だけは手作業が残ります。 誰かが kubectl create secret を手で打ち、そのことを誰も記録していない——という状態は、クラスタを作り直すときに必ず問題になります。
3 つの解き方
| 方式 | 何を Git に置くか | 鍵はどこにあるか | 本書での扱い |
|---|---|---|---|
| SealedSecrets | 暗号文(SealedSecret リソース) | クラスタの中(コントローラが持つ秘密鍵) | ラボで実装する |
| ExternalSecrets Operator | 参照だけ(「Vault のこのパスを持ってこい」) | 外部ストア(Vault / クラウドの Secrets Manager) | 概念のみ |
| SOPS + age / KMS | 暗号文(値ごとに暗号化した YAML) | ファイル or KMS | 本書では扱わない(名前のみ紹介) |
本回は SealedSecrets を実装します。 理由は 2 つです。①外部ストアを追加せずに完結する(ラボの構成を増やさない)。②「暗号文を Git に置く」という考え方そのものを実機で確かめられる。
SealedSecrets の仕組み —— 封印鍵は etcd の Secret である
この節は読み取りの解説です。
実際に手を動かすのは次の やってみよう④です。
ここで SealedSecret という語を使います。クラスタの公開鍵で暗号化した Secret を表すカスタムリソースで、復号できるのはクラスタ内のコントローラだけなので、Git に置けます。それを作る CLI が kubeseal で、平文の Secret を入力し、封印済み YAML を出力します。
SealedSecrets は 公開鍵暗号の応用です。
| 役割 | 誰が持つか | 何ができるか |
|---|---|---|
| 公開鍵(証明書) | 誰でも取得できる(kubeseal --fetch-cert) | 封印(暗号化)だけ |
| 秘密鍵 | クラスタ内のコントローラだけ | 開封(復号) |
開発者は公開鍵で封印し、その結果を Git に置きます。 クラスタの中のコントローラだけが開封し、通常の Secret を生成します。開発者はクラスタの秘密鍵を知らないので、自分が封印したものを開封できません。
動作の流れ
[開発者] Secret の YAML(平文)
↓ kubeseal(公開鍵で暗号化)
[開発者] SealedSecret の YAML(暗号文)
↓ git commit / git push
[Git] 暗号文がリポジトリに入る
↓ ArgoCD が sync
[クラスタ] SealedSecret リソースが作られる
↓ sealed-secrets コントローラが秘密鍵で復号
[クラスタ] Secret が生成される(ownerReferences 付き)
↓
[Pod] envFrom / volume で参照する(通常の Secret と同じ)
Pod から見れば、生成された Secret は普通の Secret です。 アプリ側の変更は 1 行も要りません。

封印鍵そのものが etcd の Secret である
ここが本回全体を通じて効いてくる一点です。
sealed-secrets を導入すると、kube-system に封印鍵の Secret が作られます。
| 項目 | 実測 |
|---|---|
| 名前 | sealed-secrets-keyd97dd(末尾はランダム) |
| 型 | kubernetes.io/tls |
| ラベル | sealedsecrets.bitnami.com/sealed-secrets-key |
etcd 上を見ると、こう見えます。
実行結果(抜粋):
00000030 64 39 37 64 64 0a 6b 38 73 3a 65 6e 63 3a 73 65 |d97dd.k8s:enc:se|
00000040 63 72 65 74 62 6f 78 3a 76 31 3a 6b 65 79 33 3a |cretbox:v1:key3:|
キー名(...d97dd)の直後から k8s:enc:secretbox:v1:key3: が始まっています。 つまり SealedSecrets の秘密鍵は、やってみよう②で有効にした secretbox で、やってみよう③ でローテーションした key3 によって暗号化されています。
この順序には必然性があります。
etcd を暗号化していない状態で SealedSecrets を導入した場合を考えます。 Git に置いた暗号文は安全になりますが、その暗号文を開封できる秘密鍵が、etcd に平文で保存されていることになります。etcd を読める攻撃者は、鍵を取り出して Git の暗号文を全部開封できます。
SealedSecrets は etcd 暗号化の代わりではありません。 etcd 暗号化の上に積むものです。本回が etcd 暗号化を先にやった理由がここにあります。
封印鍵は既定 30 日で自動ローテーションされ、旧世代も残る
封印鍵は 1 個ではありません。 sealed-secrets コントローラは、既定で 30 日ごと(--key-renew-period)に新しい封印鍵を生成し、kube-system に鍵 Secret を追加していきます。
| 挙動 | 帰結 |
|---|---|
| 新しい鍵を 30 日ごとに生成する | 導入直後は 1 個だが、運用が 30 日を超えると複数件になる |
| 旧世代の秘密鍵を破棄しない(同じラベルで保持し続ける) | 古い公開鍵で封印した暗号文も、同じクラスタなら開封できる |
| 封印には常に最新の鍵(の公開鍵)を使う | 新しく封印したものは最新世代で暗号化される |
この性質が 2 つのことを決めます。
①バックアップは全世代をまとめて取る必要があります。 -l sealedsecrets.bitnami.com/sealed-secrets-key というラベルセレクタは、運用が進むと複数件を返します。 「1 個だけ退避すればよい」と理解していると、古い暗号文だけ復旧できないという事故になります(具体的なコマンドは本回の後半で示します)。
②--re-encrypt の存在理由がここにあります。 暗号文の鍵世代を最新にそろえておけば、いずれ古い鍵 Secret を削除できます。 そろえないまま古い鍵を消すと、その世代で封印した暗号文が開けなくなります——etcd 暗号化キーで やってみよう③ のステップ9 に確認したのと、まったく同じ構造です。
実測の範囲について。
本書ラボでは 30 日の経過を待てないため、鍵の自動ローテーションは実測していません。 上記は コントローラの既定動作としてドキュメントに記載されている内容です。--key-renew-period を短縮して実演することも本書では行いません。導入直後の本書ラボでは、鍵 Secret は 1 個です。
導入するもののバージョン
| 項目 | 値(2026-07-28 時点) |
|---|---|
| Helm chart | 2.19.1(2026-07-03 リリース) |
| appVersion / kubeseal | 0.38.4 |
| コントローライメージ | docker.io/bitnami/sealed-secrets-controller:0.38.4 |
| CRD | sealedsecrets.bitnami.com |
| Helm リポジトリ | https://bitnami.github.io/sealed-secrets |
リポジトリ URL に注意してください。
検索で出てくる https://bitnami-labs.github.io/sealed-secrets は 404 です。 GitHub の organization は bitnami-labs のままですが、chart のホスト先は bitnami.github.io です。手順書の URL をそのまま写すと helm repo add の時点で失敗します。
スコープ —— 既定は strict
スコープ(--scope)は、暗号文を どの name / namespace に束縛するかの設定です。kubeseal は既定で、暗号文を name と namespace に束縛します。
--scope | 束縛 | 使いどころ |
|---|---|---|
strict(既定) | name + namespace | 通常。最も厳しい |
namespace-wide | namespace のみ | 同じ namespace 内で名前を変えたい |
cluster-wide | 束縛なし | どこにでも展開したい(共有の証明書など) |
strict の封印済み YAML の name を書き換えて apply すると、kubectl get sealedsecret の STATUS 列にこう出ます。
実行結果:
no key could decrypt secret (DB_USER, DB_PASSWORD)
本書ラボの fanclub-db-secret でも、やってみよう⑤ ステップ7 でこの文字列がそのまま出ます。 ただし括弧の中身の並び順は保証されません——Secret の data マップを走査した順に並ぶので、(DB_USER, DB_PASSWORD) のことも (DB_PASSWORD, DB_USER) のこともあります。読むのは「どのデータキーが開けなかったか」であって、順番ではありません。
これは「壊れている」のではなく、設計どおりの拒否です。 束縛が無ければ、他人の namespace 用に封印された暗号文を自分の namespace へコピーして開封することができてしまいます。strict はそれを暗号のレベルで防いでいます。
括弧の中身は「データキー名」であって「鍵」ではない
データキーを 2 つ(alpha / beta)持つ Secret を封印し、name だけ書き換えて apply したときの実測です。
実行結果:
no key could decrypt secret (alpha, beta)
括弧の中身は、復号できなかった Secret のデータキー名の一覧です。
| メッセージ | 括弧の中身の正体 |
|---|---|
no key could decrypt secret (k) | テスト Secret のデータキー名が k だっただけ |
no key could decrypt secret (alpha, beta) | データキーが alpha と beta の 2 つあった |
no key could decrypt secret (DB_USER, DB_PASSWORD) | 本書ラボの fanclub-db-secret を封印し直して失敗した場合 |
(k) を「key(鍵)の意味」と読むのは誤りです。
メッセージ本文の no key could decrypt に key の語があるため、括弧の k も鍵のことだと読んでしまいがちです。括弧は「どのデータキーが開けなかったか」の一覧であり、Secret のデータキーが 1 つで、その名前が k だったときにだけ (k) になります。
やってみよう④:fanclub の DB 認証情報を SealedSecret 化する
所要 25 分(試験相当の粒度は ステップ5 の --dry-run=client -o yaml の部分のみ・3 分)。
kubeseal は試験中に参照できるドキュメントが 1 つもありません(github.com も sealed-secrets のサイトも許可 8 サイトに含まれません)。コマンド形は暗記対象です。ただし CKS で SealedSecrets そのものが出題される可能性は高くありません(公式コンピテンシーは Manage Kubernetes secrets としか書いていません)。本演習の主眼は実務です。
対象は alma-proxy(root)、k8s-ops(developer)、k8s-cp-01(root・ステップ3 の etcdctl のみ)です。前提は やってみよう③ を完了済みであること(etcd 暗号化が有効で、鍵がローテーション済み)です。
ステップ1:whitelist に 1 行足す
実行コマンド(alma-proxy 上・root):
# cp -p /etc/squid/whitelist.txt /etc/squid/whitelist.txt.pre09.bak
# echo 'bitnami.github.io' >> /etc/squid/whitelist.txt
# systemctl reload squid
実行コマンド(alma-proxy 上・root):
# wc -l /etc/squid/whitelist.txt
実行結果:
42 /etc/squid/whitelist.txt
起点の 41 行から 42 行になりました。 systemctl reload squid は成功しても何も出力しません。無言で戻ってくるのが正常です。
追加は 1 行で足ります。ただし「なぜ 1 行で足りるのか」を押さえてください。
本演習で外部から取るものは 3 つです。①Helm chart(bitnami.github.io)②コントローライメージ(docker.io 系)③kubeseal のバイナリ(github.com 系)。このうち ②と③は第1回〜第8回で既に whitelist に入っています。
③については注意が要ります。 GitHub Releases のダウンロード URL は リダイレクトされます。 curl -sIL で追跡したところ、https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.38.4/kubeseal-0.38.4-linux-amd64.tar.gz の最終的な取得先は release-assets.githubusercontent.com(HTTP 200)でした。この host は本書ラボの whitelist の 16 行目に既に登録済みなので、追加は要りません。
「github.com を許可したから GitHub からは何でも取れる」と考えると、ここで止まります。 whitelist 方式のプロキシでは、リダイレクト先も個別に許可されている必要があります。 新しいツールを入れるときは、curl -sIL で最終的な取得先を確かめてから whitelist を編集する——これが本書ラボの作法です。
ステップ2:chart を導入する
実行コマンド(k8s-ops 上・developer):
$ helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
$ helm repo update
実行結果(helm repo add の分):
"sealed-secrets" has been added to your repositories
実行コマンド(k8s-ops 上・developer):
$ time helm install sealed-secrets sealed-secrets/sealed-secrets --version 2.19.1 -n kube-system --set-string fullnameOverride=sealed-secrets-controller --wait
実行結果(冒頭 8 行):
NAME: sealed-secrets
LAST DEPLOYED: Tue Jul 28 05:02:34 2026
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
helm install は STATUS: deployed / REVISION: 1 で完了し、所要は --wait 込みで 11.379 秒でした。コントローラ Pod(sealed-secrets-controller-7ff78fdccb-2qhql・末尾のハッシュは読者ごとに異なります)は 23 秒後に 1/1 Running になり、CRD sealedsecrets.bitnami.com が登録されます。
fullnameOverride を付ける理由。 kubeseal の既定の接続先が sealed-secrets-controller / kube-system だからです。付けないと、封印のたびに --controller-name と --controller-namespace を指定することになります。実機では、このフラグを付けた状態で kubeseal をオプション無しで実行できることを確認済みです。 試験や現場で「なぜか kubeseal が繋がらない」となる原因の多くがこれです。
--version 2.19.1 を必ず付けてください。 付けないと、その日の最新版が入ります。本書の実測値と一致しなくなるだけでなく、本番でも「いつ入れたかで挙動が違う」という状態を作ります。
第8回の Gatekeeper に弾かれないか。
前回入れた OPA Gatekeeper の Constraint は namespaces: ["policy-probe"] にスコープされているので、kube-system への導入は評価対象外です。イメージタグも 0.38.4 で固定されているため、latest タグ禁止の制約にも触れません。この導入は Gatekeeper に弾かれません。
ただし本番では、これは幸運です。 ポリシーエンジンを入れたクラスタに新しい chart を入れるときは、その chart が作るリソースがポリシーに引っかからないかを事前に確認する——--dry-run=server を付けて helm install を試すのが定石です。
ステップ3:封印鍵が etcd で暗号化されていることを確認する
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key
実測では sealed-secrets-keyd97dd という名前の Secret が 1 件返りました(型は kubernetes.io/tls・DATA は 2)。導入直後なので 1 件だけです。
末尾 5 文字はランダムなので、読者ごとに異なります。 次のコマンドの <封印鍵の名前> は、手元の出力で置き換えてください。
実行コマンド(k8s-cp-01 上・root):
# etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/kube-system/<封印鍵の名前> | hexdump -C | head -5
実行結果(4〜5 行目の抜粋):
00000030 64 39 37 64 64 0a 6b 38 73 3a 65 6e 63 3a 73 65 |d97dd.k8s:enc:se|
00000040 63 72 65 74 62 6f 78 3a 76 31 3a 6b 65 79 33 3a |cretbox:v1:key3:|
封印鍵の名前が長いので、プレフィックスは 4 行目から 5 行目にまたがります。 head -3 では鍵のパスを表示し終わらず、k8s:enc が 1 文字も出てきません。 head -4 まで出しても k8s:enc:se で切れて、プロバイダ名も鍵名も読めません。本書が暗号化の確認に head -5 を使っているのはこのためです。Secret の名前が長いほど、プレフィックスは後ろの行へずれます。
SealedSecrets の秘密鍵が、前半で有効にした secretbox で暗号化されています。 順序が逆だったら、この鍵は平文で保存されていました。
ステップ4:kubeseal を導入する
実行コマンド(k8s-ops 上・developer):
$ command -v kubeseal
この時点では何も出力されず、終了コードは 1 です。 kubeseal はまだ入っていません。
which は AlmaLinux 10 に入っていません。 command -v を使ってください。試験環境でも which が無い可能性があります。 command -v はシェル組み込みなので、どこでも動きます。
実行コマンド(k8s-ops 上・developer):
$ cd /tmp
$ curl -sOL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.38.4/kubeseal-0.38.4-linux-amd64.tar.gz"
実行コマンド(k8s-ops 上・developer):
$ ls -l kubeseal-0.38.4-linux-amd64.tar.gz
実測のサイズは 23,360,884 バイトでした。ここが数百バイトなら、リダイレクトを追えずに HTML を保存しています(次の注意書きを参照してください)。
実行コマンド(k8s-ops 上・developer):
$ tar -xzf kubeseal-0.38.4-linux-amd64.tar.gz kubeseal
$ sudo install -m 755 kubeseal /usr/local/bin/kubeseal
実行コマンド(k8s-ops 上・developer):
$ kubeseal --version
実行結果:
kubeseal version: 0.38.4
-L を省かないでください。
GitHub Releases の URL は release-assets.githubusercontent.com へリダイレクトされます。 -L を付けないと、curl はリダイレクトを追わず、中身が数百バイトの HTML ファイルを保存して正常終了します。 その後の tar -xzf が gzip: stdin: not in gzip format で失敗して初めて気づく、という遠回りになります。ダウンロードしたら ls -l でサイズを確認するのが確実です。
tar に kubeseal を指定しているのは、アーカイブに LICENSE と README.md も入っているためです。バイナリだけを取り出しています。install -m 755 を使うのは、cp + chmod の 2 手を 1 手にまとめられるからです(パーミッションを付け忘れる事故が減ります)。
kubeseal は試験中に参照できるドキュメントがありません。 インストール手順まで暗記する必要はありませんが、「GitHub Releases から tar.gz を取って /usr/local/bin に置く」という形は、CKS で扱う多くのツール(Trivy / Cosign / kube-bench)に共通します。形として覚えてください。
ステップ5:封印する(--dry-run=client -o yaml から直接パイプする)
実行コマンド(k8s-ops 上・developer):
$ kubectl create secret generic fanclub-db-secret -n fanclub --from-literal=DB_USER=appuser --from-literal=DB_PASSWORD=apppassword --dry-run=client -o yaml | kubeseal --format yaml > ~/db-sealedsecret.yaml
kubectl get secret -o yaml を使わない理由。
既存の Secret を -o yaml で吸い出すと、次のものがまとめて付いてきます。
| 付いてくるもの | 由来 |
|---|---|
app.kubernetes.io/managed-by=Helm ラベル | Helm |
meta.helm.sh/release-name / meta.helm.sh/release-namespace annotation | Helm |
velero.io/backup-name / velero.io/restore-name ラベル | 第2巻の Velero バックアップ |
resourceVersion / uid / creationTimestamp | API Server |
kubeseal はラベルと annotation を spec.template.metadata に引き継ぎます。 つまり、この後 chart から Secret を外しても、コントローラが再生成する Secret は「Helm 管理」を名乗り続けます。 本回の後半の主題(Secret の正本を Helm から Git へ移す)と真正面から矛盾する状態です。
kubectl create secret generic ... --dry-run=client -o yaml なら、余計な metadata が 1 つも入りません。 名前・namespace・データだけの、最小の Secret 定義が作られます。
--dry-run=client -o yaml は試験の定石です。 client は API Server に届きません。 ローカルで YAML を組み立てて表示するだけです。そのため、同じ名前の Secret がすでに存在していてもエラーになりません。 雛形作りに使うオプションです(--dry-run=server は API Server が Admission まで通すので、既存リソースの影響を受けます)。
試験開始直後に export do="--dry-run=client -o yaml" を設定しておくと、kubectl create secret generic x --from-literal=a=b $do と打てます。本回に限らず、CKS の全ドメインで効く時短です。
--format yaml を忘れないでください。既定は json です。 JSON でも動きますが、Kustomize の resources に並べるときに他のファイルと形式が揃っていないと読みにくくなります。
--from-literal はシェルの履歴に平文を残します。 本書ラボの apppassword はすでに chart にも Git にも載っている値なのでそのまま打っていますが、本番では --from-file=DB_PASSWORD=/path/to/file で渡すか、read -s で読み取った変数を使ってください。 「Git に平文を置かない」ために封印しているのに、~/.bash_history に平文が残っては意味がありません。
実行コマンド(k8s-ops 上・developer):
$ cat ~/db-sealedsecret.yaml
実行結果(spec.encryptedData の 2 つの値は 1 行が非常に長いので、途中で切って掲載しています):
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: fanclub-db-secret
namespace: fanclub
spec:
encryptedData:
DB_PASSWORD: AgBLR8TRpvcmW7Bnc4WJMhXZI82N21HIpLDzj+DJlIP3vYvTj0DWRhu5Y3tu...
DB_USER: AgCBDWBs+LUpbs9etratZR3fOJZxgjjmb4PgPEeSx4epsHP5LkDQ3FEx2j8b...
template:
metadata:
name: fanclub-db-secret
namespace: fanclub
全部で 14 行しかありません。 重要なのは次の点です——app.kubernetes.io/managed-by=Helm のラベルも、meta.helm.sh/release-name の annotation も、Velero のラベルも、1 つも入っていません。 kubectl create secret generic ... --dry-run=client が作るのは名前・namespace・データだけの最小の Secret だからです。
spec.template.metadata に name と namespace だけが引き継がれています。 コントローラはここを雛形として Secret を生成するので、生成される Secret も「Helm 管理」を名乗りません。 これで「正本を Helm から Git へ移す」準備が整いました。
ステップ6:そのまま apply して失敗を確認する
実行コマンド(k8s-ops 上・developer):
$ kubectl apply -f ~/db-sealedsecret.yaml
実行コマンド(k8s-ops 上・developer):
$ kubectl get sealedsecret -n fanclub
実行結果:
NAME STATUS SYNCED AGE
fanclub-db-secret failed update: Resource "fanclub-db-secret" already exists and is not managed by SealedSecret False 10s
SYNCED 列が False になっています。 STATUS 列は横に長いので、端末の幅によっては折り返して表示されます。
実行コマンド(k8s-ops 上・developer):
$ kubectl logs -n kube-system deploy/sealed-secrets-controller --tail=20
コントローラは Error updating, will retry を繰り返したあと、最後に Error updating, giving up を出して諦めます。 ここが次のステップの前提になります——諦めたあとのコントローラは、こちらが何かを変えても自分からは再挑戦しません。
なぜ失敗するのか。
同じ名前の Secret がすでに存在し、それを作ったのが SealedSecret ではないからです。コントローラは 他人が管理している Secret を勝手に上書きしません。 これは安全側の設計です。「apply したのに反映されない」ときは、kubectl get sealedsecret の STATUS 列を見てください。 kubectl apply 自体は成功を返すので、apply の結果だけを見ていても気づけません。
第8回の Gatekeeper と同じ構造です。 kubectl apply が通ることと、それが意図どおり動いていることは別です。カスタムリソースを apply したら、必ず status を見る——これが CKS 全体を通じた作法になります。
ステップ7:所有権を移す
実行コマンド(k8s-ops 上・developer):
$ kubectl annotate secret fanclub-db-secret -n fanclub sealedsecrets.bitnami.com/managed=true
これだけでは SYNCED は False のままです。 20 秒待っても変わりません。コントローラは前のステップで既に諦めているからです。
kubectl apply を打ち直しても直りません。 中身が同じなので unchanged になり、generation が変わらないため再調整が走らないからです。SealedSecret を 1 度削除してから作り直します。
実行コマンド(k8s-ops 上・developer):
$ kubectl delete sealedsecret fanclub-db-secret -n fanclub
$ kubectl apply -f ~/db-sealedsecret.yaml
ここで delete を打っても Secret は消えません。
この時点の SealedSecret は、まだ Secret を所有していない(ownerReferences が付いていない)からです。消えるのは SealedSecret というカスタムリソースだけで、fanclub-db-secret の Secret はそのまま残ります。アプリにも影響しません。
所有権が移ったあとは話が変わります。 やってみよう⑤ で見るとおり、ownerReferences が付いた状態で SealedSecret を消すと、Secret も一緒に消えます。 「同じ delete でも、所有権の有無で結果が変わる」——ここを取り違えないでください。
実行コマンド(k8s-ops 上・developer):
$ kubectl get sealedsecret -n fanclub
実測では、作り直しから 15 秒で SYNCED が True になりました。 STATUS 列は空になります。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub -o jsonpath='{.metadata.ownerReferences[0]}'
実行結果(uid は読者ごとに異なるので途中で切っています):
{"apiVersion":"bitnami.com/v1alpha1","controller":true,"kind":"SealedSecret","name":"fanclub-db-secret","uid":"736e6e86-..."}
"kind":"SealedSecret" と "controller":true の 2 つを見てください。 kind だけを取りたければ -o jsonpath='{.metadata.ownerReferences[0].kind}' と書けば SealedSecret だけが返ります。
ownerReferences は「このリソースは誰が作ったか」を記録するフィールドで、親が消えると子も消えます(ガベージコレクション)。これが付いたことが、所有権が移った証拠です。 これ以降、この Secret の親は SealedSecret になります。親が消えれば子も消えます(やってみよう⑤ で確認します)。
あわせて、ラベルと annotation も確認します。
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub -o jsonpath='{.metadata.labels}{"\n"}{.metadata.annotations}{"\n"}'
実行結果(2 行とも空行です):
ラベルも annotation も空になりました。 app.kubernetes.io/managed-by=Helm も meta.helm.sh/release-name も、Velero のラベルも消えています。「正本が Helm から離れた」ことが kubectl get -o jsonpath で目に見える形になります。この時点でも https://fanclub.local/ は 200 のままです。
ステップ8:アプリが動き続けていることを確認する
実行コマンド(k8s-ops 上・developer):
$ curl -k --noproxy "*" -s -o /dev/null -w '%{http_code}\n' https://fanclub.local/
実行コマンド(k8s-ops 上・developer):
$ curl -k --noproxy "*" -s https://fanclub.local/api/members
HTTP コードは 200、/api/members は会員一覧の JSON を返しました。
Secret の中身は 1 バイトも変わっていないので、Pod は何も気づきません。 変わったのは「誰がこの Secret を管理しているか」だけです。
この演習の合格条件
kubectl get sealedsecret -n fanclubのSYNCEDがTrueownerReferences[0].kindがSealedSecrethttps://fanclub.local/api/membersが JSON を返す
Secret の正本を Helm から Git へ移す
この節は読み取りの解説です。
実際に手を動かすのは次の やってみよう⑤です。
本節と次の やってみよう⑤ は、CKS の試験範囲外です。
試験の設問は単一クラスタでの操作が中心で、GitOps リポジトリを操作させる形式は想定しにくいためです。ここから先は実務の作法として読んでください。 ただし「Secret を宣言的に管理する」という設計判断そのものは、CKS の背後にある考え方と地続きです。
やってみよう④ の時点では、同じ Secret に 2 人の管理者がいる状態になっています。
| 管理者 | 何を持っているか |
|---|---|
Helm(~/fanclub-chart/templates/secret.yaml) | 平文の定義。helm upgrade のたびに書き戻す |
| SealedSecrets コントローラ | 暗号文から生成する権限(ownerReferences) |
この状態のまま helm upgrade を打つと、Helm が平文の Secret で上書きします。 SealedSecret 化した意味がなくなります。chart 側からテンプレートを削除する必要があります。
移行の 5 手
| # | 手順 | 効果 |
|---|---|---|
| 1 | 既存 Secret に sealedsecrets.bitnami.com/managed=true の annotation | 所有権を移せるようにする(やってみよう④) |
| 2 | kubeseal で封印し、SealedSecret を delete してから apply | SYNCED True / ownerReferences が付く(やってみよう④) |
| 3 | chart から templates/secret.yaml を削除して helm upgrade | Helm の管理から外す |
| 4 | SealedSecret を base/ に置き、kustomization.yaml に足して commit / push | Git を正本にする |
| 5 | ArgoCD が sync | 宣言と実体が一致する |
手順3 で何が起きるか
chart から Secret を消して helm upgrade すると、Helm は Secret を削除します。 そのとき何が起きるかを先に見ておきます。
| 観察点 | 実測 |
|---|---|
| Secret | Helm が削除 → コントローラが即座に再生成(kubectl get secret の AGE が 13 秒に戻る) |
| Pod | 影響を受けない。 envFrom は起動時にのみ読まれる |
| HTTPS | 200 を維持 |
Secret が一瞬消えて再生成される、という挙動を先に知っておいてください。 AGE が数秒に戻っているのを見て「何かが壊れた」と思わないように、この節を先に置いています。
ただし、この「一瞬」は無害ではありません。 もしこのタイミングで Pod が再作成されていたら、Secret がまだ無い状態で起動しようとして失敗します(CreateContainerConfigError)。本番では、chart から Secret を消す helm upgrade と、Pod の再作成が重ならないようにしてください。
同じ性質の裏面 —— Secret を更新しても Pod は拾わない
本回はここまで、envFrom が起動時にのみ読まれることを 3 回「都合のよい性質」として使ってきました。
| 場面 | 「Pod に影響しない」で済んだ理由 |
|---|---|
| 全件再暗号化(やってみよう② ステップ9) | 値は変わらず etcd 上の表現だけが変わったから |
| 旧鍵を消して読めなくなったとき(やってみよう③ ステップ9) | 起動済みのコンテナは Secret を読み直さないから |
| chart から Secret を消したとき(本節) | 同上 |
同じ性質には裏面があります。
SealedSecret を更新して新しいパスワードを配っても、Pod を再起動するまで古い値が使われ続けます。 envFrom / env で渡した値は、コンテナのプロセスが起動したときの環境変数として固定されるからです。Secret を書き換えても、動いているプロセスの環境変数は変わりません。
実行コマンド(k8s-ops 上・developer):
$ kubectl -n fanclub rollout restart deployment/fanclub-backend
これを打たないと反映されません。 「Secret を更新したのにアプリが古いパスワードで DB に繋ぎにいく」は、Secret 運用で最も頻繁に踏む落とし穴です。
| 渡し方 | 更新の反映 |
|---|---|
envFrom / env(環境変数) | 反映されない。 rollout restart が要る |
volume マウント(secret ボリューム) | kubelet が数十秒で更新する(ファイルの中身が入れ替わる)。ただしアプリがファイルを読み直す実装でなければ意味がない |
本書ラボの fanclub-backend は envFrom を使っています。 つまり rollout restart が必須です。「どちらの渡し方をしているか」を確認してから更新手順を決めてください。 volume マウントに変えれば自動反映に近づきますが、アプリ側が変更を検知して読み直す実装になっている必要があります。 「ファイルは変わったのにアプリは古い値のまま」は、環境変数と同じ落とし穴の別の形です。
helm upgrade で押さえる 4 点
| # | 事象 | 対処 |
|---|---|---|
| 1 | -n fanclub を付けないと失敗する | Error: UPGRADE FAILED: "fanclub" has no deployed releases。kubeconfig のコンテキストに既定 namespace が設定されておらず、リリースは fanclub namespace にある。helm upgrade fanclub ./fanclub-chart -n fanclub ... と書く |
| 2 | --force-conflicts が要る | Server-Side Apply の field manager 衝突(第8回と同じ) |
| 3 | backend が 3 → 1 に減る | chart の replicas 既定値と overlays/prod/patch-replicas.yaml の綱引き。ArgoCD の selfHeal が数十秒で 3 に戻す(第8回から続く既知挙動) |
| 4 | 所要 | 5.007 秒(revision 7 → 8) |
git push が Squid 経由になって失敗する
第6回・第8回と同じ落とし穴が、本回でも出ます。 /etc/profile.d/proxy.sh の no_proxy に k8s-registry が入っていないため、http://k8s-registry:3000/... への push が Squid を経由してしまいます。
実行結果(no_proxy を設定せずに git push したとき):
fatal: unable to access 'http://k8s-registry:3000/developer/gitops-fanclub.git/': The requested URL returned error: 403
Squid が whitelist に無い宛先として 403 を返しています。 Git のエラーではありません。
実行コマンド(k8s-ops 上・developer):
$ export no_proxy="$no_proxy,k8s-registry"
これを打てば 0.249 秒で push できます(実測)。
~/.gitconfig は第8回で作ったものが残っています(user.name=developer / user.email=developer@example.com)。第8回で遭遇した Author identity unknown は、本回では起きません。
ArgoCD の sync は最大 3 分かかる
| 回 | sync までの実測 |
|---|---|
| 第6回 | 約 80 秒 |
| 第8回 | 76 秒 |
| 本回 | 182 秒 |
ArgoCD の既定のポーリング間隔は 3 分です。 タイミングによっては 3 分近く待つことになります。「反映されない」と焦る前に、まず 3 分待ってください。 急ぐなら argocd app sync で手動 sync するか、ArgoCD UI の SYNC ボタンを押します。
やってみよう⑤:SealedSecret を GitOps に載せる
所要 30 分(うち試験相当の粒度はありません)。
本演習は完全に実務向けです。 CKS の設問は単一クラスタでの操作が中心で、GitOps リポジトリを操作させる形式は想定しにくいためです。ただし 「Secret を宣言的に管理する」という設計判断は、CKS の背後にある考え方そのものです。
この演習は必ず ステップ8 まで手を動かしてください。
chart から Secret を消したまま Git へ載せないと、クラスタを作り直したときに Secret がどこにも無い状態になります。 ステップ9 は手を動かさない読み物ですが、本番へ持ち出すときに必ず要る内容なので飛ばさないでください。
対象は k8s-ops(developer)です。前提は次の 3 つです。
- やってみよう④ を完了済み(
SYNCED True/ownerReferences付き) ~/gitops/gitops-fanclubが k8s-ops に clone 済み(第6回で作業ディレクトリとして使ったもの)~/.gitconfigにuser.name/user.emailが設定済み(第8回で作成)
ステップ1:chart から Secret のテンプレートを退避する
実行コマンド(k8s-ops 上・developer):
$ cd ~
$ mkdir -p ~/fanclub-chart/templates.bak-09
$ mv ~/fanclub-chart/templates/secret.yaml ~/fanclub-chart/templates.bak-09/
templates/ の中に置いたままリネームしないでください。 Helm は templates/ 配下のファイルをすべて読みます。.bak に変えても読まれます。 ディレクトリごと外に出してください。
ステップ2:helm upgrade する
まず、いま何が --set で渡されているかを確認します。
実行コマンド(k8s-ops 上・developer):
$ helm get values fanclub -n fanclub
実行結果:
USER-SUPPLIED VALUES:
frontend:
image:
tag: 1.0.0
gateway:
enabled: true
実行コマンド(k8s-ops 上・developer):
$ cd ~
$ time helm upgrade fanclub ./fanclub-chart -n fanclub --set gateway.enabled=true --set frontend.image.tag=1.0.0 --force-conflicts
4 つの引数はどれも省略できません。
| 引数 | 省くとどうなるか |
|---|---|
-n fanclub | Error: UPGRADE FAILED: "fanclub" has no deployed releases。kubeconfig のコンテキストに既定 namespace が設定されておらず、リリースは fanclub namespace にある |
--set gateway.enabled=true | Gateway が無効化され、https://fanclub.local/ が落ちる |
--set frontend.image.tag=1.0.0 | frontend のイメージタグが chart 既定値に戻る |
--force-conflicts | Server-Side Apply の field manager 衝突で失敗する(第8回と同じ) |
helm upgrade の前に helm get values <release> -n <ns> を打って、いま何が --set で渡されているかを確認する——これは本書ラボに限らない実務の作法です。--set は「前回の値を引き継ぐ」わけではありません。 毎回すべて渡す必要があります。
実測では STATUS: deployed / REVISION: 8 で完了し、所要は 5.007 秒でした(revision 7 → 8)。
ステップ3:Secret が再生成されたことを確認する
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret fanclub-db-secret -n fanclub
実測では AGE が 13 秒に戻っていました。 Helm が消し、SealedSecrets コントローラが作り直したことが、この数字だけで分かります(SealedSecret 側の AGE は 59 秒のままでした)。
実行コマンド(k8s-ops 上・developer):
$ kubectl -n fanclub get pod
Pod は再起動していません。 AGE は helm upgrade の前と変わりません。backend は 3 → 1 に絞られますが、ArgoCD の selfHeal が数十秒で 3 に戻します(第8回から続く既知の挙動です)。HTTPS はこの間も 200 を維持しました。
ステップ4:SealedSecret を GitOps リポジトリに置く
実行コマンド(k8s-ops 上・developer):
$ cp ~/db-sealedsecret.yaml ~/gitops/gitops-fanclub/base/db-sealedsecret.yaml
base/kustomization.yaml の resources に 1 行足します(本書ラボでは configmap.yaml の次に足しました。並び順に意味はありません)。
resources:
- backend-deployment.yaml
- frontend-deployment.yaml
- services.yaml
- configmap.yaml
- db-sealedsecret.yaml
- resourcequota.yaml
足したら、Kustomize が実際に読めているかを push の前に確かめます。
実行コマンド(k8s-ops 上・developer):
$ cd ~/gitops/gitops-fanclub
$ kubectl kustomize overlays/prod | grep -c 'kind: SealedSecret'
実行結果:
1
1 が返れば、overlay まで通って SealedSecret が 1 件生成されています。 0 なら resources への追記かファイル名の綴りを見直してください。ここで確かめておくと、push してから ArgoCD の sync を 3 分待って気づく、という遠回りを避けられます。
ステップ5:commit / push する
実行コマンド(k8s-ops 上・developer):
$ export no_proxy="$no_proxy,k8s-registry"
$ cd ~/gitops/gitops-fanclub
$ git add base/db-sealedsecret.yaml base/kustomization.yaml
$ git commit -m "Manage fanclub DB credentials as SealedSecret"
実行コマンド(k8s-ops 上・developer):
$ time git push
実行結果(git push の末尾):
77114fe..ec5c8dc main -> main
所要は 0.249 秒でした。コミットハッシュ(ec5c8dc)は読者ごとに変わります。 大事なのは main -> main が出て、エラーが返っていないことです。
export no_proxy を忘れると push がタイムアウトします。 /etc/profile.d/proxy.sh に k8s-registry を足していないためです(第6回・第8回と同じ)。恒久的に直すなら、そのファイルに追記してください。
ステップ6:ArgoCD の sync を待つ
実行コマンド(k8s-ops 上・developer):
$ kubectl get application fanclub-api-prod -n argocd -o jsonpath='{.status.sync.status}{"\t"}{.status.health.status}{"\n"}'
実行結果(sync 完了後):
Synced Healthy
Synced / Healthy になるまでの実測は 182 秒でした。第6回(約 80 秒)・第8回(76 秒)より長くかかっています。ArgoCD の既定ポーリング間隔は 3 分なので、push した直後にポーリングが走らなければ 3 分近く待つことになります。
実行コマンド(k8s-ops 上・developer):
$ kubectl get sealedsecret -n fanclub
実行結果:
NAME STATUS SYNCED AGE
fanclub-db-secret True 7m
この時点で、Secret の正本は Git になりました。 chart には定義が無く、クラスタの Secret は Git の暗号文から生成されています。
ステップ7:スコープを実測する(strict の証明)
実行コマンド(k8s-ops 上・developer):
$ sed 's/name: fanclub-db-secret/name: fanclub-db-secret-copy/' ~/db-sealedsecret.yaml > /tmp/scope-probe.yaml
$ kubectl apply -f /tmp/scope-probe.yaml
実行コマンド(k8s-ops 上・developer):
$ kubectl get sealedsecret fanclub-db-secret-copy -n fanclub
実行結果:
NAME STATUS SYNCED AGE
fanclub-db-secret-copy no key could decrypt secret (DB_USER, DB_PASSWORD) False 13s
括弧の中身の並びは実行のたびに変わります((DB_PASSWORD, DB_USER) になることもあります)。見るべきは「開けなかったデータキーが 2 つとも並んでいること」で、順番ではありません。
この sed は 2 箇所を置換します。
db-sealedsecret.yaml には name: fanclub-db-secret が 5 行目(metadata.name)と 13 行目(spec.template.metadata.name)の 2 箇所にあり、両方が fanclub-db-secret-copy に変わります。
これは意図どおりです。 片方だけ変えると、「名前が -copy の SealedSecret が、名前が元のままの Secret を作ろうとする」という不整合なマニフェストになってしまいます。実験したいのは「宛先が変わったら開封できないこと」なので、宛先は一貫して -copy にそろえます。
Secret は作られません。 kubectl get secret fanclub-db-secret-copy -n fanclub は NotFound を返します。
括弧の中身は「開けなかったデータキーの一覧」であって、鍵の名前ではありません。 前の節で説明したとおりです。no key could decrypt という文の直後に括弧があるので鍵のことだと読みたくなりますが、違います。
実行コマンド(k8s-ops 上・developer):
$ kubectl delete sealedsecret fanclub-db-secret-copy -n fanclub
名前を変えただけで復号できなくなりました。 これが strict スコープです。暗号文は「fanclub namespace の fanclub-db-secret」という宛先に束縛されています。 コピーして使い回すことはできません。
ステップ8:GC を確認する(使い捨ての SealedSecret で)
ここで確認したいのは「親(SealedSecret)を消すと子(Secret)も消える」ことです。 ただし fanclub-db-secret では確認できません。 Git が正本になった今、手で消しても ArgoCD の selfHeal が即座に作り直すからです(この点は本ステップの最後で確かめます)。
ArgoCD の管理外にある default namespace で、使い捨ての SealedSecret を作って確認します。
実行コマンド(k8s-ops 上・developer):
$ kubectl create secret generic gc-probe -n default --from-literal=probe=gc --dry-run=client -o yaml | kubeseal --format yaml > /tmp/gc-probe.yaml
$ kubectl apply -f /tmp/gc-probe.yaml
実行コマンド(k8s-ops 上・developer):
$ kubectl get secret gc-probe -n default
実行結果:
NAME TYPE DATA AGE
gc-probe Opaque 1 8s
SealedSecret を apply しただけで、コントローラが Secret を作りました。 kubectl create secret は 1 度も打っていません。
実行コマンド(k8s-ops 上・developer):
$ kubectl delete sealedsecret gc-probe -n default
$ kubectl get secret gc-probe -n default
実行結果:
Error from server (NotFound): secrets "gc-probe" not found
SealedSecret を消すと、生成された Secret も消えました。 ownerReferences によるガベージコレクションです。削除は即座に伝播します。 後片付けは不要です——Secret ごと消えているので、default namespace には何も残りません。
ステップ8(続き):同じことを fanclub でやると戻ってくる
実行コマンド(k8s-ops 上・developer):
$ kubectl delete sealedsecret fanclub-db-secret -n fanclub
$ kubectl get secret fanclub-db-secret -n fanclub
実測では、削除の 3 秒後には Secret が AGE 2 秒で存在していました。 SealedSecret 自体も AGE 0 秒で復活しています。NotFound は観測できません。
これが本演習の到達点です。
SealedSecret を手で消しても、ArgoCD が Git の宣言と突き合わせて即座に作り直します(selfHeal=true)。そこから ownerReferences で Secret も再生成されます。クラスタ側で手を加えた変更は、Git に無い限り残りません。
やってみよう④ の入口では、この Secret は「誰かが手で作ったもの」でした。 いまは Git の base/db-sealedsecret.yaml が唯一の正本です。手で消せば戻り、Git から消せば消える——正本がどこにあるかが、削除の振る舞いに表れています。
ただし、これは裏返せば次のステップ9 の危うさでもあります。 Git から消した瞬間に本当に消えるということだからです。
最後に、アプリが動き続けていることを確認します。
実行コマンド(k8s-ops 上・developer):
$ curl -k --noproxy "*" -s -o /dev/null -w '%{http_code}\n' https://fanclub.local/
実行結果:
200
ステップ9:本番ガードレール
ArgoCD の prune: true と組み合わせると、Git からファイルを消した瞬間に Secret が消えます。
本書ラボの fanclub-api-prod は syncPolicy.automated に prune=true / selfHeal=true が設定されています。base/db-sealedsecret.yaml を消して push すると、ArgoCD が SealedSecret を削除し、その ownerReferences で Secret も消えます。 そして 次に Pod が作り直されたときにアプリが起動しなくなります。
対策は 2 つです。 ①Secret を含む manifest には argocd.argoproj.io/sync-options: Prune=false を付ける(誤って消しても prune されない)。②kubectl delete sealedsecret を打つ前に、必ず封印鍵と暗号文のバックアップを確認する。
第8回で「Deployment は正常に見えるのに Pod が増えない」を扱いました。本回は「Secret が消えているのに Pod は動き続けている」です。 どちらも 「今動いていること」は「壊れていないこと」を意味しないという同じ話です。
Prune=false は、本書ラボでは適用していません。
対策として提示するに留めます。 理由は、以降の回で base/ を触るときの前提を増やさないためです。本番では付けることを勧めますが、本書のラボ手順としては入れていません——この区別を曖昧にしないために明記しておきます。
本書ラボの apppassword は、この時点でも Git の履歴に残っています。
封印したのは「これから先」の値であって、すでに漏れた値は無効化されていません。 本来はここで DB 側のパスワードを変更し、新しい値で封印し直すのが正しい手順です。本書は以降の回のラボ再現性のため値を据え置きますが、現場では必ず値を変えてください。
この演習の合格条件
~/fanclub-chart/templates/secret.yamlが存在しないbase/db-sealedsecret.yamlが Git に入っており、ArgoCD がSynced/Healthy- 名前を書き換えた SealedSecret は
no key could decrypt secret (DB_USER, DB_PASSWORD)で拒否される https://fanclub.local/api/membersが JSON を返す
kubeseal の主要フラグと、封印鍵のバックアップ
kubeseal --help の実機出力から、現場で使う 8 つを抜き出します。ここは試験中に参照できるドキュメントが 1 つも無い領域なので、表そのものが暗記材料になります。
| フラグ | 用途 | 覚えるべき点 |
|---|---|---|
--format yaml | 出力形式 | 既定は json。 YAML が欲しければ明示が要る |
--fetch-cert | 公開鍵(証明書)を取り出す | クラスタに触れない開発者がオフラインで封印できる(--cert と併用) |
--cert <ファイル or URL> | 封印に使う公開鍵を指定 | クラスタへ接続せずに封印する |
--scope | strict(既定)/ namespace-wide / cluster-wide | 既定が最も厳しい |
--re-encrypt | 最新のクラスタ鍵で封印し直す | 鍵世代をそろえて旧鍵を捨てられるようにする |
--merge-into <既存ファイル> | 既存の SealedSecret にキーを 1 つ追加 | 全体を作り直さずに済む |
--raw | 値 1 個だけを暗号化 | --name と --scope が必須 |
--recovery-unseal + --recovery-private-key | 災害復旧。バックアップした秘密鍵で暗号文を復号 | クラスタを失ったときの唯一の経路 |
--fetch-cert が変える運用
開発者にクラスタへの接続権限を与えずに、Secret を封印してもらえます。
実行コマンド(k8s-ops 上・developer):
$ kubeseal --fetch-cert > /tmp/sealed-secrets-cert.pem
このファイルを開発者に渡せば、開発者は自分の PC で封印して、暗号文だけを Pull Request に載せられます。
| クラスタ接続あり | --fetch-cert + --cert | |
|---|---|---|
| 封印する人に要る権限 | クラスタへの接続 | 公開鍵ファイルだけ |
| 封印する場所 | クラスタに繋がる端末 | どこでも |
| CI での使いやすさ | 資格情報が要る | 公開鍵をリポジトリに置いておける |
公開鍵はリポジトリにコミットして構いません。 公開鍵で復号はできません。
古い公開鍵で封印したものはどうなるか
「公開鍵が古いと開封できなくなる」は誤りです。 前の節で見たとおり、コントローラは鍵を更新しても旧世代の秘密鍵を破棄せず、kube-system に保持し続けます。
| 状況 | 開封できるか |
|---|---|
| 古い公開鍵で封印した暗号文を、同じクラスタに apply する | 開封できる(コントローラが旧世代の秘密鍵を持っているため) |
| 別のクラスタへ持って行く(鍵がまったく違う) | 開封できない |
| 運用者が旧世代の鍵 Secret を明示的に削除した | その世代で封印した暗号文は開封できなくなる |
--re-encrypt は「開けなくなるのを防ぐため」の道具ではありません。 暗号文の鍵世代を最新に揃えて、いずれ旧世代の鍵 Secret を捨てられるようにするための道具です。捨てられる状態を作ってから捨てる——やってみよう③ で etcd 暗号化キーに対してやったことと、まったく同じ順序です。
実行コマンド(k8s-ops 上・developer):
$ kubeseal --re-encrypt --format yaml < old-sealed.yaml > new-sealed.yaml
--re-encrypt はクラスタへの接続を必要とします(最新の鍵を取りに行くため)。--fetch-cert で取った公開鍵ファイルだけでは実行できません。
本書ラボでも 1 回実行しています。 ~/db-sealedsecret.yaml に対して打ったところ、終了コードは 0 で、spec.encryptedData の値は変化しました。 構造(キー名・template)は変わりません。 鍵世代が 1 つしかない導入直後でも暗号文が変わるのは、封印のたびに乱数が使われ、同じ値でも毎回違う暗号文になるからです。「暗号文が変わった=新しい世代になった」とは限らないので、世代をそろえた確認は暗号文の見た目ではなく、鍵 Secret を消す前の手順として組み込んでください。
具体的には、旧世代の鍵 Secret を消す前に「リポジトリ内のすべての SealedSecret を --re-encrypt に通して commit した」という記録を残します。 暗号文を見ても世代は分からないので、作業した事実のほうを台帳にする——etcd 暗号化で網羅確認ループを回したのと同じ役割を、こちらでは手順書が担います。
封印鍵のバックアップ
クラスタを失うと、Git にある暗号文は永久に開封できません。
SealedSecrets の秘密鍵はクラスタの中にしかないからです。バックアップは必須です。
実行コマンド(k8s-ops 上・developer):
$ umask 077
$ kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > ~/sealed-secrets-key.backup
$ ls -l ~/sealed-secrets-key.backup
実行結果:
-rw-------. 1 developer developer 7088 7月 28 05:13 /home/developer/sealed-secrets-key.backup
umask 077 を先に打つのが要点です。 これを省くと kubectl get ... > file で作られるファイルは -rw-r--r--(644)になります(実測)。このファイルを読める者は、Git 上のすべての暗号文を復号できます。 ノードやワークステーションの他ユーザーから読める状態で置いてはいけません。作ったあとに chmod 600 で直すこともできますが、その間の数秒だけ 644 で存在することになります。
このラベルセレクタは複数世代の鍵を返します。 封印鍵は既定 30 日ごとに更新され、旧世代も保持され続けます。 -o yaml は該当する鍵 Secret を List としてまとめて出力するので、上のコマンド 1 本で全世代が退避できます。「1 個だけコピーすればよい」と考えて kubectl get secret <名前> -o yaml と個別に指定すると、古い世代が漏れます。
このファイルは、封印済み Secret 全部と等価の価値を持ちます。 Git に置いてはいけません。オフラインの保管庫へ入れてください。
本書ラボでは、このファイルを k8s-ops のホームディレクトリに 600 で置いたまま第10回へ進みます。 オフラインの保管庫がラボに無いためです。「本書ラボの k8s-ops には、Git 上の暗号文をすべて平文に戻せるファイルが 1 つ置いてある」——この状態を自覚したうえで先へ進んでください。本番では、退避先へコピーしたあと作業端末から削除します(shred -u ~/sealed-secrets-key.backup)。「取ったバックアップをどこに置いたか」を管理できないうちは、バックアップを取ること自体がリスクになります。
鍵を持ち出した記録は監査ログに残ります。 kube-system の Secret を read した主体は、第16回で扱う監査ログで追えます。「バックアップを取った」と「誰が取ったか分かる」はセットで設計してください。
災害復旧 —— バックアップした秘密鍵で実際に開封できる
実行コマンド(k8s-ops 上・developer):
$ kubeseal --recovery-unseal --recovery-private-key ~/sealed-secrets-key.backup < ~/db-sealedsecret.yaml -o yaml
本書ラボで実行した結果、平文の Secret が出力されました。 data.DB_PASSWORD と data.DB_USER に base64 の値が並び、metadata.name は封印時の名前(fanclub-db-secret)に戻ります。metadata.ownerReferences も付いてきます(uid は空です)。
クラスタは 1 度も参照していません。 手元にあるのは バックアップファイルと Git の暗号文だけです。それだけで平文に戻せる、ということがこの 1 コマンドで確認できます。
この 1 コマンドが、バックアップの重さを決めています。
sealed-secrets-key.backup を持つ者は、Git 上の暗号文をすべて平文に戻せます。 「暗号文だから Git に置いても安全」という前半の説明は、この鍵ファイルが守られていることを前提にしています。 鍵ファイルの管理が甘ければ、SealedSecrets を使う意味は消えます。
鍵が漏れたときの初動
| 対象 | 漏洩したときにやること |
|---|---|
etcd 暗号化キー(enc.yaml) | 新しい鍵を先頭に足して全件再暗号化(やってみよう③の手順)。古い鍵を消す |
| SealedSecrets の秘密鍵 | コントローラの鍵を更新し、--re-encrypt で全 SealedSecret を封印し直す |
| Secret の値そのもの(DB パスワード等) | 鍵の入れ替えでは解決しない。値を変更する(DB 側のパスワード変更 + Secret 更新) |
表の 3 行目(Secret の値そのもの)を見落とさないでください。
暗号化キーをローテーションしても、漏れたパスワードは漏れたままです。 暗号化は「保管中の値を守る」仕組みであって、すでに読まれた値を無効化しません。 インシデント対応の手順全体は第18回で扱います。本回で押さえるのは、「鍵の対応」と「値の対応」は別物という 1 点です。
本書ラボが、いまその状態です。 やってみよう⑤ で確認したとおり、apppassword は Git の履歴に残ったままです。 本回でやったのは 「これから先の値を守る」ことであって、すでに漏れている値の始末はしていません。 現場では、封印し直す前に DB 側のパスワードを変更してください。
ExternalSecrets Operator(概念のみ)と SealedSecrets との使い分け
ExternalSecrets Operator(ESO) は、外部のシークレットストアを正とし、そこから Kubernetes Secret を同期する仕組みです。SealedSecrets が「暗号文を Git に置く」のに対し、ESO は 「Git には参照だけを置き、値は外部に置いたままにする」方式です。
3 つのリソース
ESO では SecretStore(外部ストアへの接続先定義)と ExternalSecret(どのキーを同期するかの定義)という 2 種類のリソースを書きます。前者にはクラスタ全体版もあります。
| リソース | スコープ | 役割 |
|---|---|---|
SecretStore | namespace | 接続先の定義(Vault の URL / 認証方法など)。その namespace からのみ使える |
ClusterSecretStore | クラスタ全体 | 同上。すべての namespace から参照できる(共有の外部ストア向け) |
ExternalSecret | namespace | どのキーを・どの Secret 名で同期するかの定義。ESO がこれを見て Secret を作る |
流れは「ExternalSecret が SecretStore を指し、ESO が外部ストアから値を取ってきて Secret を作る」です。Pod から見れば、生成された Secret は普通の Secret である——ここは SealedSecrets と同じです。
ClusterSecretStore を使うときは注意が要ります。 クラスタ全体から参照できるということは、どの namespace の ExternalSecret からも同じ外部ストアを引けるということです。マルチテナントのクラスタでは、テナントごとに SecretStore(namespace スコープ)を分けるのが定石です。テナント境界の設計は第10回で扱います。
SealedSecrets と ESO の使い分け
| 観点 | SealedSecrets | ExternalSecrets Operator |
|---|---|---|
| Git に置くもの | 暗号文 | 参照だけ(パスと名前) |
| 値の正本 | Git(暗号文が唯一の正本) | 外部ストア |
| 追加で要るもの | なし(コントローラだけ) | 外部ストア(Vault / クラウドの Secrets Manager) |
| 値を更新する手順 | 再封印して commit / push | 外部ストアで更新するだけ(Git は触らない) |
| ローテーションの自動化 | 手作業(--re-encrypt) | 得意(refreshInterval で定期同期) |
| 鍵を失ったときの復旧 | バックアップした秘密鍵が要る | 外部ストアが生きていれば復旧できる |
| クラスタ外への依存 | なし | ある(外部ストアが落ちると同期が止まる) |
| 向いている場面 | 小〜中規模・外部ストアを持たない組織 | すでに Vault / クラウドの Secrets Manager を運用している組織 |
どちらが優れているという話ではありません。 「秘密の正本をどこに置くか」という設計判断です。すでに Vault を運用しているなら ESO 一択です。二重管理になるからです。外部ストアが無いなら、SealedSecrets のほうが構成要素が少なく、壊れる箇所も少なくなります。
本書ラボに ESO を導入しない理由
本書は ESO を概念の紹介に留め、ラボには導入しません。
理由は 3 つです。
①外部ストアが無いと SecretStore が成立しません。 Vault をラボに足すと、Vault 自体の構築・初期化・unseal・認証設定に本回の半分を使うことになり、本回の主題(保存時暗号化と Git 安全化)から離れます。
②CKS の出題水準は「概念と使い分け」です。 公式コンピテンシーは Manage Kubernetes secrets としか書いておらず、特定の Operator の実装は求めていません。 加えて、external-secrets.io は試験中に参照できる 8 サイトに含まれていません。 3 つのリソース名(SecretStore / ClusterSecretStore / ExternalSecret)と、上の使い分け表の「値の正本がどちらにあるか」の行だけを覚えておけば十分です。
③alma-proxy の whitelist に charts.external-secrets.io と Vault のイメージ取得元を足す必要が生じます。 本回の環境変更点が増え、次回以降のスナップショット差分が読みにくくなります。
第1巻第10回で「ExternalSecrets Operator という選択肢がある」と紹介した約束は、本節の表で果たしました。
暗記必須コマンド(etcd 暗号化 / etcdctl / kubeseal)
第1回で扱った「試験中に参照できる 8 サイト」のうち、本回に関係するのは kubernetes.io/docs と etcd.io/docs の 2 つです。
本回で扱った 2 つの領域のうち、試験中に参照できるのは etcd 暗号化だけです。 EncryptionConfiguration は kubernetes.io/docs に完全な YAML が載っているので参照できます。SealedSecrets(github.com / sealed-secrets のドキュメント)と ExternalSecrets(external-secrets.io)は許可 8 サイトに含まれていないため、コマンド形は暗記対象です。etcd については https://etcd.io/docs/ が許可 8 サイトに入っているので、etcdctl のオプションはそこで確認できます。
参照できるページは次の 3 つです。どのページに何が書いてあるかを事前に知っておくと、試験中の検索時間が変わります。
| ページ | 中身 |
|---|---|
kubernetes.io/docs/tasks/administer-cluster/encrypt-data/ | EncryptionConfiguration の全量 YAML・鍵生成コマンド・プロバイダ比較表・再暗号化コマンド |
kubernetes.io/docs/tasks/administer-cluster/decrypt-data/ | 暗号化を解除する手順(identity を先頭にして再暗号化) |
kubernetes.io/docs/concepts/security/secrets-good-practices/ | Secret 運用のベストプラクティス |
加えて https://etcd.io/docs/ が etcdctl の参照先になります。ただし --cacert / --cert / --key の 3 点は毎回打つので、手が覚えているほうが速いです。
下の表の優先度は 2 段階です。 上半分(鍵生成から kubeadm-config への恒久化まで)は、試験で実際に打つ可能性が高いので手を動かして覚えてください。 下半分(kubeseal / ESO)は、出題可能性は高くないものの、出たときに調べる手段が無い領域です。形だけ思い出せれば十分です。第19回の総まとめでも、この 2 段階で扱います。
| 用途 | コマンド / 設定 | 試験中参照 | 補足 |
|---|---|---|---|
| 鍵を生成する | head -c 32 /dev/urandom | base64 | 可 | 32 バイト = 44 文字 |
| 設定ファイルのパス | /etc/kubernetes/enc/enc.yaml | 可 | 公式ドキュメントと同じパスに揃える |
| フラグ | --encryption-provider-config=/etc/kubernetes/enc/enc.yaml | 可 | 3 点セット(フラグ + volumeMounts + volumes) |
| 自動リロード | --encryption-provider-config-automatic-reload=true | 可 | 付けないと再起動が要る |
| プロバイダの選択 | secretbox | 可 | CIS 1.2.28 は aescbc,kms,secretbox のみ許容。aesgcm は WARN |
| 既存 Secret の再暗号化 | kubectl get secrets --all-namespaces -o json | kubectl replace -f - | 可 | これを打つまで既存は平文 |
| etcd の生データを読む | etcdctl --cacert=... --cert=... --key=... get /registry/secrets/<ns>/<name> | hexdump -C | tail -8 | 可(etcd.io/docs) | head では managedFields しか見えない |
| 暗号化の判定 | k8s:enc:secretbox:v1:key1: が見えれば暗号化済み / k8s\x00 なら平文 | — | プレフィックスで鍵名まで分かる |
| 全件の網羅確認 | KEY=key3 → for k in $(etcdctl ... --prefix --keys-only); do ... grep -q "k8s:enc:secretbox:v1:$KEY" || echo "NOT-$KEY: $k"; done | — | 0 行が合格。KEY= を差し替えて使い回す |
| 鍵ローテーションの順序 | ①新鍵を keys 先頭へ ②反映(再起動 or 自動リロード)③全件再暗号化 ④旧鍵を削除 | 可 | ③を飛ばして④をやると no matching key was found for the provided Secretbox transformer |
ETCDCTL_API=3 | 不要 | — | etcd 3.6 では警告が出る |
kubeadm への恒久化 | kubeadm-config ConfigMap の apiServer.extraArgs / extraVolumes | 可(kubernetes.io/docs) | 書かないとアップグレードで消える |
| chart 導入 | helm install sealed-secrets sealed-secrets/sealed-secrets --version 2.19.1 -n kube-system --set-string fullnameOverride=sealed-secrets-controller | 不可 | リポジトリは https://bitnami.github.io/sealed-secrets |
| kubeseal の導入 | curl -sOL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.38.4/kubeseal-0.38.4-linux-amd64.tar.gz" → tar -xzf ... kubeseal → sudo install -m 755 kubeseal /usr/local/bin/kubeseal | 不可 | -L 必須(release-assets.githubusercontent.com へリダイレクト) |
| 試験開始直後の準備 | export do="--dry-run=client -o yaml" | 可 | 全ドメインで効く時短。 kubectl create secret generic x --from-literal=a=b $do |
| 封印 | kubectl create secret generic <name> -n <ns> --from-literal=K=V --dry-run=client -o yaml | kubeseal --format yaml > sealed.yaml | 不可(--dry-run=client -o yaml は可) | 既定は json。kubectl get -o yaml を渡すと Helm / Velero の metadata が混入する |
| オフライン封印 | kubeseal --fetch-cert > cert.pem → kubeseal --cert cert.pem --format yaml < secret.yaml | 不可 | クラスタに触れない開発者向け |
| 所有権の移行 | kubectl annotate secret <name> -n <ns> sealedsecrets.bitnami.com/managed=true → kubectl delete sealedsecret <name> → kubectl apply | 不可 | 既存 Secret があると封印できない。annotate だけでは SYNCED False のまま(コントローラは諦めており、再 apply は unchanged で再調整されない) |
| 状態確認 | kubectl get sealedsecret -n <ns>(SYNCED 列) | 不可 | kubectl apply の成功は当てにならない |
| スコープ | --scope strict(既定)/ namespace-wide / cluster-wide | 不可 | 名前を変えると no key could decrypt secret (<データキー名の一覧>) |
| 封印鍵のバックアップ | umask 077 → kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > key.backup | 不可 | ラベルセレクタは複数世代を返す(既定 30 日でローテーション・旧世代も保持)。umask 077 を省くと 644 で作られる |
| 災害復旧 | kubeseal --recovery-unseal --recovery-private-key key.backup < sealed.yaml -o yaml | 不可 | 平文の Secret が出力される。 クラスタを失ったときの経路 |
| 鍵世代をそろえる | kubeseal --re-encrypt --format yaml < old-sealed.yaml > new-sealed.yaml | 不可 | 旧世代の鍵 Secret を捨てられるようにするための道具(「開けなくなるのを防ぐ」ためではない) |
| ESO | SecretStore / ClusterSecretStore / ExternalSecret | 不可 | 本書はラボに導入しない |
まとめ
本回では次の点を確認しました。
- base64 は暗号化ではない。
base64 -dに鍵は要らない。etcd を直接読めばapppasswordがそのまま出る(ただしhexdump -C | headでは出ない。先頭 12 行はmanagedFields) - 暗号化しているのは etcd ではなく API Server。 etcd へ書く直前に暗号化し、読んだ直後に復号している
- プロバイダは理由づけて選ぶ。 CIS 1.2.28 が許すのは
aescbc/kms/secretboxの 3 つだけで、aesgcmは WARN のまま。公式はaescbc(パディングオラクル)とaesgcm(20 万回ごとのローテーション必須)を非推奨とする。KMS v2 が本番の第一候補だが外部プラグインが要る。交差点がsecretbox providersは配列で、書き込みは先頭ひとつ、読み取りは上から順。identityを末尾に置くと、平文の残骸も読める。全件の再暗号化が終わるまで外さない--encryption-provider-configはファイル参照フラグなので 3 点セット(フラグ + volumeMounts + volumes)。ディレクトリをDirectoryOrCreateでマウントする(ファイル単体は鍵ローテーションで追随しない)- 設定を入れただけでは 1 件も暗号化されない。 既存 Secret は
k8s\x00のまま。kubectl get secrets --all-namespaces -o json | kubectl replace -f -を打って初めて全件が暗号化される(実測 44 件・3.924 秒・エラー 0)。ただし Operator が同じ Secret を書き換えているとConflictで一部が失敗する(実測 2 件)。もう一度打てば通るが、失敗に気づけないので網羅確認ループが安全網として要る - HA では 3 台すべてに配る。 1 台漏れると 「たまに暗号化されない Secret」ができる。第8回の AdmissionConfiguration と違って
kubectlの出力は変わらないので、etcd を直接読むまで気づけない kubeadm-configのapiServer.extraArgs/extraVolumesにも書く。 ノード上のファイルは結果であって正本ではない(第5回で確立・本回が 2 度目)- 鍵ローテーションはファイルを直しただけでは反映されない。 API Server は起動時に設定をメモリへ読む。3 台の再起動が要る(
crictl rm -fで 3〜6 秒。マニフェスト編集経由なら 40〜46 秒) --encryption-provider-config-automatic-reload=trueを入れると無停止で反映される。 起動ログにencryption-provider-config-automatic-reload-controllerが出る。ただし即時ではない。実測でおおむね 1 分以内- ローテーションの順序を破ると Secret が読めなくなる。 再暗号化せずに旧鍵を消すと
Error from server (InternalError): Internal error occurred: no matching key was found for the provided Secretbox transformer。正しい順序は ①新鍵を先頭へ ②反映 ③全件再暗号化 ④旧鍵を削除 - その状態は診断しにくい。 一覧(LIST)は 9 件すべて表示されるのに、名前を指定した取得(GET)だけが失敗する(本回で最も紛らわしい症状)/ 稼働中の Pod は落ちず、新規 Secret の作成も読み取りもできる(次の作り直しで落ちる)/ 打った直後は 8 回とも成功し、約 1 分後に 8 回とも失敗する——HA では 3 台の反映時刻がずれて「同じコマンドが成功したり失敗したりする窓」ができる(第8回の AdmissionConfiguration と同じ診断構造)/ 3 台のローカル API Server に直接聞いても同じエラー
- 鍵を失えば復号不能。 etcd スナップショットにも暗号文が入るので、バックアップと鍵は別々の場所に保管する
- kube-bench 1.2.27 / 1.2.28 を回収した(43 → 45 PASS / 11 → 9 WARN)。第2回で保留した宿題。あわせて、第5回完了時の 45 PASS から 2 件(
1.3.2/1.4.1の--profiling)が FAIL へ戻っていたことが起点で判明した——第5回のkubeadm upgradeが静的 Pod マニフェストを再生成し、第2回で手で入れた設定が消えていた(kubeadm-configに書いてあった API Server の分だけが生き残った)。修復は第16回で扱う - etcd 暗号化は Git の平文を守らない。 chart の
templates/secret.yamlにstringDataで書いたapppasswordは、Git の履歴に永久に残る - SealedSecrets の秘密鍵そのものが
kube-systemの Secret。 だから etcd 暗号化が先。順序が逆なら「Git の暗号文を開ける鍵が etcd に平文で置いてある」状態になる - 既存 Secret があると封印できない。
already exists and is not managed by SealedSecretで失敗し、コントローラはリトライを繰り返したあとError updating, giving upで諦める。所有権の移行はsealedsecrets.bitnami.com/managed=trueの annotate →delete→applyの 3 手。annotate だけでも、再 apply だけでもSYNCED Falseのまま(諦めたコントローラは自分から再挑戦せず、同じ内容の apply はunchangedで再調整されない) kubectl applyの成功は当てにならない。kubectl get sealedsecretのSYNCED列を見る(第8回の Gatekeeper と同じ構造)- スコープは既定で
strict。 name + namespace に束縛されており、名前を書き換えるとno key could decrypt secret (DB_USER, DB_PASSWORD)。括弧の中身は「開けなかったデータキー名の一覧」であって鍵の名前ではない - 封印鍵は既定 30 日で自動ローテーションされ、旧世代も残る。 だから古い公開鍵で封印したものも同じクラスタなら開封できる。バックアップはラベルセレクタで全世代をまとめて取り、
chmod 600する(既定 644)。--re-encryptは「開けなくなるのを防ぐ」道具ではなく、鍵世代をそろえて旧鍵を捨てられるようにする道具 - 封印しても、すでに漏れた値は無効化されない。 本書ラボの
apppasswordは Git の履歴に残ったまま。暗号化キーを替えても、漏れたパスワードは漏れたままであり、鍵の対応(ローテーション + 再暗号化)と値の対応(DB 側のパスワード変更)は別の作業。現場では値そのものを変える - Secret を更新しても Pod は拾わない。
envFromは起動時にのみ読まれるのでkubectl rollout restartが要る。volume マウントなら kubelet が数十秒で更新するが、アプリが読み直す実装でなければ同じこと SealedSecretを消すとownerReferencesの GC で Secret も消える。 ただし ArgoCD が管理している SealedSecret を手で消しても、selfHeal が即座に作り直す(実測で削除の 3 秒後には Secret が復活)。正本が Git にあるかどうかで、同じdeleteの結果が変わる。prune: trueと組み合わさると、今度は Git からファイルを消した瞬間に Secret が消える- kubeseal は試験中に参照できるドキュメントが 1 つも無い。 コマンド形は暗記対象
- ESO は「値の正本を外部ストアに置く」方式。 SealedSecrets は「暗号文が正本」。どちらが優れているかではなく、秘密の正本をどこに置くかの設計判断
理解度チェック(○×形式・全 9 問)
次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。
- Kubernetes Secret の
dataは base64 エンコードされているので、etcd を直接読んでも値は判読できない EncryptionConfigurationを適用して API Server を再起動すると、すでに保存されている Secret も自動的に暗号化されるproviders配列の先頭に書いたプロバイダが、新規書き込みの暗号化に使われる- kube-bench の CIS 1.2.28 は、先頭プロバイダが
aesgcmであれば PASS と判定する - 自動リロードを設定していなければ、
enc.yamlに新しい鍵を足しただけで新規 Secret はその鍵で暗号化される - SealedSecrets コントローラの秘密鍵はクラスタ内の Secret として保存されるため、etcd の暗号化対象になる
- 同じ名前の Secret がすでに存在する namespace へ SealedSecret を apply すると、その Secret は無条件に上書きされる
kubesealの既定スコープでは、封印済み YAML のmetadata.nameを書き換えると復号できなくなるSealedSecretを削除すると、それが生成したSecretも削除される
解答と解説
1=×/2=×/3=○/4=×/5=×/6=○/7=×/8=○/9=○
問1 は誤りです。判読できます。 etcdctl get ... | hexdump -C | tail -8 を打つと apppassword / appuser がそのまま読めます。base64 は可逆変換であり、etcd には base64 のまま(先頭 k8s\x00)保存されています。hexdump -C | head では managedFields しか見えないので、head で確認して「暗号化されている」と早合点しないでください。
問2 は誤りで、本回で最も重要な誤解です。暗号化されません。 暗号化が起きるのは書き込みのときだけで、すでに etcd にあるものは誰かが書き直すまで平文のまま残ります。kubectl get secrets --all-namespaces -o json | kubectl replace -f - を打って初めて全件が暗号化されます(実測 44 件・3.924 秒)。
問3 は正しいです。書き込みは先頭ひとつ、読み取りは上から順という非対称性が、移行手順のすべての土台になります。identity を末尾に置くのはこのためです。
問4 は誤りです。WARN のままです。 /etc/kube-bench/cfg/cis-1.12/master.yaml の 1.2.28 は valid_elements に aescbc,kms,secretbox しか列挙しておらず、aesgcm は含まれません。同一クラスタでの対照実験で、secretbox は [PASS]、aesgcm は [WARN] になりました。
問5 は誤りです。暗号化されません。 API Server は起動時に設定をメモリへ読み込み、その後はファイルを見ません。3 台の再起動が要ります(crictl rm -f で 3〜6 秒)。自動リロードを有効にすれば、この再起動が不要になります。
問6 は正しいです。導入直後に kube-system へ sealed-secrets-keyd97dd(kubernetes.io/tls)が作られ、etcd 上では k8s:enc:secretbox:v1:key3: で始まっています。本回が etcd 暗号化を先に扱った理由がここにあります。
問7 は誤りです。上書きされません。 failed update: Resource "fanclub-db-secret" already exists and is not managed by SealedSecret で失敗し、コントローラはリトライを繰り返したあと Error updating, giving up で諦めます。kubectl apply 自体は成功を返すので、kubectl get sealedsecret の SYNCED 列を見ないと気づけません。解決は sealedsecrets.bitnami.com/managed=true の annotate → delete → apply です。
問8 は正しいです。既定は strict(name + namespace に束縛)です。名前を変えると no key could decrypt secret (DB_USER, DB_PASSWORD) になります。他人の namespace 用の暗号文をコピーして開封することを、暗号のレベルで防いでいます。括弧の中身は「開けなかったデータキー名の一覧」であって、鍵の名前ではありません。
問9 は正しいです。ownerReferences によるガベージコレクションです。本回は ArgoCD 管理外の gc-probe で確認しました。ArgoCD が管理している fanclub-db-secret は、手で消しても selfHeal が即座に作り直します。 一方で prune: true と組み合わさると、Git からファイルを消した瞬間に Secret が消えます。本番では Prune=false の annotation か、削除前のバックアップ確認で守ります(本書ラボでは Prune=false を適用していません)。
次回予告
第10回「マルチテナンシー分離 + サンドボックス(gVisor / RuntimeClass)」では、D4 の 3 つ目のコンピテンシーへ進みます。本回までで「Pod を弾く」「秘密を守る」を扱いました。次回はそもそも隣り合わせないようにする話です。
namespace + RBAC + NetworkPolicy + ResourceQuota でテナント境界を設計し、ソフトマルチテナンシーとハードマルチテナンシーの違いを整理したうえで、高リスクな Pod を gVisor(RuntimeClass・handler runsc)でサンドボックス実行します。第8回で名前だけ出した exemptions.runtimeClasses も、そこで回収します。
