新卒インフラエンジニア向け「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
目次
- 第7回のスコープ・今ここマップ
- この回のゴール
- なぜホスト OS を削るのか(コンテナの「下」にある層)
- 稼働サービスの棚卸し — running と enabled の両輪
- パッケージの棚卸し
- 開放ポートと firewalld の突き合わせ — 無意味な穴を見つける
- firewalld のゾーンモデル — 同じポートでも来る経路で結果が変わる
- firewalld だけがパケットフィルタではない
- プロキシは強制力を持たない — 外部アクセス最小化の出発点
- OS ユーザ・sudo・SSH の現状(least-privilege identity management)
- やってみよう①:サービス・ポート・パッケージを棚卸しして削る
- やってみよう②:ノードが直接インターネットへ出られることを確かめる
- やってみよう③:firewalld の policy object で egress を絞る
- やってみよう④:sshd を drop-in で締める
- やってみよう⑤:最小権限の sudo を新規ユーザで設計する
- Kubernetes を壊さない境界(必要通信の一覧)
- 暗記必須コマンド(ホスト OS 堅牢化)
- まとめ
- 理解度チェック(○×形式・全 9 問)
- 次回予告
第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 |
| OS | AlmaLinux 10.2(kernel 6.12)・9 VM すべて Enforcing |
| containerd の SELinux 連携 | Workload Node 2 台は enable_selinux = true(第6回で有効化済み) |
| fanclub | https://fanclub.local/ が 200・Longhorn 4 ボリューム attached healthy・ArgoCD Synced Healthy |
| firewalld | 2.4.0・default zone public・trusted に 10.244.0.0/16 10.96.0.0/12 10.0.10.0/24 |
| auditd | 稼働中だが auditctl -l は No rules(監査ルールは第16回で入れます) |
演習の進め方
本回は「やってみよう」が 5 本あり、通しで実施すると約 72 分(20 / 5 / 25 / 10 / 12 分)かかります。やってみよう③(firewalld の policy object)は途中で止めないでください——作成から削除までが 1 セットで、途中で放置するとノードが外へ出られないまま残ります。①②④⑤ はそれぞれ独立しているので、日を分けても構いません。対象は k8s-wl-02 の 1 台だけです。Control Plane Node では実施しません。
この回のゴール
本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。
- 稼働サービスを
runningとenabledの両方で棚卸しし、止めてよいもの・止めてはいけないものを根拠つきで仕分けられる disableとmaskの違いを説明し、依存関係経由の起動まで止められる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 弾になります。

