インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

最小イメージとKubesec静的解析【CKS第12回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第12回です。前回までで D4(ワークロード防御)が終わりました。今回から D5 サプライチェーンセキュリティ(配点 20%) に入ります。

本回の主題は 2 つです。コンテナイメージから無駄を削ることと、マニフェストを apply する前に検査することです。前者は docker history で 1 命令ずつ容量を計測し、後者は Kubesec と KubeLinter という 2 つの静的解析ツールを使います。

そして本回には、これまでの回と決定的に違う点があります。直す対象が、読者自身が第1巻で書いた Dockerfile です。 教材のために用意した欠陥ではありません。いま動いている backend イメージの中に、100MB の重複データが実在します。 それを計測して特定し、削ります。

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • Kubesec v2.14.2
  • KubeLinter v0.8.3
  • Docker 29.6.2
  • eclipse-temurin 25-jre-alpine
  • Cilium v1.19.6
  • containerd v2.2.6
  • AlmaLinux 10.2(kernel 6.12.0-211.22.1.el10_2)
  • ArgoCD v3.4 系
  • 確認日 2026-07-30
目次
  1. 第12回のスコープ・今ここマップ
    1. 既習範囲 / 本回で上書きする観点
  2. この回のゴール
    1. 本回の起点
    2. 本回で行き来するホスト
  3. ベースイメージを小さくすると何が減るのか
    1. 最小化の 4 手段
    2. 第7回でやったことを、対象を変えてもう一度やる
  4. やってみよう①:docker history で無駄を特定し、backend イメージを最小化する
    1. ステップ1:現状を計測する
    2. ステップ2:なぜ chown -R が 100MB を作るのか
    3. ステップ3:Dockerfile を直す —— 置いた瞬間に所有者を決める
    4. ステップ4:ベースイメージを alpine に替える
    5. ステップ5:ビルドして計測する
    6. ステップ6:起動確認とレジストリへの push
  5. distroless —— どこまで小さくできるか、そして何を失うか
    1. distroless とは何か
    2. イメージの種類と使い分け
    3. 配布ホストと whitelist —— 1 行足す前に考えること
    4. 実際に触って、シェルが無いことを確認する
    5. 本ラボの backend を distroless 化すると何が起きるか
  6. 静的解析・動的スキャン・ランタイム検知の役割分担と、2 つのツールの導入
    1. Kubesec の導入
    2. KubeLinter の導入
    3. 他の実行形態
  7. やってみよう②:Kubesec でマニフェストをスコアリングする
    1. ステップ1:現行の backend を採点する
    2. ステップ2:出力スキーマを読む
    3. ステップ3:frontend を採点して差を見る
    4. ステップ4:scoring.advise を「やることリスト」として読む
    5. ステップ5:減点を確認する(apply はしません)
  8. Kubesec のスコアが見ていないもの —— ルールセットの賞味期限
    1. seccompProfile が採点されていない
    2. なぜこうなるのか
    3. スコアが語らないもの —— 加点は「どれか 1 つ」で入る
    4. この節の結論
    5. 実務での対処
  9. やってみよう③:KubeLinter を当て、既定では見つからない違反を足す
    1. ステップ1:既定チェックセットで base/ を lint する
    2. ステップ2:2 つのツールの違いを整理する
    3. ステップ3:既定チェックセットは「全部」ではない
    4. ステップ4:.kube-linter.yaml で足りないチェックを足す
    5. ステップ5:2 つのツールが同じ指摘に到達することを確認する
  10. やってみよう④:終了コードで CI ゲートを組み、指摘を GitOps で直す
    1. ステップ1:終了コードを確かめる
    2. ステップ2:ゲートスクリプトを書く
    3. ステップ3:指摘を直す
    4. ステップ4:直さないものを 1 つ残し、理由を書く
    5. ステップ5:Git に push して ArgoCD に反映させる
    6. ステップ6:動いていることを確認して再スキャンする
  11. securityContext ゴールデンセットと暗記必須コマンド
    1. ゴールデンセット
    2. 試験中に参照できるドキュメント
    3. 暗記必須コマンド
    4. 減点項目の暗記(Kubesec)
  12. まとめ
  13. 理解度チェック(○×形式・全 8 問)
  14. 次回予告

第12回のスコープ・今ここマップ

本回から CKS ドメイン D5: Supply Chain Security(配点 20%) です。D5 は 4 つのコンピテンシーで構成され、本回はそのうち 2 つを担当します。

公式コンピテンシー(D5 Supply Chain Security・20%)担当
Minimize base image footprint本回(第12回)
Understand your supply chain (e.g. SBOM, CI/CD, artifact repositories)第13回・第14回
Secure your supply chain (permitted registries, sign and validate artifacts, etc.)第14回
Perform static analysis of user workloads and container images (e.g. Kubesec, KubeLinter)本回(第12回) + 第13回(イメージ側)

4 つ目のコンピテンシーの文言に、ツール名が名指しで書かれています。 e.g. Kubesec, KubeLinter ——CKS のコンピテンシー一覧で個別ツールが名指しされる箇所は多くありません。この 2 つは避けて通れないと考えてください。

なお Understand your supply chain (CI/CD) の主担当は第13回です。本回は静的解析の実行形態として、終了コードでゲートを作る型だけを先取りします。

既習範囲 / 本回で上書きする観点

既習どこで本回で上書きする観点
Dockerfile の multi-stage build・非 root 実行(UID 10001)第1巻すでに書いてある Dockerfile を計測して直します。 multi-stage は導入済みなので、その先(レイヤの無駄・ベースの選択)へ進みます
readOnlyRootFilesystem / capabilities.drop: [ALL] / allowPrivilegeEscalation: false第1巻・第6回同じ設定を「点数」として読み直します。 ツールが何を見ているかを知ります
PSS Restricted の要件第8回PSA は「apply を拒否する」防御、静的解析は「apply する前に指摘する」防御です。効く位置が違います
ホスト OS の棚卸し(不要サービス・不要パッケージ)第7回同じ棚卸しをコンテナイメージに対して行います。 対象がホストからイメージに変わるだけです
バイナリの SHA256 検証第2回kubesec の kubesec_checksums.txt で同じ手順を反復します
GitOps 経由の変更(base/ を編集して push)第9回第10回静的解析の指摘を Git に反映して直します。 ArgoCD の selfHeal: true があるので、手で直しても戻されます
「ハードニングは後から入れる機能を止める」第10回(gVisor)・第11回(SPIRE)3 例目です。 distroless 化が kubectl exec を殺し、第15回・第18回の演習を壊します

先に 3 つ、本回で覆る前提を予告します。

①「イメージを小さくするには multi-stage build を使う」——本ラボの backend はすでに multi-stage です。それでも 100MB の無駄があります。 ②「Kubesec のスコアが高ければ安全」——Kubesec v2.14.2 は securityContext.seccompProfile を採点しません(削除済みのアノテーションを見ています)。③「kube-linter lint を通せばベストプラクティス違反は洗い出せる」——probe が 1 つも無い Deployment も既定では通りますno-liveness-probe / no-readiness-probe は既定で無効です)。

第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)
    第11回: Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)(D4)

第5部 サプライチェーンセキュリティ(D5)
  ★ 第12回: 最小イメージ + 静的解析(Kubesec + KubeLinter)(D5)  ← 今ここ
    第13回: Trivy イメージスキャン + CI/CD パイプラインセキュリティ統合(D5)

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • docker history でイメージのどの命令が容量を食っているかを特定できる
  • RUN chown -R がレイヤを二重化する理由を OverlayFS の copy_up で説明でき、COPY --chown / ADD --chown で直せる
  • distroless の適用可否を、得るもの(攻撃面)と失うもの(運用手段)を並べて判断できる
  • kubesec scan でマニフェストを採点し、scoring.critical / scoring.advise を読める
  • kube-linter lint を当て、.kube-linter.yaml既定では検出されない違反を追加できる
  • 終了コードを使って CI ゲートを組める
  • 静的解析・イメージスキャン・Admission・ランタイム検知の守備範囲の違いを説明できる

本回の起点

