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

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

ノードOS堅牢化とfirewalld【CKS第7回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第7回です。第6回ではコンテナが使えるシステムコール・権限・ラベルを絞りました。本回はその 1 段下、コンテナが載っているノード OS そのものを削ります。

本回の主題は 1 文で書けます。動いているものを減らし、権限を絞り、外に出る経路を狭める——ノード OS を 3 方向から削ります。

第6回は「カーネルが何を止めるか」でした。本回は「そもそも何を動かさないか」です。この 2 回で CKS の D3(System Hardening)が完結します。 実機作業は k8s-wl-02 の 1 台に限定し、Control Plane Node では読み取りしか行いません。

  • ラボ Kubernetes v1.36.3
  • AlmaLinux 10.2(kernel 6.12.0-211.22.1.el10_2.x86_64・SELinux Enforcing)
  • containerd v2.2.6
  • firewalld 2.4.0
  • openssh-server 9.9p1
  • CKS 試験環境 v1.35
  • 確認日 2026-07-27
目次
  1. 第7回のスコープ・今ここマップ
    1. 本回の起点
    2. 演習の進め方
  2. この回のゴール
  3. なぜホスト OS を削るのか(コンテナの「下」にある層)
  4. 稼働サービスの棚卸し — running と enabled の両輪
    1. iscsid を止めてはいけない理由
    2. running だけ見ると見落とす — enabled も見る
    3. running にも enabled にも出てこないのに、動きだすサービスがある
    4. disable と mask の違い
    5. サービスを止めたら、それを起こす timer も止める
  5. パッケージの棚卸し
    1. カーネルモジュールも見る
  6. 開放ポートと firewalld の突き合わせ — 無意味な穴を見つける
    1. 突き合わせて見つかるのは「余分な穴」だけではない — 足りない許可も見つかる
    2. listen していないのに開いているポート(Control Plane Node の実例)
    3. 使っていないのに開いているサービス
    4. ここまでのまとめ
  7. firewalld のゾーンモデル — 同じポートでも来る経路で結果が変わる
  8. firewalld だけがパケットフィルタではない
  9. プロキシは強制力を持たない — 外部アクセス最小化の出発点
    1. hostNetwork: true の Pod は「ノード自身」として扱われる
  10. OS ユーザ・sudo・SSH の現状(least-privilege identity management)
    1. ユーザの棚卸し
    2. sudo の棚卸し
    3. SSH の実効設定を読む
  11. やってみよう①:サービス・ポート・パッケージを棚卸しして削る
    1. ステップ1:3 つの側から現状を数える
    2. ステップ2:止めてよいものを判断する
    3. ステップ3:disable と mask を打つ
    4. ステップ3-b:サービスを起こす timer も止める
    5. ステップ4:使っていない firewalld の許可を外す
    6. ステップ5:不要パッケージを 1 つ削る
    7. ステップ6:壊れていないことを確かめる
    8. ステップ7:後片付け(元に戻す)
  12. やってみよう②:ノードが直接インターネットへ出られることを確かめる
    1. ステップ1:プロキシ設定の実体を確かめる
    2. ステップ2:プロキシを迂回して外へ出てみる
    3. ステップ3:時刻同期の出口も見る
    4. ステップ4:何が言えるかを整理する
  13. やってみよう③:firewalld の policy object で egress を絞る
    1. ステップ0:policy object とは何か
    2. ステップ1:現状を測る(before)
    3. ステップ2:policy を作る
    4. ステップ3:必要な宛先だけ許可して反映する
    5. ステップ4:効いたことを確かめる(after)
    6. ステップ5:閉じすぎて壊してみる
    7. ステップ6:元に戻し、クラスタが回復したことを確かめる
    8. ステップ7:本番ではどう設計するか
  14. やってみよう④:sshd を drop-in で締める
    1. ステップ1:実効値を控える(before)
    2. ステップ2:drop-in ファイルを作る
    3. ステップ3:構文チェックしてから反映する
    4. ステップ4:新しいセッションで入れることを確かめる
    5. ステップ5:後片付け
  15. やってみよう⑤:最小権限の sudo を新規ユーザで設計する
    1. ステップ1:ユーザを作る
    2. ステップ2:コマンドの実体パスを確かめる
    3. ステップ3:許可するコマンドを決める
    4. ステップ4:ファイルを作って検証してから配置する
    5. ステップ5:効いていることを確かめる
    6. ステップ6:後片付け
  16. Kubernetes を壊さない境界(必要通信の一覧)
    1. Control Plane Node の必要通信
    2. Workload Node の必要通信
  17. 暗記必須コマンド(ホスト OS 堅牢化)
  18. まとめ
  19. 理解度チェック(○×形式・全 9 問)
  20. 次回予告

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

本回も CKS ドメイン D3: System Hardening(配点 10%) です。D3 は 4 つのコンピテンシーからなり、第6回が担当したのは 4 番目の 1 つだけでした。残る 3 つを本回で埋めます。

公式コンピテンシー(D3 System Hardening・10%)担当対応セクション
Use Host OS footprint reduction to reduce attack surface本回(第7回)サービス・パッケージ・開放ポートの棚卸し / やってみよう①
Use least-privilege identity and access management本回(第7回)OS ユーザ・sudo・SSH の節 / やってみよう④・⑤
Minimize external access to the network本回(第7回)firewalld のゾーン・policy object の節 / やってみよう②・③
Appropriately use kernel hardening tools such as AppArmor, seccomp第6回

第6回と本回の 2 回で D3 が完結します。 配点は 10% ですが、ここで扱う判断(何を止めてよく、何を止めると壊れるか)は、後半の第15回以降でランタイム検知やインシデント対応を扱うときの土台にもなります。

先に 1 つ、本回で覆る前提を予告しておきます。

本書ラボは第2巻から alma-proxy(Squid・whitelist 方式)を経由して外部へ出る構成にしてきました。だからノードは whitelist に載ったドメインにしか出られない——と思っていました。実機で確かめると違いました。 どう違ったかは、外部アクセス最小化の節で扱います。

第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)

本回の起点

起点は第6回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働している前提で進めます。ラボの Kubernetes が v1.36.3 なのは、第5回のパッチアップグレード演習を通した後の状態だからです。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd 2.2.6
OSAlmaLinux 10.2(kernel 6.12)・9 VM すべて Enforcing
containerd の SELinux 連携Workload Node 2 台は enable_selinux = true(第6回で有効化済み)
fanclubhttps://fanclub.local/ が 200・Longhorn 4 ボリューム attached healthy・ArgoCD Synced Healthy
firewalld2.4.0・default zone publictrusted10.244.0.0/16 10.96.0.0/12 10.0.10.0/24
auditd稼働中だが auditctl -lNo rules(監査ルールは第16回で入れます)

演習の進め方

本回は「やってみよう」が 5 本あり、通しで実施すると約 72 分(20 / 5 / 25 / 10 / 12 分)かかります。やってみよう③(firewalld の policy object)は途中で止めないでください——作成から削除までが 1 セットで、途中で放置するとノードが外へ出られないまま残ります。①②④⑤ はそれぞれ独立しているので、日を分けても構いません。対象は k8s-wl-02 の 1 台だけです。Control Plane Node では実施しません。

この回のゴール

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

  • 稼働サービスを runningenabled の両方で棚卸しし、止めてよいもの・止めてはいけないものを根拠つきで仕分けられる
  • disablemask の違いを説明し、依存関係経由の起動まで止められる
  • ss の bind アドレスと firewalld の許可を突き合わせ、「listen していないのに開いている穴」を見つけられる
  • firewalld のゾーンモデル(同じポートでも来る経路で結果が変わる)と、iptables-nft との同居を説明できる
  • 環境変数のプロキシに強制力が無いことを実機で確かめ、firewalld の policy object で egress を強制できる
  • 閉じすぎるとクラスタのどこが落ちるか(まず DNS、次にノード間の通信)を、実際に壊して復旧した経験として説明できる。あわせて「IP 直指定のテストでは、壊れたことに気づけない」という確認手順の落とし穴を説明できる
  • 棚卸しは「余分な穴」だけでなく「足りない許可」も見つけることを、実例(構築時から塞がっていた通信)で説明できる

なぜホスト OS を削るのか(コンテナの「下」にある層)

第6回ではコンテナのを締めました。syscall を seccomp で絞り、capabilities を drop し、SELinux のラベルでファイルアクセスを拘束しました。しかし、コンテナが破られた後に攻撃者が次に触るのはノード OS そのものです。ノードには sshd が待ち受け、sudo があり、外へ出ていく経路があります。コンテナの中をどれだけ締めても、その下の層が緩ければ足場として使われます。

本回は 3 方向で削ります。最初に地図を渡します。

方向削るもの本回の担当
動かすものを減らす稼働サービス / インストール済みパッケージ / 待ち受けポート棚卸しの 3 節 + やってみよう①
権限を絞るOS ユーザ / sudo / SSH の入口least-privilege の節 + やってみよう④・⑤
出口を狭めるノードから外部への通信ゾーン・nft・プロキシの 3 節 + やってみよう②・③

3 方向に共通する原則は 1 つです。動いていないものは攻撃されません。 脆弱性が公表されても、そのサービスを止めていれば影響を受けません。パッチ適用より確実で、コストも低い。これが footprint reduction(攻撃面の縮小)という考え方の核です。

第2巻を通ってきた読者へ

第2巻でクラスタを構築したとき、firewall-cmd --add-port でポートを開け、trusted ゾーンにクラスタの CIDR を登録しました。あのときは「通すために開ける」側で firewalld を触っていました。本回は「通さないために閉じる」側から、同じコマンドを見ます。 同じ道具でも、見る向きが変わると気づくことが変わります。

第2回との接続点

kube-bench(CIS Benchmark)は Kubernetes コンポーネントの設定を見る道具で、ノード OS でどんなサービスが動いているかまでは見ません。本回で扱う領域は kube-bench の守備範囲の外側です。第6回で「ベンチマークが通ることと安全であることは別」を実測で確認しましたが、本回はその第 2 弾になります。

第6回で扱ったカーネル層(seccomp・capabilities・SELinux)の上に、第7回のホスト OS 層として「動かすものを減らす」「権限を絞る」「出口を狭める」の 3 方向が積み上がる関係を、代表コマンドとともに示した図
図1:カーネル層(第6回)の上に積み上がる、ホスト OS を削る 3 方向

稼働サービスの棚卸し — runningenabled の両輪

「動かすものを減らす」の本体です。まずノードで何が動いているかを数えます。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-units --type=service --state=running --no-pager --no-legend

LC_ALL=C を付ける理由

本書ラボは日本語ロケールなので、付けないと systemctlfirewall-cmd の一部が和文で出ます。掲載する出力と読者の手元を揃えるため、本回は一貫して LC_ALL=C を付けます(第2回で採った方針と同じです)。

本書ラボの k8s-wl-02 では 22 本が返りました。全量を並べても読み通せないので、判断の型を表にします。1 本ずつ「これは何のために動いているか」「止めると誰が困るか」を言葉にできる状態を目指してください。

分類サービス判断
ノードの本体kubelet / containerd止めない
管理経路sshd止めない。堅牢化は設定側で行う(やってみよう④)
本回の主役firewalld止めない
正しさの土台chronyd止めない。時刻がずれると証明書検証と監査ログの信頼性が壊れる
ネットワーク管理NetworkManager止めない(eth0 / eth1 を管理)
止めてはいけない罠iscsidLonghorn がボリュームを iSCSI で attach している(次の H3 で実測)
後の回で使うauditd止めない。現時点で auditctl -lNo rules第16回で使う
systemd 基盤systemd-journald / dbus-broker / systemd-logind / systemd-udevd / systemd-userdbd / polkit止めない
残す判断rsyslogjournald と二重だが、外部 SIEM への転送の受け皿。止める前に転送先を確認する
残す判断crondlogrotate 等が乗っている
残す判断irqbalance性能のための機能で、セキュリティ上の穴ではない
ラボ固有hypervkvpd / hypervvssdHyper-V 統合サービス。本書ラボ固有で、KVM や物理サーバには存在しない
コンソールgetty@tty1 / user@1000残す
止めてよいgssproxyNFS / Kerberos 用。本書ラボは NFS を使わない。この一覧で「止めてよい」と言えるのは、これ 1 本だけ

「止めてよいものが 1 本しかない」ことに違和感を持ってください

サーバに不要そうなもの——ファームウェア更新(fwupd)、リムーバブルメディア管理(udisks2)、Bluetooth——は、この 22 本の中に1 つも出てきません。入っていないからではありません。いまは動いていないだけです。この続きは次の H3 で見ます。

ここで SIEM という語を使いました。ログを集約して相関分析するセキュリティ監視基盤のことで、第16回と第17回で扱います。rsyslog は journald と役割が重なって見えますが、外部の SIEM へログを転送する経路がここに設定されていることが多いので、止める前に /etc/rsyslog.conf/etc/rsyslog.d/ の転送設定を確認してください。

hypervkvpd / hypervvssd は Hyper-V 上の仮想マシンにだけ現れます。棚卸しで最初にやるべきなのは、こうした「自環境固有のもの」を見分けることです。手元の一覧に本書と違うサービスが出ていたら、それは環境の違いであって間違いではありません。

iscsid を止めてはいけない理由

iscsid は名前だけ見ると Kubernetes と無関係に見えます。しかも systemd の依存関係には現れませんkubelet.serviceRequires= にも After= にも iscsid は書かれていません。それでも止めるとボリュームが外れます。

根拠を実機で示します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C ss -tnp state established '( dport = :3260 )'

実行結果:

Recv-Q Send-Q Local Address:Port    Peer Address:PortProcess
0      0      192.168.1.129:38284 10.244.119.177:3260 users:(("iscsid",pid=1125,fd=9))
0      0      192.168.1.129:38266 10.244.119.177:3260 users:(("iscsid",pid=1125,fd=8))
0      0      192.168.1.129:38244 10.244.119.177:3260 users:(("iscsid",pid=1125,fd=7))
0      0      192.168.1.129:38298 10.244.119.177:3260 users:(("iscsid",pid=1125,fd=10))

ソースポート・PID・宛先 IP は実行のたびに変わります。本数もボリュームの割り当て状況で変わります。手元の値が本書と一致しなくても問題ありません。見るべきは宛先ポートと相手です。

10.244.119.177Pod ネットワーク上の Longhorn インスタンスマネージャで、こちらが iSCSI のターゲット(3260/tcp)です。ノードの iscsid がイニシエータとしてそこへ接続し、ノード上にブロックデバイス(/dev/longhorn/…)を作ります。それを kubelet が Pod へマウントします。 つまりデータの通り道がノードの iscsid を経由しているので、これを止めるとボリュームが外れます。Pod 側からは何も見えないのに、ノード側のデーモン 1 つで成立している——これが「systemd の依存関係に出てこない」の中身です。

本番ガードレール

「systemd の依存関係に出てこない = 止めてよい」ではありません。 ストレージ・ネットワーク・監視のエージェントは、systemd から見ると独立したサービスでも、上位のソフトウェアが実行時に依存していることがあります。止める前に ss -tnp で誰かが通信していないかを確認してください。本回の棚卸し対象のうち、「実際に止めると壊れる」ものはこれだけです。

running だけ見ると見落とす — enabled も見る

棚卸しの一覧は、いま動いているものだけでは足りません。もう一方の側から見ます。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-unit-files --type=service --state=enabled --no-pager --no-legend

本書ラボでは 30 本が返りました。この中に、さきほどの running の一覧には現れなかったものが混ざっています。

サービス状態意味
bluetooth.serviceenabled / inactiveBluetooth ハードウェアが無いので起動していないだけ。bluez-5.85-1.el10インストール済み
sssd.serviceenabled / inactiveドメイン参加していないので起動しない。sssd-* 6 パッケージがインストール済み
udisks2.serviceenabled / inactiveWantedBy=graphical.target だが、本ノードの既定ターゲットは multi-user.targetブート時には起動しない
kdump.serviceenabled / activeクラッシュダンプ。メモリを予約する。本番では判断が分かれる
mdmonitor / lvm2-monitorenabledストレージ監視
iscsi-onboot / iscsi-starterenabledLonghorn 関連

ここが本節の要点

enabled は「次の再起動で起きてくる」という意味です。--state=running だけを見ていると、bluetoothsssdudisks2 を見落とします。いま動いていなくても、ハードウェアを足したりドメインに参加した瞬間に起動するサービスが仕込まれたまま残ります。棚卸しは running と enabled の両方を見てください。

running にも enabled にも出てこないのに、動きだすサービスがある

もう一段あります。fwupd(ファームウェア更新デーモン)は、running の一覧にも出てきませんでした。では止める必要はないのかというと、そうではありません。ユニットの定義を読むと理由が分かります。

実行コマンド(k8s-wl-02 上・developer):

$ systemctl cat fwupd.service

実行結果(抜粋):

# /usr/lib/systemd/system/fwupd.service
[Unit]
Description=Firmware update daemon
Documentation=https://fwupd.org/
Wants=modprobe@sd_mod.service
After=modprobe@sd_mod.service dbus.service
Before=display-manager.service
ConditionVirtualization=!container

[Service]
Type=dbus
TimeoutSec=180
RuntimeDirectory=motd.d
RuntimeDirectoryPreserve=yes
BusName=org.freedesktop.fwupd
ExecStart=/usr/libexec/fwupd/fwupd

この下には [Service] の残りが続きますが、[Install] セクションは最後まで現れません。手元で確認するときは systemctl cat fwupd.service | grep -c '\[Install\]'0 を返すことで確かめられます。

Type=dbus は「このバス名を取れたら起動完了」という指定です

BusName=org.freedesktop.fwupd を取得した時点で、systemd はこのサービスが立ち上がったとみなします。そして /usr/share/dbus-1/system-services/ 側に「この名前が呼ばれたら、このユニットを起動する」という対応が登録されています。この 2 つが揃うことで「呼ばれたら起きる」(D-Bus activation)が成立します。 [Install] セクションを持たないので enabled の一覧にも出ません。udisks2 も同じ Type=dbus です。