稼働サービスの棚卸し — running と enabled の両輪
「動かすものを減らす」の本体です。まずノードで何が動いているかを数えます。
実行コマンド(k8s-wl-02 上・developer):
$ LC_ALL=C systemctl list-units --type=service --state=running --no-pager --no-legend
LC_ALL=C を付ける理由
本書ラボは日本語ロケールなので、付けないと systemctl や firewall-cmd の一部が和文で出ます。掲載する出力と読者の手元を揃えるため、本回は一貫して LC_ALL=C を付けます(第2回で採った方針と同じです)。
本書ラボの k8s-wl-02 では 22 本が返りました。全量を並べても読み通せないので、判断の型を表にします。1 本ずつ「これは何のために動いているか」「止めると誰が困るか」を言葉にできる状態を目指してください。
| 分類 | サービス | 判断 |
|---|---|---|
| ノードの本体 | kubelet / containerd | 止めない |
| 管理経路 | sshd | 止めない。堅牢化は設定側で行う(やってみよう④) |
| 本回の主役 | firewalld | 止めない |
| 正しさの土台 | chronyd | 止めない。時刻がずれると証明書検証と監査ログの信頼性が壊れる |
| ネットワーク管理 | NetworkManager | 止めない(eth0 / eth1 を管理) |
| 止めてはいけない罠 | iscsid | Longhorn がボリュームを iSCSI で attach している(次の H3 で実測) |
| 後の回で使う | auditd | 止めない。現時点で auditctl -l は No rules。第16回で使う |
| systemd 基盤 | systemd-journald / dbus-broker / systemd-logind / systemd-udevd / systemd-userdbd / polkit | 止めない |
| 残す判断 | rsyslog | journald と二重だが、外部 SIEM への転送の受け皿。止める前に転送先を確認する |
| 残す判断 | crond | logrotate 等が乗っている |
| 残す判断 | irqbalance | 性能のための機能で、セキュリティ上の穴ではない |
| ラボ固有 | hypervkvpd / hypervvssd | Hyper-V 統合サービス。本書ラボ固有で、KVM や物理サーバには存在しない |
| コンソール | getty@tty1 / user@1000 | 残す |
| 止めてよい | gssproxy | NFS / 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.service の Requires= にも 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.177 は Pod ネットワーク上の 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.service | enabled / inactive | Bluetooth ハードウェアが無いので起動していないだけ。bluez-5.85-1.el10 はインストール済み |
sssd.service | enabled / inactive | ドメイン参加していないので起動しない。sssd-* 6 パッケージがインストール済み |
udisks2.service | enabled / inactive | WantedBy=graphical.target だが、本ノードの既定ターゲットは multi-user.target。ブート時には起動しない |
kdump.service | enabled / active | クラッシュダンプ。メモリを予約する。本番では判断が分かれる |
mdmonitor / lvm2-monitor | enabled | ストレージ監視 |
iscsi-onboot / iscsi-starter | enabled | Longhorn 関連 |
ここが本節の要点
enabled は「次の再起動で起きてくる」という意味です。--state=running だけを見ていると、bluetooth と sssd と udisks2 を見落とします。いま動いていなくても、ハードウェアを足したりドメインに参加した瞬間に起動するサービスが仕込まれたまま残ります。棚卸しは 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 からの起動要求も止まります)。さらに先の手段が、次の節で扱う「パッケージごと消す」です。
実際、本書ラボで最初に観察したときは fwupd と udisks2 が running の一覧に出ていました。ノードを起動してしばらく使っていると、dnf などが D-Bus 越しに呼び出して起きてくるためです。同じノードでも、見るタイミングで一覧が変わります。 棚卸しの結果を「その瞬間のスナップショット」として扱い、インストールされているものの一覧(次の H2)と突き合わせるのはこのためです。
disable と mask の違い
止め方にも 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.service は masked になっています。ところが fwupd-refresh.timer が enabled のまま残っています。この timer は fwupd-refresh.service(static)を定期的に起こし、その中身は /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.service が failed として残ります。やってみよう① のステップ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 で「サーバとして明らかに不要」と言えるものは次のとおりです。
| パッケージ | 理由 |
|---|---|
bluez | Bluetooth。仮想マシンに Bluetooth デバイスは無い |
iwlwifi-dvm-firmware / iwlwifi-mvm-firmware | Intel 無線 LAN のファームウェア |
fwupd(+ fwupd-efi / fwupd-plugin-flashrom) | ファームウェア更新 |
udisks2 | リムーバブルメディア管理 |
nfs-utils | 要注意。本書ラボは NFS を使っていないが、Longhorn の RWX ボリュームと NFS バックアップ先はこれを前提にする |
sssd-*(6 本) | ドメイン参加していない |
nfs-utils は iscsid と同じ罠です。
Longhorn の RWX(ReadWriteMany)ボリュームは share-manager が NFS で公開し、ノード側は NFS クライアントでマウントします。バックアップ先を NFS にする構成でも要ります。いま RWO しか使っていなくても、RWX を使い始めた瞬間にマウントできなくなります。 消す前に「将来 RWX を使う予定があるか」を確認してください。
一方 gssproxy は NFS の Kerberos 認証専用なので、これを止めても通常のマウント(sec=sys)は動きます。同じ「NFS 関連」でも、片方は前提でもう片方は任意——ストレージ製品の前提は、パッケージ名からは読み取れません。
この 6 群は repoquery --unneeded の 16 本には入っていません。逆に、16 本のうち 3 群は残すべきものでした。2 つのリストは重ならない——これが「ツールの判定と役割の判断は別物」ということの実際の姿です。
削除対象から除外するもの
policycoreutils-python-utils(semanage コマンドを提供)は第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 の落とし穴
lsmod も ss も先頭にヘッダ行が付きます。数を報告する場面(監査レポート・チケット)では 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 と行の並び順は実行のたびに変わります。containerd の 41927 のような 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/tcp | sshd | 0.0.0.0 / [::] | ssh サービスで許可 | 必要 |
| 179/tcp | bird(Calico BGP) | 0.0.0.0 | 許可なし | 経路依存(次節で解明) |
| 7946/tcp+udp | speaker(MetalLB memberlist) | 192.168.1.129 | 許可なし | 経路依存 |
| 9100/tcp | node_exporter | * | 許可なし | 経路依存 |
| 9120/tcp | speaker(metrics) | * | 許可なし | 経路依存 |
| 10250/tcp | kubelet | * | 10250/tcp で許可 | 必要 |
| 10256/tcp | kube-proxy(healthz) | * | 許可なし | 経路依存 |
| 10248 / 10249 / 9099 / 17472 / 41927(末尾の番号は環境ごとに変わる) | kubelet / kube-proxy / calico-node / speaker / containerd | 127.0.0.1 のみ | — | 外から届かない |
| 323/udp | chronyd | 127.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
実行結果(横に長いので、コードブロックの中で横スクロールします。読み取るのは expected と joined の 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 回あります。STATUS は Running で READY も 1/1 なので、kubectl get pod をざっと眺めるだけでは異常に見えません。数字の列まで読む習慣がないと、この種の「静かな故障」は見つかりません。
棚卸しの本当の価値はここにあります
攻撃面最小化というと「減らす作業」に見えますが、ss と firewall-cmd を突き合わせると4 通りの読み方が出てきます。
| listen | firewalld | 意味 | 本書ラボの実例 |
|---|---|---|---|
| あり | 許可あり | 必要な口 | 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 が消え、joined が 4 になるはずですが、本書では 5 ノードすべてを触る必要があるため実施しておらず、確認もしていません。自分の環境で試すときは、適用後にログを確認してください。
ここで BGP と bird という語が出ました。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/tcp | kube-apiserver | * | 必要 |
| 2379 / 2380 | etcd | 127.0.0.1 と 192.168.1.125 | 管理ネットワークに etcd のクライアントポートが出ている。攻撃面として最大級 |
| 10257/tcp | kube-controller-manager | 127.0.0.1 のみ | firewalld で開いているのに誰も待っていない |
| 10259/tcp | kube-scheduler | 127.0.0.1 のみ | 同上 |
| 2381 | etcd(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 プロンプトです。developer で sudo を付けて打っても同じです。自分の環境では 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 だけがパケットを止めているわけではないことを確認します。
ここまでのコマンドはすべて読み取りなので、手元のノードでもそのまま打って構いません(何も変わりません)。実際に手を入れるのは やってみよう① からです。

ss と firewall-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 接続を試すだけなら nc や telnet を入れずに済みます。試験ホストに nc があるとは限らないので、この書き方を覚えておくと潰しが利きます。
No route to host は経路が無いという意味ではありません。
firewalld の既定は reject with icmpx admin-prohibited で、ICMP の「管理上禁止」応答が返ってきたときにカーネルがこのエラーを返します。ルーティングの問題だと思って ip route を延々と眺めることのないよう、この対応を覚えておいてください。
なお、同じ拒否でもコマンドによって文言が変わります。あとの演習では curl が Failed to connect to … Could not connect to server と言ってきますが、起きていることは同じで、メッセージの出し方が違うだけです。
同じノードの他のポートでも、同じ構図になります。いずれも k8s-wl-02 から k8s-wl-01 宛の実測です。
| 宛先 | 結果 | 理由 |
|---|---|---|
192.168.1.128:179(BGP) | No route to host | public zone に許可なし |
192.168.1.128:9100(node_exporter) | No route to host | public 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(既定) | 明示的に許可したものだけ通す |
trusted | target が 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
| 管理者 | ルール数 | 用途 |
|---|---|---|
| Calico | 548 行(cali を含む行) | Pod 間通信・NetworkPolicy の実装 |
| kube-proxy | 373 行(KUBE- を含む行) | Service の負荷分散・NodePort |
| firewalld | inet 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=ipip が success を返してしまうことです。エラーにならないので、開けたつもりで通っていない状態になります。正しくは --add-protocol=ipv4 で、これを打つと nftables 側に meta l4proto ipv4 accept が生成されます。
ちなみに、他のディストリビューションの資料でよく見る ipencap という名前は、AlmaLinux の /etc/protocols には無いので Error: INVALID_PROTOCOL: ipencap で弾かれます。プロトコル名は環境依存です。手が覚えている名前を打つ前に /etc/protocols を grep してください。
cali-INPUT の残りは、workload(Pod)から host への通信の分岐と、Calico の host endpoint 用の判定です。本書ラボは host endpoint を設定していないので、通常の INPUT は素通りし、その後 inet firewalld の filter_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;
filter は 0 を意味する定数なので、iptables-nft 側の INPUT(priority 0)が先、firewalld の filter_INPUT(priority 10)が後に評価されます。
ここで勘違いしやすいのが「accept したら終わり」という思い込みです。
nftables では、ベースチェーン内の accept はそのチェーンの評価を抜けるだけで、パケットは次のチェーンへ進みます。フックそのものを終わらせるのは drop と reject だけです。
つまり 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.com は AlmaLinux の既定で、本書が足した内部 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 の egress | Pod からの外向き通信 | 第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-node | 5 |
metallb-speaker | 5 |
kube-prometheus-stack-prometheus-node-exporter | 5 |
kube-proxy | 5 |
| 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 -ltnup に speaker や bird や node_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
返ってきたのは root と developer のほか、sync / shutdown / halt の 3 つでした。後者 3 つのシェルは /bin/sync /sbin/shutdown /sbin/halt という特殊シェルで、ログインには使えません。つまり 実際にログインシェルを持つのは root と developer の 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(パスワードを要求する)があり、developer は wheel グループにも所属しています。それを上書きしているのが /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 の実測値です)。
| 項目 | 実測値 | 判定 |
|---|---|---|
passwordauthentication | no | 良好(第2巻で設定済み) |
kbdinteractiveauthentication | no | 良好 |
permitemptypasswords | no | 良好 |
permitrootlogin | without-password | 締める対象(鍵さえあれば root で直接入れる) |
gssapiauthentication | yes | 締める対象(Kerberos を使っていない) |
x11forwarding | yes | 締める対象(GUI が無い) |
allowtcpforwarding | yes | 締める対象(侵害時に踏み台化される経路になる) |
maxauthtries | 6 | 締める余地(3) |
logingracetime | 120 | 締める余地(30) |
clientaliveinterval | 0 | 締める余地(300) |
usepam | yes | 維持 |
ここで gssapiauthentication yes と x11forwarding 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 yes と X11Forwarding yes の出所は、本体の /etc/ssh/sshd_config ではなく ディストリビューションが置いた 50-redhat.conf でした。本体だけを読んで「そんな設定はしていない」と判断すると、実際には有効になっている機能を見落とします。
残る 2 つも役割が分かれています。00-disable-password.conf は第2巻でパスワード認証を無効化したときに作ったもの(PasswordAuthentication no と KbdInteractiveAuthentication 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 の数は、いつ数えたかで変わります
fwupd と udisks2 は Type=dbus なので、ノードを使っているうちに D-Bus 越しに呼ばれて起きてきます(dnf を叩いたあとなどが典型です)。起動直後は 22 本ですが、しばらく使ったあとに数えると 23 本や 24 本になることがあります。数が本書と違っても間違いではありません。 大事なのは「作業の前後で何が変わったか」なので、自分で数えた値を起点にしてください。
wc -l の数え方に注意
ss も lsmod も先頭にヘッダ行が付きます。「17 行 = 17 個」ではありません。手元の数と本文の数が合わないときは、まずヘッダ行を数えていないかを疑ってください。厳密に数えるなら sudo ss -ltnup | tail -n +2 | wc -l です。
ステップ2:止めてよいものを判断する
棚卸しの表に照らして、次の 4 つを対象にします。
| サービス | いまの状態 | 理由 |
|---|---|---|
gssproxy | active | NFS / Kerberos を使わない。4 つのうち、いま動いているのはこれだけ |
fwupd | inactive(Type=dbus) | 仮想マシンにファームウェア更新は不要。呼ばれたら起きてくるので封じる |
udisks2 | inactive(Type=dbus / enabled) | リムーバブルメディアを挿さない。同上 |
bluetooth | inactive(enabled) | ハードウェアが無いだけで、設定は生きている |
4 つのうち 3 つは、いま動いていません。 それでも止めるのは、「動いていない」と「動きえない」が別だからです。gssproxy だけが running の一覧に出ていた——この非対称は、ステップ6 で数を比べるときにもう一度出てきます。
止めないもの
kubelet / containerd / sshd / firewalld / chronyd / NetworkManager / iscsid / auditd。とくに iscsid を止めると Longhorn のボリュームが外れます。
ステップ3:disable と mask を打つ
実行コマンド(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.service は masked になりましたが、fwupd-refresh.timer が enabled のままです。これを止めます。
実行コマンド(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 --failed に fwupd-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-utils(semanage)は第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(bluetooth と udisks2 が外れた)と 2 本減っています。
「止めた数」と「減った数」は一致しません。 それでも作業は無駄ではありません。fwupd と udisks2 は mask したことでもう 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
gssproxy は start であって enable ではありません。
ステップ3 の disable --now で gssproxy の行が出力に現れなかったことを思い出してください。元から自動起動は無効(disabled)で、それでも動いていたのです。ここで enable を打つと、元より 1 段階「起動しやすい」状態になってしまいます。
disable / enable(次の起動時にどうするか)と active / inactive(いま動いているか)は独立した 2 つの軸です。この節の最初に見たことが、後片付けでそのまま効いてきます。「戻す」とは、2 つの軸のどちらも元に合わせることです。
実行コマンド(k8s-wl-02 上・developer):
$ sudo dnf install -y bluez
fwupd は enable しません。
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 でした)。本書では戻しません——ハードウェアが無く、戻す理由がないからです。ただし mask と disable をそのままにすると、後日ハードウェアを足したときに「なぜか起動しない」原因になります。 意図的にそうしたことを構成管理か手順書に残してください。
ステップ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 サーバです。Reach の 377 は 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 node が Ready のままなのも、まったく同じ理由です。ただし念のため、演習中に既存のセッションを閉じないでください。
復旧は 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-ipv6 は ingress-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 が正道です。

ステップ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
REJECT と DROP の違いがここに出ています。
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/tcp・5000/tcp・3128/tcp の 3 つだけで、DNS(192.168.1.121 の 53/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 sourcesのReachが377から少しずつ下がっていくだけです - ノード間の 10250: 本書ラボで常時使われているわけではありませんが、メトリクス収集の構成やトラブルシュートでノードからノードへ叩く経路です
- MetalLB の memberlist(7946): speaker 同士が「どのノードが LoadBalancer IP を広告するか」を合意するための通信です。本書ラボでは元から塞がっていたので、ここでは変化が見えません
なぜ Pod である speaker や node_exporter の通信まで、ノード向けの policy の対象になるのか。 これらは hostNetwork: true だからです。ノードのネットワーク名前空間で動くので、その外向き通信はノード自身の通信として egress policy に当たります。ss -ltnup に出てくるプロセスは、すべて firewalld の管轄——この 1 行を思い出せば説明がつきます。
そして厄介なことに、この状態でも kubectl get node は 5 台すべて Ready のままです。kubelet が API Server との間にすでに張っている接続は、既存の通信として維持されるからです。監視も鳴らず、ノードも健全に見える——これが、ハードニングで最も厄介な壊れ方です。
「10250 を塞いだから kubectl logs が失敗する」——これは誤りです。
引っかかりやすいので、通信の向きを整理しておきます。
kubectl logs / kubectl exec は、API Server から、対象 Pod が載っているノードの kubelet:10250 へ入っていく通信(inbound)です。ノードから出ていく通信ではありません。今回塞いだのは k8s-wl-02 の egress(HOST → 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-lb が 192.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 node | 5/5 Ready |
| speaker の Pod | すべて 1/1 Running。RESTARTS は演習前から増えていないこと |
| speaker のログ | partial join が出続けているのは元からの状態(本回では直していません) |
| k8s-wl-02 上の Pod のログ取得 | 成功する(10250 の受け側の許可が壊れていないことの確認) |
| Longhorn | 4 ボリュームが attached healthy |
| Gateway | fanclub-gateway が True・アドレスは 192.168.1.200 |
| HTTPS | 200 |
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 node が Ready でも、それはノードが API Server と話せているという意味しかありません。10250 の受け側(inbound)が生きているかは別の話なので、実際にログを取ってみるのがいちばん確実です。ステップ5 で /dev/tcp のテストが失敗したのを見たあとだと、ここが壊れていないか不安になります。その不安を、コマンド 1 本で解消しておくのが回帰確認の役目です。
ステップ7:本番ではどう設計するか
正しい順序は「閉じてから開ける」ではなく、「まず観測してから閉じる」です。
ss -tnp state establishedを一定期間サンプリングして、そのノードが実際にどこへ出ているかを記録する- 監視・ログ・バックアップ・時刻同期のエージェントを洗い出す(これらは通信先が広い)
- クラスタ内部ネットワークの CIDR は丸ごと許可する(本演習のステップ3 の形)。ポート単位で絞るのは、内部通信の全量を把握できている場合に限る
- 1 台で試してから展開する。全ノードに同時適用すると、壊れたときに切り分けるための健全なノードが残らない
- ロールバック手順を先に書く(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 つを必ず守ってください。
- いま開いている SSH セッションを閉じない。 別のターミナルで新しく接続して確認します(既存セッションは設定変更の影響を受けません)
sshd -tで構文チェックを通してからreloadする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.conf は 50-redhat.conf より先に読まれます。OpenSSH は先に読まれた値が勝つので、こちらの GSSAPIAuthentication no と X11Forwarding 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 no | root で直接入れなくする。作業は developer で入って sudo する(誰が何をしたかがログに残る) |
PasswordAuthentication no | 総当たり攻撃を成立させない(すでに no・明示して固定する) |
KbdInteractiveAuthentication no | 対話型の認証経路も閉じる(パスワード認証の抜け道になり得る) |
PermitEmptyPasswords no | 空パスワードでのログインを禁止する |
GSSAPIAuthentication no | Kerberos を使っていないので、認証機構を 1 つ減らす |
X11Forwarding no | GUI が無い。攻撃面を残す理由が無い |
AllowTcpForwarding no | 侵害時に踏み台化される経路を塞ぐ |
AllowAgentForwarding no | エージェント転送は、踏み台側で鍵を悪用されるリスクがある |
MaxAuthTries 3 | 1 接続あたりの試行回数を減らす |
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 で明示して固定した結果です。とくに gssapiauthentication と x11forwarding は 50-redhat.conf が yes にしていたものを、こちらのファイルが先に読まれることで上書きできました。
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 の設定は変更しません。
developer は NOPASSWD: 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 の :!sh、less の !sh)。エディタとページャに sudo を与えると、実質的に root を与えたのと同じです。同じ理由で find(-exec)・awk(system())・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_READ | nodeops が、どのホストでも、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 Server | 6443/tcp | in | 全ノード・k8s-lb・作業端末 |
| etcd クライアント | 2379/tcp | in | 同ノードの API Server(本来は localhost で足りる) |
| etcd ピア | 2380/tcp | in | 他の Control Plane Node |
| kubelet | 10250/tcp | in | API Server・metrics-server |
| controller-manager / scheduler | 10257 / 10259 | — | localhost bind。firewalld で開ける必要は無い |
Workload Node の必要通信
| 用途 | ポート | 方向 | 相手 |
|---|---|---|---|
| kubelet | 10250/tcp | in | API Server(logs / exec はここを通る)・metrics-server |
| NodePort | 30000-32767/tcp | in | 外部 |
| Calico BGP | 179/tcp | in/out | 他ノード(クラスタ内部 NIC) |
| Calico IPIP | プロトコル 4 | in/out | 他ノード |
| MetalLB memberlist | 7946/tcp+udp | in/out | 他ノード。閉じても L2 モードの LoadBalancer は動いてしまう(本書ラボがその状態だった)が、speaker 同士の合意が成立せずノード障害時の切り替えが遅くなる |
| Longhorn iSCSI | 3260/tcp | out | Pod(ノードの iscsid がイニシエータとして接続する) |
| node_exporter | 9100/tcp | in | Prometheus |
| API Server | 6443/tcp | out | k8s-lb |
| DNS / NTP | 53 / 123 | out | alma-proxy |
| プロキシ | 3128/tcp | out | alma-proxy |
| レジストリ | 5000/tcp | out | k8s-registry |
この 2 つの表から、試験でも実務でも使える判断軸が 4 行にまとまります。
- クラスタ内部ネットワークの CIDR は丸ごと許可する。 ポート単位で絞る価値より、壊すリスクのほうが大きい
- ノードとノードの通信は、思っているより多い。 kubelet・CNI・LoadBalancer・監視の 4 系統を必ず数える
- 外向き(egress)を絞るときは、DNS / NTP / レジストリ / プロキシ / API Server の 5 つを先に許可する
- 1 台で試して、監視が正常なまま数時間経ってから展開する
第11回への申し送り
本回の表は Calico 前提です。第11回で Cilium へ移行すると、本書が採る構成(VXLAN + kube-proxy 置換)では BGP(179)と IPIP は不要になり、代わりに VXLAN(8472/udp)または geneve、Cilium 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 も存在しません。
試験で問われるのは「どのファイアウォール実装を使うか」ではなく、「何を閉じ、何を開けたままにするか」の判断です。 実装が nftables や ufw に変わっても、必要通信の表と「閉じすぎると壊れるもの」の判断はそのまま使えます。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 root → visudo -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 -ltnup と sshd -T の 2 つです。どちらも「設定ファイルに何と書いてあるか」ではなく「いま実際にどうなっているか」を答えてくれるコマンドです。ハードニングの作業では、この 2 つを起点にしてください。
第19回の総まとめへ持ち越すもの
25 行のうち、試験中に参照できるのは最後の 1 行だけです。この比率は本シリーズでも際立っています。第19回(試験直前対策)では暗記項目を全 19 回分まとめますが、本回からは次の 7 つを最優先で持っていってください。
systemctl mask/unmask(disableとの差)systemctl list-unit-files --type=service --state=enabled(running だけでは足りない)ss -ltnupの bind アドレスの読み方(127.0.0.1/0.0.0.0/*)firewall-cmd --permanent+--reloadの関係sshd -T(実効値)とsshd -t(構文チェック)とreloadの 3 点セットvisudo -c -f <file>→install -m 0440→visudo -cの配置手順sudo -l -U <user>(書いた権限を確認する)
コマンドそのものより判断の型のほうが応用が利きます。「ss と firewall-cmd を突き合わせる」「止める前に ss -tnp で通信を確認する」「閉じたら DNS から確かめる」の 3 つは、実装が firewalld でなくても同じように使えます。
まとめ
本回では次の点を確認しました。
- ノード OS は 3 方向で削る。動かすものを減らす / 権限を絞る / 出口を狭める
- 棚卸しは
runningとenabledの両方を見る。bluetooth/sssdは running の一覧に出てこない disableでは依存経由の起動を止められない。 確実に止めるならmask。戻すときはunmaskが要るrunningにもenabledにも出てこないのに動きだすサービスがある。fwupd/udisks2はType=dbusで、呼ばれた瞬間に起きてくる。だからmaskが要る- サービスを止めたら、それを起こす timer も止める。
fwupd.serviceを mask してもfwupd-refresh.timerは残り、systemctl --failedにfwupd-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/10259は localhost 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 → ANYをREJECTにして、必要な宛先だけ 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 問)
次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。
- 本書ラボのノードは alma-proxy の whitelist を経由しなければ外部へ出られない
systemctl list-units --type=service --state=runningを見れば、稼働サービスの棚卸しとしては十分であるsystemctl maskしたユニットは、systemctl startを打っても起動を拒否される- Workload Node の
iscsidは Kubernetes のコンポーネントではないので停止してよい - firewalld で許可されているポートは、必ずそのノードのいずれかのプロセスが待ち受けている
- Calico と kube-proxy は、firewalld とは別のテーブル(iptables-nft が管理)にルールを書いている
- firewalld の policy object は
--permanentを付けずに作成し、--reloadで破棄できる --set-target=REJECTで拒否された通信は、DROPと違って即座に失敗する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 / sssd は enabled だが 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 の speaker・calico-node・node_exporter・kube-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)