起点は第11回の完了状態です。第2巻から引き継いだ 9 VM 構成が稼働している前提で進めます。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod111 個・異常 0
CNICilium v1.19.6第11回で Calico から移行)・Cluster Pods 69/69 managed
fanclubhttps://fanclub.local/200
backend イメージk8s-registry:5000/fanclub-backend:0.3.2(DISK 849MB / CONTENT 297MB
frontend イメージk8s-registry:5000/fanclub-frontend:1.0.0(DISK 72.4MB / CONTENT 21MB
ArgoCDfanclub-api-prodSyncedprune: true / selfHeal: true
PSAfanclub namespace は enforce / warn / audit すべて restricted第8回
Gatekeeper稼働中・ConstraintTemplate は 0 件(本回の作業を妨げません)
alma-proxy の whitelist43 行本回で 1 行だけ足します
Docker29.6.2(containerd image store)・/28G 空き

本回で行き来するホスト

ホスト本回での役割
k8s-ops本回の作業のほぼすべて。Docker ビルド・kubesec / kube-linter の導入と実行・Git 操作
alma-proxywhitelist に gcr.io を 1 行追加(distroless の pull のため)
k8s-registry新イメージ 0.4.0 の push 先(コマンドは k8s-ops から)

ベースイメージを小さくすると何が減るのか

「イメージは小さいほうがよい」という言い方は広く共有されていますが、何が減るのかを分解しないと、どこまでやるべきかが決まりません。 演習に入る前に整理します。

小さくすると減るもの具体
既知 CVE の件数パッケージが少ないほど CVE の母数が減ります(定量的な確認は第13回の Trivy で行います
攻撃者が使えるツールcurl / wget / sh / パッケージマネージャが無ければ、侵入後の横展開の手数が減ります
pull 時間とレジストリ容量ノード追加・スケールアウト・障害時の再配置が速くなります
更新すべき対象入っていないものにはパッチを当てなくて済みます
小さくしても減らないもの具体
アプリ自身の脆弱性自作コードと依存ライブラリの問題は、ベースを替えても残ります
設定の誤りprivileged: true は distroless でも危険です

2 つ目の表が、本回の後半(静的解析)へつながります。 イメージを小さくすることとマニフェストを正すことは別の防御であり、両方が D5 の範囲です。 どれだけ小さいイメージを作っても、そのイメージを privileged: true で動かせば意味がありません。

最小化の 4 手段

手段効果代償
multi-stage buildビルドツール(Maven / JDK / ソース)を最終イメージから外すなし(本ラボは第1巻で導入済み
レイヤの無駄を消すRUN chown -R / chmod -R による複製をなくすなし(本回の演習①・確実に効きます
軽量ベースへ替えるUbuntu ベース → alpine 等musl libc の互換性リスク・デバッグ用コマンドが減る
distroless / scratchシェル・パッケージマネージャごと消すkubectl exec でシェルに入れない・起動スクリプトが使えない

本ラボの backend はすでに 1 つ目を済ませています。 演習①では 2 つ目と 3 つ目を扱い、4 つ目は次の H2 で「やってみて採用しない」という形で扱います。

本番ガードレール①: 「小さいイメージ」を目的化しない。

運用チームがトラブル時にコンテナの中を見る手段を失うと、障害対応にかかる時間が伸びます。 サイズの削減量とトラブル対応時間の増加は、どちらも運用コストです。

ephemeral containerkubectl debug --image)という代替手段を組織が持っているかどうかで、distroless を選べるかどうかが変わります。 ephemeral container は、稼働中の Pod に後から一時的なコンテナを差し込む機能です。調査用のツールが入ったイメージを別コンテナとして起動し、対象コンテナのプロセス空間を共有して覗きます——対象イメージにシェルが無くても調査できるのが要点で、第15回で扱います。代替手段が無いまま「小さいほうがよい」で押し切ると、次の障害で困るのは自分たちです。

第7回でやったことを、対象を変えてもう一度やる

第7回ではノードの不要サービスと不要パッケージを棚卸ししました。コンテナイメージに対して同じことをするのが本節です。 対象がホスト OS からイメージに変わるだけで、「使っていないものは置かない」という原則は変わりません。

違うのは 計測の精度です。ホスト OS では「このパッケージは要るのか」を人が判断するしかありませんでしたが、イメージは docker history で 1 命令ずつ容量を数えられます。 どこに何 MB あるかが分かれば、議論は「削るべきか」ではなく「この 100MB は何か」から始められます。

やってみよう①:docker history で無駄を特定し、backend イメージを最小化する

所要時間の目安: 12 分(ビルド 2 回の待ち時間を含みます)。作業はすべて k8s-ops で行います。

ステップ1:現状を計測する

実行コマンド(k8s-ops・developer):

$ docker images

実行結果(抜粋):

IMAGE                                      ID             DISK USAGE   CONTENT SIZE
k8s-registry:5000/fanclub-backend:0.2.0    e8e5a145c510        657MB          206MB
k8s-registry:5000/fanclub-backend:0.3.0    52f4e4a01129        849MB          297MB
k8s-registry:5000/fanclub-backend:0.3.1    e93e0fe6177a        849MB          297MB
k8s-registry:5000/fanclub-backend:0.3.2    d29184a6c12a        849MB          297MB
k8s-registry:5000/fanclub-frontend:1.0.0   508f164f4046       72.4MB           21MB
nginx:1.27-alpine                          65645c7bb6a0         73MB         21.9MB

SIZE 列がありません。

Docker 29 系(containerd image store)の docker images は、旧来の SIZE の代わりに DISK USAGECONTENT SIZE の 2 列を出します。

CONTENT SIZE は content store が保持する blob——つまりレジストリに置かれる圧縮済みのレイヤ——の合計です。DISK USAGE は、それに展開済みのスナップショットを加えたローカルディスクの実消費です。本回で削減を評価する軸は CONTENT SIZE——これがレジストリに置かれ、ノードが pull するものの大きさに直結するためです。

この関係は手元で検算できます。 このあと使う docker history の各行は展開後(非圧縮)のレイヤサイズです。上の一覧にある nginx:1.27-alpine で合計すると 約 51.2MB になりますが、同じイメージの CONTENT SIZE21.9MBDISK USAGE73MB でした。21.9 + 51.2 ≒ 73.1——DISK USAGE が「圧縮済み blob + 展開済みスナップショット」であることが数字で読めます。CONTENT SIZEdocker history の合計ではありません。

次に、どの命令が容量を作っているのかを 1 行ずつ見ます。

実行コマンド(k8s-ops・developer):

$ docker history k8s-registry:5000/fanclub-backend:0.3.2 --no-trunc --format '{{.Size}}\t{{.CreatedBy}}'

実行結果(上が新しいレイヤ・命令は要約して表にしました):

サイズ命令
0BENTRYPOINT / ENV JAVA_OPTS / EXPOSE 8080 / USER 10001
100MBRUN useradd ... && mkdir -p /opt/payara/tmp && chmod +x ... && chown -R 10001:0 /opt/payara && chmod -R g=u /opt/payara
4.1kBCOPY entrypoint.sh entrypoint.sh
1.18MBCOPY /app/target/fanclub-api.war app.war
98.7MBADD https://.../payara-micro-7.2026.4.jar payara-micro.jar
201MB(temurin ベース)JRE 25.0.3+9 の展開
38.9MB(temurin ベース)fontconfig / ca-certificates / p11-kit / tzdata / locales
4.1kBベース = ubuntu:26.04umoci が積むメタデータ層)
113MBベース = ubuntu:26.04 の rootfs

2 行目に注目してください。 useradd でユーザを作り、ディレクトリを 1 つ作り、所有者とモードを変えているだけの RUN100MB を占めています。ファイルを 1 つも追加していないのに、です。

ステップ2:なぜ chown -R が 100MB を作るのか

答えは OverlayFS の copy_up にあります。

OverlayFS と copy_up

OverlayFS はイメージの層を重ねて 1 つのファイルシステムに見せる仕組みです。下位レイヤ(lowerdir)がイメージ側の読み取り専用の層上位レイヤ(upperdir)が変更の書き込み先になる層です。

下位レイヤは書き換えられません。したがって変更しようとした瞬間に、そのファイルが上位レイヤへ複製されます。これを copy_up と呼びます。 Dockerfile の各命令が作るレイヤも同じ機構で積まれます。

Docker 公式ドキュメント(docs.docker.com/engine/storage/drivers/overlayfs-driver/)の記述です。

OverlayFS works at the file level rather than the block level. This means that all OverlayFS copy_up operations copy the entire file, even if the file is large and only a small part of it’s being modified.

要約すると「OverlayFS はブロック単位ではなくファイル単位で動くので、ファイルのごく一部を変えるだけでもファイル全体がコピーされる」ということです。

chownchmod が変えるのは inode のメタデータだけです。それでも下位レイヤの inode は書き換えられないため、ファイル全体を上位レイヤへコピーしてからメタデータを変えます。 /opt/payara の配下には直前に置いた 2 つのファイルがあります。

  • payara-micro.jar —— 98.7MB
  • app.war —— 1.18MB

98.7 + 1.18 = 99.88。 docker history が示した 100MB とほぼ一致します。推測ではなく計算が合います。 この 100MB は、直前のレイヤにあるファイルとまったく同じ内容のもう 1 部です。

RUN chown -R が 100MB の重複レイヤを作る仕組みを左右対比で示す図。左の現行 Dockerfile(0.3.2)では ADD payara-micro.jar(98.7MB)と COPY app.war(1.18MB)の上に RUN chown -R(100MB)が積まれ、その中に同じ内容の jar と war のコピーが入っている。両方から copy_up の矢印が上へ伸びる。右の ADD --chown 版(0.4.0-nochown)では同じ位置の 100MB 層が消え、RUN useradd の 49.2kB だけが残る。下段に CONTENT SIZE が 297MB から 207MB へ減ることを示す
図1:RUN chown -R が 100MB の重複レイヤを作る仕組み

レジストリに残っている過去のタグも裏づけになります。先ほどの docker images の出力を見直してください。

k8s-registry:5000/fanclub-backend:0.2.0    657MB   206MB
k8s-registry:5000/fanclub-backend:0.3.0    849MB   297MB

0.2.0 から 0.3.0 にかけて CONTENT SIZE が +91MB 増えています。 このバージョン間で追加された機能の実体が 91MB あったわけではありません。両方の docker history を並べると、増分の正体が分かります。

実行コマンド(k8s-ops・developer):

$ docker history k8s-registry:5000/fanclub-backend:0.2.0 --format '{{.Size}}|{{.CreatedBy}}'
$ docker history k8s-registry:5000/fanclub-backend:0.3.0 --format '{{.Size}}|{{.CreatedBy}}'

実行結果(0.2.0・上が新しいレイヤ・抜粋):

0B|ENTRYPOINT ["sh" "-c" "java $JAVA_OPTS -jar …
0B|ENV JAVA_OPTS=-XX:MaxRAMPercentage=75.0
0B|EXPOSE [8080/tcp]
8.19kB|COPY /app/target/fanclub-api.war app.war # b…
98.7MB|ADD https://repo.maven.apache.org/maven2/fis…
0B|WORKDIR /opt/payara

実行結果(0.3.0・同上):

0B|ENTRYPOINT ["/opt/payara/entrypoint.sh"]
0B|ENV JAVA_OPTS=-XX:MaxRAMPercentage=75.0 -Dja…
0B|EXPOSE [8080/tcp]
0B|USER 10001
100MB|RUN /bin/sh -c useradd --uid 10001 --no-crea…
4.1kB|COPY entrypoint.sh entrypoint.sh # buildkit
1.18MB|COPY /app/target/fanclub-api.war app.war # b…
98.7MB|ADD https://repo.maven.apache.org/maven2/fis…

0.2.0 には RUN useradd ... chown -R の層そのものがありません。 0.2.0 は root で動いていたためです。非 root 化(USER 10001)を入れた 0.3.0 で、100MB の層が新しく生まれました。 war も 8.19kB から 1.18MB に育っていますが、増分の大半は RUN の層です。

非 root 化というセキュリティ上正しい変更が、副作用として 100MB を持ち込んだ——これが本節で直す対象です。非 root をやめるのではなく、非 root のまま 100MB を消します。

ステップ3:Dockerfile を直す —— 置いた瞬間に所有者を決める

現行の ~/app-src/fanclub-api/Dockerfile は次のとおりです。

FROM maven:3.9-eclipse-temurin-25 AS build
COPY settings.xml /root/.m2/settings.xml
WORKDIR /app
COPY pom.xml .
RUN mvn -B --no-transfer-progress dependency:go-offline
COPY src ./src
RUN mvn -B --no-transfer-progress package

FROM eclipse-temurin:25-jre AS runtime
WORKDIR /opt/payara
ADD https://repo.maven.apache.org/maven2/fish/payara/extras/payara-micro/7.2026.4/payara-micro-7.2026.4.jar payara-micro.jar
COPY --from=build /app/target/fanclub-api.war app.war
COPY entrypoint.sh entrypoint.sh
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin appuser \
    && mkdir -p /opt/payara/tmp \
    && chmod +x /opt/payara/entrypoint.sh \
    && chown -R 10001:0 /opt/payara \
    && chmod -R g=u /opt/payara
USER 10001
EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/opt/payara/tmp"
ENTRYPOINT ["/opt/payara/entrypoint.sh"]

直す方針は 1 つです。後から所有者を変えるのではなく、置いた瞬間に所有者を決めます。 COPYADD にはそのためのフラグがあります。

Dockerfile リファレンス(docs.docker.com/reference/dockerfile/)の --chown の説明です。

Sets ownership of copied files. Without this flag, files are created with UID and GID of 0.

「コピーしたファイルの所有者を設定する。このフラグが無い場合、ファイルは UID / GID 0 で作られる」。置いた瞬間に所有者が決まるので、copy_up が起きません。

変更点は 4 つです。

  1. ADD https://... payara-micro.jarADD --chown=10001:0 https://... payara-micro.jar
  2. COPY --from=build ... app.warCOPY --from=build --chown=10001:0 ... app.war
  3. COPY entrypoint.sh entrypoint.shCOPY --chown=10001:0 --chmod=0755 entrypoint.sh entrypoint.sh
  4. RUN から chown -Rchmod -Rchmod +x を削除する(useraddmkdir -p は残します)

ユーザ名ではなく数値(10001:0)で指定する理由。

--chown にユーザ名を書くと、そのユーザがベースイメージの /etc/passwd にすでに存在している必要があります。 本ラボの appuser は後段の RUN useradd で作られるため、ADD / COPY の時点ではまだ存在しません。

Dockerfile リファレンスの記述です。

BuildKit resolves them using /etc/passwd and /etc/group in the container’s root filesystem. If these files are missing or don’t contain the specified names, the build fails. Numeric IDs don’t require this lookup.

「BuildKit はコンテナのルートファイルシステムの /etc/passwd/etc/group で名前を解決する。これらが無いか、指定された名前を含まない場合はビルドが失敗する。数値 ID はこの解決を必要としない」。だから --chown=10001:0 と書きます。

chmod -R g=u も消してよいのか

RUN から落とすのは chown -R だけではありません。chmod -R g=u /opt/payara も同じ理由で copy_up を起こします。 ただしこちらは、消してよい条件を理解せずに真似すると本番で起動しなくなります。

chown 10001:0chmod -R g=u の組み合わせは、「どの UID で起動されても group 0 の権限で読み書きできる」ようにする作法です。OpenShift のように、Pod の UID をプラットフォームが動的に割り当てる基盤で必要になります。

本ラボは USER 10001 とファイル所有者の UID が一致しているので、ユーザ権限だけで足ります。 任意 UID 実行が前提の基盤へ持っていくときは、--chmod で同等の指定を置き直してください——消してよいのは「所有者 UID と実行 UID が一致している」という条件が成り立つ場合だけです。

ADD が remote URL から取得したファイルの既定パーミッションは 600 です

(Docker 公式の明記)。所有者以外は読めません。--chown=10001:0 で所有者が 10001 になれば USER 10001 から読めるので、動作上の問題はありません。group に権限が要らない理由がここにあります。

ここまでを反映した Dockerfile の全量です。ベースイメージはまだ変えていません(次のステップで替えます)。

FROM maven:3.9-eclipse-temurin-25 AS build
COPY settings.xml /root/.m2/settings.xml
WORKDIR /app
COPY pom.xml .
RUN mvn -B --no-transfer-progress dependency:go-offline
COPY src ./src
RUN mvn -B --no-transfer-progress package

FROM eclipse-temurin:25-jre AS runtime
WORKDIR /opt/payara
# 置いた瞬間に所有者を決めることで RUN chown -R の copy_up をなくす。
# ユーザ名 appuser はこの時点で /etc/passwd に無いため数値で指定する。
ADD --chown=10001:0 https://repo.maven.apache.org/maven2/fish/payara/extras/payara-micro/7.2026.4/payara-micro-7.2026.4.jar payara-micro.jar
COPY --from=build --chown=10001:0 /app/target/fanclub-api.war app.war
# RUN chmod +x の代わりに COPY --chmod で実行ビットを立てる
COPY --chown=10001:0 --chmod=0755 entrypoint.sh entrypoint.sh
# 空ディレクトリ 1 つの chown なので copy_up の対象になるファイルが無い
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin appuser \
    && mkdir -p /opt/payara/tmp \
    && chown 10001:0 /opt/payara/tmp
USER 10001
EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/opt/payara/tmp"
ENTRYPOINT ["/opt/payara/entrypoint.sh"]

chown 10001:0 /opt/payara/tmp は残しています。 mkdir が作ったディレクトリは root 所有になり、USER 10001 のプロセスが java.io.tmpdir として書き込めないためです。ただし対象は空のディレクトリ 1 つだけなので、複製されるファイルがありません。 消すべきは chown という命令ではなく、「中身のあるディレクトリに対する再帰的な chownです。

いったんここでビルドして、効果を切り分ける

次のステップではベースイメージも替えます。2 つの変更をまとめてビルドすると、どちらが何 MB 効いたのかが分からなくなります。 ここで一度、chown -R 除去だけのイメージを別タグでビルドして計測します。

実行コマンド(k8s-ops・developer):

$ cd ~/app-src/fanclub-api
$ time docker build -t fanclub-backend:0.4.0-nochown .

実行結果(末尾):

#19 [runtime 6/6] RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin appuser     && mkdir -p /opt/payara/tmp     && chown 10001:0 /opt/payara/tmp
#19 DONE 0.4s

#20 exporting to image
#20 exporting layers 2.2s done
#20 naming to docker.io/library/fanclub-backend:0.4.0-nochown done
#20 unpacking to docker.io/library/fanclub-backend:0.4.0-nochown 0.4s done
#20 DONE 2.7s

実測のビルド所要は real 0m6.563s でした。Maven のビルドステージがキャッシュ済みだからです——変更したのは runtime ステージだけなので、mvn package は再実行されません。ソースを触った直後はキャッシュが効かず、数分かかります。

実行コマンド(k8s-ops・developer):

$ docker images | grep 0.4.0-nochown
$ docker history fanclub-backend:0.4.0-nochown --format '{{.Size}}|{{.CreatedBy}}'

実行結果:

fanclub-backend:0.4.0-nochown              449d00739fc9        659MB          207MB

実行結果(docker history 全行):

0B|ENTRYPOINT ["/opt/payara/entrypoint.sh"]
0B|ENV JAVA_OPTS=-XX:MaxRAMPercentage=75.0 -Dja…
0B|EXPOSE [8080/tcp]
0B|USER 10001
49.2kB|RUN /bin/sh -c useradd --uid 10001 --no-crea…
4.1kB|COPY --chown=10001:0 --chmod=0755 entrypoint…
1.18MB|COPY --chown=10001:0 /app/target/fanclub-api…
98.7MB|ADD --chown=10001:0 https://repo.maven.apach…
0B|WORKDIR /opt/payara
0B|ENTRYPOINT ["/__cacert_entrypoint.sh"]
8.19kB|COPY --chmod=755 entrypoint.sh /__cacert_ent…
0B|RUN /bin/sh -c set -eux;     echo "Verifying…
201MB|RUN /bin/sh -c set -eux;     ARCH="$(dpkg --…
0B|ENV JAVA_VERSION=jdk-25.0.3+9
38.9MB|RUN /bin/sh -c set -eux;     apt-get update;…
0B|ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_AL…
0B|ENV PATH=/opt/java/openjdk/bin:/usr/local/sb…
0B|ENV JAVA_HOME=/opt/java/openjdk
4.1kB|umoci raw add-layer --image /home/buildd/roc…
0B|umoci config --image /home/buildd/rockcraft-…
0B|umoci config --image /home/buildd/rockcraft-…
0B|umoci config --image /home/buildd/rockcraft-…
0B|umoci config --image /home/buildd/rockcraft-…
0B|umoci config --image /home/buildd/rockcraft-…
113MB|umoci raw add-layer --image /home/buildd/roc…

RUN の層が 100MB から 49.2kB になりました。 残った 49.2kB は useradd が書き換える /etc/passwd / /etc/shadow / /etc/group と、空ディレクトリ /opt/payara/tmp の分です。イメージ全体では DISK 849MB → 659MB、CONTENT 297MB → 207MB。 Dockerfile の 4 行を書き換えただけで 90MB 減りました。

アプリの機能は 1 つも変えていません。 ここまでが「確実に効く最小化」です。次のステップからは、代償を伴う領域に入ります。

ステップ4:ベースイメージを alpine に替える

docker history の下から 2 段目と 3 段目——JRE の展開 201MB と依存パッケージ 38.9MB——はベースイメージが持ち込んでいるものです。現行の eclipse-temurin:25-jreubuntu:26.04 ベースで、ここを alpine ベースに替えます。

Docker Hub の公式タグとして次の 4 つが存在します(library/eclipse-temurin のタグ一覧で確認済み)。

25-jre-alpine
25-jre-alpine-3.23
25.0.3_9-jre-alpine
25.0.3_9-jre-alpine-3.23

Docker Hub は本ラボの whitelist に登録済みなので、プロキシ側の追加設定は要りません。あわせて 1 つ書き換えが必要になります。alpine には useradd がありません。

RUN adduser -D -H -u 10001 -s /sbin/nologin appuser \
    && mkdir -p /opt/payara/tmp \
    && chown 10001:0 /opt/payara/tmp

-D がパスワード無し、-H がホームディレクトリを作らない、-u が UID、-s がログインシェルの指定です。alpine の adduser は busybox 版で、useradd とはオプションの綴りが違います。上の 4 つは busybox の adduser でそのまま通ります。

alpine 版の Dockerfile 全量です。

FROM maven:3.9-eclipse-temurin-25 AS build
COPY settings.xml /root/.m2/settings.xml
WORKDIR /app
COPY pom.xml .
RUN mvn -B --no-transfer-progress dependency:go-offline
COPY src ./src
RUN mvn -B --no-transfer-progress package

# ubuntu:26.04 ベースから alpine ベースへ変更する
FROM eclipse-temurin:25-jre-alpine AS runtime
WORKDIR /opt/payara
ADD --chown=10001:0 https://repo.maven.apache.org/maven2/fish/payara/extras/payara-micro/7.2026.4/payara-micro-7.2026.4.jar payara-micro.jar
COPY --from=build --chown=10001:0 /app/target/fanclub-api.war app.war
COPY --chown=10001:0 --chmod=0755 entrypoint.sh entrypoint.sh
# alpine には useradd が無いので adduser を使う
RUN adduser -D -H -u 10001 -s /sbin/nologin appuser \
    && mkdir -p /opt/payara/tmp \
    && chown 10001:0 /opt/payara/tmp
USER 10001
EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/opt/payara/tmp"
ENTRYPOINT ["/opt/payara/entrypoint.sh"]

entrypoint.sh#!/bin/sh は alpine の busybox ash でも動きます。ヒアドキュメントと exec も同様です。シェルスクリプトの書き換えは不要です。

本番ガードレール②: alpine は musl libc を使います。

libc は C の標準ライブラリで、Linux では glibc が主流です。alpine はより軽量な musl を採用しています。

純 Java のアプリは影響を受けにくいのですが、JNI を使うライブラリや glibc 依存のネイティブコードは動かないことがあります。 本ラボの backend は Payara Micro(純 Java)と PostgreSQL JDBC(純 Java)なので動く見込みが高いものの、見込みは確認の代わりになりません。

「サイズが小さいから」という理由だけでベースを替えず、アプリを起動して疎通まで確認してください。 本回でも起動確認を演習に含めます。

ステップ5:ビルドして計測する

実行コマンド(k8s-ops・developer):

$ cd ~/app-src/fanclub-api
$ time docker build -t k8s-registry:5000/fanclub-backend:0.4.0 .

実行結果(末尾):

#19 [runtime 6/6] RUN adduser -D -H -u 10001 -s /sbin/nologin appuser     && mkdir -p /opt/payara/tmp     && chown 10001:0 /opt/payara/tmp
#19 DONE 0.2s

#20 exporting to image
#20 exporting layers 2.2s done
#20 naming to k8s-registry:5000/fanclub-backend:0.4.0 done
#20 unpacking to k8s-registry:5000/fanclub-backend:0.4.0 0.3s done
#20 DONE 2.7s

ビルド所要は real 0m13.900s でした。先ほどの 6.563s より長いのは、alpine ベースイメージの pull が入るためです。adduser の行(#19)が 0.2 秒で完了していることも読めます。ベースイメージが pull 済みの状態で測り直すと 8.108s になりました——時間は環境で変わるので、一致しなくて構いません。

実行コマンド(k8s-ops・developer):

$ docker images | grep fanclub-backend
$ docker history k8s-registry:5000/fanclub-backend:0.4.0 --format '{{.Size}}|{{.CreatedBy}}'

実行結果(docker images | grep fanclub-backend):

fanclub-backend:0.1.0                       e8e5a145c510        657MB          206MB
fanclub-backend:0.4.0-nochown               449d00739fc9        659MB          207MB
k8s-registry:5000/fanclub-backend:0.2.0     e8e5a145c510        657MB          206MB
k8s-registry:5000/fanclub-backend:0.3.0     52f4e4a01129        849MB          297MB
k8s-registry:5000/fanclub-backend:0.3.1     e93e0fe6177a        849MB          297MB
k8s-registry:5000/fanclub-backend:0.3.2     d29184a6c12a        849MB          297MB
k8s-registry:5000/fanclub-backend:0.4.0     751060294328        493MB          166MB

grep を通すとヘッダ行は落ちますので、列の並びは先ほどの docker images と同じ(IMAGE / ID / DISK USAGE / CONTENT SIZE)と読んでください。最終行が今回作った 0.4.0 です。

実行結果(docker history 全行):

0B|ENTRYPOINT ["/opt/payara/entrypoint.sh"]
0B|ENV JAVA_OPTS=-XX:MaxRAMPercentage=75.0 -Dja…
0B|EXPOSE [8080/tcp]
0B|USER 10001
24.6kB|RUN /bin/sh -c adduser -D -H -u 10001 -s /sb…
4.1kB|COPY --chown=10001:0 --chmod=0755 entrypoint…
1.18MB|COPY --chown=10001:0 /app/target/fanclub-api…
98.7MB|ADD --chown=10001:0 https://repo.maven.apach…
0B|WORKDIR /opt/payara
0B|ENTRYPOINT ["/__cacert_entrypoint.sh"]
8.19kB|COPY --chmod=755 entrypoint.sh /__cacert_ent…
0B|RUN /bin/sh -c set -eux;     echo "Verifying…
198MB|RUN /bin/sh -c set -eux;     ARCH="$(apk --p…
0B|ENV JAVA_VERSION=jdk-25.0.3+9
21MB|RUN /bin/sh -c set -eux;     apk add --no-ca…
0B|ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_AL…
0B|ENV PATH=/opt/java/openjdk/bin:/usr/local/sb…
0B|ENV JAVA_HOME=/opt/java/openjdk
0B|CMD ["/bin/sh"]
8.71MB|ADD alpine-minirootfs-3.23.5-x86_64.tar.gz /…

ベースの内訳が置き換わっています。 ubuntu:26.04(4.1kB)+ apt 依存 38.9MB + JRE 201MB だったところが、alpine minirootfs 8.71MB + apk 依存 21MB + JRE 198MB になりました。減ったのは主に依存パッケージ側(38.9MB → 21MB)とベース本体側で、JRE 自体はほとんど変わりません。

削減量を 1 つの表にまとめます。

DISK USAGECONTENT SIZE0.3.2 からの差分
0.3.2(現行)849MB297MB
0.4.0-nochownchown -R 除去のみ)659MB207MB-190MB / -90MB
0.4.0(+ alpine 化)493MB166MB-356MB / -131MB(-42% / -44%)

2 段目までが「代償なし」の削減です。 Dockerfile の 4 行を書き換えただけで CONTENT SIZE が 90MB 減りました。3 段目の alpine 化はさらに 41MB 減らしますが、musl libc という前提の変更を伴います——だから次のステップで起動確認をします。

ステップ6:起動確認とレジストリへの push

クラスタへの反映は演習④(GitOps 経由)で行います。ここではローカルで起動することだけを確認します。確認基準は「JVM が起動し、Payara Micro が ready になること」です。ローカルには PostgreSQL がないので、アプリのデプロイ自体は失敗します。

実行コマンド(k8s-ops・developer):

$ docker run -d --name fanclub-smoke k8s-registry:5000/fanclub-backend:0.4.0
$ sleep 75
$ docker logs fanclub-smoke | tail -20

実行結果(末尾・抜粋):

[2026-07-29T15:25:34.437+0000] [] [SEVERE] [] [javax.enterprise.system.core] ... JNDI lookup failed for the resource: Name: [fanclubPU], Lookup: [jdbc/fanclubDS], Type: [javax.sql.DataSource] -- Lookup failed for 'jdbc/fanclubDS' in SerialContext[...]

[2026-07-29T15:25:34.461+0000] [] [WARNING] [] [fish.payara.boot.runtime.BootCommand] ... Boot Command deploy failed ... AdminCommandError occurred during deployment: JNDI lookup failed for the resource: Name: [fanclubPU] ...

[2026-07-29T15:25:34.464+0000] [] [INFO] [] [PayaraMicro] ... Deployed 0 archive(s)

[2026-07-29T15:25:34.476+0000] [] [INFO] [] [PayaraMicro] ... Payara Micro 7.2026.4 (build 7) ready in 7,347 (ms)

最終行に Payara Micro 7.2026.4 (build 7) ready in ... (ms) が出れば合格です(ミリ秒の値は実行ごとに変わります。素の環境で通し直したときは 6,460 (ms) でした)。その上の SEVEREDeployed 0 archive(s) は、DB が無いために jdbc/fanclubDS を解決できなかったことによるもので、musl libc の問題ではありません。 JVM が起動し、Payara Micro が最後まで立ち上がっている以上、alpine 化によるネイティブ依存の破綻は起きていません。

確認できたらコンテナを片づけます。-d でバックグラウンド起動しているので、明示的に消さないと残り続けます。

実行コマンド(k8s-ops・developer):

$ docker rm -f fanclub-smoke

実行コマンド(k8s-ops・developer):

$ docker push k8s-registry:5000/fanclub-backend:0.4.0

実行結果:

8dac9a37eb9d: Pushed
eb38d5fad5f7: Pushed
293dd424414c: Pushed
542bedd8019d: Pushed
e6f31ffc071e: Pushed
e99771bc67c2: Pushed
9f5487bef4f3: Pushed
0.4.0: digest: sha256:751060294328ea62c2e89fa965d335e8fbbf765ce0b86d08247a9ee37c415ff3 size: 856

レジストリのタグ一覧も確認します。

実行コマンド(k8s-ops・developer):

$ curl -s --noproxy '*' http://k8s-registry:5000/v2/fanclub-backend/tags/list

実行結果:

{"name":"fanclub-backend","tags":["0.2.0","0.3.0","0.3.1","0.3.2","0.4.0"]}

手元の出力と一致しない値があります。

イメージ ID・push の digest・レイヤ ID・ビルド所要時間・Payara の起動ミリ秒は、ビルドするたびに変わります。 本書は実測値をそのまま載せていますが、これらは一致しなくて構いません。

一致すべきなのは、自分の Dockerfile に由来する層の大きさと、削減の幅です。 とくに RUN chown -R の層が 100MB から 49.2kB に落ちること——ここが本節の主張そのものなので、必ず再現します。alpine 版(0.4.0)は 493MB / 166MB と各層(49.2kB / 24.6kB / 98.7MB / 1.18MB / 21MB / 8.71MB)まで再現しました。

一方、ベースイメージ由来の層は上流の更新で動きます。 中間版 0.4.0-nochown は本書の実測が 659MB / 207MB ですが、再検証時は 693MB / 216MB になりました。原因は docker history の 1 行で特定できます——eclipse-temurin:25-jreapt-get install 層が 38.9MB から 63.6MB へ増えていました(JRE 層 198MB と JAVA_VERSION は同じまま)。25-jre は動くタグなので、上流が再ビルドすれば中身が変わります。

これは本回が distroless の節で書いていることと同じ話です——「:nonroot のような役割を表すタグも動く。本番で使うなら digest でピン留めする」。サイズを固定値として語りたいなら、ベースイメージも eclipse-temurin:25.0.3_9-jre-alpine-3.23 のようにパッチまで含めて指定します。 本書がタグ指定に留めているのは可読性のためで、本番の Dockerfile では digest 固定が正解です。

0.3.2 のイメージは消さないでください。

第13回で 0.3.2(Ubuntu ベース)と 0.4.0(alpine ベース)の CVE 件数を Trivy で比較します。本回の「小さくすると CVE の母数が減る」という説明を、次回に実際の件数で確認するためです。

一方、計測用の中間タグ fanclub-backend:0.4.0-nochown(659MB)はもう使いません。 効果の切り分けが終わったら消してかまいません。

実行コマンド(k8s-ops・developer):

$ docker rmi fanclub-backend:0.4.0-nochown

第13回で必要になるのは 0.3.20.4.0 の 2 つだけです。中間成果物を残し続けると、k8s-ops のディスクが計測用のイメージで埋まっていきます。

試験ではこう問われる。

与えられた Dockerfile のベースイメージを、より小さく安全なものに変更せよ」「root で動いているコンテナを非 root にせよ」という粒度で出ます。試験相当の作業(Dockerfile を直すだけ)なら 5〜8 分です。

手が覚えているべきものは 3 つです。USER <UID> / COPY --chown=<uid>:<gid> / multi-stage build の FROM ... AS buildCOPY --from=builddocker history はイメージが手元にあるときの調査手段であり、試験では Dockerfile の読解のほうが問われやすいと考えてください。

distroless —— どこまで小さくできるか、そして何を失うか

alpine よりさらに小さくする手段が distroless です。CKS の学習材料には必ず出てくる語彙であり、選択肢の文言としても頻出します。本節では実際に pull して中身を確認し、そのうえで本書ラボには採用しないという判断を書きます。

第10回で gVisor の代償を数えて撤去したのと同じ骨格です。「知らないから使わない」と「試して採らないと決めた」は別のもので、CKS が問うのは後者の判断です。

distroless とは何か

公式リポジトリ(GoogleContainerTools/distroless)の README の記述です。

Distroless images are minimal and lack shell access. The :debug image set for each language provides a busybox shell to enter.

(distroless イメージは最小限の構成でシェルを持たない。言語ごとに用意された :debug イメージには、入るための busybox シェルが含まれる)

Note that distroless images by default do not contain a shell. That means the Dockerfile ENTRYPOINT command, when defined, must be specified in vector form, to avoid the container runtime prefixing with a shell.

(distroless イメージは既定でシェルを含まない。したがって Dockerfile の ENTRYPOINTベクタ形式で書く必要がある。コンテナランタイムがシェルを前置するのを避けるためである)

ベクタ形式とは ENTRYPOINT ["java","-jar","app.jar"] のように配列で書く形式です。ENTRYPOINT java -jar app.jar のように文字列 1 本で書くと(シェル形式)、ランタイムが /bin/sh -c を前に付けて実行します。 シェルが無いイメージでは、この /bin/sh が見つからずに起動しません。

「シェルが無い」がすべての出発点です。 以降の代償はここから派生します。

イメージの種類と使い分け

イメージ中身向く用途
gcr.io/distroless/static-debian13ca-certificates / tzdata / /etc/passwd 等の最小セット静的リンクした Go / Rust
gcr.io/distroless/base-debian13static + glibc / libssl / openssl動的リンクバイナリ
gcr.io/distroless/base-nossl-debian13base から OpenSSL を除いたものTLS を使わないもの
gcr.io/distroless/cc-debian13base + libgcc / libstdc++C / C++
gcr.io/distroless/java-base-debian13base + Java 実行に必要な依存(JDK 本体なし)自前 JRE(jlink)を持ち込む場合
gcr.io/distroless/java25-debian13base + Temurin OpenJDK 25Java アプリ(本ラボの候補)

タグは 4 種類です——latest / nonroot(非 root ユーザで動く構成)/ debug(busybox シェル入り)/ debug-nonrootJava 25 に対応するタグは存在しますjava25-debian13)。Debian 12 系は java21-debian12 までです。

scratch との違い。

scratch完全に空のイメージで、ファイルが 1 つもありません(静的リンクしたバイナリ 1 本だけを置く用途です)。distroless は空ではなく、ca-certificates / tzdata / /etc/passwd を持ちます。

この違いは実務で効きます。TLS 通信をするなら ca-certificates が要り、ログのタイムスタンプを正しく出すなら tzdata が要り、非 root ユーザを名前で指定するなら /etc/passwd が要ります。 それらが必要なアプリは scratch では動きません。試験では両者の違いが選択肢として出ます。

java イメージには、もう 1 つ注意すべき性質があります。java/README.md の記述です。

The entrypoint of this image is set to the equivalent of java -jar, so this image expects users to supply a path to a JAR file in the CMD.

(このイメージの entrypoint は java -jar 相当に設定されており、CMD で JAR ファイルのパスを渡すことを期待している)

つまり CMD に書いたものは、jar のパスとアプリ引数として扱われます。 -XX:MaxRAMPercentage=75.0 のような JVM オプションは CMD には書けません。渡すには JAVA_TOOL_OPTIONS 環境変数を使います——JVM が起動時に自動で読み込む環境変数で、コマンドライン引数を渡せない場面のための仕組みです。

配布ホストと whitelist —— 1 行足す前に考えること

README には配布ホストについての記述があります。

Distroless’s serving infrastructure has moved to artifact registry but we still use the gcr.io domain.

(配信基盤は Artifact Registry へ移行したが、ドメインは gcr.io のままである)

本ラボの Squid は gcr.io を遮断しています。 実測では https://gcr.io/v2/000(接続に至らない)でした。演習のため 1 行だけ追加します。

実行コマンド(alma-proxy・root):

# cp /etc/squid/whitelist.txt /etc/squid/whitelist.txt.bak-12
# echo 'gcr.io' >> /etc/squid/whitelist.txt
# systemctl reload squid
# wc -l /etc/squid/whitelist.txt

実行結果:

44 /etc/squid/whitelist.txt

43 行から 44 行になります。 blob の配信元である storage.googleapis.com は第2巻から登録済みなので、追加はこの 1 行だけです。

実行コマンド(alma-proxy・root):

# tail -3 /etc/squid/whitelist.txt

実行結果:

bitnami.github.io
helm.cilium.io
gcr.io

本番ガードレール③: whitelist に 1 行足すことは、そのドメイン配下のすべてを許可することです。

gcr.io と書けば gcr.io にあるあらゆるイメージが pull できるようになります。

いま起きていることを言葉にしてください。 第7回で「不要な外部接続を削る」設計をした直後に、演習のために 1 行足しています。これ自体は間違いではありませんが、無自覚にやると whitelist は増える一方になります。

演習が終わったら戻すか、残す理由を記録するかを決めてください。 本書は第14回(Cosign / SBOM)でも gcr.io を使う可能性があるため残しますが、残すと決めた記録を書くところまでが運用の型です。バックアップを .bak-12 という名前で取ったのは、どの回で足したかを後から追えるようにするためです。

実際に触って、シェルが無いことを確認する

実行コマンド(k8s-ops・developer):

$ docker pull gcr.io/distroless/java25-debian13:nonroot
$ docker pull gcr.io/distroless/java25-debian13:debug
$ docker images | grep distroless

実行結果:

Digest: sha256:9ccf2b8bce700d9f450523e2055afabe4d4ee795e6d69a0647a2dcd6e180e411
Status: Downloaded newer image for gcr.io/distroless/java25-debian13:nonroot
Digest: sha256:59a02af2fb174d3617f4757a9bb6ee3d56c0ee00d901b48ad7b880b5a58f2aae
Status: Downloaded newer image for gcr.io/distroless/java25-debian13:debug

gcr.io/distroless/java25-debian13:debug     59a02af2fb17        502MB          154MB
gcr.io/distroless/java25-debian13:nonroot   9ccf2b8bce70        303MB         73.2MB

whitelist に 1 行足しただけで pull は通りました。 Artifact Registry へ移行した後も blob の配信元は storage.googleapis.com のままで、そちらは第2巻から登録済みだからです。

digest とサイズは手元では違う値になります。

:nonroot:debug動くタグで、上流がベースを更新するたびに中身が変わります。上の出力は執筆時の実測ですが、本書がこの節を 2 日後に測り直したときには digest が両方とも変わり、:nonrootDISK USAGE は 303MB から 304MB になっていましたCONTENT SIZE は 73.2MB のままでした)。

これは distroless に限った話ではありません。 :latest だけでなく :nonroot のような「役割を表すタグ」も動きます。本番で使うなら digest でピン留めします——gcr.io/distroless/java25-debian13@sha256:... の形です。第14回で Cosign の検証をダイジェスト指定で行うのも同じ理由で、タグは後から別のイメージを指すことがあります。

ここでサイズを見比べます。先に土俵を揃えてください。

:nonroot73.2MB は CONTENT SIZE(圧縮済み blob)ですが、演習①で見た本ラボの内訳 JRE 198MB + apk 依存 21MB + minirootfs 8.71MB は docker history の値(展開後・非圧縮)です。この 2 つを直接引き算してはいけません。

展開後サイズに揃えて比べます。:nonroot の展開後は DISK 303MB − CONTENT 73.2MB = 229.8MB、alpine ベース側は 198 + 21 + 8.71 = 227.7MB229.8MB 対 227.7MB——ほぼ同じで、distroless のほうがむしろわずかに大きいという結果でした。ベースを distroless に替えても、ベース側の削減はほとんどありません。

この差は上流の更新で多少動きます。2 日後に測り直したときは DISK 304MB − CONTENT 73.2MB = 230.8MB でしたが、結論は変わりません——227.7MB との差は数 MB で、どちらに転んでも「ベースを替えれば小さくなる」と言える規模ではありません。

さらに、0.4.0 のアプリ側は payara-micro.jar だけで 98.7MB を占めています(こちらも展開後の値です)。ベース側が減らないうえに、アプリ側の 98.7MB は 1 バイトも減りません。

「distroless にすれば小さくなる」という期待そのものが、このアプリでは成立しません——これは後で述べる「採用しない判断」の根拠を 1 本強くします。削減がほぼゼロなのに kubectl exec を失うのであれば、天秤は最初から傾いています。削減量を見積もってから、失うものと釣り合うかを判断する順序です。

実行コマンド(k8s-ops・developer):

$ docker run --rm --entrypoint sh gcr.io/distroless/java25-debian13:nonroot -c 'echo hello'

実行結果:

docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "sh": executable file not found in $PATH

Run 'docker run --help' for more information

exec: "sh": executable file not found in $PATH——シェルというファイルがイメージに存在しません。:debug なら通ります。

実行コマンド(k8s-ops・developer):

$ docker run --rm --entrypoint sh gcr.io/distroless/java25-debian13:debug -c 'echo hello'

実行結果:

hello

UID と ENTRYPOINT はコンテナの外から読みます。シェルが無いので id を実行できません。

実行コマンド(k8s-ops・developer):

$ docker inspect gcr.io/distroless/java25-debian13:nonroot --format '{{.Config.User}}'
$ docker inspect gcr.io/distroless/java25-debian13:debug --format '{{.Config.User}}'
$ docker inspect gcr.io/distroless/java25-debian13:nonroot --format '{{json .Config.Entrypoint}}'

実行結果:

65532
0
["/usr/bin/java","-jar"]

ENTRYPOINT が ["/usr/bin/java","-jar"] であることが読めます。README の「java -jar 相当」は文字どおりでした。そして 2 行目に問題があります。

:debugConfig.User0——root です。

:nonroot は 65532 ですが、シェルが欲しくて :debug に替えると、非 root 実行という別の防御を同時に失います。

これは第8回の PSS Restricted と直結します。Restricted は runAsNonRoot: true を必須にするので、:debug のイメージをそのまま動かすとマニフェスト側で UID を上書きしない限り admission に弾かれます。非 root とシェルの両方が要るなら :debug-nonroot を選びます——タグが 4 種類ある理由がここにあります。

「デバッグ用だから」と :debug を選ぶ判断が、意図せず root 実行を持ち込む——タグ名だけを見て選ばず、docker inspectConfig.User を確認する習慣を持ってください。

id が実行できないという事実も、そのまま代償です。 シェルが無いイメージでは、UID を確認するという最小限の調査すらコンテナの外からしかできません。これが本節の結論に直結します。

本ラボの backend を distroless 化すると何が起きるか

#代償内容
1entrypoint.sh が動かないシェルがありません。entrypoint.sh は環境変数(DB_USER / DB_PASSWORD は Secret 由来)をヒアドキュメントで展開して /opt/payara/tmp/postboot.txt を生成しています。initContainer に切り出して payara-tmp の emptyDir 経由で渡す書き換えが必要です
2JVM オプションの渡し方が変わる既定 ENTRYPOINT が java -jar なので -XX:MaxRAMPercentage=75.0 を CMD には書けません。JAVA_TOOL_OPTIONS へ移す必要があり、書き換え先は 2 箇所です——Dockerfile の ENV JAVA_OPTS と ConfigMap fanclub-configJAVA_OPTS同じ値が二重に定義されています
3UID が合わない:nonrootUID 65532 です。Deployment の runAsUser: 10001 と、initContainer が書くファイルの所有者を揃え直す必要があります
4kubectl exec -- sh が使えなくなる第15回(Falco の shell_in_container 検知演習)と第18回(DAIR の証拠保全)が成立しなくなります

判断: 本書は backend を distroless 化しません。

決め手は 4 番目です。第15回では「コンテナ内でシェルが起動されたことを Falco が検知する」演習を行い、第18回ではインシデント時の証拠保全に kubectl execkubectl cp を使います。distroless 化すると、これらの演習そのものが成立しません。

ただし distroless を「使えない技術」として片づけないでください。 実運用で distroless を選ぶ場合、失った kubectl exec の代わりに ephemeral containerkubectl debug --image=busybox --target=<container>)を使います。この手段は第15回で扱います。

代替手段を持っているかどうかが、distroless を採れるかどうかを決めます。 「シェルが無くて困る」のではなく「シェルが無くても調査できる仕組みがあるか」を先に問うのが順序です。

この構図は 3 度目です。 第10回では gVisor が SELinux と衝突して撤去し、第11回では第6回で入れた SELinux が Cilium の mutual authentication を止めました。本回は 「ハードニングの選択が、後で必要になる手段を奪う」 という同じ構図の 3 例目です。

CKS が問うのは個々の技術の可否ではありません。 このトレードオフを言葉にできるかどうかです。

試験ではこう問われる。

distroless は選択肢の文言として出ます。手が覚えているべきは 3 点です。①シェルが無い:debug にはある)gcr.io/distroless/<lang><ver>-debian<ver> という命名:nonroot タグの存在)③scratch との違い(scratch は完全に空・distroless は ca-certificates / tzdata / /etc/passwd を持つ)。