つまり running にも enabled にも出てこないのに、必要になれば動きだすサービスが存在します。攻撃面という観点では、「いま動いているか」ではなく「起動しうるか」が問題です。動かしたまま封じるなら、確実な手段は mask です(ユニット側を封じるので、D-Bus からの起動要求も止まります)。さらに先の手段が、次の節で扱う「パッケージごと消す」です。

実際、本書ラボで最初に観察したときは fwupdudisks2 が running の一覧に出ていました。ノードを起動してしばらく使っていると、dnf などが D-Bus 越しに呼び出して起きてくるためです。同じノードでも、見るタイミングで一覧が変わります。 棚卸しの結果を「その瞬間のスナップショット」として扱い、インストールされているものの一覧(次の H2)と突き合わせるのはこのためです。

disablemask の違い

止め方にも 2 段階あります。ここを混同していると「止めたはずのサービスが動いている」という状況に出会います。

コマンド何をするか依存関係経由の起動
systemctl disable --now <svc>自動起動の設定を外し、いま動いていれば止める止まらない(他のユニットの Wants= / Requires= で起きてくる)
systemctl mask <svc>ユニットを /dev/null への symlink に置き換える止まる(起動そのものが不可能になる)

実機で打つと、出力に違いが出ます。まず disable です。

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl disable --now udisks2

実行結果:

Removed '/etc/systemd/system/graphical.target.wants/udisks2.service'.

次に mask です。

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl mask fwupd

実行結果:

Created symlink '/etc/systemd/system/fwupd.service' -> '/dev/null'.

上が disable で、wants の symlink を消しただけです。下が mask で、/dev/null を指す symlink を作っています。ユニットの実体が /dev/null に置き換わるので、systemd はそのユニットを起動できません。確実に止めるなら mask まで打ちます。

元に戻すとき

mask したユニットは systemctl unmask <svc> を打つまで、start しても拒否されます。実際に打つと Failed to start fwupd.service: Unit fwupd.service is masked. と出ます。復旧手順書に unmask を書いておかないと、障害対応中に「サービスが起動しない」原因を延々と探すことになります。

サービスを止めたら、それを起こす timer も止める

ここまでで <svc>.service を止める話をしてきました。しかし systemd では、サービスを起動するのはサービス自身とは限りません。timer・socket・path といった別のユニットが、必要になった瞬間にサービスを起こします。

対象のサービスに紐づくユニットをまとめて見ます。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-unit-files 'fwupd*' --no-pager

実行結果:

UNIT FILE             STATE   PRESET
fwupd-refresh.service static  -
fwupd.service         masked  disabled
fwupd-refresh.timer   enabled enabled

3 unit files listed.

fwupd.servicemasked になっています。ところが fwupd-refresh.timerenabled のまま残っています。この timer は fwupd-refresh.servicestatic)を定期的に起こし、その中身は /usr/bin/fwupdmgr refresh です。本体を封じても、周辺のユニットは動き続けます。

ノード全体で有効な timer も見ておきます。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-unit-files --type=timer --state=enabled --no-pager --no-legend

実行結果:

dnf-makecache.timer enabled enabled
fstrim.timer        enabled enabled
fwupd-refresh.timer enabled enabled
logrotate.timer     enabled enabled
raid-check.timer    enabled enabled

止め忘れると systemctl --failed に出ます

fwupd.service を mask したまま fwupd-refresh.timer を放置すると、timer が発火したときに fwupdmgr refresh が失敗し、fwupd-refresh.servicefailed として残ります。やってみよう① のステップ6 で systemctl --failed を打つのは、これを拾うためです。「止めたつもりが半分だけ止まっている」状態を、機械的に見つけられます。

パッケージの棚卸し

サービスを止めても、パッケージが入っている限りバイナリはディスク上に残ります。攻撃者が別の経路で実行できてしまうので、footprint reduction は最終的に「入っていない状態」を目指します。まず数えます。

実行コマンド(k8s-wl-02 上・developer):

$ rpm -qa | wc -l

実行結果:

461

461 本を 1 つずつ見るのは現実的ではありません。dnf には「どのパッケージからも依存されていないもの」を挙げる機能があるので、そこから当たりを付けます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo dnf repoquery --unneeded

メタデータのキャッシュ状態によっては、先頭に Last metadata expiration check: … の 1 行が付きます。以下はその行を除いた本体です。

実行結果:

binutils
binutils-gold
dracut-network
dracut-squash
erofs-utils
ethtool
gnupg2-smime
kdump-utils
libgusb
libsecret
makedumpfile
pcsc-lite
pcsc-lite-ccid
pcsc-lite-libs
pinentry
snappy

--unneeded を鵜呑みにしない

このリストには ethtool(NIC 診断)kdump-utils / makedumpfile(クラッシュ解析)dracut-network(initramfs のネットワーク起動) が含まれています。いずれも障害対応で効く道具です。--unneeded は「どのパッケージからも依存されていない」という意味であって、「不要」という意味ではありません。 消してよいかは、ツールの判定ではなく役割で決めてください。

では、役割で判断するとどうなるか。本書ラボの k8s-wl-02 で「サーバとして明らかに不要」と言えるものは次のとおりです。

パッケージ理由
bluezBluetooth。仮想マシンに Bluetooth デバイスは無い
iwlwifi-dvm-firmware / iwlwifi-mvm-firmwareIntel 無線 LAN のファームウェア
fwupd(+ fwupd-efi / fwupd-plugin-flashromファームウェア更新
udisks2リムーバブルメディア管理
nfs-utils要注意。本書ラボは NFS を使っていないが、Longhorn の RWX ボリュームと NFS バックアップ先はこれを前提にする
sssd-*(6 本)ドメイン参加していない

nfs-utilsiscsid と同じ罠です。

Longhorn の RWX(ReadWriteMany)ボリュームは share-manager が NFS で公開し、ノード側は NFS クライアントでマウントします。バックアップ先を NFS にする構成でも要ります。いま RWO しか使っていなくても、RWX を使い始めた瞬間にマウントできなくなります。 消す前に「将来 RWX を使う予定があるか」を確認してください。

一方 gssproxyNFS の Kerberos 認証専用なので、これを止めても通常のマウント(sec=sys)は動きます。同じ「NFS 関連」でも、片方は前提でもう片方は任意——ストレージ製品の前提は、パッケージ名からは読み取れません。

この 6 群は repoquery --unneeded の 16 本には入っていません。逆に、16 本のうち 3 群は残すべきものでした。2 つのリストは重ならない——これが「ツールの判定と役割の判断は別物」ということの実際の姿です。

削除対象から除外するもの

policycoreutils-python-utilssemanage コマンドを提供)は第6回で k8s-wl-02 に導入したものです。rpm -qa を眺めていると見慣れないパッケージに見えますが、SELinux のラベルを恒久設定するために必要なので消さないでください。実測では repoquery --unneeded の 16 本にも含まれていません(明示インストール扱いのため)。

本番では削除するより、そもそも入れないほうが安全です。AlmaLinux の Minimal Install から始め、必要なものだけ足す。稼働中のノードからパッケージを消すのは、依存関係で予期しないものまで外れるリスクがあります。まず dnf remove --setopt=tsflags=test(実際には消さない試行)で巻き込みを確認してから実行してください。本書ラボの dnf は 4.20.0 で、このオプションが受理されることを実機で確認しています。実際の手順は やってみよう① のステップ5 で扱います。

カーネルモジュールも見る

パッケージの下にもう 1 層あります。カーネルにロードされているモジュールです。使わないファイルシステムやプロトコルのモジュールは、それ自体が攻撃面になります。

実行コマンド(k8s-wl-02 上・developer):

$ lsmod | wc -l

実行結果:

89

89 行(ヘッダ 1 行を含む)です。lsmod は先頭に Module Size Used by というヘッダ行を出すので、ロード済みモジュールは 88 個になります。厳密に数えるなら lsmod | tail -n +2 | wc -l のようにヘッダを落としてください。

次に、CIS Benchmark が名指しするモジュールがロードされていないかを見ます。

実行コマンド(k8s-wl-02 上・developer):

$ lsmod | grep -E "cramfs|freevxfs|jffs2|hfs|squashfs|udf|usb_storage|dccp|sctp|rds|tipc|bluetooth|firewire"

実行結果は 1 行も返りません。CIS が名指しする上記 13 種は、1 つもロードされていません。Minimal Install から作ったノードだからです。

wc -l の落とし穴

lsmodss も先頭にヘッダ行が付きます。数を報告する場面(監査レポート・チケット)では 1 つずれます。 tail -n +2 を挟むか、ヘッダ込みであることを明記してください。本回はこれ以降も「N 行(ヘッダ 1 行を含む)」の形で書きます。

押さえておく点

CIS のホスト系ベンチマークは「使わないファイルシステム・プロトコルのモジュールをロードさせない」ことを求めます(/etc/modprobe.d/install <mod> /bin/true を書く方式です)。本書ラボは Minimal Install なので最初から入っていません。 汎用イメージから作ったノードでは状況が違うので、lsmod で確認してください。

開放ポートと firewalld の突き合わせ — 無意味な穴を見つける

ここからが本回の核心の 1 つ目です。開放ポートの棚卸しは、2 つの側から見て、突き合わせて初めて意味を持ちます。片側だけを見ても「安全かどうか」は分かりません。

コマンド分かること
プロセス側ss -ltnup誰が、どのアドレスで待ち受けているか
ファイアウォール側firewall-cmd --list-allどのポートを通してよいことにしているか

まずプロセス側です。生の出力を全量で載せます。手元で同じコマンドを打って、並べて読んでください。

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C ss -ltnup

実行結果:

Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
udp   UNCONN 0      0      192.168.1.129:7946       0.0.0.0:*    users:(("speaker",pid=3483,fd=9))
udp   UNCONN 0      0          127.0.0.1:323        0.0.0.0:*    users:(("chronyd",pid=979,fd=4))
udp   UNCONN 0      0              [::1]:323           [::]:*    users:(("chronyd",pid=979,fd=5))
tcp   LISTEN 0      4096   192.168.1.129:7946       0.0.0.0:*    users:(("speaker",pid=3483,fd=8))
tcp   LISTEN 0      4096       127.0.0.1:17472      0.0.0.0:*    users:(("speaker",pid=3483,fd=13))
tcp   LISTEN 0      4096       127.0.0.1:41927      0.0.0.0:*    users:(("containerd",pid=1079,fd=16))
tcp   LISTEN 0      4096       127.0.0.1:9099       0.0.0.0:*    users:(("calico-node",pid=4598,fd=4))
tcp   LISTEN 0      8            0.0.0.0:179        0.0.0.0:*    users:(("bird",pid=4817,fd=7))
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*    users:(("sshd",pid=1075,fd=7))
tcp   LISTEN 0      4096       127.0.0.1:10249      0.0.0.0:*    users:(("kube-proxy",pid=2582,fd=11))
tcp   LISTEN 0      4096       127.0.0.1:10248      0.0.0.0:*    users:(("kubelet",pid=2093,fd=15))
tcp   LISTEN 0      4096               *:10256            *:*    users:(("kube-proxy",pid=2582,fd=12))
tcp   LISTEN 0      4096               *:10250            *:*    users:(("kubelet",pid=2093,fd=18))
tcp   LISTEN 0      4096               *:9100             *:*    users:(("node_exporter",pid=2576,fd=4))
tcp   LISTEN 0      4096               *:9120             *:*    users:(("speaker",pid=3483,fd=14))
tcp   LISTEN 0      128             [::]:22            [::]:*    users:(("sshd",pid=1075,fd=8))

PID と行の並び順は実行のたびに変わります。containerd41927 のような 5 桁のポート番号も、サービスが起動するたびに変わります(一時的に割り当てられる番号なので、覚える意味はありません)。手元の値が本書と違っても問題ありません。16 個の待ち受けがあり、ヘッダ 1 行を含めて 17 行です。

先に bind アドレス欄の 3 つの形を押さえてください。

127.0.0.1 は自分自身からしか届かない0.0.0.0 は IPv4 の全アドレス* は IPv4 と IPv6 の両方です。この欄を読み飛ばすと、以降の突き合わせが成立しません。

次にファイアウォール側です。こちらも全量で載せます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --list-all

実行結果:

public (default, active)
  target: default
  ingress-priority: 0
  egress-priority: 0
  icmp-block-inversion: no
  interfaces: eth0 eth1
  sources:
  services: cockpit dhcpv6-client ssh
  ports: 10250/tcp 30000-32767/tcp
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

この 2 つを突き合わせると、次の表になります。「必要」「経路依存」「外から届かない」の 3 種類に分かれます。

ポートプロセスbind アドレスfirewalld public zone判定
22/tcpsshd0.0.0.0 / [::]ssh サービスで許可必要
179/tcpbird(Calico BGP)0.0.0.0許可なし経路依存(次節で解明)
7946/tcp+udpspeaker(MetalLB memberlist)192.168.1.129許可なし経路依存
9100/tcpnode_exporter*許可なし経路依存
9120/tcpspeaker(metrics)*許可なし経路依存
10250/tcpkubelet*10250/tcp で許可必要
10256/tcpkube-proxy(healthz)*許可なし経路依存
10248 / 10249 / 9099 / 17472 / 41927(末尾の番号は環境ごとに変わる)kubelet / kube-proxy / calico-node / speaker / containerd127.0.0.1 のみ外から届かない
323/udpchronyd127.0.0.1 / [::1]外から届かない

突き合わせて見つかるのは「余分な穴」だけではない — 足りない許可も見つかる

上の表で 7946/tcp+udp(MetalLB の speaker)を「経路依存」と書きました。この判定は保留です。プロセスは 192.168.1.129、つまり管理ネットワーク側の IP で待ち受けているのに、その経路の public ゾーンには許可がありません。BGP のように内部ネットワーク(eth1)へ逃げているわけでもありません。

ここは推測せずに、当のプロセスに聞きます。MetalLB の speaker は、memberlist という仕組みでノード同士が合意を取ります。 合意が取れているかどうかは、ログに出ます。

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

$ kubectl -n metallb-system logs -l app.kubernetes.io/component=speaker --tail=200 | grep 'partial join' | tail -1

実行結果(横に長いので、コードブロックの中で横スクロールします。読み取るのは expectedjoined の 2 つです):

{"caller":"speakerlist.go:281","error":"4 errors occurred:\n\t* Failed to join 192.168.1.126:7946: dial tcp 192.168.1.126:7946: connect: no route to host\n\t* Failed to join 192.168.1.125:7946: dial tcp 192.168.1.125:7946: connect: no route to host\n\t* Failed to join 192.168.1.128:7946: dial tcp 192.168.1.128:7946: connect: no route to host\n\t* Failed to join 192.168.1.127:7946: dial tcp 192.168.1.127:7946: connect: no route to host\n\n","expected":4,"joined":0,"level":"error","msg":"partial join","op":"memberDiscovery","ts":"2026-07-27T06:02:10Z"}

"expected":4,"joined":0 —— memberlist は 1 度も成立していません

4 ノードすべてに対して connect: no route to host、つまりfirewalld に弾かれています。これは本回の演習で壊したのではなく、クラスタを構築したときから、ずっとこの状態でした。7946 を public ゾーンに開けていなかったからです。

それでも LoadBalancer の Service には外部 IP が付いており、https://fanclub.local/ は 200 を返しています。memberlist は「どのノードが生きているか」を speaker 同士で速く知り合うための仕組みで、これが使えない場合でも、speaker は Kubernetes API 側の情報にフォールバックして「どのノードが広告するか」を決められます。動くけれど、ノードが落ちたときの切り替えが遅くなる——そういう壊れ方です。壊れているのに動いているので、今まで誰も気づきませんでした。

speaker の再起動回数を見ると、痕跡が残っています。

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

$ kubectl -n metallb-system get pod -o wide

実行結果(抜粋):

NAME                    READY   STATUS    RESTARTS       AGE   IP              NODE
metallb-speaker-2htkt   1/1     Running   39 (12m ago)   38h   192.168.1.126   k8s-cp-02
metallb-speaker-4dgzr   1/1     Running   20 (10m ago)   38h   192.168.1.129   k8s-wl-02
metallb-speaker-fhk2v   1/1     Running   39 (12m ago)   38h   192.168.1.125   k8s-cp-01
metallb-speaker-xmv6d   1/1     Running   19 (10m ago)   38h   192.168.1.128   k8s-wl-01
metallb-speaker-zhnjd   1/1     Running   38 (11m ago)   38h   192.168.1.127   k8s-cp-03

RESTARTS が 19〜39 回あります。STATUSRunningREADY1/1 なので、kubectl get pod をざっと眺めるだけでは異常に見えません。数字の列まで読む習慣がないと、この種の「静かな故障」は見つかりません。

棚卸しの本当の価値はここにあります

攻撃面最小化というと「減らす作業」に見えますが、ssfirewall-cmd を突き合わせると4 通りの読み方が出てきます。

listenfirewalld意味本書ラボの実例
あり許可あり必要な口10250(kubelet)
あり許可なし経路依存。別の経路で通っているか、本当に届いていないかを確かめる179(BGP・eth1 で通っている)
あり許可なし足りない許可。必要なのに塞がれている7946(MetalLB memberlist)
なし許可あり無意味な穴9090(cockpit・未インストール)

2 行目と 3 行目は表の上では同じ形です。区別できるのは「その通信が本当に必要か」を知っている人だけです。だから棚卸しは、コマンドを打つ作業ではなく設計を思い出す作業になります。

本回では直しません

7946 を開けるには 5 ノードすべてで次の 2 行を打つ必要があります。本回は k8s-wl-02 の 1 台に作業を限定し、Control Plane には変更を加えない方針なので、「見つけた」までに留めます。自分の環境で同じものを見つけたら、全ノードに適用してください。

# firewall-cmd --permanent --add-port=7946/tcp --add-port=7946/udp
# firewall-cmd --reload

# は root プロンプトです。sudo を付けて打っても同じです。適用すれば speaker のログから partial join が消え、joined4 になるはずですが、本書では 5 ノードすべてを触る必要があるため実施しておらず、確認もしていません。自分の環境で試すときは、適用後にログを確認してください。

ここで BGPbird という語が出ました。BGP は Calico がノード間で「どの Pod がどのノードにいるか」を伝え合うために使う経路交換プロトコルで、bird はその実装デーモンです。179/tcp は firewalld で許可していないのに、BGP セッションは張れています。 なぜ通るのかは次のセクションで解明します。

listen していないのに開いているポート(Control Plane Node の実例)

Control Plane Node 側も同じ 2 コマンドで見ます。ここは読み取りだけで、Control Plane Node には変更を加えません。

実行コマンド(k8s-cp-01 上・developer):

$ sudo LC_ALL=C firewall-cmd --list-all

実行結果:

public (default, active)
  target: default
  ingress-priority: 0
  egress-priority: 0
  icmp-block-inversion: no
  interfaces: eth0 eth1
  sources:
  services: cockpit dhcpv6-client ssh
  ports: 6443/tcp 10250/tcp 2379-2380/tcp 10257/tcp 10259/tcp
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

実行コマンド(k8s-cp-01 上・developer):

$ sudo LC_ALL=C ss -ltnup

実行結果:

Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
udp   UNCONN 0      0      192.168.1.125:7946       0.0.0.0:*    users:(("speaker",pid=3339,fd=9))
udp   UNCONN 0      0          127.0.0.1:323        0.0.0.0:*    users:(("chronyd",pid=921,fd=4))
udp   UNCONN 0      0              [::1]:323           [::]:*    users:(("chronyd",pid=921,fd=5))
tcp   LISTEN 0      8            0.0.0.0:179        0.0.0.0:*    users:(("bird",pid=4096,fd=7))
tcp   LISTEN 0      4096       127.0.0.1:17472      0.0.0.0:*    users:(("speaker",pid=3339,fd=13))
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*    users:(("sshd",pid=1000,fd=7))
tcp   LISTEN 0      4096   192.168.1.125:2379       0.0.0.0:*    users:(("etcd",pid=2096,fd=6))
tcp   LISTEN 0      4096   192.168.1.125:2380       0.0.0.0:*    users:(("etcd",pid=2096,fd=3))
tcp   LISTEN 0      4096       127.0.0.1:10257      0.0.0.0:*    users:(("kube-controller",pid=2237,fd=4))
tcp   LISTEN 0      4096       127.0.0.1:10259      0.0.0.0:*    users:(("kube-scheduler",pid=2330,fd=4))
tcp   LISTEN 0      4096       127.0.0.1:10248      0.0.0.0:*    users:(("kubelet",pid=1986,fd=23))
tcp   LISTEN 0      4096       127.0.0.1:10249      0.0.0.0:*    users:(("kube-proxy",pid=2584,fd=14))
tcp   LISTEN 0      4096   192.168.1.125:7946       0.0.0.0:*    users:(("speaker",pid=3339,fd=8))
tcp   LISTEN 0      4096       127.0.0.1:2379       0.0.0.0:*    users:(("etcd",pid=2096,fd=7))
tcp   LISTEN 0      4096       127.0.0.1:2381       0.0.0.0:*    users:(("etcd",pid=2096,fd=20))
tcp   LISTEN 0      4096       127.0.0.1:39579      0.0.0.0:*    users:(("containerd",pid=1008,fd=15))
tcp   LISTEN 0      4096       127.0.0.1:9099       0.0.0.0:*    users:(("calico-node",pid=3950,fd=4))
tcp   LISTEN 0      4096               *:10250            *:*    users:(("kubelet",pid=1986,fd=21))
tcp   LISTEN 0      128             [::]:22            [::]:*    users:(("sshd",pid=1000,fd=8))
tcp   LISTEN 0      4096               *:10256            *:*    users:(("kube-proxy",pid=2584,fd=12))
tcp   LISTEN 0      4096               *:6443             *:*    users:(("kube-apiserver",pid=2224,fd=4))
tcp   LISTEN 0      4096               *:9120             *:*    users:(("speaker",pid=3339,fd=14))
tcp   LISTEN 0      4096               *:9100             *:*    users:(("node_exporter",pid=2603,fd=4))

突き合わせると、2 つのポートは誰も待っていないことが分かります。

ポートプロセスbind アドレス所見
6443/tcpkube-apiserver*必要
2379 / 2380etcd127.0.0.1 192.168.1.125管理ネットワークに etcd のクライアントポートが出ている。攻撃面として最大級
10257/tcpkube-controller-manager127.0.0.1 のみfirewalld で開いているのに誰も待っていない
10259/tcpkube-scheduler127.0.0.1 のみ同上
2381etcd(metrics)127.0.0.1

なぜこの 2 つが localhost にしか bind していないのか。根拠は static Pod のマニフェストにあります。

実行コマンド(k8s-cp-01 上・developer):

$ sudo grep -n "bind-address" /etc/kubernetes/manifests/kube-controller-manager.yaml

実行結果:

16:    - --bind-address=127.0.0.1

実行コマンド(k8s-cp-01 上・developer):

$ sudo grep -n "bind-address" /etc/kubernetes/manifests/kube-scheduler.yaml

実行結果:

15:    - --bind-address=127.0.0.1

これが footprint reduction の実例です。

公式のポート一覧(https://kubernetes.io/docs/reference/networking/ports-and-protocols/)には、いまも 10257 / 10259 が Control Plane の inbound ポートとして載っています。第2巻でこの 2 つを開けたのは、公式ドキュメントのとおりにやった結果であって、古い手順の名残ではありません。

ところが kubeadm は static Pod に --bind-address=127.0.0.1 を書き込みます。コンポーネント側の --bind-address の既定値は 0.0.0.0 なので、これはディストリビューション(kubeadm)が公式の一般論より狭く作っているということです。結果、公式どおりに開けた 2 つのポートには誰も待っていません

「公式ドキュメントが間違っている」のではありません。 公式は「このポートを使う構成があり得る」と書いており、kubeadm は「うちは localhost に閉じる」と決めている。どちらが自分の環境に当てはまるかは、ss で bind アドレスを見て初めて分かります。 試験でも実務でも、ポート一覧を写すだけでは判断できません——これが本セクションの要点です。

見つけたら、どう塞ぐか。この 2 つは static Pod の引数を触る必要がなく、firewalld から外すだけで塞げます。参考として手順だけ示します。本回は Control Plane Node に変更を加えない方針なので実施しません。

実行コマンド(k8s-cp-01 上・root・本回では実施しません):

# firewall-cmd --permanent --remove-port=10257/tcp --remove-port=10259/tcp
# firewall-cmd --reload

# は root プロンプトです。developersudo を付けて打っても同じです。自分の環境では 2 行で減らせる攻撃面なので、覚えておいてください。

もう 1 つ、etcd の 2379 についても見ておきます。192.168.1.125(管理ネットワーク)でも待ち受けているのは、kubeadm が生成した引数の帰結です。実機で確認できます。

実行コマンド(k8s-cp-01 上・developer):

$ sudo grep -n "listen-client-urls\|listen-peer-urls" /etc/kubernetes/manifests/etcd.yaml

実行結果:

23:    - --listen-client-urls=https://127.0.0.1:2379,https://192.168.1.125:2379
25:    - --listen-peer-urls=https://192.168.1.125:2380

etcd の 2379 について(本番批評)

kubeadm は localhost と advertise アドレス(既定経路のインタフェース= eth0)の 2 つを書きます。本番では etcd のクライアントポートをクラスタ内部ネットワークに限定します。 etcd を奪われるとクラスタ内のあらゆるオブジェクト(Secret を含む)が読めるため、第9回で扱う保存時暗号化と合わせて多層で守る対象です。ただし static Pod の引数書き換えは本回の範囲外なので、ここでは「見つけた」までに留めます。

使っていないのに開いているサービス

k8s-wl-02 の firewall-cmd --list-all に戻ります。services: 行に cockpit がありました。cockpit は Web 管理コンソールで、9090/tcp を使います。本当に動いているのか確かめます。

実行コマンド(k8s-wl-02 上・developer):

$ rpm -q cockpit

実行結果:

package cockpit is not installed

実行コマンド(k8s-wl-02 上・developer):

$ systemctl is-enabled cockpit.socket

実行結果:

not-found

cockpit(9090/tcp)は firewalld で許可されているのに、パッケージ自体が入っていません。 AlmaLinux の既定ゾーン設定の名残です。dhcpv6-client(546/udp)も、静的 IP 運用の本書ラボでは不要です。この 2 つは削除できます——やってみよう① のステップ4 で外します。

trusted ゾーンの 10.0.10.0/24 は残骸ではありません。

クラスタ内部 NIC(eth1)のレンジで、現役です。削除するとクラスタが壊れます。「見慣れないから消す」ではなく、何のためにあるかを確かめてから消す——これが棚卸しの作法です。

ここまでのまとめ

ここまでで、動いているもの(サービス)・入っているもの(パッケージ)・待っているもの(ポート)の 3 つを数えました。次の 2 セクションでは、そのポートが実際に届くかどうかは firewalld のゾーンで決まること、そして firewalld だけがパケットを止めているわけではないことを確認します。

ここまでのコマンドはすべて読み取りなので、手元のノードでもそのまま打って構いません(何も変わりません)。実際に手を入れるのは やってみよう① からです。

縦軸にプロセスが listen しているか、横軸に firewalld で許可しているかを取った 4 象限のマトリクスで、listen なし かつ 許可あり の象限を無意味な穴として強調し、10257 / 10259 / cockpit 9090 を例示した図
図2:ssfirewall-cmd の突き合わせマトリクス(listen なし × 許可ありが「無意味な穴」)

firewalld のゾーンモデル — 同じポートでも来る経路で結果が変わる

核心の 2 つ目です。前節で、179/tcp(Calico の BGP)は public zone に許可が無いのに BGP セッションは張れていることが分かりました。「firewalld が効いていないのでは」と疑うのが自然ですが、実測すると逆です。

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C ss -tnp state established '( dport = :179 )'

実行結果:

Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
0      0        10.0.10.129:60811  10.0.10.127:179  users:(("bird",pid=4817,fd=10))
0      0        10.0.10.129:42125  10.0.10.126:179  users:(("bird",pid=4817,fd=9))
0      0        10.0.10.129:48229  10.0.10.128:179  users:(("bird",pid=4817,fd=11))
0      0        10.0.10.129:49389  10.0.10.125:179  users:(("bird",pid=4817,fd=8))

ソースポートと PID は起動のたびに変わります。見るべきはローカル側のアドレスです。

BGP は 10.0.10.x(eth1・クラスタ内部)で、他の 4 ノードすべてと張られています.125 / .126 / .127 は Control Plane Node、.128 は k8s-wl-01)。そしてそのレンジは trusted ゾーン(target: ACCEPT)に登録されています。ポートを開けたから通っているのではなく、ゾーンが ACCEPT だから通っているのです。

同じポートへ管理ネットワーク側から入ろうとすると、結果が変わります。

ここだけ「的」に別のノードを使います。

自分自身(k8s-wl-02)へ接続するとループバック経由になってしまい、firewalld のゾーン評価を通りません。そこで k8s-wl-02 から k8s-wl-01(192.168.1.128)へ接続します。両ノードの firewalld 設定は同じなので、結果は k8s-wl-02 の設定を確かめたことになります。

実行コマンド(k8s-wl-02 上・developer・的は k8s-wl-01 = 192.168.1.128):

$ timeout 5 bash -c 'cat < /dev/null > /dev/tcp/192.168.1.128/179'

実行結果:

bash: connect: No route to host
bash: line 1: /dev/tcp/192.168.1.128/179: No route to host

/dev/tcp/<host>/<port> は bash の組み込み機能

で、TCP 接続を試すだけなら nctelnet を入れずに済みます。試験ホストに nc があるとは限らないので、この書き方を覚えておくと潰しが利きます。

No route to host は経路が無いという意味ではありません。

firewalld の既定は reject with icmpx admin-prohibited で、ICMP の「管理上禁止」応答が返ってきたときにカーネルがこのエラーを返します。ルーティングの問題だと思って ip route を延々と眺めることのないよう、この対応を覚えておいてください。

なお、同じ拒否でもコマンドによって文言が変わります。あとの演習では curlFailed to connect to … Could not connect to server と言ってきますが、起きていることは同じで、メッセージの出し方が違うだけです。

同じノードの他のポートでも、同じ構図になります。いずれも k8s-wl-02 から k8s-wl-01 宛の実測です。

宛先結果理由
192.168.1.128:179(BGP)No route to hostpublic zone に許可なし
192.168.1.128:9100(node_exporter)No route to hostpublic zone に許可なし
192.168.1.128:10250(kubelet)接続できるpublic zone に 10250/tcp の許可あり
10.0.10.128:179(BGP)接続できる(上の ss で確立済みを確認)trusted ゾーン(ACCEPT)

9100 宛は 179 と同じ 2 行のエラーが返り、10250 宛は何も出力せずに終了します(成功したという意味です)。Unix の作法どおり、うまくいったときは黙っています。終了コードで判定したい場合は、コマンドの直後に echo $? を打ってください(成功なら 0、拒否なら 1 です)。

ここまでを整理すると、firewalld のゾーンモデルは次の 4 点に集約できます。

概念意味
ゾーン「どこから来たか」に応じた許可セット。インタフェースか送信元アドレスで振り分ける
public(既定)明示的に許可したものだけ通す
trustedtarget が ACCEPT。このゾーンに振り分けられた通信はすべて通る
振り分けの優先送信元アドレス(sources)による一致がインタフェースより優先される

試験でも実務でも効く読み方

「ポートが開いているか」ではなく「どのゾーンから来た通信として評価されるか」で判断します。firewall-cmd --list-all は既定ゾーンしか出さないので、firewall-cmd --list-all-zones で全ゾーンを見るか、--get-active-zones でどのインタフェースがどのゾーンかを確認してください。本書ラボのように trusted に広い CIDR が入っていると、そのレンジからは何でも通ります

firewalld だけがパケットフィルタではない

Kubernetes ノード特有の事情がもう 1 つあります。nft list ruleset を読もうとすると、firewalld 以外のテーブルが大量に出てきます。まずテーブルの一覧だけを見ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo nft list tables

実行結果(抜粋):

table inet firewalld
table ip filter
table ip nat
table ip mangle
table ip raw
table arp calico-arp

ip filter 以下は iptables-nft が管理しています。実際に覗くと、先頭に警告が出ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo nft list table ip filter

実行結果(先頭行):

# Warning: table ip filter is managed by iptables-nft, do not touch!

誰が何行書いているかを数えると、firewalld は少数派だと分かります。

実行コマンド(k8s-wl-02 上・developer):

$ sudo iptables-save | grep -c cali
$ sudo iptables-save | grep -c KUBE-

実行結果:

548
373
管理者ルール数用途
Calico548 行cali を含む行)Pod 間通信・NetworkPolicy の実装
kube-proxy373 行KUBE- を含む行)Service の負荷分散・NodePort
firewalldinet firewalld テーブルホスト自身への通信の制御

数はクラスタの規模(Service 数・NetworkPolicy 数・Pod 数)で変わります。 手元の値が本書と違って当然です。見てほしいのはで、firewalld が書いている数十行に対して、Kubernetes 側が 3 桁のルールを積んでいる、という比率です。

INPUT チェーンの入り口を見ると、この同居の形が読み取れます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo iptables -S INPUT

実行結果:

-P INPUT ACCEPT
-A INPUT -m comment --comment "cali:Cz_u1IQiXIMmKD4c" -j cali-INPUT
-A INPUT -m conntrack --ctstate NEW -m comment --comment "kubernetes load balancer firewall" -j KUBE-PROXY-FIREWALL
-A INPUT -m comment --comment "kubernetes health check service ports" -j KUBE-NODEPORTS
-A INPUT -m conntrack --ctstate NEW -m comment --comment "kubernetes externally-visible service portals" -j KUBE-EXTERNAL-SERVICES
-A INPUT -j KUBE-FIREWALL

cali: に続く 16 文字は Calico がルールを識別するために振るハッシュで、環境ごとに違います。手元の値が本書と一致しなくても問題ありません。

では、なぜ firewalld の許可が効いているのか。cali-INPUT の中身を見ると答えが出ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo iptables -S cali-INPUT

実行結果(先頭 3 行):

-N cali-INPUT
-A cali-INPUT -p 4 -m comment --comment "cali:PajejrV4aFdkZojI" -m comment --comment "Allow IPIP packets from Calico hosts" -m set --match-set cali40all-hosts-net src -m addrtype --dst-type LOCAL -j ACCEPT
-A cali-INPUT -p 4 -m comment --comment "cali:_wjq-Yrma8Ly1Svo" -m comment --comment "Drop IPIP packets from non-Calico hosts" -j DROP

-p 4 は「IP プロトコル番号 4」= IPIP トンネルです。IPIP は Calico がノード間のパケットを別の IP パケットで包んで運ぶトンネル方式で、本書ラボの IPPool は ipipMode: Always です。ポート番号ではなく IP プロトコル番号なので、firewalld でも --add-port ではなく --add-protocol で扱います。

ここで名前の罠があります。プロトコル名は /etc/protocols を見て決めます。

実行コマンド(k8s-wl-02 上・developer):

$ grep -nE "^(ipv4|ipip)" /etc/protocols

実行結果:

18:ipv4	4	IPv4		# IPv4 encapsulation
110:ipip	94	IPIP		# Yet Another IP encapsulation

--add-protocol=ipip は通りますが、開くのは別のプロトコルです。

Calico が使っているのは プロトコル番号 4/etc/protocols での名前は ipv4 です。ipip という名前も存在しますが、そちらは番号 94 の別方式です。

厄介なのは、sudo firewall-cmd --zone=public --add-protocol=ipipsuccess を返してしまうことです。エラーにならないので、開けたつもりで通っていない状態になります。正しくは --add-protocol=ipv4 で、これを打つと nftables 側に meta l4proto ipv4 accept が生成されます。