gcr.io も distroless の GitHub も試験中は参照できません。 CKS の Resources Allowed 8 件に含まれないため、暗記対象です。

静的解析・動的スキャン・ランタイム検知の役割分担と、2 つのツールの導入

ここから後半です。なぜ 3 種類も 4 種類も道具が要るのかを先に整理してから、ツールを入れます。

手段いつ効くか何を見るか本巻での回
静的解析(Kubesec / KubeLinter)apply する前(CI / エディタ)YAML の書き方本回
イメージスキャン(Trivy)build 直後 / push 前イメージの中身(CVE)第13回
Admission 制御(PSA / Gatekeeper / VAP)apply の瞬間クラスタに入るリソース第8回
ランタイム検知(Falco)動き出した後syscall の挙動第15回
4 段の防御が効く区間を時間軸の帯で示す図。横軸はコードを書く、build、apply、実行中の順に進む。静的解析(Kubesec・KubeLinter・本回)は apply する前の区間で YAML の書き方を見る。イメージスキャン(Trivy・第13回)は build 直後から push 前の区間でイメージの中身の CVE を見る。Admission(PSA・Gatekeeper・第8回)は apply の瞬間に入ってくるリソースを見る。ランタイム検知(Falco・第15回)は動き出した後の syscall の挙動を見る。4 つの帯は重ならず、すき間なく並ぶ
図2:4 段の防御が効く区間(静的解析・スキャン・Admission・ランタイム)

この 4 段は「同じことを 4 回やっている」のではありません。 それぞれが、前の段をすり抜けたものを捕まえます。

  • 静的解析は「privileged: true と書いてある」ことを指摘できますが、イメージの中に古い OpenSSL が入っていることは分かりません(→ Trivy)
  • Admission は「クラスタに入る前に止める」ものですが、CI で止めるほうが開発者へのフィードバックが速い(→ 静的解析)
  • どれも「起動後にシェルが立ち上がった」ことは分かりません(→ Falco)

第8回fanclub namespace に PSA restricted を enforce しました。PSA は「apply を拒否する」防御、静的解析は「apply する前に開発者へ返す」防御です。同じ securityContext を見ていても、働く位置が違います。

本回では、指摘対象のマニフェストを kubectl apply しません。

ファイルに対してだけ検査します。理由は 2 つあります。fanclub namespace は PSA restricted なので、弱いマニフェストはそもそも受け付けられません。②CKS の設問形式が「YAML ファイルを直せ」だからです。 apply しないほうが試験に近い形になります。

Kubesec の導入

GitHub release のバイナリを取得し、ハッシュを検証してから /usr/bin に置きます。/usr/local/bin にしないのは、sudosecure_path に含まれないためです(第2回の kube-bench・第10回の runsc・第11回の cilium CLI と同じ理由です)。

実行コマンド(k8s-ops・developer):

$ KUBESEC_VER=v2.14.2
$ curl -sSL --fail -O https://github.com/controlplaneio/kubesec/releases/download/${KUBESEC_VER}/kubesec_linux_amd64.tar.gz
$ curl -sSL --fail -O https://github.com/controlplaneio/kubesec/releases/download/${KUBESEC_VER}/kubesec_checksums.txt
$ grep kubesec_linux_amd64.tar.gz kubesec_checksums.txt
$ grep kubesec_linux_amd64.tar.gz kubesec_checksums.txt | sha256sum --check

実行結果:

bc252e35f01bc4f133a49404315da3ccfed0209cc9baba33883eaeca0656f35c  kubesec_linux_amd64.tar.gz
kubesec_linux_amd64.tar.gz: 完了

成功メッセージが 完了 と出ています。

sha256sum --check の出力はロケール依存で、本ラボの VM は日本語ロケールなので 完了、英語ロケールでは OK です。手順書や CI のログを英語表記で揃えたいときは LC_ALL=C を付けて実行してください。

検証で見るべきは文言ではなく終了コードです。sha256sum --check は不一致なら非ゼロで終わるので、スクリプトの中では if ! ... ; then で判定します。

アーカイブの中身を確認してから取り出します。

実行コマンド(k8s-ops・developer):

$ tar tzf kubesec_linux_amd64.tar.gz

実行結果:

CHANGELOG.md
LICENSE
README.md
kubesec

アーカイブ直下に kubesec があります(サブディレクトリに入っていません)。したがって tar xzf ... kubesec でバイナリだけを取り出せます。

実行コマンド(k8s-ops・developer):

$ tar xzf kubesec_linux_amd64.tar.gz kubesec
$ sudo install -m 0755 kubesec /usr/bin/kubesec
$ rm -f kubesec kubesec_linux_amd64.tar.gz kubesec_checksums.txt
$ kubesec version

実行結果:

version 2.14.2
git commit bb804de5ed6f311a7d281c3d119fe85e77e75a13
build date 2024-11-22T16:34:22Z

3 行目の build date 2024-11-22 を覚えておいてください。 このあとの H2 で効いてきます。

第2回でプラットフォームバイナリの SHA256 検証を扱いました。外部から持ち込むバイナリには毎回同じ検証をします。 サプライチェーンセキュリティの回で、検証せずにバイナリを入れるのは筋が通りません。

ここで 1 つ、後の節(H2-7)への伏線になる事実を挙げておきます。Kubesec の最新リリースは v2.14.2 で、公開日は 2024-11-22 です。 執筆時点から数えて 1 年 8 か月、更新が止まっています。

KubeLinter の導入

実行コマンド(k8s-ops・developer):

$ KUBELINTER_VER=v0.8.3
$ curl -sSL --fail -O https://github.com/stackrox/kube-linter/releases/download/${KUBELINTER_VER}/kube-linter-linux.tar.gz
$ tar tzf kube-linter-linux.tar.gz
$ tar xzf kube-linter-linux.tar.gz kube-linter
$ sudo install -m 0755 kube-linter /usr/bin/kube-linter
$ rm -f kube-linter kube-linter-linux.tar.gz
$ kube-linter version

実行結果(tar tzfkube-linter version):

LICENSE
README.md
kube-linter
0.8.3

バージョン表記は 0.8.3 の 1 行だけで、v は付きません(ダウンロード URL のタグは v0.8.3 です)。kubesec が 3 行出したのと対照的で、この差は「CI で --version の出力を parse するな」という実務上の教訓にもなります。

KubeLinter のリリースには、アーカイブと並んで kube-linter-linux.sigstore.json という Sigstore バンドルが同梱されています。Sigstore は OSS の配布物に署名を付けて検証する仕組みです。配布物の署名検証は第14回(Cosign)で扱います。

他の実行形態

形態コマンド本ラボでの扱い
コンテナdocker run -i kubesec/kubesec:v2 scan /dev/stdin < manifest.yaml紹介のみ(バイナリを使います)
ホスト APIcurl -sSX POST --data-binary @"manifest.yaml" https://v2.kubesec.io/scan使いません(下記)

本番ガードレール④: 社内のマニフェストを外部 API に投げない。

kubesec のホスト API(v2.kubesec.io)については、公式の README 自身が注意を書いています——Please do not submit sensitive YAML to this service.(機密性のある YAML をこのサービスに送らないでください)

社内のマニフェストには、Secret 名・内部ホスト名・namespace 構成・イメージのレジストリ URL といった内部情報が含まれます。 スキャン結果を得る手軽さと引き換えに、何を外に出すのかを判断してください。

本ラボはそもそも v2.kubesec.io が whitelist 外なので使えません。それでも「使えないから使わない」ではなく「使うべきでないから使わない」という理由づけを持ってください。 プロキシの設定は変えられますが、判断の理由は変わりません。

ここまでの準備は、本書ラボ固有の作法です。

試験では問われません。 本回で出てくるラボ固有の準備は 4 つあります。

  • kubesec / kube-linter のバイナリ導入curl + sha256sum --check + sudo install -m 0755 /usr/bin
  • Squid の whitelist に gcr.io を 1 行足したこと
  • Gitea への push と no_proxy の指定(演習④)
  • ArgoCD 経由での反映(演習④)

CKS の SSH ホストにプリインストールされているのは kubectlk エイリアスと bash 補完つき)/ yq / curl / wget / man です。ツールを要求するタスクでは、そのタスクの指定ホストに必要なものが用意されている前提で出題されます。導入手順そのものは問われません。

覚えるのは導入ではありません。 kubesec scan <file>kube-linter lint <path> の 2 コマンド、そしてその出力の読み方です。プロキシも Git も ArgoCD も試験環境には出てきません。

やってみよう②:Kubesec でマニフェストをスコアリングする

所要時間の目安: 10 分。 実在のマニフェストを採点し、出力を読みます。

この時点でクラスタは 0.3.2 のまま動いています。

演習①で作った 0.4.0 への差し替えは演習④で行います。いま採点するのは Git に入っているファイルであって、動いているものではありません。

ステップ1:現行の backend を採点する

実行コマンド(k8s-ops・developer):

$ kubesec scan ~/gitops/gitops-fanclub/base/backend-deployment.yaml

実行結果(JSON の総評部分):

"object": "Deployment/fanclub-backend.default",
"valid": true,
"message": "Passed with a score of 13 points",
"score": 13,

scoring.passed は 11 件・合計 13 点でした。各要素の selector をそのまま並べます——どのフィールドを見て加点したのかが読めます。

idselectorpoints
ServiceAccountName.spec .serviceAccountName3
AutomountServiceAccountToken.spec .automountServiceAccountToken == false1
RunAsNonRoot.spec, .spec.containers[] | .securityContext .runAsNonRoot == true1
RunAsUser.spec, .spec.containers[] | .securityContext .runAsUser -gt 100001
LimitsCPUcontainers[] .resources .limits .cpu1
LimitsMemorycontainers[] .resources .limits .memory1
RequestsCPUcontainers[] .resources .requests .cpu1
RequestsMemorycontainers[] .resources .requests .memory1
CapDropAnycontainers[] .securityContext .capabilities .drop1
CapDropAllcontainers[] .securityContext .capabilities .drop | index("ALL")1
ReadOnlyRootFilesystemcontainers[] .securityContext .readOnlyRootFilesystem == true1

scoring.advise は 3 件です。

idselectorpoints
ApparmorAny.metadata .annotations ."container.apparmor.security.beta.kubernetes.io/nginx"3
SeccompAny.metadata .annotations ."container.seccomp.security.alpha.kubernetes.io/pod"1
RunAsGroup.spec, .spec.containers[] | .securityContext .runAsGroup -gt 100001

この advise の上 2 行は、あとで扱う節の材料そのものです。 いまは「advise に出ている」という事実だけ覚えておいてください。

objectDeployment/fanclub-backend.default になっています。

クラスタでは fanclub namespace にいるのに .default と出るのは、base/backend-deployment.yamlnamespace: の記述が無いためです。namespace は overlays/ 側の Kustomize が付けています。

静的解析が見ているのは「ファイルに書いてあること」だけ——実際にどこへデプロイされるかは知りません。この性質は、このあと KubeLinter の偽陽性を読み解くときにもう一度出てきます。

"valid": true も確認してください。kubesec はスキーマ検証(kubeconform 相当)を raw.githubusercontent.com 上の JSON スキーマを取得して行います。 kubeconform は「その YAML が Kubernetes の API スキーマとして正しいか」だけを検査する単機能のツールで、セキュリティの良し悪しは見ません。「書式として妥当か」と「設定として安全か」は別の検査で、kubesec は前者を通してから後者を採点します。 本ラボではこのホストが whitelist 済みなので通ります。取得できない環境ではここで止まるので、閉じたネットワークで使うときは --schema-location でローカルのスキーマを指す必要があります。

ステップ2:出力スキーマを読む

フィールド意味
objectKind/name.namespace の形(本ラボの base/namespace: を持たないので Deployment/fanclub-backend.default になります)
validスキーマ検証を通ったか
message総評
score合計点
scoring.passed加点された = 満たしているルール
scoring.critical減点ルール(マイナス点)。最優先で直す
scoring.advise未適用だが推奨されるルール。ここが「次にやること」のリスト

各要素は id / selector / reason / points を持ちます。selector が「どのフィールドを見ているか」を直接示すので、修正箇所がそのまま分かります。 指摘の意味が分からないときは、まず selector を読んでください。

なお kubesec scanPod / Deployment / StatefulSet / DaemonSet を直接スキャンできます。 Deployment を Pod に展開する必要はありません。

ステップ3:frontend を採点して差を見る

実行コマンド(k8s-ops・developer):

$ kubesec scan ~/gitops/gitops-fanclub/base/frontend-deployment.yaml

実行結果(JSON の総評部分):

"message": "Passed with a score of 7 points",
"score": 7,

backend 13 点に対して frontend は 7 点、差は 6 点です。 scoring.passedRunAsNonRoot / LimitsCPU / LimitsMemory / RequestsCPU / RequestsMemory / CapDropAny / CapDropAll の 7 件でした。

scoring.advise は 7 件です。

ApparmorAny(3) / ServiceAccountName(3) / SeccompAny(1) /
AutomountServiceAccountToken(1) / RunAsGroup(1) / RunAsUser(1) /
ReadOnlyRootFilesystem(1)