ちなみに、他のディストリビューションの資料でよく見る ipencap という名前は、AlmaLinux の /etc/protocols には無いので Error: INVALID_PROTOCOL: ipencap で弾かれます。プロトコル名は環境依存です。手が覚えている名前を打つ前に /etc/protocolsgrep してください。

cali-INPUT の残りは、workload(Pod)から host への通信の分岐と、Calico の host endpoint 用の判定です。本書ラボは host endpoint を設定していないので、通常の INPUT は素通りし、その後 inet firewalldfilter_INPUT で評価されます。

なぜ firewalld のほうが後なのか。nftables のチェーンには優先度(priority)があり、同じフックにぶら下がる複数のチェーンは数字の小さい順に評価されますnft list ruleset の該当行を見ると、それが読み取れます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo nft list chain ip filter INPUT | head -3

実行結果:

# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;

実行コマンド(k8s-wl-02 上・developer):

$ sudo nft list chain inet firewalld filter_INPUT | head -3

実行結果:

table inet firewalld {
	chain filter_INPUT {
		type filter hook input priority filter + 10; policy accept;

filter0 を意味する定数なので、iptables-nft 側の INPUT(priority 0)が先、firewalld の filter_INPUT(priority 10)が後に評価されます。

ここで勘違いしやすいのが「accept したら終わり」という思い込みです。

nftables では、ベースチェーン内の accept はそのチェーンの評価を抜けるだけで、パケットは次のチェーンへ進みます。フックそのものを終わらせるのは dropreject だけです。

つまり Calico や kube-proxy が ACCEPT したパケットも、そのあと firewalld で改めて評価されます。逆に、Calico が DROP したパケット(さきほどの「Calico ホスト以外からの IPIP」)は firewalld まで届きません。「Calico が通したのだから firewalld は関係ない」ではなく、両方を通り抜けないと届かない——これが同居の実際の姿です。

ここで押さえること

Kubernetes ノードのパケットフィルタは 3 者の共同管理です。 nft list ruleset の出力が 1000 行を超えて読めないのは正常で、自分が書いたルールがどのテーブルにあるかを分かって読むのが正解です。firewalld のルールは inet firewalld テーブル、Calico と kube-proxy のルールは ip filter / ip nat テーブルにあります。iptables-nft が管理するテーブルを nft で直接書き換えてはいけません(警告のとおりです)。

第3回との住み分け

NetworkPolicy は Pod 間の通信を制御し、実装は Calico が iptables-nft 側に書きます。firewalld は ノード自身への出入りを制御します。同じ「通信を止める」でも層が違うので、遮断したい対象で使い分けてください。Pod からの外部通信を止めたいなら NetworkPolicy の egress、ノード自身の外部通信を止めたいなら firewalld です。

プロキシは強制力を持たない — 外部アクセス最小化の出発点

冒頭で予告した前提の話です。本書ラボは、第2巻から alma-proxy(Squid・whitelist 方式) を通して外部へ出る構成にしてきました。企業の出口制御を再現するための設計です。「だからノードは whitelist に載ったドメインにしか出られない」——これは誤りでした。

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' https://github.com/

実行結果:

200

プロキシを迂回して、直接インターネットへ出られます。 理由を分解します。

何が設定されているか実体強制力
http_proxy / https_proxy/etc/profile.d/proxy.sh の環境変数無い。読むかどうかはプロセス次第
containerd のプロキシ設定systemd drop-in の Environment="HTTP_PROXY=..."無い。containerd が行儀よく従っているだけ
firewalld の egress設定されていない(既定は出ていく通信を制限しない)

これが攻撃者の視点です。

侵害されたプロセスは環境変数を読みませんcurl --noproxy '*' と同じことを、マルウェアは何もしなくても実現できます。443 番で外部の C2 サーバ(Command and Control の略で、攻撃者が乗っ取った端末に指示を出したりデータを受け取ったりする拠点)へ出て、コンテナイメージや Secret を持ち出せます。「whitelist プロキシがあるから外部アクセスは制御済み」は成立しません。 環境変数はお願いであって、強制ではありません。

時刻同期でも同じことが起きています。

実行コマンド(k8s-wl-02 上・developer):

$ grep -E "^(server|pool)" /etc/chrony.conf

実行結果:

server time.cloudflare.com iburst
server 192.168.1.121 iburst

time.cloudflare.comAlmaLinux の既定で、本書が足した内部 NTP(192.168.1.121)と両方が生きています。実際にどちらと同期しているかは chronyc -n sources で確認できます。事前観察では 162.159.200.123(Cloudflare の NTP)と実際に同期していることを確認しました(Reach 377)。出力の全量は やってみよう② のステップ3 で掲載します。

なぜ NTP がセキュリティの話になるのか

時刻をずらされると、証明書の有効期限判定・トークンの期限・監査ログのタイムスタンプがすべて信用できなくなります。外部の NTP を直接引いているということは、その経路を握られると時刻を操作されうるということです。本番では内部 NTP 1 系統に寄せ、外部への 123/udp を閉じます。

では、どうやって強制するか。答えは 2 つあります。

手段何を止められるか本回での扱い
firewalld の egress 制限ノード自身からの外向き通信(プロセスの意図に関係なく止まる)やってみよう③ で実施
NetworkPolicy の egressPod からの外向き通信第3回で実施済み

この 2 つは別の層であり、両方いります。Pod を NetworkPolicy で締めても、ノード上で直接動くプロセス(侵害された kubelet プラグイン、SSH で入った攻撃者のシェル)は NetworkPolicy の対象外です。

hostNetwork: true の Pod は「ノード自身」として扱われる

「firewalld はノード、NetworkPolicy は Pod」と分けたばかりですが、その境目にいる Pod があります。数えてみます。

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

$ kubectl get pods -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,HOSTNET:.spec.hostNetwork' --no-headers | grep true

本書ラボでは 32 個が該当しました。内訳は次のとおりです。

種別個数
calico-node5
metallb-speaker5
kube-prometheus-stack-prometheus-node-exporter5
kube-proxy5
static Pod(etcd / kube-apiserver / kube-controller-manager / kube-scheduler × Control Plane Node 3 台)12

fanclub の Pod は 1 つも含まれませんhostNetwork は未設定= false です)。この違いが、外向き通信の扱いをそのまま分けます。

Pod の種類ネットワーク名前空間外向き通信の扱い効く制御
hostNetwork: true(calico-node / speaker / node_exporter / kube-proxy / static Pod)ノードと同じノード自身の通信(OUTPUT)firewalld の egress policy
通常の Pod(fanclub-backend など)Pod 専用ノードを転送(forward)して出るNetworkPolicy の egress

だから ss -ltnupspeakerbirdnode_exporter が出てきました。

これらは hostNetwork: true で動いているので、ノードのポートを直接使い、その外向き通信もノード自身の通信として firewalld の管轄に入ります。Pod のマニフェストにしか書かれていない設定が、ノードのファイアウォールに影響する——「Pod だから NetworkPolicy の話だろう」と思っていると、やってみよう③ で通信が落ちたときに原因に辿り着けません。

覚え方は 1 つです。ss -ltnup に出てくるプロセスは、すべて firewalld の管轄

OS ユーザ・sudo・SSH の現状(least-privilege identity management)

3 方向のうち「権限を絞る」に移ります。まず現状把握です。ここで見たものが、やってみよう④・⑤ の前提になります。

ユーザの棚卸し

実行コマンド(k8s-wl-02 上・developer):

$ wc -l < /etc/passwd

実行結果:

24

24 行あります。全部を読む必要はありません。見るべきは「ログインシェルを持つのは誰か」です。

実行コマンド(k8s-wl-02 上・developer):

$ awk -F: '$7 !~ /(nologin|false)$/ {print $1, $3, $7}' /etc/passwd

返ってきたのは rootdeveloper のほか、sync / shutdown / halt の 3 つでした。後者 3 つのシェルは /bin/sync /sbin/shutdown /sbin/halt という特殊シェルで、ログインには使えません。つまり 実際にログインシェルを持つのは rootdeveloper の 2 つだけです。

もう 1 つ、パスワードでログインできるユーザも見ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo awk -F: '$2 ~ /^\$/ {print $1}' /etc/shadow

返ってきたのは developer の 1 行だけでした。root のパスワード欄は ! でロックされています。/etc/shadow の 2 列目は「$ + 方式 ID + $ + …」の形で、先頭がパスワードハッシュの方式を表します。developer$y$ = yescrypt でした。この awk は「2 列目が $ で始まる行」= ハッシュが設定されている行を拾っています。

良好な状態です。

サービス用アカウント(containerd が使う nobody など)はすべて nologin で、ログインできません。公式コンピテンシー Use least-privilege identity and access management に照らすと、まず押さえるべきは「シェルを持つユーザが誰か」「パスワードでログインできるユーザが誰か」の 2 点です。上の 2 つのコマンドで確認できます。

sudo の棚卸し

次に「誰が root になれるか」を見ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo ls -l /etc/sudoers.d/

実行結果:

total 4
-r--r-----. 1 root root 34 Jun 20 21:25 developer

developer という 34 バイトのファイル 1 つだけです。パーミッションは -r--r-----、オーナーは root:root です。中身を開きます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo cat /etc/sudoers.d/developer

実行結果:

developer ALL=(ALL) NOPASSWD: ALL

本体側も見ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo grep -E "^(Defaults|%)" /etc/sudoers

実行結果:

Defaults   !visiblepw
Defaults    always_set_home
Defaults    match_group_by_gid
Defaults    always_query_group_plugin
Defaults    env_reset
Defaults    env_keep =  "COLORS DISPLAY HOSTNAME HISTSIZE KDEDIR LS_COLORS"
Defaults    env_keep += "MAIL PS1 PS2 QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE"
Defaults    env_keep += "LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES"
Defaults    env_keep += "LC_MONETARY LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE"
Defaults    env_keep += "LC_TIME LC_ALL LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY"
Defaults    secure_path = /sbin:/bin:/usr/sbin:/usr/bin
%wheel	ALL=(ALL)	ALL

本体の /etc/sudoers には %wheel ALL=(ALL) ALLパスワードを要求する)があり、developerwheel グループにも所属しています。それを上書きしているのが /etc/sudoers.d/developer の 1 行です。最後から 2 行目の Defaults secure_path = /sbin:/bin:/usr/sbin:/usr/bin は、あとで効いてきます。

/etc/sudoers.d を 2 段で開く理由