同じリポジトリの中に、点数の高いものと低いものが並んでいます。 backend の passed にあって frontend の advise に落ちているものが、そのまま差分です。

  • serviceAccountName が無い(advise 3
  • automountServiceAccountToken の指定が無い(advise 1
  • readOnlyRootFilesystem が無いadvise 1
  • runAsUser: 10110000 以下なので加点されないadvise 1

この差には理由があります。 第1巻・第2巻で backend を先に作り込み、frontend は静的配信だから後回しになりました。「重要そうな方だけ固める」という判断が、そのまま点差として可視化されています。

静的解析の価値の 1 つがここにあります。「どこが手薄か」を人の記憶ではなく数字で持てるということです。

ステップ4:scoring.advise を「やることリスト」として読む

advise は「点数が入っていない = やっていない」推奨項目です。critical のように危険を示すものではなく、「入れれば加点される」という未実施リストです。

frontend の advise をそのまま改善計画にします。実施は演習④(GitOps 経由)で行います。

ステップ5:減点を確認する(apply はしません)

加点側だけ見ていても、減点の規模感が分かりません。わざと弱いマニフェストを /tmp に作り、スキャンだけします。

実行コマンド(k8s-ops・developer):

$ cat > /tmp/bad-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: bad-pod
spec:
  hostNetwork: true
  hostPID: true
  containers:
  - name: app
    image: nginx:latest
    securityContext:
      privileged: true
      allowPrivilegeEscalation: true
      capabilities:
        add: ["SYS_ADMIN"]
    volumeMounts:
    - name: docker-sock
      mountPath: /var/run/docker.sock
  volumes:
  - name: docker-sock
    hostPath:
      path: /var/run/docker.sock
EOF

<<'EOF' とクォートしているのは、YAML 中の $ を含む文字列がシェルに展開されるのを防ぐためです。ヒアドキュメントで YAML を書くときの定石です。

実行コマンド(k8s-ops・developer):

$ kubesec scan /tmp/bad-pod.yaml

実行結果(JSON の総評部分):

"message": "Failed with a score of -94 points",
"score": -94,

scoring.critical は 6 件でした。

idpoints
CapSysAdmin-30
Privileged-30
HostNetwork-9
HostPID-9
DockerSock-9
AllowPrivilegeEscalation-7

合計 -94 点です。総評も Passed ではなく Failed に変わっています。この Failed という語が、あとで CI ゲートを組むときの分かれ目になります。

この Pod は kubectl apply しないでください。

fanclub namespace は第8回で PSA restricted を enforce にしてあるので、適用しようとしても admission が拒否します。そして本回の主題は「apply する前に見つける」ことです。

静的解析はファイルに対して行うものであり、クラスタに入れる必要がありません。 CKS の設問も「YAML を直せ」という形で出ます。

v2.14.2 の減点ルールは 10 個です(ソース pkg/ruler/ruleset.go で確認)。

IDSelector(要約)Points
Privilegedcontainers[] .securityContext .privileged == true-30
CapSysAdmincontainers[] .securityContext .capabilities .add == SYS_ADMIN-30
HostNetwork.spec .hostNetwork == true-9
HostPID.spec .hostPID == true-9
HostIPC.spec .hostIPC == true-9
DockerSockvolumes[] .hostPath .path == /var/run/docker.sock-9
ProcMountvolumes[] .hostPath .path == /proc-9
AllowPrivilegeEscalationcontainers[] .securityContext .allowPrivilegeEscalation == true-7
HostAliases.spec .hostAliases-3
SeccompUnconfined削除済みアノテーション container.seccomp.security.alpha.kubernetes.io/podunconfined-1

Privileged の -30 が突出しています。 加点側は 1〜3 点刻みなので、privileged: true が 1 つあるだけで、他の加点をすべて満たしても打ち消されます。 点数設計そのものが「何が致命的か」を語っています。

試験ではこう問われる。

与えられたマニフェストを kubesec でスキャンし、critical な指摘を修正せよ」という粒度です。手が覚えているべきは kubesec scan <file> の 1 コマンドと、scoring.critical を先に潰すという優先順位です。試験相当の作業(スキャンして critical を直すだけ)なら 5〜7 分です。

kubesec.io は試験中に参照できません。 減点項目(privileged / hostNetwork / hostPID / hostIPC / allowPrivilegeEscalation / SYS_ADMIN / docker.sock マウント)を暗記しておいてください。加点側は「runAsNonRoot + readOnlyRootFilesystem + drop: [ALL] + serviceAccountName + requests/limits」の 5 点セットで覚えます。

バイナリ自体も、あるものとして頼らないでください。 kubesec が試験環境に入っていれば kubesec scan で採点できますが、入っていない前提で減点項目と加点セットを暗記するのが安全です。ツールが無くても直せる状態にしておくことが目標です。

Kubesec のスコアが見ていないもの —— ルールセットの賞味期限

ここで、演習②の出力に出てこなかったものを扱います。本回で最も重要な節です。

seccompProfile が採点されていない

base/backend-deployment.yaml は Pod レベルに securityContext.seccompProfile.type: RuntimeDefault を持っています(第6回で入れたものです)。ところが演習②の出力では、SeccompAnyscoring.passed ではなく scoring.advise に出ていました。

理由は同じ出力の中に書いてあります。 advise に並んだ selector をもう一度見てください。

ApparmorAny   .metadata .annotations ."container.apparmor.security.beta.kubernetes.io/nginx"
SeccompAny    .metadata .annotations ."container.seccomp.security.alpha.kubernetes.io/pod"

どちらの selector.metadata .annotations を指しています。 securityContext ではありません。ソースを読まなくても、kubesec scan の出力だけで「何を見て採点しているか」が分かる——これが selector がそのまま出力に含まれていることの効用です。

設定の書き方が間違っているのではありません。原因は kubesec v2.14.2 のルール定義そのものにあります。 ソース(pkg/ruler/ruleset.go)の該当箇所です。

seccompAnyRule := Rule{
	Predicate: rules.SeccompAny,
	ID:        "SeccompAny",
	Selector:  ".metadata .annotations .\"container.seccomp.security.alpha.kubernetes.io/pod\"",

container.seccomp.security.alpha.kubernetes.io/pod は Kubernetes v1.25 で削除されたアノテーションです。現行は securityContext.seccompProfile(第6回で扱いました)。ツールが 1 世代前のフィールドを見ています。

AppArmor 側はさらに露骨です。

apparmorAnyRule := Rule{
	Predicate: rules.ApparmorAny,
	ID:        "ApparmorAny",
	Selector:  ".metadata .annotations .\"container.apparmor.security.beta.kubernetes.io/nginx\"",

コンテナ名が nginx に固定されています.../nginx)。汎用ルールとして成立していません。第6回で「旧アノテーション記法 container.apparmor.security.beta.kubernetes.io/<container>」を対比表で示しましたが、その旧記法そのものです(現行は securityContext.appArmorProfile・v1.30 で置き換わりました)。

なぜこうなるのか

導入のときに触れたとおり、kubesec の最新リリースは v2.14.2(2024-11-22)で、1 年 8 か月更新が止まっています。 GitHub の master ブランチでは修正されており、selector は .spec .securityContext .seccompProfile .type | ... に更新されています。修正はされている。リリースされていないだけです。

master には v2.14.2 に無いルールもあります。

master にのみ存在するルールPoints本ラボへの影響
SecretsAsEnvironmentVariables.env[].valueFrom.secretKeyRef / .envFrom[].secretRef-5backend は envFrom.secretRef: fanclub-db-secret を使っているので、master 版なら減点されます
HostUsers.spec .hostUsers == false+1該当なし
BindingsToSystemAnonymous-30該当なし

本書はリリース版(v2.14.2)をピン留めして使います(再現性のためです)。その結果、seccompProfile は加点されず、Secret の環境変数注入も減点されません。

減点側も同じです。 演習②の減点ルール表の最下段にあった SeccompUnconfined-1)の Selector も、SeccompAny とまったく同じ削除済みアノテーション container.seccomp.security.alpha.kubernetes.io/pod を指しています。したがって現行記法で securityContext.seccompProfile.type: Unconfined——つまり seccomp を明示的に無効化した危険な設定——を書いても、この -1 は発火しません。

v2.14.2 は seccomp について、加点も減点も機能していません。 「正しく設定してあるのに評価されない」だけでなく、「危険に設定してあるのに検出されない」も同時に起きています。 片方だけの問題ではないと理解してください。

スコアが語らないもの —— 加点は「どれか 1 つ」で入る

ルールセットの古さとは別に、点数の作られ方そのものにも読み違えやすい性質があります。 演習②の backend の出力に戻ってください。

base/backend-deployment.yaml には、メインの backend コンテナのほかに initContainer wait-for-db とサイドカー log-shipper が入っています。この 2 つには readOnlyRootFilesystemresources もありません。 それでも ReadOnlyRootFilesystem / LimitsCPU / RequestsCPU などは passed に入って加点されています。

切り分けのため、メインコンテナには readOnlyRootFilesystem を書かず、initContainer にだけ書いた Deployment を作って採点しました。手元で確かめられるように全量を載せます。

実行コマンド(k8s-ops・developer):

$ cat > /tmp/any-test.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: any-test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: any-test
  template:
    metadata:
      labels:
        app: any-test
    spec:
      initContainers:
      - name: good-init
        image: busybox:1.37
        command: ["sh","-c","true"]
        securityContext:
          readOnlyRootFilesystem: true
      containers:
      - name: app
        image: busybox:1.37
        command: ["sh","-c","sleep infinity"]
EOF
$ kubesec scan /tmp/any-test.yaml

実行結果(要点):

"message": "Passed with a score of 1 points"
passed: ReadOnlyRootFilesystem

2 行目は生の出力そのままではなく、JSON の scoring.passed[] から id だけを抜き出したものです(実際には selector / reason / points も並びます)。

containers[] というセレクタは initContainers も対象にし、しかも「どれか 1 つが満たしていれば加点する」という判定でした。メインコンテナが満たしていなくても、initContainer が満たしていれば点が入ります。

減点側も同じです。メインコンテナは固めたうえで、initContainer にだけ privileged: true を置いた Deployment を採点すると、こうなります。

実行コマンド(k8s-ops・developer):

$ cat > /tmp/init-test.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: init-test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: init-test
  template:
    metadata:
      labels:
        app: init-test
    spec:
      initContainers:
      - name: bad-init
        image: busybox:1.37
        command: ["sh","-c","true"]
        securityContext:
          privileged: true
      containers:
      - name: app
        image: busybox:1.37
        command: ["sh","-c","sleep infinity"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
EOF
$ kubesec scan /tmp/init-test.yaml

実行結果(要点):

"message": "Failed with a score of -25 points"
critical: Privileged

こちらも 2 行目は scoring.critical[]id を抜き出したものです。Privileged の -30 に、メインコンテナで得た加点 5 点が乗って -25 になっています。

Kubesec のスコアは「全部のコンテナが満たしている」ことを意味しません。

メインコンテナだけ固めてサイドカーを放置しても、加点は満額入ります。逆に、サイドカーが 1 つ危険なら全体が減点されます。

スコアという 1 つの数字にまとめる以上、こうなるのは避けられません。 数字は「Pod 全体で最も良い値」と「最も悪い値」を混ぜたものであって、「点が高い = 全コンテナが安全」ではありません。

この性質は、次の H2 で扱う KubeLinter と対比すると分かりやすくなります。KubeLinter はコンテナを 1 つずつ名指しで指摘します——container "wait-for-db" does not have a read-only root file system のように。スコアは全体を 1 つに丸め、指摘の一覧は個別に残す。 どちらが要るかは、それを何に使うかで決まります。

この節の結論

静的解析ツールのルールセットには賞味期限があります。

ツールが更新されないまま Kubernetes 側のフィールドが変わると、「正しく設定してあるのに評価されない」あるいは 「危険なのに検出されない」が起きます。

スコアは絶対値ではなく、「そのツールのそのバージョンが見ている範囲」の中での相対値です。 本節で見た穴は 2 種類ありました——①ルールが古くて評価されない(seccompProfile)②点数に丸める過程で個別のコンテナが消える(any 判定)。どちらも「スコアが高い」では埋まりません。

この構図は 2 度目です。 第2回で kube-bench が enable_selinux = false(コンテナが無拘束の状態)を FAIL にしなかったことを扱いました。ベンチマークが通ることと安全であることは別であり、スコアが満点であることと安全であることも別です。

実務での対処

  1. ツールのバージョンと最終更新日を確認する。 CI に組み込む道具ほど、更新が止まっていないかを定期的に見ます。リリース日は GitHub の releases か api.github.com/repos/<org>/<repo>/releases で分かります
  2. 1 つのツールに依存しない。 本回は Kubesec と KubeLinter の 2 つを当てます。同じ指摘に別経路で到達するかを確認します(次の H2)
  3. クラスタ側の防御を併用する。 静的解析をすり抜けても、第8回の PSA / Gatekeeper が apply の瞬間に止めます

本番ガードレール⑤: 「静的解析のスコアが基準値を超えたらマージ可」というルールを作らない。

そのルールを作った瞬間、ツールの盲点がそのまま組織の盲点になります。 本節で見たとおり、v2.14.2 は seccompProfile を見ていません。「スコア 10 点以上ならマージ可」という運用は、seccompProfile の有無をレビュー対象から外すのと同じことです。

スコアはゲートの一部であって、ゲートそのものではありません。

やってみよう③:KubeLinter を当て、既定では見つからない違反を足す

所要時間の目安: 10 分。 ディレクトリ単位で lint し、設定ファイルでチェックを取捨選択します。

ステップ1:既定チェックセットで base/ を lint する

実行コマンド(k8s-ops・developer):

$ kube-linter lint ~/gitops/gitops-fanclub/base/
$ echo $?

実行結果(末尾):

Error: found 8 lint errors
1

8 件・終了コード 1 でした。実際にはこの 2 行の前に KubeLinter 0.8.3 のバナーと 8 件の指摘本体が出ています。出力は 1 件ごとに次の形です(公式 README の例。件数は本ラボのものではありません)。

pod.yaml: (object: <no namespace>/security-context-demo /v1, Kind=Pod)
container "sec-ctx-demo" does not have a read-only root file system
(check: no-read-only-root-fs, remediation: Set readOnlyRootFilesystem to true...)

1 件が「ファイル名 / 対象オブジェクト / 何が問題か / どのチェックか / どう直すか」の 5 点セットで出ます。 remediation がそのまま修正手順になる点が Kubesec との大きな違いです。Kubesec は点数、KubeLinter は文章です。

本ラボで出た 8 件の内訳です。

#対象チェック指摘内容
1backendno-read-only-root-fscontainer “wait-for-db”
2backendno-read-only-root-fscontainer “log-shipper”
3backendnon-existent-service-accountserviceAccount “fanclub-backend” not found
4backendunset-cpu-requirementscontainer “wait-for-db” has cpu request 0
5backendunset-cpu-requirementscontainer “log-shipper” has cpu request 0
6backendunset-memory-requirementscontainer “wait-for-db” has memory limit 0
7backendunset-memory-requirementscontainer “log-shipper” has memory limit 0
8frontendno-read-only-root-fscontainer “frontend”

8 件のうち 6 件が initContainer とサイドカーへの指摘です。 前の H2 で見たとおり、Kubesec はこの 2 つを名指ししませんでした(メインコンテナが満たしていたので加点されていました)。同じマニフェストに 2 つの道具を当てると、片方が丸めたものをもう片方が個別に出す——これが「両方当てる」ことの実際の効果です。

Error: found 8 lint errors は stderr に出ます。

指摘の本文は stdout です。CI で指摘だけをファイルに残したいなら 2>/dev/null、件数だけを見たいなら 2>&1 >/dev/null と使い分けます。

出力先を確かめずにリダイレクトを書くと、ログに指摘が 1 件も残らないゲートができあがります。ツールを CI に入れる前に、stdout と stderr のどちらに何が出るかを確認してください。

3 番目の指摘は偽陽性です。

non-existent-service-account は「参照している ServiceAccount が見つからない」というチェックですが、fanclub-backend という ServiceAccount はクラスタに実在しますkubectl -n fanclub get sa で確認できます)。

無いのは base/ ディレクトリの中だけです。ServiceAccount のマニフェストが Git のこのディレクトリに入っていないため、KubeLinter は「見つからない」と判断します。 しかもこのチェックは既定で有効です——既定の状態で、既定のチェックが偽陽性を出しています。

本来の直し方は、ServiceAccount のマニフェストを Git に入れることです。 「クラスタにあるが Git に無い」ものが存在すること自体が、GitOps としては欠けた状態だからです。この指摘の扱いはステップ 4 で決めます。

ディレクトリを渡すと Kustomize として解釈される

出力のオブジェクト表記をよく見ると fanclub/fanclub-backend になっています。渡した 2 つの Deployment(backend-deployment.yaml / frontend-deployment.yaml)には namespace: の記述がありませんgrep -n 'namespace:' base/*-deployment.yaml で確認できます)。Kubesec は同じファイルを .default と表示しました。なぜ KubeLinter は fanclub を知っているのでしょうか。

渡し方を変えて比べると分かります。

実行オブジェクト表記
kube-linter lint base/frontend-deployment.yaml(単一ファイル)<no namespace>/fanclub-frontend
kube-linter lint base/kustomization.yaml あり)fanclub/fanclub-backend
kube-linter lint /tmp/basecopy/kustomization.yaml を除いたコピー)<no namespace>/fanclub-backend

kustomization.yaml があるディレクトリを渡すと、KubeLinter はそれを Kustomize としてビルドしてから lint します。 その結果 kustomization.yaml が指定する namespace: fanclub が適用された状態で検査されます。

kustomization.yaml 自身と db-sealedsecret.yaml に警告が出ないのも同じ理由です。 これらは lint 対象の「オブジェクト」ではなく、Kustomize の入力として消費されて、ビルド後の出力に現れないためです。

この性質は使えます。

overlays/prod/ を渡せば、overlay を解決したあとの実際の値——本番のレプリカ数やイメージタグ——に対して検査できます。base/ だけを lint していると、overlay で上書きされる値の問題を見逃します。

CI で lint するのは base/overlays/<env>/——Kustomize を使っている組織では、これが設計判断になります。本番に出るものを検査したいなら overlay 側です。具体例はステップ 4 で出てきます。

ステップ2:2 つのツールの違いを整理する

観点KubesecKubeLinter
出力スコア(数値)指摘の一覧(文章 + remediation)
単位ファイル / ドキュメント単位ディレクトリを再帰的に処理できる
判断「何点か」「どのチェックに引っかかったか」
設定ルールの取捨選択は不可.kube-linter.yaml で取捨選択できる
対象Pod / Deployment / StatefulSet / DaemonSetDeployment 系 + Service / Ingress / NetworkPolicy / RBAC / PDB / HPA など幅広い
終了コード--exit-code既定 2)・Failed のときだけ1(指摘が 1 件でもあれば)

使い分けの結論: Kubesec は「securityContext がどれだけ固まっているか」の定点観測、KubeLinter は「設計上の抜け」の洗い出しに向きます。両方当てます。

ステップ3:既定チェックセットは「全部」ではない

実行コマンド(k8s-ops・developer):

$ kube-linter checks list

実行結果:

Name: access-to-create-pods
Description: Indicates when a subject (Group/User/ServiceAccount) has create access to Pods. CIS Benchmark 5.1.4: ...
Remediation: Where possible, remove create access to pod objects in the cluster.
Template: access-to-resources
Parameters: map[resources:[^pods$ ^deployments$ ...] verbs:[^create$]]
Enabled by default: false

------------------------------

1 件が 6 行 + 区切り線で出ます。 本ラボの v0.8.3 では 全 63 チェックが並び、Enabled by default: true が 31 件、false が 32 件でした。半分が既定で無効です。

注目すべきは Parameters: の行です。 既定で有効か無効かだけでなく、チェックが発火する条件までここに書かれています。 後で出てくる no-anti-affinityminReplicas: 2 のような条件も、この 1 行から読めます。

ここで、ステップ 1 の結果を frontend の実態と突き合わせてください。base/frontend-deployment.yaml には probe が 1 つもありません。 それでも既定の kube-linter lint では指摘されません。

理由は単純です。no-liveness-probeno-readiness-probe は既定で無効Enabled by default: No)だからです。

既定で無効な主なチェック内容
no-liveness-probeliveness probe が無い
no-readiness-probereadiness probe が無い
read-secret-from-env-varSecret を環境変数で読んでいる
default-service-accountdefault ServiceAccount を使っている
use-namespacedefault namespace にデプロイしている
writable-host-mounthostPath を書き込み可でマウントしている
unsafe-proc-mount危険な /proc マウント
wildcard-in-rulesRole / ClusterRole のワイルドカード
access-to-secrets / access-to-create-pods / cluster-admin-role-bindingRBAC 系
non-isolated-podどの NetworkPolicy にも選択されていない
schema-validationスキーマ検証

既定で有効なチェックはこちらです。

latest-tag / privileged-container / privilege-escalation-container /
no-read-only-root-fs / run-as-non-root / drop-net-raw-capability /
unset-cpu-requirements / unset-memory-requirements /
host-network / host-pid / host-ipc / docker-sock / sensitive-host-mounts /
env-var-secret / duplicate-env-var / ssh-port / unsafe-sysctls /
dangling-service / mismatching-selector / no-anti-affinity /
no-extensions-v1beta / deprecated-service-account-field /
invalid-target-ports / liveness-port / readiness-port / startup-port /
non-existent-service-account / job-ttl-seconds-after-finished /
pdb-max-unavailable / pdb-min-available / pdb-unhealthy-pod-eviction-policy

liveness-portno-liveness-probe は別物です。

有効なチェック一覧に liveness-port があるのを見て「probe は見てくれている」と読まないでください。

liveness-port は「probe が指すポートが公開されているか」を見るチェックで既定有効、no-liveness-probe は「probe そのものが無い」ことを見るチェックで既定無効です。probe が無ければ liveness-port は何も言いません——検査する対象が存在しないからです。

ステップ4:.kube-linter.yaml で足りないチェックを足す

実行コマンド(k8s-ops・developer):

$ cat > ~/gitops/gitops-fanclub/.kube-linter.yaml <<'EOF'
checks:
  include:
    - no-liveness-probe
    - no-readiness-probe
    - read-secret-from-env-var
    - default-service-account
    - non-isolated-pod
EOF

実行コマンド(k8s-ops・developer):

$ kube-linter lint --config ~/gitops/gitops-fanclub/.kube-linter.yaml ~/gitops/gitops-fanclub/base/
$ echo $?

実行結果(末尾):

Error: found 13 lint errors
1

8 件から 13 件に増えました。 チェック別の内訳です。

チェック件数既定評価
no-read-only-root-fs3有効直す
unset-memory-requirements2有効直す
unset-cpu-requirements2有効直す
non-isolated-pod2無効(include で追加)偽陽性
non-existent-service-account1有効偽陽性
no-readiness-probe1無効(include で追加)直す
no-liveness-probe1無効(include で追加)直す
default-service-account1無効(include で追加)直す
read-secret-from-env-var0無効(include で追加)出ない(後述)

frontend の probe 欠落は、有効化した瞬間に出ました。 一方、期待していた read-secret-from-env-var は 0 件です。この 2 つを順に見ます。

read-secret-from-env-var が 0 件だった理由

base/backend-deployment.yamlenvFrom.secretRef: fanclub-db-secret で Secret を環境変数に流し込んでいます。「Secret を環境変数で読んでいる」チェックを有効にしたのだから、当然ここが出るはずです。出ませんでした。

切り分けのため、2 通りの書き方をした Deployment を 1 ファイルに並べ、このチェックだけを有効にして実行しました。 手元で確かめられるように全量を載せます。

実行コマンド(k8s-ops・developer):

$ mkdir -p /tmp/secrettest
$ cat > /tmp/secrettest/a.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: envfrom-style
spec:
  replicas: 1
  selector:
    matchLabels:
      app: envfrom-style
  template:
    metadata:
      labels:
        app: envfrom-style
    spec:
      containers:
      - name: app
        image: busybox:1.37
        envFrom:
        - secretRef:
            name: some-secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secretkeyref-style
spec:
  replicas: 1
  selector:
    matchLabels:
      app: secretkeyref-style
  template:
    metadata:
      labels:
        app: secretkeyref-style
    spec:
      containers:
      - name: app
        image: busybox:1.37
        env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: some-secret
              key: password
EOF
$ cat > /tmp/secrettest/cfg.yaml <<'EOF'
checks:
  doNotAutoAddDefaults: true
  include:
    - read-secret-from-env-var
EOF
$ kube-linter lint --config /tmp/secrettest/cfg.yaml /tmp/secrettest/a.yaml

実行結果:

KubeLinter 0.8.3

/tmp/secrettest/a.yaml: (object: <no namespace>/secretkeyref-style apps/v1, Kind=Deployment) environment variable "DB_PASSWORD" in container "app" uses SecretKeyRef (check: read-secret-from-env-var, remediation: If possible, rewrite application code to read secrets from mounted secret files, rather than from environment variables. Refer to https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets for details.)

Error: found 1 lint errors

doNotAutoAddDefaults: true を使うと、既定セットを足さずに「このチェックだけ」で走らせられます。 ツールの挙動を切り分けるときに使う書き方です。2 つの Deployment のうち、指摘されたのは secretkeyref-style だけでした。

書き方検出
envFrom: [{secretRef: {name: some-secret}}]検出されない
env: [{name: DB_PASSWORD, valueFrom: {secretKeyRef: ...}}]検出されるenvironment variable "DB_PASSWORD" in container "app" uses SecretKeyRef

read-secret-from-env-var が見ているのは env[].valueFrom.secretKeyRef だけです。envFrom でまとめて注入する書き方は素通りします。 環境変数に Secret が乗るという危険の度合いは同じなのに、書き方が違うだけで検出されません。

前の H2 と並べて読んでください。

Kubesec は seccompProfile を見ておらず、KubeLinter は envFrom を見ていません。 2 つの道具を当てても、両方が見ていない領域は残ります。

「1 つのツールに依存しない」は正しい方針ですが、「2 つ当てたから網羅した」ではありません。 道具を増やすことで減るのは見落としの確率であって、見落としそのものではない——だからクラスタ側の防御(Admission・ランタイム検知)を重ねます。

non-isolated-pod の偽陽性を読み解く

include に入れた non-isolated-pod(どの NetworkPolicy にも選択されていない Pod)は、backend と frontend の 2 件を指摘しました。

理由は base/ の中身にあります。base/ には NetworkPolicy のファイルがありません(Deployment 2 本 + services / configmap / db-sealedsecret / resourcequota / kustomization)。実際のクラスタには第3回・第11回で張った NetworkPolicy が 7 本、CiliumNetworkPolicy が 1 本 fanclub namespace に存在しますが、non-isolated-podbase/ の中しか見ません。

静的解析はディレクトリの中しか見ません。

リソースを複数のディレクトリやリポジトリに分けて管理していると、「そのディレクトリに無いもの」を根拠に誤検知します。

non-isolated-pod の指摘は本ラボでは偽陽性です。しかし、それを偽陽性だと判断するには、クラスタの実態(NetworkPolicy が別管理で存在すること)を知っている必要があります。 ツールの指摘を機械的に信じることも、機械的に無視することもできません。

これが exclude の判断根拠を書き残す練習として一番良い例です。 「うるさいから外した」と「別管理のリソースを見ていないから外した」は、書かなければ半年後に区別できません。

2 つの偽陽性の扱いを決めて、設定を確定させる

13 件のうち偽陽性は 2 種類・3 件でした。扱いを分けます。

偽陽性扱い理由
non-isolated-pod(2 件)include から外すNetworkPolicy は別のディレクトリで管理している。base/ を lint する限り必ず全 Deployment を指摘し、ノイズにしかならない
non-existent-service-account(1 件)exclude に入れる既定で有効なので include を消しても止まらない。暫定措置であり、本来は ServiceAccount を Git に入れて解消する

確定した .kube-linter.yaml です。

実行コマンド(k8s-ops・developer):

$ cat > ~/gitops/gitops-fanclub/.kube-linter.yaml <<'EOF'
checks:
  include:
    - no-liveness-probe
    - no-readiness-probe
    - read-secret-from-env-var
  exclude:
    # base/ に ServiceAccount のマニフェストが無いだけで、クラスタには存在する。
    # 静的解析はディレクトリの中しか見ないため偽陽性になる。
    # 本来の直し方は ServiceAccount を Git に入れることであり、除外は暫定である。
    - non-existent-service-account
EOF

read-secret-from-env-var は 0 件でしたが include に残します。 いまの書き方(envFrom)では出ませんが、誰かが env[].valueFrom.secretKeyRef の書き方を足したときに検出させるためです。ゲートは「いま出るか」ではなく「これから入るものを止められるか」で組みます。

no-anti-affinityexclude に入れたくなりますが、入れないでください。

no-anti-affinity既定で有効のチェックですが、本ラボの 13 件にも 8 件にも出ていません。 発火条件が Parameters: に書いてあります——minReplicas: 2base/ の Deployment はどちらも replicas: 1 なので、そもそも発火しません。

ここで 「本ラボは replicas 1 だから anti-affinity は不要」というコメント付きで exclude に入れると、2 つの問題が起きます。①何も除外していない(元から出ていない)②理由が本番に対して誤りです。overlays/prod/patch-replicas.yamlbackend 3 / frontend 2 レプリカに書き換えており、本番相当の値では anti-affinity が必要な状態だからです。

先ほどの Kustomize 解釈がここで効きます。 kube-linter lint overlays/prod/ を実行すれば、レプリカ 3 に解決された状態で検査されるので no-anti-affinity が出ます。「出ないから除外してよい」ではなく「どのディレクトリを検査したから出なかったのか」——これが exclude を書く前に確認することです。

設定ファイルのキーと優先順位を整理します。

キー意味
checks.addAllBuiltIntrue で既定無効のものも含めて全部有効化する
checks.doNotAutoAddDefaultstrue で既定セットを自動追加しない(include したものだけになる)
checks.include / checks.exclude個別の有効化 / 無効化
checks.ignorePaths除外パス
customChecksテンプレートベースの独自チェック

公式ドキュメントが明記している優先順位が 2 つあります。

  • When both doNotAutoAddDefaults and addAllBuiltIn are true, addAllBuiltIn takes precedence(両方 true なら addAllBuiltIn が優先)
  • The exclude option always takes precedenceexclude は常に優先される——include より強い)

2 つ目は CI ゲートを設計するうえで効いてきます。 includeexclude に同じチェック名を書いた場合、黙って無効になります。 「有効にしたつもりだった」という事故はここで起きます。

ステップ5:2 つのツールが同じ指摘に到達することを確認する

指摘Kubesec v2.14.2KubeLinter v0.8.3
frontend に readOnlyRootFilesystem が無いscoring.adviseReadOnlyRootFilesystemno-read-only-root-fs既定有効
Secret を env[].valueFrom.secretKeyRef で注入している検出されない(master にはある)read-secret-from-env-var既定無効・include で有効化
Secret を envFrom.secretRef で注入している検出されない検出されないenv[] しか見ていない)
seccompProfile が設定されている加点されない(旧アノテーションを見ている)(対象外)
probe が無い(対象外)no-liveness-probe / no-readiness-probe既定無効
サイドカーだけ readOnlyRootFilesystem が無い加点される(any 判定で丸められる)no-read-only-root-fsコンテナ名を名指し

この表が「1 つのツールに依存しない」ことの根拠と、その限界の両方を示します。 1 行目と最終行では、片方が見落としたものにもう片方が到達しています。同じ問題に別経路で到達できるかどうかが、道具立ての良し悪しを決めます。

読むべきは 2 行目です。 envFrom.secretRef両方の列が「検出されない」——本ラボの backend が使っているのがこの書き方です。2 つ当てても埋まらない領域は残ります。 だからこそ Admission(第8回)とランタイム検知(第15回)を重ねます。

試験ではこう問われる。

与えられたディレクトリのマニフェストを kube-linter で検査し、指摘を修正せよ」という粒度です。手が覚えているべきは kube-linter lint <path> と、代表チェック名——no-read-only-root-fs / run-as-non-root / latest-tag / privileged-container / unset-cpu-requirements / unset-memory-requirements / host-network / host-pid / docker-sock です。試験相当の作業(検査して指摘を直すだけ)なら 5〜7 分です。

docs.kubelinter.iogithub.com も試験中は参照できません。 kube-linter checks list試験環境にツールが入っていれば使えますが、入っていない前提で暗記してください。

やってみよう④:終了コードで CI ゲートを組み、指摘を GitOps で直す

所要時間の目安: 12 分(手を動かす時間。これとは別に ArgoCD の収束待ちが約 10 分かかりますが、待っている間は記事の続きを読んでいて構いません)。スクリプトを書き、Git に push し、同期を待ちます。

ここまでで 2 つのツールを手で動かしました。手で動かす道具は、動かし忘れます。 CI に組み込むには、人間が読む出力ではなく機械が読む終了コードが要ります。

ステップ1:終了コードを確かめる

実行コマンド(k8s-ops・developer):

$ kubesec scan /tmp/bad-pod.yaml > /dev/null; echo $?
$ kubesec scan ~/gitops/gitops-fanclub/base/backend-deployment.yaml > /dev/null; echo $?
$ kubesec scan ~/gitops/gitops-fanclub/base/frontend-deployment.yaml > /dev/null; echo $?
$ kube-linter lint ~/gitops/gitops-fanclub/base/ > /dev/null 2>&1; echo $?

実行結果:

2
0
0
1

2 行目と 3 行目に注目してください。

backend は 13 点、frontend は 7 点。どちらも終了コードは 0 です。 kubesec が非ゼロを返すのは 総評が Failed——つまり score がマイナスになったときだけでした。

「点が低い」ことでは落ちません。 落ちるのは scoring.critical が積み上がって合計がマイナスになったときだけです。kubesec を CI ゲートに置いても、止められるのは「明らかに危険な設定」だけ——advise がいくつ並んでいてもゲートは緑のままです。

これは前の H2 の「基準値でマージ可にしない」と裏表です。 スコアで足切りしたいなら、終了コードではなく score の値を自分で取り出して比較するスクリプトを書くことになります。その場合、比較する数字の意味(any 判定で丸められている)を理解したうえで閾値を決めてください。

一次ソースで確認できている仕様は次のとおりです。

ツール仕様
Kubesec--exit-code フラグを持ち、既定値は 2 です。ヘルプの原文は Set the exit-code to use on failure(失敗時に使う終了コードを設定する)
KubeLinterREADME は「returns a non-zero exit code」とだけ書いており、具体値は明記されていません(本ラボの実測では 1)。--fail-on-invalid-resource を付けると「YAML がパースできない場合」も失敗させられます

「非ゼロを返す」と書いてあっても値は書いていない——これは珍しいことではありません。CI で if [ $? -eq 1 ] のように値を決め打ちで比較すると、ツールの更新で壊れます。 ゼロか非ゼロかだけで判定してください。

ステップ2:ゲートスクリプトを書く

本ラボの Gitea にはパイプライン機能がないため、シェルスクリプトで CI ゲートを模擬します。 中身は CI サービス上で書くものと変わりません。

~/gitops/gitops-fanclub/scan-gate.sh

#!/bin/bash
# 静的解析ゲート。1 つでも失敗があれば非ゼロで終了する。
# set -e は使わない(理由は本文)。
set -uo pipefail
shopt -s nullglob

BASE_DIR="$(dirname "$0")/base"
CONFIG="$(dirname "$0")/.kube-linter.yaml"
FAILED=0

echo "=== kubesec ==="
for f in "${BASE_DIR}"/*-deployment.yaml; do
  if ! kubesec scan "$f"; then
    echo "KUBESEC FAILED: $f"
    FAILED=1
  fi
done

echo "=== kube-linter ==="
if ! kube-linter lint --config "${CONFIG}" "${BASE_DIR}"; then
  echo "KUBELINTER FAILED"
  FAILED=1
fi

exit "${FAILED}"

実行コマンド(k8s-ops・developer):

$ chmod +x ~/gitops/gitops-fanclub/scan-gate.sh
$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"

実行結果(末尾):

Error: found 9 lint errors
KUBELINTER FAILED
gate exit=1

ゲートは赤(非ゼロ)です。落としたのは KubeLinter だけ——kubesec は backend / frontend とも Passed で終了コード 0 だったため、KUBESEC FAILED は 1 行も出ていません。9 件の指摘があるマニフェストに対して、片方の道具は「合格」と言っています。

件数が 9 になった理由を確認してください。 ここで走っているのはステップ 4 で確定させた .kube-linter.yamlinclude 3 件 + exclude 1 件)であって、切り分けのために 5 件を include していた途中版ではありません。途中版の 13 件とは前提が違います。

内訳件数
既定チェックセットでの検出8
exclude: non-existent-service-account で消えるもの-1
include: no-liveness-probe / no-readiness-probe で増えるもの+2
include: read-secret-from-env-var で増えるもの(envFrom は見ない)+0
合計9

チェック別に数え直すと no-read-only-root-fs 3 件・unset-memory-requirements 2 件・unset-cpu-requirements 2 件・no-readiness-probe 1 件・no-liveness-probe 1 件です。設定を変えれば件数は変わります。 ゲートの数字を読むときは、どの設定ファイルで走らせたのかを先に確認してください。

shopt -s nullglob を付ける理由。

for f in "${BASE_DIR}"/*-deployment.yaml は、マッチするファイルが 0 件のときにグロブ文字列そのものが $f に入ります。 すると存在しないファイルを kubesec scan に渡して失敗し、中身を 1 つも検査していないのにゲートが赤くなります。 nullglob を有効にすれば、マッチ 0 件でループ自体が回りません。

set -e を使わない理由。 set -e を有効にしたまま kubesec scan を裸で呼ぶと、最初の失敗でスクリプトが即終了し、残りのファイルを検査しません。 CI ゲートは「全部検査してからまとめて落とす」ほうが、開発者へのフィードバックが速くなります。

本スクリプトは全箇所を if ! cmd; then ... fi で囲んであるので set -e があっても止まりませんが、後からガードの無いコマンドを足したときにゲートが途中で終わってしまうのを防ぐため、最初から -e を外してあります。

本番ではこのまま真似しないでください。 kubesec scan "$f" を裸で呼んでいるので、JSON が全量そのまま CI のログに流れます。 学習中は中身が見えるほうがよいのですが、実務では kubesec scan "$f" | jq -r '.[0].score' のように必要な値だけを取り出して要約するのが普通です。読まれないログは、無いログと同じです。

第13回では Trivy の --exit-code 1--severity HIGH,CRITICAL同じ形のゲートをイメージに対して作ります。終了コードが CI の言語であるという点は共通です。

ステップ3:指摘を直す

frontend への変更は 3 つです。

変更根拠
spec.template.spec.automountServiceAccountToken: false を追加Kubesec AutomountServiceAccountToken / 第4回で扱った原則
containers[0].securityContext.readOnlyRootFilesystem: true を追加Kubesec ReadOnlyRootFilesystem / KubeLinter no-read-only-root-fs
livenessProbe / readinessProbe を追加KubeLinter no-liveness-probe / no-readiness-probe

readOnlyRootFilesystem: true を入れても emptyDir を足す必要はありません。 nginx が書き込む先の /var/cache/nginxnginx-cache)と /var/runnginx-run)は、第8回ですでに emptyDir としてマウント済みです。

変更後の ~/gitops/gitops-fanclub/base/frontend-deployment.yaml の全量です。

---
# Source: fanclub-api/templates/frontend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fanclub-frontend
  labels:
    app: fanclub-frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: fanclub-frontend
  template:
    metadata:
      labels:
        app: fanclub-frontend
    spec:
      # frontend は Kubernetes API を呼ばないのでトークンを配らない
      automountServiceAccountToken: false
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: frontend
        image: k8s-registry:5000/fanclub-frontend:1.0.0
        ports:
        - containerPort: 80
        securityContext:
          runAsNonRoot: true
          runAsUser: 101
          allowPrivilegeEscalation: false
          # 書き込み先は nginx-cache / nginx-run の emptyDir に限定される
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            memory: "32Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "200m"
        livenessProbe:
          httpGet:
            path: /
            port: 80
        readinessProbe:
          httpGet:
            path: /
            port: 80
        volumeMounts:
        - name: config
          mountPath: /usr/share/nginx/html/config.js
          subPath: config.js
        # 第3巻第11回: L7 NetworkPolicy 適用時の HTTP 426 を避けるため
        # イメージ同梱の default.conf を ConfigMap の内容で上書きする
        - name: config
          mountPath: /etc/nginx/conf.d/default.conf
          subPath: default.conf
        - name: nginx-cache
          mountPath: /var/cache/nginx
        - name: nginx-run
          mountPath: /var/run
      volumes:
      - name: config
        configMap:
          name: fanclub-frontend-config
      - name: nginx-cache
        emptyDir: {}
      - name: nginx-run
        emptyDir: {}

backend への変更は 3 つです。

変更根拠
image...fanclub-backend:0.3.2:0.4.0演習①で作ったイメージ
initContainer wait-for-db とサイドカー log-shipperresources を追加KubeLinter unset-cpu-requirements / unset-memory-requirements既定有効
同 2 つに readOnlyRootFilesystem: true を追加KubeLinter no-read-only-root-fs既定有効

変更後の ~/gitops/gitops-fanclub/base/backend-deployment.yaml の全量です。変更 3 点がどの行に当たるかを、YAML の上で確認してください——containers[0].image:0.4.0、init / サイドカー 2 つの readOnlyRootFilesystem: true、同 2 つの resources です(securityContext の残り 4 行は第8回で入れたものです)。

---
# Source: fanclub-api/templates/backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fanclub-backend
  labels:
    app: fanclub-backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: fanclub-backend
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: fanclub-backend
    spec:
      serviceAccountName: fanclub-backend
      automountServiceAccountToken: false
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      initContainers:
      - name: wait-for-db
        image: busybox:1.37
        command: ["sh","-c","until nslookup fanclub-db.fanclub.svc.cluster.local >/dev/null 2>&1; do echo waiting; sleep 2; done; echo resolved"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            memory: "16Mi"
            cpu: "10m"
          limits:
            memory: "32Mi"
            cpu: "50m"
      - name: log-shipper
        image: busybox:1.37
        restartPolicy: Always
        command: ["sh","-c","echo sidecar; tail -F /var/log/app/app.log 2>/dev/null || sleep infinity"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            memory: "16Mi"
            cpu: "10m"
          limits:
            memory: "32Mi"
            cpu: "50m"
        volumeMounts:
        - name: applog
          mountPath: /var/log/app
      containers:
      - name: backend
        image: k8s-registry:5000/fanclub-backend:0.4.0
        ports:
        - containerPort: 8080
        envFrom:
        - configMapRef:
            name: fanclub-config
        - secretRef:
            name: fanclub-db-secret
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        startupProbe:
          httpGet:
            path: /health/started
            port: 8080
          failureThreshold: 30
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health/live
            port: 8080
          periodSeconds: 10
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 8080
          periodSeconds: 10
          failureThreshold: 3
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "768Mi"
            cpu: "1000m"
        volumeMounts:
        - name: applog
          mountPath: /var/log/app
        - name: payara-tmp
          mountPath: /opt/payara/tmp
      volumes:
      - name: applog
        emptyDir: {}
      - name: payara-tmp
        emptyDir: {}

envFromsecretRef: fanclub-db-secret がここに見えています。 前の H2 で「KubeLinter の read-secret-from-env-var が 0 件だった」と書いたのは、この行が env[].valueFrom.secretKeyRef ではないためです。設定を直したあとでも、この書き方は両方のツールの盲点に落ちたままです。

startupProbefailureThreshold: 30 × periodSeconds: 10maxUnavailable: 0 も覚えておいてください。 このあとステップ 5 で ArgoCD の収束に約 10 分かかる理由が、この 2 箇所です。

サイドカー log-shipper にも readOnlyRootFilesystem: true を入れています。 tail -F が読むのは applog の emptyDir(/var/log/app)だけで、ルートファイルシステムには書きません。反映後も 2/2 Running のまま動きました(確認は演習④のステップ 6 で行います)。

ステップ4:直さないものを 1 つ残し、理由を書く

frontend の runAsUser: 101 は、Kubesec の RunAsUser ルールが加点する条件(10000 超)を満たしていません。 10001 に変えればスコアは上がります。それでも変えません。

理由は原則のほうにあります。

イメージが前提としている UID を、スコアを上げるために外から書き換えないでください。 nginx:1.27-alpine の nginx ユーザは UID 101 であり、イメージ内のファイルの所有者もそれに合わせて作られています。

いま動くかどうかはイメージの実装次第であり、次のベースイメージ更新で前提が変われば壊れます。 Kubesec の RunAsUser ルールの意図は「ホストのユーザ ID と衝突しない高い UID を使え」であって、「イメージの UID を書き換えろ」ではありません。意図に沿って直すなら、変えるのはマニフェストではなくイメージ側です。

スコアを上げることと安全にすることは同じではありません。 ここは第7回で「公式手順に setenforce 0 と書いてあっても従わない」と判断したのと同じ姿勢です。

「では実際に壊れるのか」も確認しました。 fanclub namespace に、runAsUser: 10001 以外は frontend と同条件の使い捨て Pod を作った結果です。

実行結果:

NAME       READY   STATUS    RESTARTS   AGE   IP             NODE
uid-test   1/1     Running   0          25s   10.244.4.167   k8s-wl-02

2026/07/29 15:44:57 [notice] 1#1: start worker processes
2026/07/29 15:44:57 [notice] 1#1: start worker process 21

本ラボでは起動しました。それでも変えません。

nginx:1.27-alpine は、書き込み先を emptyDir に逃がしてある本ラボの構成なら UID 10001 でも動きます。

「動いた」は「変えてよい」の根拠になりません。 いま動くのはイメージの現在の実装がたまたまそうなっているからで、次のベースイメージ更新で前提が変われば壊れます。 判断の根拠は実測ではなく原則の側にあります——スコアを上げるためにイメージの前提を書き換えない。

逆に言えば、「動かないから変えない」と書いてしまうと、動くと分かった瞬間に理由が消えます。 残す判断には、実測で覆らない理由を書いてください。

ステップ5:Git に push して ArgoCD に反映させる

手で kubectl apply しても戻されます。 ArgoCD の syncPolicy.automatedprune: true / selfHeal: true です。第9回第10回と同じく、Git へ push する経路でしか変更は通りません。

先に戻し方を確認してください。

反映後に https://fanclub.local/api/members が 200 を返さない場合は、git revert HEAD && git push origin main0.3.2 の状態へ戻します。

kubectl rollout undo では戻りません。 selfHeal: true の ArgoCD が Git の内容へ引き戻すためです。レジストリには 0.2.0 / 0.3.0 / 0.3.1 / 0.3.2 が在庫として残っているので、pull は必ず成功します。

GitOps 下ではロールバックも Git 操作です。 第9回第10回で扱った原則の反復になります。

.kube-linter.yamlscan-gate.sh もリポジトリに入れます。クラスタへ適用される心配はありません——ArgoCD が見ているのは overlays/prod 配下だけなので、リポジトリ直下に置いた検査設定とスクリプトは同期対象になりません。

実行コマンド(k8s-ops・developer):

$ cd ~/gitops/gitops-fanclub
$ git add base/backend-deployment.yaml base/frontend-deployment.yaml .kube-linter.yaml scan-gate.sh
$ git commit -m 'Minimize backend image (0.4.0) and fix static analysis findings'
$ export no_proxy="$no_proxy,k8s-registry"
$ git push origin main

実行結果:

[main 15e5022] Minimize backend image (0.4.0) and fix static analysis findings
 4 files changed, 64 insertions(+), 1 deletion(-)
 create mode 100644 .kube-linter.yaml
 create mode 100755 scan-gate.sh
remote: . Processing 1 references
remote: Processed 1 references in total
To http://k8s-registry:3000/developer/gitops-fanclub.git
   06d8c88..15e5022  main -> main

コミットのハッシュ(15e5022)は手元では別の値になります。 以降の出力に出てくるリビジョンも同様です。

export no_proxy="$no_proxy,k8s-registry" は省略できません。 付けないと Gitea 宛ての HTTP が Squid に送られ、whitelist に無いホストとして弾かれます。社内ホストへの通信をプロキシに出さない——第2巻から繰り返している設定です。

実行コマンド(k8s-ops・developer):

$ kubectl -n argocd get application fanclub-api-prod -w

実行結果(15 秒間隔で revision / sync / health を観測したもの。リビジョンは手元では別の値になります):

t+18s   rev=06d8c88 sync=Synced health=Healthy
t+64s   rev=06d8c88 sync=Synced health=Healthy
t+79s   rev=15e5022 sync=Synced health=Progressing
t+562s  rev=15e5022 sync=Synced health=Progressing
t+578s  rev=15e5022 sync=Synced health=Healthy
段階所要
push → ArgoCD が新リビジョンを取り込む約 79 秒
取り込み → Healthy へ収束約 499 秒
push → 完全収束約 578 秒(9 分 38 秒)

10 分近くかかります。内訳を分けて理解してください。

前半の約 80 秒は ArgoCD がリポジトリを見に行く周期で決まります。webhook を設定していない限り、push した瞬間ではなく次の巡回で検知されます

周期は ConfigMap argocd-cm で確認できます。

実行コマンド(k8s-ops・developer):

$ kubectl -n argocd get cm argocd-cm -o jsonpath='{.data}' | tr ',' '\n' | grep -i reconciliation

実行結果:

"timeout.hard.reconciliation":"0s"
"timeout.reconciliation":"120s"
"timeout.reconciliation.jitter":"60s"

巡回は 120 秒ごとで、そこに最大 60 秒のジッタが乗ります。 ジッタ(jitter)は、周期にランダムな上乗せをして実行時刻をばらけさせる仕組みです。これが無いと全 Application の巡回が同じ瞬間に走り、Git サーバと ArgoCD に負荷が集中します。つまり間隔は 120〜180 秒です。push がその周期のどこに落ちるかで待ち時間が変わるので、0 秒から 180 秒までのどこかで検知されると考えてください。本ラボの実測 79 秒は、たまたま周期の途中で push したためです。「毎回 80 秒」ではありません——同じ手順を素の環境から通し直したときは 85 秒でした。

新しいリビジョンを取り込んだ直後に、一瞬 OutOfSync が見えることがあります。 リビジョンを取り込んでから同期を実行するまでのわずかな間に出るもので、異常ではありません。次の観測ではもう Synced に戻っています。

後半の約 8 分はローリング更新そのものです。 backend は overlays/prod3 レプリカ、更新戦略は maxUnavailable: 0 なので 1 台ずつ入れ替わります。さらに各 Pod は startupProbefailureThreshold: 30 × periodSeconds: 10 = 最大 5 分の猶予)を通過するまで Ready になりません。「3 レプリカ × 起動待ち」がそのまま所要時間になります。

この待ち時間は画面の前で見ている必要がありません。 同期の完了待ちは記事の続きを読みながらで構いません。本番の GitOps でも同じで、「push したら数分から十数分かかる」ことを前提に手順書を書きます——待てない変更は、そもそも GitOps に乗せる形を考え直す対象です。

ステップ6:動いていることを確認して再スキャンする

実行コマンド(k8s-ops・developer):

$ kubectl -n fanclub get pods
$ kubectl -n fanclub get deploy fanclub-backend -o jsonpath='{.spec.template.spec.containers[0].image}'
$ curl -k -s --noproxy '*' -o /dev/null -w '%{http_code}\n' https://fanclub.local/api/members

実行結果:

NAME                               READY   STATUS    RESTARTS   AGE
fanclub-backend-8459b87d8-55cz6    2/2     Running   0          4m37s
fanclub-backend-8459b87d8-d76qx    2/2     Running   0          10m
fanclub-backend-8459b87d8-r4hs9    2/2     Running   0          7m18s
fanclub-db-0                       1/1     Running   0          112m
fanclub-frontend-697d6c8cf-5kk76   1/1     Running   0          10m
fanclub-frontend-697d6c8cf-75f5x   1/1     Running   0          10m
fanclub-logcollector-jxrf9         1/1     Running   5          2d2h
fanclub-logcollector-t7qbd         1/1     Running   5          2d2h

k8s-registry:5000/fanclub-backend:0.4.0
200

backend 3 レプリカが 2/2 Running サイドカー log-shipperreadOnlyRootFilesystem: true を入れても tail -F は動いています。frontend も readOnlyRootFilesystem: true のまま 1/1 Running——既存の emptyDir(nginx-cache / nginx-run)で足りており、追加は不要でした。

アプリの応答も確認します。

実行コマンド(k8s-ops・developer):

$ curl -k -s --noproxy '*' https://fanclub.local/api/members

実行結果:

[{"createdAt":"2026-07-25T14:28:03.572932","email":"hanako@example.com","id":1,"name":"鈴木花子","plan":"standard"},{"createdAt":"2026-07-25T14:28:03.572932","email":"ichiro@example.com","id":2,"name":"佐藤一郎","plan":"free"},{"createdAt":"2026-07-25T18:00:53.008964","email":"restore-test@example.com","id":11,"name":"復元テスト太郎","plan":"premium"}]

会員 3 名がそのまま返ってきました。 イメージのベースを Ubuntu から alpine に替え、100MB のレイヤを消し、非 root 実行と readOnlyRootFilesystem を全コンテナに広げても、アプリの振る舞いは変わっていません。

ResourceQuota の消費は増えるどころか減りました。

initContainer とサイドカーに resources を書き足したのだから、namespace の quota 消費は増えそうに見えます。実測は逆でした。

実行結果(kubectl -n fanclub describe resourcequota の該当行・修正前 → 修正後):

limits.cpu        5500m / 8    →    4150m / 8
requests.memory   1952Mi / 4Gi →    1808Mi / 4Gi
pods              8 / 20       →    8 / 20

理由は fanclub namespace に設定されている LimitRange です。resources を書かないコンテナには、LimitRange の既定値(request 50m / 64Mi・limit 500m / 512Mi)が自動で付きます。今回 init / サイドカーに書いたのは request 10m / 16Mi・limit 50m / 32Mi——どれも既定値より小さい値でした。

「未指定は 0 ではなく既定値」——これが LimitRange と本回の unset-cpu-requirements をつなぐ理解です。KubeLinter が has cpu request 0 と言うのはマニフェストに書いていないという意味であって、クラスタ上で 0 が割り当たるという意味ではありません。静的解析はファイルを見ており、クラスタの admission が何をするかは知りません。

実行コマンド(k8s-ops・developer):

$ kubesec scan ~/gitops/gitops-fanclub/base/frontend-deployment.yaml
$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"

実行結果(抜粋):

"message": "Passed with a score of 9 points",
"score": 9,

=== kube-linter ===
No lint errors found!
gate exit=0

ゲートが緑になりました。 修正前後を並べます。

対象修正前修正後
base/backend-deployment.yaml の score1313(変化なし)
base/frontend-deployment.yaml の score79
kube-linter lint の検出件数(確定版 .kube-linter.yaml 適用)90
scan-gate.sh の終了コード10

backend のスコアが 1 点も動いていません。

initContainer とサイドカーに readOnlyRootFilesystemresources を足し、KubeLinter の指摘を 6 件も潰したのに、点数は 13 のままです。

理由は前の H2 で見た any 判定です。メインコンテナが満たしていた時点で、その加点はすでに入っていました。 残りのコンテナを直しても、加点できる枠がもうありません。

この非対称が、2 つの道具の性質の違いをそのまま表しています。 KubeLinter は 9 → 0 と動き、Kubesec は 13 → 13 で動かない。「改善したのにスコアが上がらない」は、改善していないことの証拠ではありません。 スコアを進捗指標に使うと、この作業は「何もしていない」と記録されます。

本番ガードレール⑥: ゲートを通すためにチェックを exclude するのは、ゲートを外すのと同じです。

赤いゲートを緑にする方法は 2 つあります——指摘を直すか、チェックを消すかです。後者は 1 行の編集で済みます。

exclude を追加するときは、なぜ本ラボ / 本番で妥当なのかをコミットメッセージか設定ファイルのコメントに残してください。 本回の .kube-linter.yamlnon-existent-service-account の上に 3 行のコメントを書いたのはそのためです。「偽陽性である理由」だけでなく「本来の直し方」と「これが暫定であること」まで書いています。

理由の書かれていない exclude は、半年後には誰も外せなくなります。 「消していいのか分からないから残す」が積み上がると、ゲートは形だけのものになります。

試験ではこう問われる。

CI で HIGH 以上の指摘があればビルドを失敗させよ」という設問は第13回の Trivy 側で出やすいものです。本回で試験に直結するのは 「指摘されたマニフェストを直す」部分で、試験相当の作業だけなら 5〜8 分です。

試験環境には ArgoCD も Git もありません。 その場で YAML を編集して kubectl apply する形になります。GitOps 経由の反映は本書ラボ固有の作法であり、試験では不要です。

securityContext ゴールデンセットと暗記必須コマンド

本回で扱った 2 つの静的解析ツールと、第8回の PSS Restricted は、同じ securityContext を別々の言葉で要求しています。 それを 1 つの雛形にまとめます。

ゴールデンセット

「毎回これを書けば、Kubesec でも KubeLinter でも PSS Restricted でも落ちない」形です。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: golden-sample
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: golden-sample
  template:
    metadata:
      labels:
        app: golden-sample
    spec:
      serviceAccountName: golden-sample
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: app
        image: k8s-registry:5000/sample:1.0.0
        ports:
        - containerPort: 8080
        securityContext:
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            cpu: "50m"
            memory: "64Mi"
          limits:
            cpu: "200m"
            memory: "128Mi"
        livenessProbe:
          httpGet:
            path: /
            port: 8080
        readinessProbe:
          httpGet:
            path: /
            port: 8080
        volumeMounts:
        - name: tmp
          mountPath: /tmp
      volumes:
      - name: tmp
        emptyDir: {}

この雛形は kubectl apply しないでください。

ServiceAccount golden-sample もイメージ k8s-registry:5000/sample:1.0.0 も実在しません。読み物として、そして kubesec scan の練習台として使ってください。

securityContext のゴールデンセットの各行が Kubesec・KubeLinter・PSS Restricted の 3 つでどう呼ばれるかを対応づけた図。中央に YAML の各行を縦に並べ、左に Kubesec のルール名(RunAsNonRoot・RunAsUser・ReadOnlyRootFilesystem・CapDropAll・ServiceAccountName・AutomountServiceAccountToken・requests/limits 系)、右に KubeLinter のチェック名(run-as-non-root・no-read-only-root-fs・privilege-escalation-container・drop-net-raw-capability・unset-cpu-requirements・unset-memory-requirements・no-liveness-probe・no-readiness-probe)、さらに右に PSS Restricted の必須項目を置く。seccompProfile.type: RuntimeDefault の行だけ Kubesec 側がグレーアウトされ、加点の SeccompAny も減点の SeccompUnconfined も同じ旧アノテーションを見ているため seccomp については加点も減点も機能していないことを示す。PSS Restricted では同じ行が必須になっている
図3:securityContext ゴールデンセットの各行の 3 つの呼ばれ方

各行がどの道具のどの項目に対応するかを並べます。

記述KubesecKubeLinterPSS Restricted(第8回)
runAsNonRoot: true加点run-as-non-root必須
runAsUser: 10001加点(10000 超)
readOnlyRootFilesystem: true加点no-read-only-root-fs
allowPrivilegeEscalation: false減点回避privilege-escalation-container必須
capabilities.drop: ["ALL"]加点(2 ルール)drop-net-raw-capability必須
seccompProfile.type: RuntimeDefault0(v2.14.2 は採点しない)必須
serviceAccountName加点(3 点)default-service-account(既定無効)
automountServiceAccountToken: false加点
requests / limits加点(4 ルール)unset-cpu-requirements / unset-memory-requirements
probe 2 種no-liveness-probe / no-readiness-probe(既定無効)
タグを明示(latest 禁止)latest-tag

この 1 表が本回の要約になります。 3 つの防御が同じ securityContext を別々の言葉で要求しており、どれか 1 つを満たせば済むわけでも、3 つが完全に重なるわけでもありません。 seccompProfile の行を見れば、Kubesec だけを基準にした運用の穴が分かります。

試験中に参照できるドキュメント

本回で扱ったツールのドキュメントは、CKS 試験中に 1 つも参照できません。 参照できるのは 8 件だけで、うち 3 件はサブパス限定です。

参照先可否
kubernetes.io/docs / kubernetes.io/blog
falco.org/docs / etcd.io/docs
kubernetes-sigs.github.io/bom/cli-reference/ / kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/ / docs.cilium.io/en/stable可(サブパス限定・この 3 件)
istio.io/latest/docs/
kubesec.io / v2.kubesec.io不可
docs.kubelinter.io不可
github.com(両ツールのリポジトリ)不可
docs.docker.com / distroless の GitHub不可
helm.sh/docs不可(CKAD / CKA では可・CKS では不可

逃げ道が 1 つあります。 kubectl explain は試験環境で使えます。

$ kubectl explain deployment.spec.template.spec.containers.securityContext --recursive
$ kubectl explain pod.spec.securityContext --recursive

「ドキュメントが引けないから securityContext を丸暗記する」のではなく、「kubectl explain を引く手を覚える」——これが実際に使える方針です。フィールド名のつづりを思い出せなくても、この 2 コマンドで確認できます。

なお試験の SSH ホストには k というエイリアス(alias k=kubectl)と bash 補完が設定済みです。k explain pod.spec.securityContext --recursive と打てます。本文では読みやすさのために kubectl と全綴りで書いていますが、試験ではエイリアスを使ってください——1 タスクあたり数十秒の差が、最後の見直し時間になります。

暗記必須コマンド

用途コマンド / 記法補足
マニフェスト採点kubesec scan <file>出力は JSON。scoring.criticaladvise の順に読む
CI ゲート(Kubesec)kubesec scan <file> --exit-code 2既定が 2score がマイナスのときだけ非ゼロ
ベストプラクティス検査kube-linter lint <path>ディレクトリ可・非ゼロ終了(実測 1)・kustomization.yaml があれば Kustomize として解決してから検査
チェック一覧kube-linter checks list既定有効 / 無効の別がある
チェックの取捨選択.kube-linter.yamlchecks.include / excludeexclude が常に優先
全チェック有効化checks.addAllBuiltIn: true既定無効のものも含む
レイヤの計測docker history <image>Docker 29 の docker imagesDISK USAGE / CONTENT SIZE
所有者を置いた瞬間に決めるCOPY --chown=10001:0 / ADD --chown=10001:0RUN chown -R を消す。 ユーザ名でなく数値
実行権限COPY --chmod=0755RUN chmod +x を消す
非 root 実行USER 10001Dockerfile 側
distrolessgcr.io/distroless/java25-debian13:nonrootシェル無し。:debug に busybox
securityContext の確認kubectl explain ... --recursive試験中に使える唯一の引き方

減点項目の暗記(Kubesec)

加点は 1〜3 点刻み、減点は -30 まであります。 覚える順序は減点側が先です。

-30 : privileged: true / capabilities.add: SYS_ADMIN
 -9 : hostNetwork / hostPID / hostIPC / docker.sock マウント / /proc マウント
 -7 : allowPrivilegeEscalation: true
 -3 : hostAliases
 -1 : SeccompUnconfined(旧アノテーション判定・現行記法では発火しない)

最下段の -1 は「暗記して使う」項目ではありません。 v2.14.2 の SeccompUnconfined は削除済みアノテーションを見ているため、securityContext.seccompProfile.type: Unconfined と書いても検出されません。覚えておくべきは「-1 という段がある」ことではなく、「seccomp については加点も減点も機能していない」ほうです。

まとめ

  • イメージの無駄は docker history で 1 命令ずつ計測できます。 本ラボの backend は multi-stage build でありながら、RUN chown -R100MB の重複レイヤを作っていました。0.2.00.3.0 の history を比べると、非 root 化と同時にこの層が生まれたことが読めます
  • chown -R / chmod -R がレイヤを二重化するのは、OverlayFS の copy_up がファイル単位で全体をコピーするためです。正しい解は COPY --chown / ADD --chown(ユーザ名でなく数値で指定します)。この 4 行の書き換えだけで CONTENT SIZE が 297MB → 207MB、alpine 化まで含めて 166MB(-44%)になりました
  • distroless(gcr.io/distroless/java25-debian13)は実在しますが、本ラボは採用しません。 シェルが無いと entrypoint.sh が動かず、kubectl exec も使えず、第15回・第18回の演習が成立しません。ハードニングが後で必要になる手段を奪う構図の 3 例目です
  • distroless の削減量は、このアプリではほぼゼロです。 展開後サイズで揃えると :nonroot229.8MB(DISK 303MB − CONTENT 73.2MB)、alpine ベース側が 227.7MB(198 + 21 + 8.71)でほぼ同じでした(:nonroot は動くタグなので上流更新で数 MB 動きます。2 日後は 230.8MB でした)。加えてアプリ側の payara-micro.jar だけで 98.7MBあり、ベースを替えてもここは減りません。CONTENT SIZE(圧縮済み)と docker history(展開後)を混ぜて比べない——これが判断を誤らせない前提です。また :debugConfig.User0(root)で、シェルを得る代わりに非 root 実行を失います
  • 静的解析(Kubesec / KubeLinter)・イメージスキャン(Trivy)・Admission(PSA / Gatekeeper)・ランタイム検知(Falco)は、それぞれ前の段をすり抜けたものを捕まえます
  • Kubesec はスコア、KubeLinter は指摘の一覧です。両方当てて、同じ問題に別経路で到達するかを確認します
  • Kubesec v2.14.2 は securityContext.seccompProfile を採点しません(削除済みのアノテーションを見ています)。加点側の SeccompAny も減点側の SeccompUnconfined(-1)も同じ旧アノテーションを見ており、seccomp については加点も減点も機能していません。 これは kubesec scan の出力の selector だけで確認できます——ソースを読む必要はありません
  • Kubesec の加点は any 判定です。 containers[] は initContainers も対象にし、どれか 1 つが満たしていれば加点され、1 つでも危険なら全体が減点されます。 「点が高い = 全コンテナが安全」ではありません
  • kube-linter の既定チェックセットは全部ではありません(v0.8.3 は全 63 件中 31 件が既定有効)。no-liveness-probe / no-readiness-probe / read-secret-from-env-var は既定で無効です
  • 2 つ当てても両方の盲点は残ります。 Kubesec は seccompProfile を見ず、KubeLinter の read-secret-from-env-varenvFrom.secretRef を見ませんenv[].valueFrom.secretKeyRef だけ)
  • 既定で有効なチェックが偽陽性を出すこともありますnon-existent-service-account)。exclude には理由と「本来の直し方」を書き残します。 出ていないチェックを exclude するのは、何も除外していないのと同じです(no-anti-affinityminReplicas: 2 が発火条件)
  • CI ゲートは終了コードで作ります。 Kubesec が非ゼロを返すのは score がマイナス(Failed)のときだけで、7 点でも 13 点でも 0 です。KubeLinter は指摘が 1 件でもあれば 1 を返します
  • 指摘を直してもスコアは上がらないことがあります。 本回の修正で KubeLinter は 9 → 0、frontend の score は 7 → 9backend の score は 13 のままでした。スコアを進捗指標に使うと、この作業は「何もしていない」と記録されます
  • ArgoCD の selfHeal: true があるので、指摘の修正は Git 経由でしか通りません。 反映は即座ではなく、検知に約 80 秒、3 レプリカが入れ替わり切るまで約 10 分かかりました
  • スコアを上げることと安全にすることは一致しません(frontend の runAsUser: 101 は、10001 でも起動することを確認したうえで変えません)

第19回で復習する項目。

本回で実運用に採らないと判断した distroless は、第19回の重点復習に送ります。暗記すべきは gcr.io/distroless/<lang><ver>-debian<ver> の命名・:nonroot / :debug(busybox)・ENTRYPOINT が java -jar 相当であること・scratch との違いの 4 点です。第6回の AppArmor、第10回の Kata と同じ枠です。

理解度チェック(○×形式・全 8 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. RUN chown -R は所有者のメタデータだけを変えるので、イメージのサイズはほとんど増えない
  2. COPY --chown=10001:0 にユーザ名ではなく数値を指定するのは、ベースイメージの /etc/passwd に存在しないユーザ名を解決できないためである
  3. distroless イメージの :debug タグには busybox のシェルが含まれる
  4. gcr.io/distroless/java25-debian13 の既定 ENTRYPOINT は java -jar 相当なので、JVM オプションは CMD の先頭に書けば渡せる
  5. Kubesec で privileged: true は -30 点であり、加点項目をすべて満たしても打ち消される規模である
  6. securityContext.seccompProfile.type: RuntimeDefault を書いておけば、Kubesec v2.14.2 は加点する
  7. kube-linter lint を既定設定で実行すれば、liveness probe / readiness probe が無い Deployment を検出できる
  8. Kubesec のスコアが高ければ、その Pod のすべてのコンテナ(initContainer / サイドカーを含む)が securityContext を満たしていると判断してよい
解答と解説

1=×/2=○/3=○/4=×/5=○/6=×/7=×/8=×

  • 問1: OverlayFS はファイル単位で copy_up するため、メタデータの変更でもファイル全体が新しいレイヤに複製されます。本ラボでは /opt/payara 配下の約 100MB が丸ごと複製されていました
  • 問2: --chown にユーザ名を書くと BuildKit が /etc/passwd / /etc/group を引きます。存在しなければビルドが失敗します。数値 ID はこの解決を必要としません
  • 問3: 公式 README が「:debug image set は busybox shell を提供する」と明記しています。逆に言えば :latest / :nonroot にはシェルがありません
  • 問4: ENTRYPOINT が java -jar に固定されているため、CMD に書いたものは jar のパスとアプリ引数として扱われます。JVM オプションは JAVA_TOOL_OPTIONS 環境変数で渡します
  • 問5: 加点は 1〜3 点刻みです。privileged: true の -30 は、加点をすべて集めても届かない規模です
  • 問6: v2.14.2 の SeccompAny は削除済みアノテーション container.seccomp.security.alpha.kubernetes.io/pod を見ています。master ブランチでは修正済みですがリリースされていません
  • 問7: no-liveness-probe / no-readiness-probeEnabled by default: No です。.kube-linter.yamlchecks.include で明示的に有効化する必要があります。既定有効の liveness-port は「probe が指すポート」を見るチェックなので、probe が無ければ何も言いません
  • 問8: containers[] セレクタは initContainers も対象にしますが、判定は「どれか 1 つが満たしていれば加点」です。本ラボの backend は、readOnlyRootFilesystemresources も持たない initContainer とサイドカーを抱えたまま 13 点でした。逆にサイドカーが 1 つ privileged: true なら全体が減点されます。 コンテナ単位の状態を知りたいなら、名指しで指摘する KubeLinter を併用します

次回予告

第13回「Trivy イメージスキャン + CI/CD パイプラインセキュリティ統合」では、本回で作った fanclub-backend:0.4.0 に Trivy を当てます。image(CVE)/ config(IaC 誤設定)/ fs / k8s の 4 種のスキャンを使い分け、--severity HIGH,CRITICAL--exit-code 1 でビルドを止めるゲートを作ります。

本回で「イメージを小さくすると CVE の母数が減る」と述べたことを、0.3.20.4.0 の実際の件数で確認します。 本回の scan-gate.sh に Trivy を足して、1 本のゲートにまとめるところまで進めます。

→ 詳しくは第13回 Trivy イメージスキャン + CI/CD パイプラインセキュリティ統合

前の記事
次の記事