このディレクトリは 0750 root:root なので、一般ユーザのシェルではグロブ(/etc/sudoers.d/*)が展開されません。sudo ls でファイル名を得てから sudo cat で開く、という 2 段構えにします。

本番批評

NOPASSWD: ALL は「この鍵を盗まれた時点で root を取られる」という設定です。SSH 鍵が漏れれば、パスワードによる二段目の確認なしに、いきなり全権限が使えます。本番では ① NOPASSWD を外してパスワードを要求する ② ALL ではなく必要なコマンドだけを列挙する、の 2 段階で絞ります。

ただし本書ラボではこの設定を外しません。 外すと、本シリーズの残り 12 回の全手順が sudo のパスワード入力待ちで止まります。やってみよう⑤ では新しいユーザを作り、そちらでコマンド単位の sudo を設計します。 developer の設定は「本番ならこう直す」という批評の対象に留めます。

Defaults secure_path にも触れておきます。sudo で実行されるコマンドの PATH は、呼び出し元のシェルではなく secure_path の値に置き換わります。だから /usr/local/bin に置いたバイナリは sudo から見えません。第2回で kube-bench を /usr/bin に置いたのはこのためです。sudo の下では環境が変わる——これは ~/root に展開される話とも同じ系統で、「手元で動いたのに sudo だと動かない」の定番の原因です。この性質は やってみよう⑤ でもう一度効いてきます。

SSH の実効設定を読む

/etc/ssh/sshd_config を読むのではなく、sshd -T で実効値を読みます。 drop-in や Include があると、ファイルを読むだけでは実際の値が分からないからです。

実行コマンド(k8s-wl-02 上・developer):

$ sudo sshd -T | sort

出力は 100 行以上あるので、堅牢化に効く項目だけを抜き出して表にします(openssh-server 9.9p1 の実測値です)。

項目実測値判定
passwordauthenticationno良好(第2巻で設定済み)
kbdinteractiveauthenticationno良好
permitemptypasswordsno良好
permitrootloginwithout-password締める対象(鍵さえあれば root で直接入れる)
gssapiauthenticationyes締める対象(Kerberos を使っていない)
x11forwardingyes締める対象(GUI が無い)
allowtcpforwardingyes締める対象(侵害時に踏み台化される経路になる)
maxauthtries6締める余地(3)
logingracetime120締める余地(30)
clientaliveinterval0締める余地(300)
usepamyes維持

ここで gssapiauthentication yesx11forwarding yes が出ています。ところが /etc/ssh/sshd_config を開いても、この 2 行は見つかりません。どこから来ているのでしょうか。 drop-in ディレクトリを見ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo ls -l /etc/ssh/sshd_config.d/

実行結果:

total 12
-rw-r--r--. 1 root root  58 Jun 20 22:24 00-disable-password.conf
-rw-------. 1 root root 412 May 20 09:00 40-redhat-crypto-policies.conf
-rw-------. 1 root root 307 May 20 09:00 50-redhat.conf

sudo が要ります

/etc/ssh/sshd_config.d/ は一般ユーザから読めません。sudo なしで ls すると Permission denied になります。エラーを「何も無い」と読み違えないでください——実際にはこの中に、いま見ている値の出所があります。

3 つあります。中身を確認します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo cat /etc/ssh/sshd_config.d/50-redhat.conf

実行結果:

SyslogFacility AUTHPRIV

ChallengeResponseAuthentication no

GSSAPIAuthentication yes
GSSAPICleanupCredentials no

UsePAM yes

X11Forwarding yes

# It is recommended to use pam_motd in /etc/pam.d/sshd instead of PrintMotd,
# as it is more configurable and versatile than the built-in version.
PrintMotd no

これが「ファイルではなく sshd -T を読め」の理由です

GSSAPIAuthentication yesX11Forwarding yes の出所は、本体の /etc/ssh/sshd_config ではなく ディストリビューションが置いた 50-redhat.conf でした。本体だけを読んで「そんな設定はしていない」と判断すると、実際には有効になっている機能を見落とします

残る 2 つも役割が分かれています。00-disable-password.conf は第2巻でパスワード認証を無効化したときに作ったもの(PasswordAuthentication noKbdInteractiveAuthentication no の 2 行)、40-redhat-crypto-policies.conf はシステム全体の暗号ポリシーを読み込む Include です。

やってみよう④ では、この 3 つに触らず新しい drop-in ファイルを 1 つ足します。本体もディストリビューションのファイルも書き換えずに済むので、切り戻しが 1 ファイルの削除で済みます。

AllowTcpForwarding がなぜ危ないのか

SSH のポートフォワードは、踏み台の内側へ穴を掘る機能です。ノードに侵入した攻撃者は、ssh -L で自分の手元から etcd(2379)や kubelet(10250)へ直接届く経路を作れます。これをピボット(侵入した 1 台を足場にして、そこからしか届かない別の資産へ移動すること)と呼びます。先ほどの表で「踏み台化される経路」と書いたのは、これのことです。 管理用の SSH に必要なのは「シェル」だけで、フォワーディングは通常いりません。

やってみよう①:サービス・ポート・パッケージを棚卸しして削る

本回の 5 つの演習には、所要時間と「試験相当の粒度はどこか」を併記しています。

LF は出題数・各問の配点・試験クラスタのノード構成を公表していません。本書が書く「試験ではここが 1 問(何分)」は試験時間 120 分から逆算した本書独自の目安であり、公式情報ではありません。数字そのものではなく、ラボで手を動かす範囲と試験で問われる範囲の差を読み取ってください。

所要 20 分(うち試験相当の粒度は ステップ3〜4 の 5 分)。

公式コンピテンシー Use Host OS footprint reduction to reduce attack surface に照らすと、ステップ3〜4 の形式(不要なサービスを止め、再起動後も起動しないようにする)の設問を想定できます。ステップ1〜2(棚卸し)とステップ6(回帰確認)は、本番で必要な前後の作法です。

対象は k8s-wl-02 の 1 台だけです。Control Plane Node では実施しません。

本文のコマンドは読みやすさのため kubectl と書いていますが、試験では alias k=kubectl が最初から設定されています(bash 補完も入っています)。本回はノード上の作業が中心で kubectl の比重は低いものの、クラスタ側の回帰確認では k get node と打てるほうが速いです。

ステップ1:3 つの側から現状を数える

変更を加える前に、起点の数値を控えます。ステップ6 で同じものを数えて比べます。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-units --type=service --state=running --no-legend | wc -l

実行結果:

22

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-unit-files --type=service --state=enabled --no-legend | wc -l

実行結果:

30

実行コマンド(k8s-wl-02 上・developer):

$ sudo ss -ltnup | wc -l

実行結果:

17

実行コマンド(k8s-wl-02 上・developer):

$ rpm -qa | wc -l

実行結果:

461

起点は running 22 本 / enabled 30 本 / ss -ltnup 17 行(ヘッダ 1 行を含むので待ち受けは 16 個)/ パッケージ 461 本です。この 4 つを控えておいてください。

running の数は、いつ数えたかで変わります

fwupdudisks2Type=dbus なので、ノードを使っているうちに D-Bus 越しに呼ばれて起きてきますdnf を叩いたあとなどが典型です)。起動直後は 22 本ですが、しばらく使ったあとに数えると 23 本や 24 本になることがあります。数が本書と違っても間違いではありません。 大事なのは「作業の前後で何が変わったか」なので、自分で数えた値を起点にしてください。

wc -l の数え方に注意

sslsmod先頭にヘッダ行が付きます。「17 行 = 17 個」ではありません。手元の数と本文の数が合わないときは、まずヘッダ行を数えていないかを疑ってください。厳密に数えるなら sudo ss -ltnup | tail -n +2 | wc -l です。

ステップ2:止めてよいものを判断する

棚卸しの表に照らして、次の 4 つを対象にします。

サービスいまの状態理由
gssproxyactiveNFS / Kerberos を使わない。4 つのうち、いま動いているのはこれだけ
fwupdinactive(Type=dbus仮想マシンにファームウェア更新は不要。呼ばれたら起きてくるので封じる
udisks2inactive(Type=dbus / enabled)リムーバブルメディアを挿さない。同上
bluetoothinactive(enabled)ハードウェアが無いだけで、設定は生きている

4 つのうち 3 つは、いま動いていません。 それでも止めるのは、「動いていない」と「動きえない」が別だからです。gssproxy だけが running の一覧に出ていた——この非対称は、ステップ6 で数を比べるときにもう一度出てきます。

止めないもの

kubelet / containerd / sshd / firewalld / chronyd / NetworkManager / iscsid / auditd。とくに iscsid を止めると Longhorn のボリュームが外れます

ステップ3:disablemask を打つ

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl disable --now fwupd udisks2 gssproxy bluetooth

実行結果:

Removed '/etc/systemd/system/dbus-org.bluez.service'.
Removed '/etc/systemd/system/bluetooth.target.wants/bluetooth.service'.
Removed '/etc/systemd/system/graphical.target.wants/udisks2.service'.

4 つ指定したのに、消えた symlink は 3 本です。 fwupd[Install] セクションを持たない(static)ので外す symlink がなく、gssproxy はもともと自動起動が無効だったので何も出ません。disable は「自動起動の symlink を外す」だけの操作だと分かります。

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl mask fwupd udisks2 gssproxy bluetooth

実行結果:

Created symlink '/etc/systemd/system/fwupd.service' -> '/dev/null'.
Created symlink '/etc/systemd/system/udisks2.service' -> '/dev/null'.
Created symlink '/etc/systemd/system/gssproxy.service' -> '/dev/null'.
Created symlink '/etc/systemd/system/bluetooth.service' -> '/dev/null'.

こちらは 4 本すべてに symlink ができました。disable だけでは、他のユニットの Wants= / Requires= 経由や D-Bus 越しに起動する余地が残ります。2 段で打つのがこの演習の型です。

ステップ3-b:サービスを起こす timer も止める

fwupd.service を封じても、それを呼ぶ側が残っています。関連ユニットを確認します。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-unit-files 'fwupd*' --no-pager

実行結果:

UNIT FILE             STATE   PRESET
fwupd-refresh.service static  -
fwupd.service         masked  disabled
fwupd-refresh.timer   enabled enabled

3 unit files listed.

fwupd.servicemasked になりましたが、fwupd-refresh.timerenabled のままです。これを止めます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl disable --now fwupd-refresh.timer

実行結果:

Removed '/etc/systemd/system/timers.target.wants/fwupd-refresh.timer'.

ここを飛ばすとステップ6 で failed が出ます

timer を残したまま放置すると、次の発火で fwupd-refresh.service が起動し、fwupdmgr refresh が失敗して systemctl --failedfwupd-refresh.service loaded failed failed と並びます。止めたつもりが半分しか止まっていない状態です。すでに failed になってしまった場合は、timer を止めたあとに sudo systemctl reset-failed で表示をクリアしてください。

ステップ4:使っていない firewalld の許可を外す

cockpit はパッケージ自体が入っておらず、dhcpv6-client は静的 IP 運用なので不要です。2 つとも外します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --remove-service=cockpit

実行結果:

success

firewall-cmd は成功すると success の 1 行だけを返します。 以降に出てくる firewall-cmd も同じなので、本書では結果の掲載を省きます。何も出ないのは異常だと思ってください。

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --remove-service=dhcpv6-client

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --reload

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --list-all

実行結果:

public (default, active)
  target: default
  ingress-priority: 0
  egress-priority: 0
  icmp-block-inversion: no
  interfaces: eth0 eth1
  sources:
  services: ssh
  ports: 10250/tcp 30000-32767/tcp
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

services: の行が cockpit dhcpv6-client ssh から ssh だけになりました。9090/tcp(cockpit)と 546/udp(dhcpv6-client)が閉じたことになります。

--permanent--reload の組み合わせ

firewalld の設定には runtime(いまの状態)permanent(設定ファイル) の 2 面があります。--permanent を付けると設定ファイルだけが変わり、--reload するまで実際の動作は変わりません。逆に --permanent を付けないと、再起動や --reload で消えます設問文に「恒久的に」「再起動後も」といった語があれば --permanent + --reload、無ければどちらでも通る形で答えます。これは firewalld に限らず、「いまの状態」と「次の起動時の状態」を分けて持つ道具に共通する読み方です。

runtime だけで試す安全策: --permanent を付けずに変更 → 壊れたら firewall-cmd --reload で設定ファイルの内容に戻る、という切り戻しが使えます。ただし後述の policy object にはこの手が使えません(やってみよう③ で扱います)。

ステップ5:不要パッケージを 1 つ削る

サービスを止めても、パッケージが入っている限りバイナリはディスク上に残ります。攻撃者が別の経路で実行できてしまうので、footprint reduction は最終的に「入っていない状態」を目指します。本演習では、確実に不要で依存も浅い bluez を対象にします。

まず、消さずに試します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo dnf remove --setopt=tsflags=test -y bluez

実行結果(末尾):

Freed space: 3.3 M
DNF will only download packages, install gpg keys, and check the transaction.
Running transaction check
Transaction check succeeded.
Running transaction test
Transaction test succeeded.
Complete!

tsflags=test を付けると、依存解決とトランザクションの検証だけを行い、実際には削除しません。 実行後も rpm -q bluez はインストール済みを返します。巻き込みが無いことを確認してから、本番の削除に進みます。

同じ確認は --assumeno でもできます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo dnf remove --assumeno bluez

実行結果(末尾):

Removing:
 bluez           x86_64           5.85-1.el10           @baseos           3.3 M

Transaction Summary
Remove  1 Package

Freed space: 3.3 M
Operation aborted.

対象は bluez 1 本・3.3 M だけで、他を巻き込みません。tsflags=test が「トランザクションを実際に試す」のに対し、--assumeno は「確認プロンプトに No と答える」動きです。どちらでもよいので、消す前に必ず一覧を見てください。

本番の削除に進みます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo dnf remove -y bluez

実行結果(末尾):

Running transaction
  Preparing        :                                                        1/1
  Running scriptlet: bluez-5.85-1.el10.x86_64                               1/1
Unit /etc/systemd/system/bluetooth.service is masked, ignoring.

  Erasing          : bluez-5.85-1.el10.x86_64                               1/1
  Running scriptlet: bluez-5.85-1.el10.x86_64                               1/1

Removed:
  bluez-5.85-1.el10.x86_64

Complete!

途中に Unit /etc/systemd/system/bluetooth.service is masked, ignoring. が混ざっています。パッケージのアンインストールスクリプトが bluetooth.service を止めようとして、すでに mask されているので何もしなかったという報告です。エラーではありません。

実行コマンド(k8s-wl-02 上・developer):

$ rpm -q bluez

実行結果:

package bluez is not installed

実行コマンド(k8s-wl-02 上・developer):

$ rpm -qa | wc -l

実行結果:

460

461 から 1 減って 460 になりました。バイナリがディスクから消えたので、もう「起動しうる」ことすらありません

本番ではもう一歩先へ

稼働中のノードから消すより、そもそも入っていないイメージからノードを作るほうが安全です。AlmaLinux の Minimal Install で始め、必要なものだけ足す。削除は「すでに動いているノードを後から締める」ときの手段だと考えてください。

削除候補に挙げてはいけないもの

policycoreutils-python-utilssemanage)は第6回で導入したものです。rpm -qa を眺めると見慣れないパッケージに見えますが、SELinux のラベルを恒久設定するために必要なので消さないでください。

ステップ6:壊れていないことを確かめる

まずノード側です。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl --failed

実行結果:

  UNIT LOAD ACTIVE SUB DESCRIPTION

0 loaded units listed.

実行コマンド(k8s-wl-02 上・developer):

$ systemctl is-active kubelet containerd

実行結果:

active
active

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-units --type=service --state=running --no-legend | wc -l

実行結果:

21

enabled の数も見ておきます。

実行コマンド(k8s-wl-02 上・developer):

$ LC_ALL=C systemctl list-unit-files --type=service --state=enabled --no-legend | wc -l

実行結果:

28

4 つ止めたのに、running は 1 本しか減っていません。

22 → 21 です。ステップ2 で見たとおり、4 つのうち動いていたのは gssproxy だけだったからです。enabled のほうは 30 → 28(bluetoothudisks2 が外れた)と 2 本減っています。

「止めた数」と「減った数」は一致しません。 それでも作業は無駄ではありません。fwupdudisks2mask したことでもう D-Bus から呼ばれても起きてきませんし、bluetooth は次の再起動でも起動しません。数字が動かない作業にも意味がある——これが「攻撃面」を「いま動いているもの」ではなく「動きうるもの」で測る、ということです。

クラスタ側からも確認します。

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

$ kubectl get node k8s-wl-02

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

$ kubectl get pod -A --field-selector spec.nodeName=k8s-wl-02 | grep -v Running

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

$ kubectl -n longhorn-system get volumes.longhorn.io

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

$ curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' https://fanclub.local/

実行結果(最後の curl):

200

ノードは Ready、Longhorn の 4 ボリュームは attached healthy、アプリは 200 を返しています。grep -v Running には Velero のバックアップ用ジョブ(Completed)が残りますが、これは正常です。Running 以外がすべて Completed なら問題ありません。

k8s-ops からは --noproxy '*' が必須です(付けないと Squid 経由になり 000 が返ります)。第5回で確定した作法です。

ステップ7:後片付け(元に戻す)

本書ではここまでの変更を残したまま次回へ進みます(ハードニングの成果なので、戻す理由がありません)。ただし戻す手順は必ず持っておいてください。「元に戻す」は、思っているより難しい——それがこのステップの主題です。

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl unmask fwupd udisks2 gssproxy bluetooth

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl enable --now udisks2
$ sudo systemctl start gssproxy

gssproxystart であって enable ではありません。

ステップ3 の disable --nowgssproxy の行が出力に現れなかったことを思い出してください。元から自動起動は無効(disabled)で、それでも動いていたのです。ここで enable を打つと、元より 1 段階「起動しやすい」状態になってしまいます

disable / enable(次の起動時にどうするか)と active / inactive(いま動いているか)は独立した 2 つの軸です。この節の最初に見たことが、後片付けでそのまま効いてきます。「戻す」とは、2 つの軸のどちらも元に合わせることです。

実行コマンド(k8s-wl-02 上・developer):

$ sudo dnf install -y bluez

fwupdenable しません。

fwupd.service[Install] セクションを持たないので、systemctl enable fwupd を打つと The unit files have no installation config (WantedBy=, RequiredBy=, ...) で始まる長い説明が返ってきます(エラーで止まるわけではなく、「このユニットは enable する対象ではない」という案内です)。D-Bus からの要求で起動するタイプのサービスなので、unmask すれば元の挙動に戻ります。

また、ステップ3-b で止めた fwupd-refresh.timer を戻すなら sudo systemctl enable --now fwupd-refresh.timer です。止めたものの一覧を作っておかないと、戻すときに必ず取りこぼします。

ここに disable / mask / unmask / enable の関係が凝縮されています。enable できるのは [Install] を持つユニットだけで、持たないユニットは mask / unmask でしか制御できません。systemctl cat <svc>[Install] の有無を確認してから手順書を書いてください。

bluetooth を完全に戻すなら sudo systemctl enable bluetooth です

(元は enabled / inactive でした)。本書では戻しません——ハードウェアが無く、戻す理由がないからです。ただし maskdisable をそのままにすると、後日ハードウェアを足したときに「なぜか起動しない」原因になります。 意図的にそうしたことを構成管理か手順書に残してください。

ステップ4 で外した firewalld の cockpit / dhcpv6-client戻しません(次回はこの状態から始めます)。戻すなら sudo firewall-cmd --permanent --add-service=cockpit --add-service=dhcpv6-client + sudo firewall-cmd --reload です。

やってみよう②:ノードが直接インターネットへ出られることを確かめる

所要 5 分(全体が「観察」であり、手を加える操作はありません)。

ただしこの観察がやってみよう③ の動機になるので飛ばさないでください。公式コンピテンシーの Minimize external access to the network が求めているのは対処のほうですが、何を対処すべきかを見つける力は実務で先に要ります。

対象は k8s-wl-02 です。

ステップ1:プロキシ設定の実体を確かめる

実行コマンド(k8s-wl-02 上・developer):

$ cat /etc/profile.d/proxy.sh

実行結果:

export http_proxy="http://192.168.1.121:3128"
export https_proxy="http://192.168.1.121:3128"
export no_proxy="localhost,127.0.0.1,192.168.1.0/24,10.0.10.0/24,10.96.0.0/12,10.244.0.0/16,.svc,.cluster.local,k8s-lb,k8s-cp-01,k8s-cp-02,k8s-cp-03,k8s-wl-01,k8s-wl-02,alma-proxy,k8s-registry"
export HTTP_PROXY="$http_proxy"
export HTTPS_PROXY="$https_proxy"
export NO_PROXY="$no_proxy"

全行が export、つまりシェルの環境変数を定義しているだけです。ログインシェルを経由しないプロセスには、そもそも届きません。

実行コマンド(k8s-wl-02 上・developer):

$ systemctl show containerd -p Environment

実行結果(横に長いので、コードブロックの中で横スクロールします):

Environment=HTTP_PROXY=http://192.168.1.121:3128 HTTPS_PROXY=http://192.168.1.121:3128 NO_PROXY=127.0.0.1,localhost,192.168.1.0/24,10.0.10.0/24,10.96.0.0/12,10.244.0.0/16,.svc,.cluster.local,k8s-lb,k8s-cp-01,k8s-cp-02,k8s-cp-03,k8s-wl-01,k8s-wl-02,k8s-registry

プロキシ設定が環境変数と systemd drop-in の 2 箇所にしか無いことを、目で確認してください。どちらも「読んだプロセスだけが従う」ものです。

ステップ2:プロキシを迂回して外へ出てみる

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' https://github.com/

実行結果:

200

--noproxy '*' は「すべてのホストでプロキシを使わない」という指定です。

つまりこの 1 行は、環境変数を無視するプロセスの挙動を再現しています。侵害されたプロセスは、これと同じことを何の細工もなく実行できます。

ステップ3:時刻同期の出口も見る

実行コマンド(k8s-wl-02 上・developer):

$ grep -E "^(server|pool)" /etc/chrony.conf

実行結果:

server time.cloudflare.com iburst
server 192.168.1.121 iburst

設定に書いてあるだけでは、実際にどちらと同期しているかは分かりません。実測します。

実行コマンド(k8s-wl-02 上・developer):

$ chronyc -n sources

実行結果:

MS Name/IP address         Stratum Poll Reach LastRx Last sample
===============================================================================
^- 162.159.200.123               3   6   377    32   +101us[ +101us] +/-   63ms
^* 192.168.1.121                 2   6   377   114    -38us[  -55us] +/- 9819us

162.159.200.123 は Cloudflare の NTP サーバです。Reach377 は 8 進数で 11111111、つまり直近 8 回の問い合わせがすべて成功しているという意味です。^* が付いている 192.168.1.121 が実際に採用されている同期元で、^- の Cloudflare は候補として保持されています。採用されていなくても、通信は毎回外へ出ています。

ステップ4:何が言えるかを整理する

経路制御されているか
dnf / curl(環境変数を読むもの)プロキシ経由。行儀よく従っているだけ
containerd のイメージ pullプロキシ経由。同上
環境変数を読まないプロセス制御なし。443 で直接出られる
NTP(123/udp)制御なし。Cloudflare へ直接出ている

この状態を「外部アクセスが最小化されている」とは呼べません。 強制するにはパケットの層で止めるしかない——それが次の演習です。

やってみよう③:firewalld の policy object で egress を絞る

本回の主演習です。前の演習で見た「環境変数のプロキシには強制力が無い」という状態を、パケットの層で強制に変えます。

所要 25 分(うち試験相当の粒度は ステップ2〜3 の 8 分)。

公式コンピテンシー Minimize external access to the network に照らすと、ステップ2〜3 の形式(このノードから、指定した宛先以外への外向き通信を拒否する)の設問を想定できます。ステップ4〜6(閉じすぎて壊す → 直す → 削除)は本番の設計作業そのもので、本回で最も学びの大きい部分です。

この演習は途中で止めないでください。

policy object は --permanent でしか作れず、runtime だけで試して --reload で捨てる、という安全策が使えません。必ずステップ6(削除)まで通してください。

とくに ステップ2 まで打った状態で firewall-cmd --reload を実行したり、ノードを再起動したりしないでください。 ステップ2 の時点では「すべての外向き通信を拒否・例外なし」という設定がファイルに保存されているだけで、まだ有効になっていません。ここで反映すると、kubelet が API Server(6443)に届かず、containerd がレジストリ(5000)に届かず、DNS(53)も NTP(123)も止まります。ノードは NotReady に落ち、Pod が退避されます。

いま張っている SSH セッションは切れません。 SSH の応答パケットはホスト自身が送り出すものなので、形の上ではこの policy の対象です。それでも切れないのは、firewalld が policy を評価する前に「すでに確立している通信(established / related)」を通しているからです。ステップ5 で kubelet と API Server の接続が維持され、kubectl get nodeReady のままなのも、まったく同じ理由です。ただし念のため、演習中に既存のセッションを閉じないでください。

復旧は 2 行です。演習を始める前に、この 2 行を手元に控えておいてください。

実行コマンド(k8s-wl-02 上・developer・復旧用):

$ sudo firewall-cmd --permanent --delete-policy=egress-guard
$ sudo firewall-cmd --reload

対象は k8s-wl-02 です。

ステップ0:policy object とは何か

firewalld 2.x の policy object は、ゾーンとゾーンの間(あるいはホストとゾーンの間)を流れる通信にポリシーを当てる仕組みです。ゾーンが「入ってくる通信」を扱うのに対し、policy は方向を持った通信を扱えます。

概念指定意味
--add-ingress-zone=HOST特別なゾーン HOSTこのホスト自身が発信元の通信
--add-egress-zone=ANY特別なゾーン ANY任意の宛先ゾーンへ向かう通信
--set-target=REJECT一致した通信を拒否する
--add-rich-rule=...accept例外的に許可する宛先

つまり HOST → ANY を REJECT にして、必要な宛先だけ accept で穴を開ける——これが egress 制限の型です。

作る前に、既定の policy を見ておきます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --get-policies

実行結果:

allow-host-ipv6 gateway-dmz-to-HOST gateway-lan-to-HOST gateway-lan-to-work gateway-lan-to-world gateway-world-to-HOST

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --info-policy=allow-host-ipv6

実行結果:

allow-host-ipv6 (active)
  disable: no
  priority: -15000
  target: CONTINUE
  ingress-zones: ANY
  egress-zones: HOST
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
	rule family="ipv6" icmp-type name="mld-listener-done" accept
	rule family="ipv6" icmp-type name="mld-listener-query" accept
	rule family="ipv6" icmp-type name="mld-listener-report" accept
	rule family="ipv6" icmp-type name="mld2-listener-report" accept
	rule family="ipv6" icmp-type name="neighbour-advertisement" accept
	rule family="ipv6" icmp-type name="neighbour-solicitation" accept
	rule family="ipv6" icmp-type name="redirect" accept
	rule family="ipv6" icmp-type name="router-advertisement" accept

これから作る policy と方向が逆であることに注目してください。allow-host-ipv6ingress-zones: ANY / egress-zones: HOST、つまり外からホストへ入ってくる通信が対象です。IPv6 の近隣探索など、止めるとネットワークが成立しなくなるものを通しています。priority: -15000 という大きな負の値で、ほかのどの policy より先に評価されるように置かれています。

本書ラボの既定 policy は 6 本で、allow-host-ipv6 のみ active、他は disabled です。既定で 6 本あるということは、policy object は特殊な機能ではなく firewalld の標準的な部品だということです。

なぜ direct rule ではないのか

古い記事では firewall-cmd --direct --add-rule で iptables のルールを直接ねじ込む方法が紹介されています。これは非推奨で、firewalld のゾーン評価順の外側に置かれるため、他の設定との相互作用が読めなくなります。firewalld 2.x では policy object が正道です。

egress 制御の before と after を対比した図。before ではノードからプロキシ経由・443 での直接接続・Cloudflare NTP の 3 本の経路が出ており、after では egress-guard policy がゾーンより手前で 2 本を拒否し、許可した宛先だけが通る。右下に閉じすぎると落ちるものとして 53/udp の DNS と 123/udp の NTP を明示している
図3:egress 制御の before / after と、閉じすぎたときに落ちる通信

ステップ1:現状を測る(before)

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' https://github.com/

実行結果:

200

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' -x http://192.168.1.121:3128 https://github.com/

実行結果:

200

プロキシ経由でも直接でも 200 です。 これを「直接は落とす・プロキシ経由は通す」に変えます。

ステップ2:policy を作る

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --new-policy=egress-guard

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-ingress-zone=HOST

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-egress-zone=ANY

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --set-target=REJECT

この時点では、まだ --reload していないので動作は変わりません。ここで --reload を打たないでください。 次のステップで例外を足してから反映します。

ステップ3:必要な宛先だけ許可して反映する

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-rich-rule='rule family=ipv4 destination address=192.168.1.0/24 accept'

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-rich-rule='rule family=ipv4 destination address=10.0.0.0/8 accept'

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --reload

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --info-policy=egress-guard

実行結果:

egress-guard (active)
  disable: no
  priority: -1
  target: REJECT
  ingress-zones: HOST
  egress-zones: ANY
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
	rule family="ipv4" destination address="10.0.0.0/8" accept
	rule family="ipv4" destination address="192.168.1.0/24" accept

rich rule は打った順ではなく firewalld が並べ替えた順で表示されます。順序が本書と違っても、評価結果は同じです(どちらも accept なので競合しません)。

192.168.1.0/24 は管理ネットワーク(プロキシ・レジストリ・LB・他ノード)、10.0.0.0/8 はクラスタ内部ネットワークと Pod / Service の CIDR をまとめて含みます。policy の priority が -1 でゾーンより小さいので、policy はゾーンより先に評価されます。

ステップ4:効いたことを確かめる(after)

5 つの通信を順番に試します。

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' https://github.com/

実行結果:

000

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' -x http://192.168.1.121:3128 https://github.com/

実行結果:

200

実行コマンド(k8s-wl-02 上・developer):

$ getent hosts k8s-lb

実行結果:

192.168.1.124   k8s-lb

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' http://k8s-registry:5000/v2/

実行結果:

200

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' https://192.168.1.124:6443/livez

実行結果:

200

直接の https://github.com/ だけが 000 に変わりました。DNS もレジストリも API Server も生きています。

失敗の中身を見ます。-s の代わりに -sS を使うと、進捗表示は出さずにエラーだけを見せてくれます。実行コマンド(k8s-wl-02 上・developer):

$ timeout 10 curl -sS -o /dev/null --noproxy '*' https://github.com/

実行結果:

curl: (7) Failed to connect to github.com port 443 after 1 ms: Could not connect to server

REJECTDROP の違いがここに出ています。

1 ms で失敗しています。REJECT は「拒否しました」という応答を返すので即座に失敗します。DROP(黙って捨てる)だとクライアントはタイムアウトまで待つため、数十秒固まってから失敗します。

どちらを選ぶか: 攻撃者に「ファイアウォールがある」と教えたくないなら DROP運用上の切り分けを速くしたいなら REJECT です。本書は教材として、失敗が即座に分かる REJECT を採ります。本番では「アプリのタイムアウト待ちで障害が長引く」という実害があるため、内部向けは REJECT を選ぶことが多いです。

ステップ5:閉じすぎて壊してみる

ここまでは 192.168.1.0/24 を丸ごと許可していました。「必要そうなものだけ列挙する」に変えると何が起きるかを確かめます。

この状態のまま長時間放置しないでください。

このあと見るとおり、ここでは DNS と NTP が落ちます。containerd がイメージを pull しようとすれば失敗し、時刻同期もじわじわずれていきます。数分で確認を終えて、ステップ6 まで進んでください。 席を立つなら、先にステップ6 の削除を済ませてからにしてください。

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --remove-rich-rule='rule family=ipv4 destination address=192.168.1.0/24 accept'

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-rich-rule='rule family=ipv4 destination address=192.168.1.124/32 port port=6443 protocol=tcp accept'

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-rich-rule='rule family=ipv4 destination address=192.168.1.123/32 port port=5000 protocol=tcp accept'

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --policy=egress-guard --add-rich-rule='rule family=ipv4 destination address=192.168.1.121/32 port port=3128 protocol=tcp accept'

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --reload

変更後の policy を見ておきます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --info-policy=egress-guard

実行結果(末尾の rich rules のみ):

  rich rules:
	rule family="ipv4" destination address="10.0.0.0/8" accept
	rule family="ipv4" destination address="192.168.1.121/32" port port="3128" protocol="tcp" accept
	rule family="ipv4" destination address="192.168.1.123/32" port port="5000" protocol="tcp" accept
	rule family="ipv4" destination address="192.168.1.124/32" port port="6443" protocol="tcp" accept

192.168.1.0/24 の一括許可が消え、宛先ホストとポートを名指しした 3 本に置き換わりました。「API Server・レジストリ・プロキシがあれば十分だろう」という、もっともらしい設計です。確かめます。

実行コマンド(k8s-wl-02 上・developer):

$ timeout 5 curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' https://192.168.1.124:6443/livez

実行結果:

200

実行コマンド(k8s-wl-02 上・developer):

$ timeout 5 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' http://192.168.1.123:5000/v2/

実行結果:

200

実行コマンド(k8s-wl-02 上・developer):

$ timeout 5 curl -s -o /dev/null -w '%{http_code}\n' -x http://192.168.1.121:3128 https://github.com/

実行結果:

200

3 つとも 200 です。ここで「問題なし」と結論を出したくなります。 ですが、同じレジストリにホスト名でアクセスすると結果が変わります。

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -sS -o /dev/null --noproxy '*' http://k8s-registry:5000/v2/

実行結果:

curl: (6) Could not resolve host: k8s-registry

実行コマンド(k8s-wl-02 上・developer):

$ getent hosts k8s-lb

何も出力されません。 直後に echo $? を打つと 2(該当なし)が返ります。ステップ4 では 192.168.1.124 k8s-lb と返っていた同じコマンドです。

名前解決が死んでいます。

許可したのは 6443/tcp5000/tcp3128/tcp の 3 つだけで、DNS(192.168.1.12153/udp)を書き忘れました。「API Server・レジストリ・プロキシがあれば十分」という設計が、いちばん基礎的なものを落としていたわけです。

そして IP 直指定のテストでは、これに気づけません。 直前の 3 つの curl はすべて IP アドレスを直接書いていたので、DNS を一切使っていませんでした。「確認したから大丈夫」がいちばん危ない——確認の仕方が、壊した箇所を避けていたのです。

プロキシ経由の curl が 200 のままなのも同じ理由です。-x http://192.168.1.121:3128プロキシを IP で指定しているので、github.com の名前解決はプロキシ側が代わりに行います。ノードの DNS が死んでいても、プロキシ経由なら外に出られてしまう——プロキシは通信の制御にはならないが、名前解決の肩代わりはしているという、この回の主題の裏面です。

ノード間の通信も見ます。

実行コマンド(k8s-wl-02 上・developer):

$ timeout 5 bash -c 'cat < /dev/null > /dev/tcp/192.168.1.128/10250'

実行結果:

bash: connect: No route to host
bash: line 1: /dev/tcp/192.168.1.128/10250: No route to host

さきほどまで通っていた 10250 が落ちました。7946 も同じく No route to host になりますが、こちらは元から通っていなかったので、ここでは変化が観測できません。

これが本演習の核心です。

「必要そうなものを列挙して許可する」だけでは、基礎的なサービスとクラスタ内部の相互通信を落とします。落ちたものは、どれも普段まったく意識しない通信です。

  • DNS(53/udp): 名前解決。落ちるとホスト名でのアクセスがすべて失敗します。containerd のイメージ pull も、レジストリをホスト名で書いていれば止まります。今回いちばん実害の大きい漏れがこれです
  • NTP(123/udp): 時刻同期。落ちてもすぐには気づきません。chronyc -n sourcesReach377 から少しずつ下がっていくだけです
  • ノード間の 10250: 本書ラボで常時使われているわけではありませんが、メトリクス収集の構成やトラブルシュートでノードからノードへ叩く経路です
  • MetalLB の memberlist(7946): speaker 同士が「どのノードが LoadBalancer IP を広告するか」を合意するための通信です。本書ラボでは元から塞がっていたので、ここでは変化が見えません

なぜ Pod である speakernode_exporter の通信まで、ノード向けの policy の対象になるのか。 これらは hostNetwork: true だからです。ノードのネットワーク名前空間で動くので、その外向き通信はノード自身の通信として egress policy に当たります。ss -ltnup に出てくるプロセスは、すべて firewalld の管轄——この 1 行を思い出せば説明がつきます。

そして厄介なことに、この状態でも kubectl get node5 台すべて Ready のままです。kubelet が API Server との間にすでに張っている接続は、既存の通信として維持されるからです。監視も鳴らず、ノードも健全に見える——これが、ハードニングで最も厄介な壊れ方です。

「10250 を塞いだから kubectl logs が失敗する」——これは誤りです。

引っかかりやすいので、通信の向きを整理しておきます。

kubectl logs / kubectl exec は、API Server から、対象 Pod が載っているノードの kubelet:10250 へ入っていく通信(inbound)です。ノードから出ていく通信ではありません。今回塞いだのは k8s-wl-02 の egressHOST → ANY)で、10250受け側の許可(public ゾーンの 10250/tcp)はそのままです。したがって kubectl logs は成功します

「壊したつもりのものが壊れていない」——これも、確認の落とし穴です。 さきほどは「IP 直指定で確認したので DNS の故障に気づけなかった」でした。ここでは逆に、塞いだ結果が表に出ない通信があります。/dev/tcp で「繋がらない」ことは確かめられても、それが誰の、どちら向きの通信だったかまで分かっていないと、影響範囲は判断できません。

落ちなかったものにも意味があります

Calico の BGP(10.0.10.x:179)はそのまま張られたままでした。ステップ3 で入れた 10.0.0.0/8 の許可を、ステップ5 では外していないからです。クラスタ内部の CIDR を丸ごと許可しておくと、CNI とストレージの通信はまとめて守れます。 ポート単位で絞る価値より、壊すリスクのほうが大きい——これがステップ7 の設計指針につながります。

ステップ6:元に戻し、クラスタが回復したことを確かめる

まず policy を消します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --permanent --delete-policy=egress-guard

実行コマンド(k8s-wl-02 上・developer):

$ sudo firewall-cmd --reload

実行コマンド(k8s-wl-02 上・developer):

$ sudo LC_ALL=C firewall-cmd --get-policies

実行コマンド(k8s-wl-02 上・developer):

$ timeout 8 curl -s -o /dev/null -w '%{http_code}\n' --noproxy '*' https://github.com/

実行結果:

200

--get-policies は既定の 6 本に戻り、直接の外向き通信も 200 に戻りました。名前解決も戻っています(getent hosts k8s-lb192.168.1.124 k8s-lb を返します)。ここで終わりにしてはいけません。 ステップ5 で DNS と kubelet 間通信を実際に落としているので、戻ったことを確認するまでが演習です。

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

$ kubectl get node

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

$ kubectl -n metallb-system get pod -o wide

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

$ kubectl -n metallb-system logs -l app.kubernetes.io/component=speaker --tail=20 --prefix

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

$ kubectl -n fanclub logs -l app=fanclub-backend -c backend --tail=5 --prefix

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

$ kubectl -n longhorn-system get volumes.longhorn.io

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

$ kubectl get gateway -A

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

$ curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' https://fanclub.local/

確認する内容は次のとおりです。

見るもの期待
kubectl get node5/5 Ready
speaker の Podすべて 1/1 RunningRESTARTS は演習前から増えていないこと
speaker のログpartial join が出続けているのは元からの状態(本回では直していません)
k8s-wl-02 上の Pod のログ取得成功する10250 の受け側の許可が壊れていないことの確認)
Longhorn4 ボリュームが attached healthy
Gatewayfanclub-gatewayTrue・アドレスは 192.168.1.200
HTTPS200

kubectl get node の実行結果:

NAME        STATUS   ROLES           AGE   VERSION
k8s-cp-01   Ready    control-plane   44h   v1.36.3
k8s-cp-02   Ready    control-plane   41h   v1.36.3
k8s-cp-03   Ready    control-plane   41h   v1.36.3
k8s-wl-01   Ready    <none>          41h   v1.36.3
k8s-wl-02   Ready    <none>          41h   v1.36.3

kubectl get gateway -A の実行結果:

NAMESPACE   NAME              CLASS     ADDRESS         PROGRAMMED   AGE
fanclub     fanclub-gateway   traefik   192.168.1.200   True         35h

最後の curl の実行結果:

200

なぜ Pod のログまで見るのか

kubectl get nodeReady でも、それはノードが API Server と話せているという意味しかありません10250 の受け側(inbound)が生きているかは別の話なので、実際にログを取ってみるのがいちばん確実です。ステップ5 で /dev/tcp のテストが失敗したのを見たあとだと、ここが壊れていないか不安になります。その不安を、コマンド 1 本で解消しておくのが回帰確認の役目です。

ステップ7:本番ではどう設計するか

正しい順序は「閉じてから開ける」ではなく、「まず観測してから閉じる」です。

  1. ss -tnp state established を一定期間サンプリングして、そのノードが実際にどこへ出ているかを記録する
  2. 監視・ログ・バックアップ・時刻同期のエージェントを洗い出す(これらは通信先が広い)
  3. クラスタ内部ネットワークの CIDR は丸ごと許可する(本演習のステップ3 の形)。ポート単位で絞るのは、内部通信の全量を把握できている場合に限る
  4. 1 台で試してから展開する。全ノードに同時適用すると、壊れたときに切り分けるための健全なノードが残らない
  5. ロールバック手順を先に書く(policy object は --permanent --delete-policy + --reload

firewalld の policy はノードごとのローカル設定

です。第6回enable_selinux と同じく、kubeadm join で足した新しいノードには入りません。構成管理(Ansible / cloud-init / イメージ焼き込み)に載せて、firewall-cmd --get-policies を受け入れテストに入れてください。

やってみよう④:sshd を drop-in で締める

所要 10 分(全体が試験相当の粒度)。

公式コンピテンシー Use least-privilege identity and access management に照らすと、SSH の入口を絞る形式の設問を想定できます。設定ファイルの場所と項目名を覚えているかどうかがそのまま速度になります(sshd_config の公式ドキュメントは試験中に参照できません。man sshd_config は使えます)。

対象は k8s-wl-02 です。

作業前に必ず読んでください。

SSH の設定を間違えると、そのノードに二度と入れなくなります。次の 3 つを必ず守ってください。

  1. いま開いている SSH セッションを閉じない。 別のターミナルで新しく接続して確認します(既存セッションは設定変更の影響を受けません)
  2. sshd -t で構文チェックを通してから reload する
  3. restart ではなく reload を使う(reload は既存セッションを切りません)

ステップ1:実効値を控える(before)

実行コマンド(k8s-wl-02 上・developer):

$ sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|gssapiauthentication|x11forwarding|allowtcpforwarding|maxauthtries|logingracetime|clientaliveinterval)"

実行結果:

logingracetime 120
maxauthtries 6
clientaliveinterval 0
permitrootlogin without-password
gssapiauthentication yes
passwordauthentication no
x11forwarding yes
allowtcpforwarding yes

grep は元の出力の順序を保つので、アルファベット順にはなりません(並べたいときは末尾に | sort を足してください)。この 8 行を控えておき、ステップ3 のあとで同じコマンドを打って見比べます。

ステップ2:drop-in ファイルを作る

なぜ drop-in なのか

本体を直接書き換えると、OpenSSH のパッケージ更新時に .rpmnew が出て、自分の変更が取り残されることがあります。drop-in なら自分の変更が 1 ファイルに閉じるので、差分の管理も切り戻しも 1 ファイルで済みます。

drop-in 方式が成立する根拠を、記憶ではなく実物で確かめます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo grep -n -i "^include" /etc/ssh/sshd_config

実行結果:

15:Include /etc/ssh/sshd_config.d/*.conf

Include がファイルの先頭近く(15 行目)にある

ことが重要です。OpenSSH は「先に読まれた値が勝つ」という仕様なので、本体の設定より前に drop-in が読まれれば、drop-in の値が採用されます。逆に Include が末尾にあるディストリビューションでは、drop-in を置いても本体の値に負けます。他の OS で同じことをするときは、まずこの 1 行の位置を確認してください。

作成するファイル /etc/ssh/sshd_config.d/50-hardening.conf の全量は次のとおりです。

ファイル名の 50-50-redhat.conf と同じ番号です。それでも勝ちます。

Include /etc/ssh/sshd_config.d/*.conf のワイルドカードはファイル名の辞書順で展開されるので、50-hardening.conf50-redhat.conf より先に読まれます。OpenSSH は先に読まれた値が勝つので、こちらの GSSAPIAuthentication noX11Forwarding no が採用されます。

番号だけでなくその後ろの名前まで含めて順序が決まる——ここを勘違いすると、置いたのに効かないファイルができます。確実にしたいなら 10- のように若い番号を振ってください。

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
GSSAPIAuthentication no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2

実行コマンド(k8s-wl-02 上・developer):

$ sudo tee /etc/ssh/sshd_config.d/50-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
GSSAPIAuthentication no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
EOF

各行の意図は次のとおりです。

設定意図
PermitRootLogin noroot で直接入れなくする。作業は developer で入って sudo する(誰が何をしたかがログに残る)
PasswordAuthentication no総当たり攻撃を成立させない(すでに no・明示して固定する)
KbdInteractiveAuthentication no対話型の認証経路も閉じる(パスワード認証の抜け道になり得る)
PermitEmptyPasswords no空パスワードでのログインを禁止する
GSSAPIAuthentication noKerberos を使っていないので、認証機構を 1 つ減らす
X11Forwarding noGUI が無い。攻撃面を残す理由が無い
AllowTcpForwarding no侵害時に踏み台化される経路を塞ぐ
AllowAgentForwarding noエージェント転送は、踏み台側で鍵を悪用されるリスクがある
MaxAuthTries 31 接続あたりの試行回数を減らす
LoginGraceTime 30認証を終えないまま接続を掴む時間を減らす(DoS 耐性)
ClientAliveInterval 300 + ClientAliveCountMax 2放置されたセッションを 10 分で切る

AllowTcpForwarding no を入れる前の確認

ポートフォワードを使った運用(kubectl port-forward とは別物です・SSH の -L / -R)をしていないか確認してください。踏み台経由で管理ツールを繋いでいる環境では、これを切ると運用が止まります。 本書ラボでは使っていないので問題ありません。

ステップ3:構文チェックしてから反映する

実行コマンド(k8s-wl-02 上・developer):

$ sudo sshd -t

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl reload sshd

実行コマンド(k8s-wl-02 上・developer):

$ sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|gssapiauthentication|x11forwarding|allowtcpforwarding|maxauthtries|logingracetime|clientaliveinterval)"

実行結果:

logingracetime 30
maxauthtries 3
clientaliveinterval 300
permitrootlogin no
gssapiauthentication no
passwordauthentication no
x11forwarding no
allowtcpforwarding no

ステップ1 で控えた 8 行と、同じ並びで見比べてください。7 項目が狙いどおりに変わり、passwordauthentication だけが no のままです。これは元から no だったものを、drop-in で明示して固定した結果です。とくに gssapiauthenticationx11forwarding50-redhat.confyes にしていたものを、こちらのファイルが先に読まれることで上書きできました

drop-in に書いた残り 4 項目も、同じ要領で確認できます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo sshd -T | grep -E "^(permitemptypasswords|kbdinteractiveauthentication|allowagentforwarding|clientalivecountmax)"

実行結果:

clientalivecountmax 2
kbdinteractiveauthentication no
permitemptypasswords no
allowagentforwarding no

書いた 12 項目すべてが sshd -T に反映されていることを確認できました。設定ファイルを置いただけで満足せず、実効値で確かめる——これが本節の型です。

sshd -t は成功すると何も出力しません(Unix の作法どおりです)。エラーがあれば行番号つきで出ます。systemctl reload sshd も同様に無出力です。この 1 コマンドを挟むかどうかで、ノードに入れなくなる事故が防げます。

ステップ4:新しいセッションで入れることを確かめる

既存のセッションは閉じずに、別のターミナルから接続します。

どの端末から打つか

いま k8s-wl-02 に入るのに使っているのと同じ端末・同じ鍵で、もう 1 本セッションを張ってください。本書ラボの k8s-ops には k8s-wl-02 へ入るための鍵を置いていないので、k8s-ops からは接続できませんPermission denied (publickey) になります)。それは設定を壊したせいではなく、元からです。

実行コマンド(作業端末から):

$ ssh developer@192.168.1.129 'hostname'

実行結果:

k8s-wl-02

鍵認証は生きています。root で入れなくなったことも確認します。

実行コマンド(作業端末から):

$ ssh -o BatchMode=yes root@192.168.1.129 'hostname'

実行結果:

root@192.168.1.129: Permission denied (publickey).

PermitRootLogin no が効いています。-o BatchMode=yes を付けているのは、パスワードの入力待ちで止まらないようにするためです。スクリプトから疎通を確かめるときの定番オプションなので、覚えておくと便利です。

ステップ5:後片付け

本回の以降の演習に影響しないので残して構いませんroot ログインも SSH のポートフォワードも本書では使っていません)。ただし戻す手順は持っておいてください。

実行コマンド(k8s-wl-02 上・developer):

$ sudo rm -f /etc/ssh/sshd_config.d/50-hardening.conf

実行コマンド(k8s-wl-02 上・developer):

$ sudo sshd -t

実行コマンド(k8s-wl-02 上・developer):

$ sudo systemctl reload sshd

本番では全ノードに配ります。

1 台だけ締めても、緩いノードが 1 台あればそこから入られます。構成管理で全ノードへ配布し、sshd -T の値を受け入れテストで確認するのが正しい運用です。

やってみよう⑤:最小権限の sudo を新規ユーザで設計する

所要 12 分(全体が試験相当の粒度)。

公式コンピテンシー Use least-privilege identity and access management に照らすと、特定のユーザに特定のコマンドだけを許可する形式の設問を想定できます。/etc/sudoers.d/ に置く・visudo -c で検証する・sudo -l -U で確認するの 3 点が型です。

対象は k8s-wl-02 です。

developer の設定は変更しません。

developerNOPASSWD: ALL ですが、これを剥がすと本シリーズの残りの手順がすべてパスワード入力待ちで止まります。新しいユーザを作り、そちらで最小権限を設計します。

シナリオはこうです。監視当番のオペレータに、ノードの状態を確認する権限だけを渡します。 ログの閲覧とサービスの状態確認はできるが、サービスの停止や設定変更はできない。この境界を sudoers で表現します。

ステップ1:ユーザを作る

実行コマンド(k8s-wl-02 上・developer):

$ sudo useradd -m -s /bin/bash nodeops

実行コマンド(k8s-wl-02 上・developer):

$ sudo passwd -l nodeops

実行結果:

passwd: password changed.

実行コマンド(k8s-wl-02 上・developer):

$ id nodeops

実行結果:

uid=1001(nodeops) gid=1001(nodeops) groups=1001(nodeops)

wheel グループに入っていないことを確認してください。ここに入れてしまうと %wheel ALL=(ALL) ALL が効いて、これから書く制限が意味をなくします。

passwd -l でパスワードをロックする理由

このユーザには鍵認証だけで入ってもらいます。パスワードを設定しないことで、総当たりの対象から外します。実際に運用するなら、この後 ~nodeops/.ssh/authorized_keys に公開鍵を置きます。

出力が passwd: password changed. なのは紛らわしいのですが、パスワードを変更したのではなく、ハッシュの先頭に ! を付けてロックしたという意味です。sudo passwd -S nodeops で確認すると、2 列目が L(Locked)を示す表示になります(パスワードが使えるユーザなら P です)。

ステップ2:コマンドの実体パスを確かめる

sudoers はフルパスで書きます。 そしてそのパスが実体と一致していないと、ルールは一致せず常に拒否されます。書く前に必ず確認します。

実行コマンド(k8s-wl-02 上・developer):

$ command -v systemctl journalctl ss

実行結果:

/usr/bin/systemctl
/usr/bin/journalctl
/usr/sbin/ss

ss/usr/bin ではなく /usr/sbin にあります。

AlmaLinux / RHEL 系では iproute パッケージが /usr/sbin/ss に置きます。sudo ss がふつうに動くのは Defaults secure_path/usr/sbin が含まれているからで、sudoers に /usr/bin/ss と書いてしまうと、どのルールにも一致せず常に拒否されます。しかもエラーは「そんなファイルはない」ではなく「許可されていない」と出るので、原因に辿り着きにくいところです。command -v で確かめてから書く——これが作法です。

ステップ3:許可するコマンドを決める

許可するもの理由
/usr/bin/systemctl --no-pager status *サービスの状態確認
/usr/bin/systemctl is-active *同上
/usr/bin/journalctl --no-pager / /usr/bin/journalctl --no-pager *ログ閲覧
/usr/sbin/ss *接続状況の確認
許可しないものsystemctl stop / restart / firewall-cmd / vi / シェル起動

sudo 設計で最も多い事故

/usr/bin/vi/usr/bin/less を許可すると、そこからシェルを起動できますvi:!shless!sh)。エディタとページャに sudo を与えると、実質的に root を与えたのと同じです。同じ理由で find-exec)・awksystem())・tar--checkpoint-action)も危険です。「そのコマンドから外部コマンドを起動できるか」を必ず確認してください。

見落としやすい落とし穴 —— systemd 系は既定でページャを呼ぶ

危ないのは journalctl だけではありません。systemctl status も既定でページャ(less)を起動しますsudo systemctl status kubelet を許可すると、root 権限の less が立ち上がり、そこで !sh と打てば root シェルが取れます

したがって --no-pager は「ユーザが付けてもよいオプション」ではなく、許可の一部として前に固定する必要があります。Cmnd_Alias/usr/bin/systemctl --no-pager status * と書けば、--no-pager を含まない呼び出しはルールに一致しません(sudoers の引数照合は、argv を連結した 1 本の文字列に対する、シェルのワイルドカードと同じ照合fnmatch)です)。/usr/bin/systemctl status * と書いてしまうと、--no-pager を付けるかどうかは利用者の任意になり、制限になりません。

「サービスの状態を sudo で見せるときは --no-pager を前置きする」——これを型として覚えてください。

* を付け忘れると引数付き実行が通らない

sudoers でコマンド名だけを書くと、引数を一切付けない実行のみが許可されます。/usr/sbin/ss と書くと sudo ss -tnp は拒否されます。逆に /usr/bin/journalctl --no-pager * は、追加引数が 0 個の sudo journalctl --no-pager に一致しません。だから 2 本並べます。ワイルドカードの有無で挙動が変わるのは sudoers の癖なので、書いたら必ず sudo -l -U で確認してください。

ステップ4:ファイルを作って検証してから配置する

壊れた sudoers ファイルを /etc/sudoers.d/ に置いてはいけません。 sudo はこのディレクトリの全ファイルをパースし、1 つでも壊れていれば全体を拒否します。つまり root を含む全ユーザが sudo を使えなくなります

そこで 「一時ファイルに書く → 配置前に構文検証 → 検証が通ってから配置」 の順で進めます。

実行コマンド(k8s-wl-02 上・developer):

$ cat > /tmp/nodeops.sudoers <<'EOF'
Cmnd_Alias NODE_READ = /usr/bin/systemctl --no-pager status *, /usr/bin/systemctl is-active *, /usr/sbin/ss *, /usr/bin/journalctl --no-pager, /usr/bin/journalctl --no-pager *
Defaults:nodeops !authenticate
nodeops ALL=(root) NODE_READ
EOF

各行の意味は次のとおりです。

意味
Cmnd_Alias許可するコマンドの名前つきの集合を定義する。増えたときに 1 箇所で直せる
Defaults:nodeops !authenticateこのユーザだけパスワードを省略する(鍵認証で入る運用のため)。本番では外して、パスワードを要求するのが基本
nodeops ALL=(root) NODE_READnodeops が、どのホストでもroot としてNODE_READ の集合だけ実行できる

配置する前に構文を検証します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo visudo -c -f /tmp/nodeops.sudoers

実行結果:

/tmp/nodeops.sudoers: parsed OK

visudo -c -f <file> は対話エディタを開きません。

指定したファイルの構文を検査して結果を出すだけです(-c は check の意味です)。visudo -f <file> だと vi が開いてしまうので、スクリプトや手順書に書けるのは -c を付けた形のほうです。

検証が通ったので配置します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo install -m 0440 -o root -g root /tmp/nodeops.sudoers /etc/sudoers.d/nodeops

実行コマンド(k8s-wl-02 上・developer):

$ sudo ls -l /etc/sudoers.d/

実行結果:

total 8
-r--r-----. 1 root root  34 Jun 20 21:25 developer
-r--r-----. 1 root root 236 Jul 27 15:07 nodeops

実行コマンド(k8s-wl-02 上・developer):

$ sudo visudo -c

実行結果:

/etc/sudoers: parsed OK
/etc/sudoers.d/developer: parsed OK
/etc/sudoers.d/nodeops: parsed OK

3 行すべてが parsed OK です。1 つでも壊れていれば、sudo は全体を拒否します。配置したあとにこれを打つのは、「置いたファイルが他のファイルと矛盾していないか」まで含めて確認するためです。

実行コマンド(k8s-wl-02 上・developer):

$ rm -f /tmp/nodeops.sudoers

-m 0440 を外すと sudo はこのファイルを読みません。

/etc/sudoers.d/ のファイルは root 所有・グループ root・モード 0440(他者に書き込み権があってはならない)が要求されます。install コマンドはコピーと権限設定を 1 回で行うので、cp + chmod + chown の 3 回に分けるより事故が少なくなります。

ステップ5:効いていることを確かめる

実行コマンド(k8s-wl-02 上・developer):

$ sudo -l -U nodeops

実行結果(末尾):

User nodeops may run the following commands on k8s-wl-02:
    (root) /usr/bin/systemctl --no-pager status *, /usr/bin/systemctl is-active *, /usr/sbin/ss *, /usr/bin/journalctl --no-pager, /usr/bin/journalctl --no-pager *

この前に Matching Defaults entries for nodeops on k8s-wl-02: という長い行が出ます。そこには secure_path と、こちらが書いた !authenticate が含まれます。書いたものが読み取られているかを、この 1 コマンドで確認できます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo -u nodeops sudo -n systemctl --no-pager status kubelet

実行結果(先頭):

* kubelet.service - kubelet: The Kubernetes Node Agent
     Loaded: loaded (/usr/lib/systemd/system/kubelet.service; enabled; preset: disabled)
    Drop-In: /usr/lib/systemd/system/kubelet.service.d
             `-10-kubeadm.conf
     Active: active (running) since Mon 2026-07-27 14:47:00 JST; 20min ago
   Main PID: 2093 (kubelet)
      Tasks: 15 (limit: 48736)
     Memory: 95M (peak: 104.2M)
        CPU: 29.250s
     CGroup: /system.slice/kubelet.service

実行コマンド(k8s-wl-02 上・developer):

$ sudo -u nodeops sudo -n systemctl stop kubelet

実行結果:

Sorry, user nodeops is not allowed to execute '/bin/systemctl stop kubelet' as root on k8s-wl-02.

狙いどおり拒否されました。sudo -u nodeops sudo ...sudo が二重になっているのは、いったん nodeops になってから、そのユーザとして sudo を試すためです。-n は「パスワードを聞かれたら即座に失敗する」オプションで、入力待ちで止まらないようにしています。

メッセージが /bin/systemctl になっている理由

sudoers には /usr/bin/systemctl と書いたのに、拒否メッセージは /bin/systemctl です。secure_path の先頭寄りにある /bin で先に見つかったためで、/bin/usr/bin への symlink なので実体は同じです。許可の判定はパスの実体で行われるので、この表示の違いは動作に影響しません。「書いたパスと違う」と慌てないでください。

もう 1 つ、ページャ経由の抜け道が塞がっているかも確かめておきます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo -u nodeops sudo -n systemctl status kubelet

実行結果:

Sorry, user nodeops is not allowed to execute '/bin/systemctl status kubelet' as root on k8s-wl-02.

--no-pager を付けない status は拒否されます。

これがステップ3 で --no-pager を前置き固定にした効果です。もし /usr/bin/systemctl status * と書いていたら、この呼び出しは通ってしまい、root 権限の less から !sh で root シェルが取れていました。1 単語の位置が、権限の境界を決めています。

NODE_READ に書いたのは status / is-active / ss / journalctl の 4 種類だけなので、状態を見ることはできても、状態に手を入れることはできません。監視当番に渡す権限としては、これで足ります。

sudo -l -U <user> は試験で効きます。

「このユーザは何を実行できるか」を 1 コマンドで列挙できます。第4回で使った kubectl auth can-i --as= と同じ役割を、OS 側で果たすコマンドです。権限を書いたら、必ず「見え方」を確認する——RBAC でも sudo でも同じ作法です。

ステップ6:後片付け

実行コマンド(k8s-wl-02 上・developer):

$ sudo rm -f /etc/sudoers.d/nodeops

実行コマンド(k8s-wl-02 上・developer):

$ sudo visudo -c

実行コマンド(k8s-wl-02 上・developer):

$ sudo userdel -r nodeops

userdel -r はホームディレクトリごと消します。

本番で誤って打つとデータが消えるので、退職者アカウントの削除は「ロック(usermod -L + chage -E 0)→ 一定期間後に削除」の 2 段階にするのが定石です。

Kubernetes を壊さない境界(必要通信の一覧)

「閉じすぎると壊れる」を演習で確認したので、判断軸を表にまとめます。試験で「どこまで閉じるのが正解か」を決めるときの下敷きになります。

Control Plane Node の必要通信

用途ポート方向相手
API Server6443/tcpin全ノード・k8s-lb・作業端末
etcd クライアント2379/tcpin同ノードの API Server(本来は localhost で足りる)
etcd ピア2380/tcpin他の Control Plane Node
kubelet10250/tcpinAPI Server・metrics-server
controller-manager / scheduler10257 / 10259localhost bind。firewalld で開ける必要は無い

Workload Node の必要通信

用途ポート方向相手
kubelet10250/tcpinAPI Server(logs / exec はここを通る)・metrics-server
NodePort30000-32767/tcpin外部
Calico BGP179/tcpin/out他ノード(クラスタ内部 NIC)
Calico IPIPプロトコル 4in/out他ノード
MetalLB memberlist7946/tcp+udpin/out他ノード閉じても L2 モードの LoadBalancer は動いてしまう(本書ラボがその状態だった)が、speaker 同士の合意が成立せずノード障害時の切り替えが遅くなる
Longhorn iSCSI3260/tcpoutPod(ノードの iscsid がイニシエータとして接続する)
node_exporter9100/tcpinPrometheus
API Server6443/tcpoutk8s-lb
DNS / NTP53 / 123outalma-proxy
プロキシ3128/tcpoutalma-proxy
レジストリ5000/tcpoutk8s-registry

この 2 つの表から、試験でも実務でも使える判断軸が 4 行にまとまります。

  1. クラスタ内部ネットワークの CIDR は丸ごと許可する。 ポート単位で絞る価値より、壊すリスクのほうが大きい
  2. ノードとノードの通信は、思っているより多い。 kubelet・CNI・LoadBalancer・監視の 4 系統を必ず数える
  3. 外向き(egress)を絞るときは、DNS / NTP / レジストリ / プロキシ / API Server の 5 つを先に許可する
  4. 1 台で試して、監視が正常なまま数時間経ってから展開する

第11回への申し送り

本回の表は Calico 前提です。第11回で Cilium へ移行すると、本書が採る構成(VXLAN + kube-proxy 置換)では BGP(179)と IPIP は不要になり、代わりに VXLAN(8472/udp)または geneveCilium health(4240/tcp)Hubble(4244/tcp) が要ります(Cilium にも BGP を使う構成はあり、その場合は 179 が要ります)。CNI を替えると必要ポートが変わる——これも「閉じすぎると壊れる」の一形態です。

公式のポート一覧は試験中に参照できます。

https://kubernetes.io/docs/reference/networking/ports-and-protocols/ は CKS の許可 8 サイトの 1 番目(kubernetes.io/docs)に含まれます。Kubernetes のポートだけは調べられるので、覚えるべきは CNI と付随コンポーネント(MetalLB / Longhorn / Prometheus)のほうです。

ただし、このページを写すだけでは足りません。 公式には 10257 / 10259 が載っていますが、kubeadm クラスタでは両方とも 127.0.0.1 にしか bind していません。公式は「このポートを使う構成があり得る」と書いており、実際にどうなっているかは ss -ltnup で確かめるしかありません公式ページで当たりを付け、ss で確定させる——これが本回の判断手順です。

暗記必須コマンド(ホスト OS 堅牢化)

本回のコマンドは、ほぼすべてが試験中に参照できません。

CKS で参照できるのは 8 サイトのみで、firewalld・systemd・OpenSSH・sudo の公式ドキュメントは 1 つも含まれていません。頼れるのは man と暗記の 2 つだけです。

幸い、試験ホストには man pages がインストールされています(Linux Foundation の Resources Allowed に、試験ターミナル上の man ページの参照が明示されています)。man のどこに何が書いてあるかを事前に知っておくと、試験中の検索時間が大きく変わります。本書ラボで実在を確認できたページは次のとおりです(man -w <ページ名> でパスが返れば存在します)。

ページ本書ラボでの実在中身
man sshd_configありSSH の全設定項目
man sudoersありCmnd_Alias の書式・引数照合の規則
man firewalld.policies / man firewalld.policy両方ありpolicy object の概念とファイル形式
man firewalld.richlanguageありrich rule の文法
man firewalld.zone / man firewalld.confありゾーンの定義
man sudoers.d無いこのページ名は存在しない(man sudoers の中に記載がある)

man sudoers.d は存在しません。 ページ名を思い込みで打つと「No manual entry」で時間を落とすので、迷ったら man -k sudo / man -k firewalldまず一覧を出すのが速いです。

firewalld が試験ホストにあるとは限りません。

firewalld は本書ラボ(AlmaLinux)の実装です。 CKS の試験ノードで同じツールが使える保証は公式にありません(試験環境の OS やパッケージ構成は公開されていません)。firewalld が入っていなければ、man firewalld.policies も存在しません。

試験で問われるのは「どのファイアウォール実装を使うか」ではなく、「何を閉じ、何を開けたままにするか」の判断です。 実装が nftablesufw に変わっても、必要通信の表と「閉じすぎると壊れるもの」の判断はそのまま使えます。firewalld の個々のオプションは、本書ラボと実務のための知識として押さえてください。

用途コマンド / 設定試験中参照補足
稼働サービス一覧systemctl list-units --type=service --state=running不可man 可)棚卸しの起点
自動起動の一覧systemctl list-unit-files --type=service --state=enabled不可man 可)running だけでは足りない
停止 + 起動抑止systemctl disable --now <svc>不可man 可)依存経由の起動は止まらない
完全に封じるsystemctl mask <svc>不可man 可)/dev/null への symlink。戻すのは unmask
異常ユニットの確認systemctl --failed不可(man 可)変更後の回帰確認に使う
開放ポート一覧ss -ltnup不可man 可)bind アドレスを必ず見る
確立済み接続ss -tnp state established不可(man 可)「実際にどこへ出ているか」の観測
firewalld 全体firewall-cmd --list-all / --list-all-zones / --get-active-zones不可man 可・ラボ実装既定ゾーンだけ見ない
恒久設定firewall-cmd --permanent ... + firewall-cmd --reload不可(man 可・ラボ実装--permanent だけでは効かない
egress 制限(policy)--new-policy / --add-ingress-zone=HOST / --add-egress-zone=ANY / --set-target=REJECT不可man 可・ラボ実装--permanent 専用man firewalld.policies
policy の確認・削除--get-policies / --info-policy=<name> / --delete-policy=<name>不可(man 可・ラボ実装削除も --permanent + --reload
例外許可--add-rich-rule='rule family=ipv4 destination address=<cidr> accept'不可(man 可・ラボ実装man firewalld.richlanguage
nft の全体像nft list tables / iptables -S INPUT不可(man 可)iptables-nft 管理テーブルは触らない
SSH の実効値sshd -T不可man 可)ファイルではなく実効値を読む
SSH の構文チェックsshd -t不可(man 可)reload の前に必ず打つ(成功時は無出力)
SSH 堅牢化の項目PermitRootLogin no / PasswordAuthentication no / X11Forwarding no / AllowTcpForwarding no / MaxAuthTries / LoginGraceTime不可(man 可)drop-in は /etc/ssh/sshd_config.d/*.conf
SSH の反映systemctl reload sshd不可(man 可)restart は既存セッションを切る
sudo の権限確認sudo -l -U <user>不可man 可)「このユーザは何ができるか」を列挙
sudoers の検証・配置visudo -c -f <file>install -m 0440 -o root -g rootvisudo -c不可man 可)-c を付けると非対話。直接 vi で開かない
sudo の最小許可Cmnd_Alias + <user> ALL=(root) <ALIAS>不可(man 可)エディタ・ページャ・find は与えない。systemctl status--no-pager を前置き固定
コマンドの実体パスcommand -v <cmd>不可(man 可)sudoers に書く前に必ず確認ss/usr/sbin
パッケージ棚卸しrpm -qa / dnf repoquery --unneeded不可(man 可)--unneeded は「不要」の意味ではない
パッケージ削除の試行dnf remove --setopt=tsflags=test <pkg> / dnf remove --assumeno <pkg>不可(man 可)消す前に巻き込みを見る
カーネルモジュールlsmod / /etc/modprobe.d/install <mod> /bin/true不可(man 可)CIS が名指しする 13 種
TCP 到達性の確認bash -c 'cat < /dev/null > /dev/tcp/<host>/<port>'不可nc が無くても試せる
K8s のポート一覧kubernetes.io/docs/reference/networking/ports-and-protocols/本回で唯一参照できるもの。ただし kubeadm の実際の bind は ss で確認する

この表で最も使う頻度が高いのは ss -ltnupsshd -T の 2 つです。どちらも「設定ファイルに何と書いてあるか」ではなく「いま実際にどうなっているか」を答えてくれるコマンドです。ハードニングの作業では、この 2 つを起点にしてください。

第19回の総まとめへ持ち越すもの

25 行のうち、試験中に参照できるのは最後の 1 行だけです。この比率は本シリーズでも際立っています。第19回(試験直前対策)では暗記項目を全 19 回分まとめますが、本回からは次の 7 つを最優先で持っていってください。

  1. systemctl mask / unmaskdisable との差)
  2. systemctl list-unit-files --type=service --state=enabled(running だけでは足りない)
  3. ss -ltnup の bind アドレスの読み方(127.0.0.1 / 0.0.0.0 / *
  4. firewall-cmd --permanent + --reload の関係
  5. sshd -T(実効値)と sshd -t(構文チェック)と reload の 3 点セット
  6. visudo -c -f <file>install -m 0440visudo -c の配置手順
  7. sudo -l -U <user>(書いた権限を確認する)

コマンドそのものより判断の型のほうが応用が利きます。「ssfirewall-cmd を突き合わせる」「止める前に ss -tnp で通信を確認する」「閉じたら DNS から確かめる」の 3 つは、実装が firewalld でなくても同じように使えます。

まとめ

本回では次の点を確認しました。

  • ノード OS は 3 方向で削る。動かすものを減らす / 権限を絞る / 出口を狭める
  • 棚卸しは runningenabled の両方を見る。bluetooth / sssd は running の一覧に出てこない
  • disable では依存経由の起動を止められない。 確実に止めるなら mask。戻すときは unmask が要る
  • running にも enabled にも出てこないのに動きだすサービスがある。 fwupd / udisks2Type=dbus で、呼ばれた瞬間に起きてくる。だから mask が要る
  • サービスを止めたら、それを起こす timer も止める。 fwupd.service を mask しても fwupd-refresh.timer は残り、systemctl --failedfwupd-refresh.service が並ぶ
  • iscsid を止めると Longhorn のボリュームが外れる。 systemd の依存関係には現れないので、ss -tnp で通信を確認してから止める
  • dnf repoquery --unneeded は「不要」の意味ではない。 ethtool / kdump-utils / makedumpfile / dracut-network など障害対応で必要な道具が挙がる。消してよいかは、ツールの判定ではなく役割で決める
  • ss の bind アドレスと firewall-cmd --list-all を突き合わせる。 Control Plane Node の 10257 / 10259localhost bind なのに開いていた(無意味な穴)
  • firewalld は経路(ゾーン)で結果が変わる。 BGP が通っていたのは eth1 が trusted だったから。192.168.1.x 側からは No route to host で弾かれる
  • Kubernetes ノードのパケットフィルタは 3 者の共同管理。 Calico 548 行・kube-proxy 373 行が iptables-nft 側にあり、firewalld は inet firewalld テーブルにいる
  • 環境変数のプロキシに強制力は無い。 ノードは --noproxy '*'github へ 200 を返し、chrony は Cloudflare と直接同期している。「whitelist プロキシがあるから安全」は成立しない
  • 強制するなら firewalld の policy object。 HOST → ANYREJECT にして、必要な宛先だけ accept する。--permanent 専用なので、削除手順とセットで実施する
  • REJECT は 1 ms で失敗し、DROP はタイムアウトまで待つ
  • 棚卸しは「余分な穴」だけでなく「足りない許可」も見つける。 MetalLB の memberlist(7946)はクラスタ構築時から一度も張れていなかった(speaker のログが "joined":0)。それでも LoadBalancer は動いていたので、誰も気づかなかった
  • 閉じすぎるとまず DNS が落ちる。 しかも IP 直指定のテストでは気づけないcurl http://192.168.1.123:5000/v2/ は 200 のまま、curl http://k8s-registry:5000/v2/Could not resolve host)。ノードは Ready のままなので監視も鳴らない
  • 逆に、塞いだのに壊れないものもある。 ノード間の 10250 を塞いでも kubectl logs は成功する(あれは API Server から入ってくる通信だから)。影響範囲は、通信の向きまで見ないと判断できない
  • sshd -T で実効値を読み、drop-in で締め、sshd -t を通してから reload する
  • sudo はコマンド単位で与える。 エディタ・ページャ・find を許可すると、実質的に root を与えたのと同じになる。systemctl status--no-pager を前置きで固定する

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

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

  1. 本書ラボのノードは alma-proxy の whitelist を経由しなければ外部へ出られない
  2. systemctl list-units --type=service --state=running を見れば、稼働サービスの棚卸しとしては十分である
  3. systemctl mask したユニットは、systemctl start を打っても起動を拒否される
  4. Workload Node の iscsid は Kubernetes のコンポーネントではないので停止してよい
  5. firewalld で許可されているポートは、必ずそのノードのいずれかのプロセスが待ち受けている
  6. Calico と kube-proxy は、firewalld とは別のテーブル(iptables-nft が管理)にルールを書いている
  7. firewalld の policy object は --permanent を付けずに作成し、--reload で破棄できる
  8. --set-target=REJECT で拒否された通信は、DROP と違って即座に失敗する
  9. hostNetwork: true の Pod の外向き通信は、ノード自身の通信として firewalld の egress policy の対象になる
解答と解説

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

問1 は誤りで、本回の核心です。curl --noproxy '*' https://github.com/200 を返しました。プロキシは環境変数によるお願いであって強制ではありません。強制するには firewalld の policy object で egress を絞る必要があります。「制御しているつもり」と「制御されている」の差を、実機のコマンド 1 本で確かめてください。

問2 は誤りです。bluetooth / sssdenabled だが inactive なので running の一覧に出てきません。list-unit-files --state=enabled も見てください。enabled は「次の再起動で起きてくる」という意味です。

問3 は正しいです。mask はユニットを /dev/null への symlink に置き換えるので、start しても Unit is masked. で拒否されます。disable にはこの効果はなく、他のユニットの Wants= / Requires= から起動します。戻すには unmask が要ります。

問4 は誤りです。Longhorn の instance-manager が iSCSI ターゲットを公開し、ノードの iscsid がイニシエータとして接続してブロックデバイスを作っています。systemd の依存関係には現れませんが、ss -tnp で 3260 への接続が見えます。「依存に出てこない = 止めてよい」ではありません。

問5 は誤りです。Control Plane Node の 10257 / 10259 が反例でした。kubeadm が --bind-address=127.0.0.1 を書いているので localhost にしか bind していないのに、公式ポート一覧どおりに firewalld で開いています。ss と突き合わせないと見つかりません。

問6 は正しいです。Calico が 548 行、kube-proxy が 373 行ip filter / ip nat(iptables-nft 管理)に書いており、firewalld は inet firewalld テーブルにいます。評価順は nftables のチェーン priority で決まり、iptables-nft 側(filter = 0)が先、firewalld(filter + 10)が後です。ただしベースチェーンの accept はフックを終わらせないので、Calico が通したパケットも firewalld で改めて評価されます。iptables-nft が管理するテーブルを nft で直接書き換えてはいけません。

問7 は誤りです。policy object は --permanent でしか作れません。「runtime だけ触って --reload で戻す」という安全策が使えないので、削除手順(--delete-policy + --reload)を先に用意しておいてください。ゾーンやポートの設定とは切り戻しの作法が違う、という点が要注意です。

問8 は正しいです。実測では 1 ms で失敗しました。DROP だとタイムアウトまで待ちます。失敗までの時間が切り分けの手がかりになるので、この差は覚えておいてください。

問9 は正しいです。hostNetwork: true の Pod はノードのネットワーク名前空間で動くので、その外向き通信はノード自身の通信(OUTPUT)として扱われます。MetalLB の speakercalico-nodenode_exporterkube-proxy がこれに当たり、ss -ltnup にこれらのプロセスが並んでいたのもそのためです。通常の Pod はノードを転送して出るので、この policy の対象外です(そちらは NetworkPolicy の egress で制御します)。Pod のマニフェストの 1 行が、ノードのファイアウォールの守備範囲を左右します——ここが両者の境目です。

次回予告

第8回「Pod Security Standards + Admission(Gatekeeper / ValidatingAdmissionPolicy)」では、D3 を終えて D4 ワークロード防御へ進みます。第6回で個別に扱った seccomp・capabilities・runAsNonRoot といった部品が、PSS の Restricted プロファイルというとして namespace 単位で強制される仕組みを扱います。

本回まではノード 1 台ずつ手で締めてきましたが、次回はクラスタ全体に対して、入口で自動的に弾くという方向に変わります。「1 台ずつ配って、配り忘れたノードが穴になる」という本回の弱点が、Admission ではどう解消されるのかを見比べてください。

→ 詳しくは 第8回 Pod Security Standards + Admission(Gatekeeper / ValidatingAdmissionPolicy)

前の記事
次の記事