はじめに
最近セキュリティ問題が騒がれているので、自宅で動かしてるKubernetesクラスタのUbuntuもいい加減根本的な部分をメンテしないと思っていたのですが、なかなか時間が取れず放置していました。 普段は apt upgrade は定期的に回してたのである程度はパッチ適用されていたものの古いカーネルのままだったり、夏くらいにKubernetesをえいやで作っていたものがずっと残っていたので諸々、細かい部分で躓きがありました。
今回はちゃんと脆弱性に対応したかったので、Claudeと一緒に作業しました。そのときの作業ログとして参考になればと
TL;DR
-
apt upgradeだけではカーネル / libcの修正は反映されない →reboot-required.pkgsを見て再起動する - KVMホスト上のノードはホストごと止まる → ゲストのパッチを先に当てて、停止はホスト単位で1回にまとめる
- 起動しっぱなしの検証環境では、壊れた永続化設定・古いVM定義・期限切れのトークンが隠れる → 再起動して初めて見つかる
- ローカルPV / ノード固定のPod × PDB はdrainを止める → 事前に縛りを確認。cnpgはメンテナンスモードで回避(今後は
enablePDB)
環境
物理サーバ2台にKVMを入れて、KubernetesのノードはVMで立っているので物理側のOS VM側のOS含めてメンテが必要でした。ざっくりこんな感じ
| 名前 | 種別 | 役割 | ネットワーク |
|---|---|---|---|
| host-a | 物理(ミニPC) | KVMホスト | LAN |
| cp | host-a上のVM | control-plane | libvirt既定のNATネットワーク |
| worker-1 | host-a上のVM | worker | libvirt既定のNATネットワーク |
| host-b | 物理(ミニPC) | KVMホスト | LAN |
| worker-2 | host-b上のVM | worker(ローカルPVを持つ) | LANに直接接続 |
ポイントはworker-2だけLAN側にいて、APIサーバはNATの内側にいるところです。host-aの転送設定で無理やりつないでるので、ちょっといびつな構成になっている(後でノードを急いで作った名残なので今後整理予定)
- OSは全部Ubuntu 24.04(ホストはHWEカーネル、ゲストはGAカーネル)
- Kubernetesはkubeadmで構築、ランタイムはcontainerd
- この記事で出てくるのはArgo CD、Sealed Secrets、CloudNativePG(以下cnpg)、local-path-provisioner あたり
やったこと
状況確認
いきなり更新しないで、まずは今どうなってるかをこのあたりの観点で見ました
-
OSの状態
- 動いてるカーネルと、インストール済みの最新カーネルに差がないか
- 更新待ちのパッケージと、再起動待ちのパッケージ(libcやapparmorなど)が何か
- どのくらい再起動してないか
- サポート期限と、ESMがないと入らない更新があるか
-
更新で壊したくないもの
- kubeletやcontainerdなど、K8sまわりのパッケージがholdされてるか
- GPUドライバなどDKMSのモジュールが、新しいカーネル向けにビルド済みか
- ディスク(/boot)に空きがあるか
-
構成
- どの物理ホストにどのVM(ノード)が載ってるか
- ホストを再起動したとき、VMが自動で上がる設定になってるか
- コントロールプレーンが何台で、どこにいるか
-
止めたときの影響
- そのノードに何のPodがいて、他のノードに移れるか(ローカルPV、ノード固定)
- PDBでdrainが止まらないか
- 移り先のノードにリソースの余裕があるか
-
戻せるか
- etcdのバックアップがあるか(VMの外にも置いてるか)
- 各ホストとVMにちゃんとSSHで入れるか(接続情報を忘れてないか)
それぞれ、こんなコマンドで確認できます(Ubuntu 24.04 + kubeadm + libvirtの場合)
# --- OSの状態(各ホスト・VMで)---
uname -r # 動いてるカーネル
sort -u /var/run/reboot-required.pkgs # 再起動待ちのパッケージ(なければファイル自体がない)
sudo apt update && apt list --upgradable # 更新待ちのパッケージ
uptime # どのくらい再起動してないか
pro security-status # サポート期限とESMの更新
# --- 更新で壊したくないもの ---
apt-mark showhold # holdされてるパッケージ
sudo apt-mark hold kubelet kubeadm kubectl containerd # 未holdなら
dkms status # DKMSモジュールが新カーネル向けにもあるか
df -h /boot / # ディスクの空き
# --- 構成(KVMホストで)---
systemd-detect-virt # noneなら物理
sudo virsh list --all # 載ってるVM
sudo virsh list --all --autostart # 自動起動するVM(空なら要注意)
kubectl get nodes -o wide # ノード、役割、カーネル、IP
# --- 止めたときの影響 ---
kubectl get pods -A -o wide --field-selector spec.nodeName=<ノード名> # そのノードのPod
kubectl get pv -o custom-columns=PV:.metadata.name,CLAIM:.spec.claimRef.name,SC:.spec.storageClassName,NODE:'.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]' # ノードに縛られたPV
kubectl get pdb -A # ALLOWED DISRUPTIONS が0のものに注意
kubectl describe node <移り先ノード> | grep -A6 'Allocated resources' # 移り先の余裕
# --- 戻せるか ---
ls -lh /var/backups/etcd-*.db # etcdのスナップショット(取り方はQ3参照)
ssh -o ConnectTimeout=5 <ノード> hostname # 全台に入れるか
パッケージは入ってるけど、動いてるのは古いカーネルとlibc。つまりカーネルの脆弱性対応は、実質なにも効いてない状態でした。 ちなみにゲストのVMも同じで、こちらは4世代分たまってました
ついでに pro security-status も見ておくといいです。Universeのパッケージは、Ubuntu Proのesm-appsを有効にしないと入らないセキュリティ更新があります。個人なら5台まで無料なので入れときましょう
作業の流れ
KVMホストを再起動すると上のVMもまとめて止まるので、ゲストのパッチも先に当てておいて、再起動はホストごとに1回で済ませます。 ホスト1台ずつ、下の順番で進めます。各ステップの「確認」がOKになってから次へ
1. ゲスト(ノードのVM)にパッチを当てる(再起動はまだしない)
ホストを再起動するとVMも一緒に止まるので、先にゲストのパッチを入れておけば停止が1回で済みます。K8sまわりをholdするのは、kubeletやcontainerdが勝手に上がるとAPIサーバとのバージョンがずれたりコンテナが再起動されたりするから。ここで再起動しないのは、あとでホストと一緒に止めるので二度手間になるためです
sudo apt-mark hold kubelet kubeadm kubectl containerd
sudo apt update && sudo apt full-upgrade -y
確認:apt-mark showhold にK8s関連が並んでて、sort -u /var/run/reboot-required.pkgs に新しいカーネルが出てればOK
2. etcdのバックアップを取る
etcdにはクラスタの状態が全部入っているので、VMの中にしかないとホストごと壊れたときに一緒に失う可能性があります。そのため外にも置きます。chmod 600 は、Secretがほぼ平文で入っているからです
取り方はQ3を参照。取ったらVMの外にもコピーしておきます
ssh <cpのVM> 'sudo cat /var/backups/etcd-$(date +%F).db' > ~/etcd-$(date +%F).db
chmod 600 ~/etcd-$(date +%F).db
確認:ls -lh でサイズが0じゃなくて、パーミッションが -rw------- ならOK
3. そのホストにいるworkerをdrainする
いきなりVMを止めるとPodは強制終了になるし、別ノードへの移動もNotReadyを検知してから(デフォルトで5分後)になります。drainしておけば、正常に止めてからすぐ移せます
ローカルPVのDBがいるノードなら、先にcnpgをメンテナンスモードにしておきます(Q6)
kubectl drain <ノード名> --ignore-daemonsets --delete-emptydir-data --timeout=10m
kubectl get pods -A -o wide | grep -v -E 'Running|Completed'
確認:node/<ノード名> drained が出て、残ってるPendingがローカルPVのPodだけならOK。PDBで止まったらQ6、Q8へ
4. VMを止める
ホストの再起動任せにすると、libvirt-guests の設定次第でVMが止まるんじゃなくサスペンドされたり、強制終了されたりします。K8sのノードは普通に止めて普通に起動した方が安全なので、自分で shutdown します。autostartもここで見るのは、再起動してから気づいても遅いから
sudo virsh shutdown <VM名>
sudo virsh list --all
sudo virsh list --all --autostart
確認:対象のVMが全部 shut off で、autostartの一覧にも載ってればOK(載ってないと再起動後に上がりません。Q1)
5. ホストを更新して再起動する
カーネルとlibcの修正は、再起動しないと反映されません(この記事の本題)。VMは止めてあるので、ホストが上がってVMが起動すれば、ゲストも新しいカーネルで立ち上がります
sudo apt update && sudo apt full-upgrade -y
sudo reboot
確認:再起動後に uname -r が新しいカーネルで、sudo virsh list --all でVMが running ならOK
6. ノードを戻す
ノードが起動しても、cordonされたままだとPodは乗りません。cnpgのメンテナンスモードも解除しないと、PDBが外れたままになります。最後の3つの確認は、ノード→Pod→DBの順に「ちゃんと戻った」を見るためです
kubectl get nodes -o wide
kubectl uncordon <ノード名>
cnpgをメンテナンスモードにしてたら、ここで解除します(Q6)
確認:この3つがそろえばOK
-
kubectl get nodes -o wideで全ノードがReady、KERNEL-VERSIONが新しいカーネル -
kubectl get pods -A | grep -v -E 'Running|Completed'がヘッダーだけ -
kubectl get clusters.postgresql.cnpg.io -Aが全部Cluster in healthy state
ここで別ホストのノードがNotReadyになってたら、Q4を疑いましょう
7. 片付け
古いカーネルがたまり続けると /boot を圧迫します。全部消さずに1つ前を残すのは、新しいカーネルで何かあったときに戻れるようにするためです
sudo apt autoremove --purge -y
dpkg -l 'linux-image-*' | grep ^ii
確認:古いカーネルが消えて、動作中のものともうひとつくらいが残ってればOK
これをホストの台数分繰り返します
コントロールプレーンを冗長化してないので、そのホストの再起動中はAPIが止まります。とはいえ別ホストのPodは動き続けるので、自宅なら許容範囲。
Ansible化も考えたんですが、100日ぶりの再起動は絶対何か起きるので初回は手でやりました。結果、ちゃんと(?)何か起きました
作業中にハマったところ
上の流れで進めていたのですが、実際には途中で何度も足を止めることになりました。ぶつかった順に書いていきます。 特にQ4・Q5・Q7は、再起動して初めて表に出てきたものでした
Q1. 事前確認で、VMのautostartが入っていないことに気づいた
ホストを再起動する前に、念のため virsh list --all --autostart でVMの自動起動の設定を見てみたところ、一覧が空っぽでした。このまま再起動していたら、VMが上がらずクラスタが止まったままになるところでした。 VMを作ったときに設定し忘れていたようです。その後ホストを一度も再起動していなかったので、今まで気づきませんでした。再起動の前に設定して、一覧に出ることを確認してから先に進めています。
sudo virsh autostart <VM名>
sudo virsh list --all --autostart # 一覧に出ていればOK
Q2. ゲストにパッチを当てようとしたら、SSHの接続先を忘れていた
いざVMに入ってパッチを当てようとしたところ、ユーザー名も接続先も覚えていないことに気づきました。~/.ssh/config にも登録しておらず、どこにもメモを残していなかったのが原因です。 kubectl get nodes -o wide でIPを、シェルの履歴からユーザー名を拾い、ssh-keygen -F <IP> で過去に接続したことがあるかを確かめて、なんとか入れました。そのあとは ~/.ssh/config に登録して、運用ドキュメントにも書き残しています。
Q3. etcdのバックアップを取ろうとしたら、etcdctlがなかった
コントロールプレーンで etcdctl snapshot save を実行したところ、command not found と言われてしまいました。kubeadmで構築した場合、etcdはStatic Podとして動いていて、ホスト側には etcdctl が入っていないことがあるそうです。 ただしetcdのコンテナの中には etcdctl があるので、kubectl exec でコンテナの中から実行しました。保存先は、コンテナとホストで共有されている /var/lib/etcd にして、あとから移動しています。
kubectl --kubeconfig /etc/kubernetes/admin.conf -n kube-system exec etcd-<ノード名> -- \
etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
snapshot save /var/lib/etcd/snapshot.db
mv /var/lib/etcd/snapshot.db /var/backups/etcd-$(date +%F).db
ちなみに、スナップショットにはSecretがほぼ平文で入っています。VMの外にコピーしたらパーミッションが 664 になっていたので、chmod 600 で直しておきました。
Q4. host-aを再起動したら、別ホストのworker-2がNotReadyになった
host-aを更新して再起動したところ、host-aのVMは無事に戻ってきたのですが、関係ないはずのhost-bのworker-2がNotReadyになってしまいました。kubeletのログを見ると、APIサーバへの接続が connection refused で失敗し続けています。
調べていくと、原因が2つ重なっていました。 まずlibvirtのNATネットワークは、外から内側への新規接続をREJECTする仕様です。そのため相手からは「connection refused」に見えます。
$ sudo iptables -S LIBVIRT_FWI
-A LIBVIRT_FWI -d 192.168.122.0/24 -o virbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A LIBVIRT_FWI -o virbr0 -j REJECT --reject-with icmp-port-unreachable
これを許可するルールは、ノードを追加したときに再起動後も復元されるようsystemdのunitで永続化していたはずでした。ところがログを見ると、そのunitが起動のたびに失敗していました。
$ journalctl -b -u <unit名>
<設定ファイル>: line 30: HOME: unbound variable
<unit名>: Main process exited, code=exited, status=1/FAILURE
systemdのシステムサービスには $HOME が設定されないため、set -u 付きのスクリプトがそこで止まっていたようです。手で実行すると普通に動くので、作ったときには気づけませんでした。その後は一度もホストを再起動していなかったので、ずっと壊れたままだったというわけです。
とりあえず応急処置としてFORWARDチェーンの先頭に許可ルールを差し込んで復旧させ、そのあとunitはいじらずにdrop-inで HOME を渡して直しました。
sudo mkdir -p /etc/systemd/system/<unit名>.service.d
printf '[Service]\nEnvironment=HOME=<ホームディレクトリ>\n' \
| sudo tee /etc/systemd/system/<unit名>.service.d/env-home.conf
sudo systemctl daemon-reload && sudo systemctl restart <unit名>
Q5. worker-2が戻ったら、今度は一部のアプリがイメージを取得できなくなった
Q4の対応でworker-2はReadyに戻ったのですが、あるアプリだけが ImagePullBackOff のまま、いつまでたっても起動しませんでした。
failed to fetch oauth token: unexpected status from GET request to https://ghcr.io/token?...: 403 Forbidden
エラーを見ると、imagePullSecretに入れていたGHCRのトークンが期限切れになっていました。 Q4でNotReadyが5分を超えたためにtaintによる退避が動き、Podが作り直されていたんですね。このアプリは imagePullPolicy: Always なので、作り直しのときにイメージを取り直そうとして失敗していました。それまではPodがずっと動き続けていたので、期限切れに気づけていなかったようです。
まずは、トークンが生きているかを中身を画面に出さずに確かめました。
auth=$(kubectl -n <ns> get secret <secret名> -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d \
| python3 -c 'import json,sys; print(next(iter(json.load(sys.stdin)["auths"].values()))["auth"])')
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Basic $auth" \
"https://ghcr.io/token?scope=repository:<owner>/<image>:pull&service=ghcr.io" # 200なら有効
SecretはSealed SecretsでGitOps管理しているので、有効なトークンで kubeseal し直してリポジトリに反映し、rollout restart で復旧させました。 ちなみにここでも少しハマりまして、sealed-secretsはSecretを作り直さず中身だけを更新するので、creationTimestamp を見ていても反映されたかはわかりません。しばらく待ちぼうけしたあと、上の認証テストで確かめるように切り替えました。
Q6. worker-2をdrainしようとしたら、DBのPDBで止まった
worker-2にもパッチを当てるためにdrainしようとしたところ、cnpgのDBのPDBが ALLOWED DISRUPTIONS 0 になっていて、そのままではdrainできない状態でした。 worker-2には、local-pathのPVを使うcnpgの単一インスタンスのDBが乗っています。cnpgはprimaryを守るPDBを作るので、他に移れる先のない単一インスタンスだと、drainが通らないんですね。
そこでcnpgを nodeMaintenanceWindow でメンテナンスモードにしてから通しました。PDBが外れ、退避したDBのPodは同じPVCを使うので、ノードが戻るまでPendingで待ってくれます。Postgresも正常に停止して、データもそのまま戻ってきました。
kubectl -n <ns> patch cluster <cluster名> --type merge \
-p '{"spec":{"nodeMaintenanceWindow":{"inProgress":true,"reusePVC":true}}}'
# … drain → メンテナンス → uncordon …
kubectl -n <ns> patch cluster <cluster名> --type merge \
-p '{"spec":{"nodeMaintenanceWindow":{"inProgress":false}}}'
ただ実行時にcnpgから「.spec.enablePDB を使うように」という警告が出たので、次回からはそちらに切り替える予定です。
Q7. worker-2を中から再起動したら、VMが戻ってこなかった
worker-2にパッチを当ててゲストの中から systemctl reboot したのですが、いつまでたっても返事がありません。host-bを見に行くと、VMが shut off のまま止まっていました。on_reboot は restart になっていて、設定自体は合っています。
host-bのlibvirtdのログを見ると、再起動した瞬間にこんなエラーが出ていました。
Unable to get XATTR trusted.libvirt.security.ref_dac on /var/lib/libvirt/boot/virtinst-<ランダム>-cloudinit.iso: No such file or directory
VMは virt-install --cloud-init で作っていて、そのときの一時ISOはもう削除されています。ところが、初回起動からずっと動いていた実行中の定義は、まだそのISOを参照したままだったようです。そのせいで再起動時の後片付けに失敗して止まったのではないか、と推測しています(確証までは取れていません)。
virsh start で起動して復旧させたところ、永続化された定義(CD-ROMは空)から立ち上がったので、以降は再発しないはずです。ついでにautostartも設定しておきました。
sudo virsh start <VM名>
sudo virsh autostart <VM名>
sudo virsh domblklist <VM名> --details # cdrom の Source が - ならOK
Q8. host-bのメンテナンスでdrainしたら、いつまでも終わらなかった
最後にhost-b自体を更新するため、worker-2を丸ごとdrainしたところ、あるDeploymentだけPDB違反で退避できず、drainがリトライし続ける状態になりました。代わりのPodを見てみると、Pendingのままどこにも割り当てられていません。
0/3 nodes are available: 1 node(s) didn't match Pod's node affinity/selector,
1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: },
1 node(s) were unschedulable.
そのDeploymentはレプリカ2で、PDBが minAvailable: 1 でした。ところがnode affinityでworker-2にしか置けない設定になっていたため、worker-2をdrainすると代わりのPodを置く場所がなく、PDBを満たせないという状態です。 依存先のDBも止める前提だったので、残りの1個は kubectl delete pod で削除してdrainを通しました。「1ノードに固定する」と「PDBで守る」は両立しないので、どちらかを見直す予定です。
次からどうするか
結局いちばんの対策は「ちゃんと定期的に再起動する」です。再起動は脆弱性対応でもあるし、「永続化したつもり」の設定のテストにもなります
- ノードには kured を入れる。
/var/run/reboot-requiredを見て、1台ずつdrain→再起動→uncordonしてくれます - KVMホストはVMごと止まるのでkuredでは無理。月一でメンテ日を決めて、今回の手順をAnsibleにする
-
reboot-requiredが立ちっぱなしなら気づけるように監視しておく - Ubuntu Pro(esm-apps / Livepatch)を入れて、再起動までの穴も塞ぐ
- 自作のsystemd unitは、作ったら一回再起動して確かめる(手で動く=起動時に動く、ではない)
- pull用トークンの期限はカレンダーに入れとく。キャッシュがあるうちは絶対気づかないので
- 開発用の単一インスタンスDBは
enablePDB: falseを検討。cnpgの公式ドキュメントでも、そうしないとdrainできないと書かれてます。nodeMaintenanceWindowは後方互換のために残ってる仕組みで、今は.spec.enablePDB推奨です(公式ドキュメント)
おわりに
「aptは回してるから大丈夫」は大丈夫じゃなかった。 再起動を先延ばしにするほど「再起動したら壊れるもの」がたまっていくので、自宅ラボでもたまにはちゃんと再起動したりメンテしないとなって思いました。
参考
作業中に使ったコマンドや仕様の確認に使ったドキュメント系
Ubuntu
-
Handling security vulnerabilities(Ubuntu Pro Client):
pro security-statusとESMについて - apt-mark(8):パッケージのhold
Kubernetes
-
Installing kubeadm:kubelet / kubeadm / kubectlを
apt-mark holdしておく理由 -
Safely Drain a Node:
kubectl drainとuncordon - Specifying a Disruption Budget for your Application:PDBの考え方(Q6、Q8)
- Taints and Tolerations(Taint based Evictions):NotReadyが続くとPodが退避される仕組み(Q5)
- Operating etcd clusters for Kubernetes:etcdのスナップショット(Q3)
-
Images(Image pull policy):
imagePullPolicy: Alwaysの挙動(Q5)
libvirt / KVM
-
virsh:
autostart/start/shutdown/domblklist(Q1、Q7) - Firewall and network filtering in libvirt:NATネットワークでは外からの新規接続が拒否される件(Q4)
- libvirt-guests:ホスト停止時に既定ではVMがサスペンドされる件(作業の流れ 4.)
systemd
-
systemd.exec:
Environment=。User=などを指定しないサービスでは$HOMEが設定されない件(SetLoginEnvironment=の項目)(Q4) -
systemd.unit:drop-in(
<unit名>.service.d/*.conf)での上書き(Q4)
コンテナレジストリ / Secret
-
Working with the Container registry(GitHub Docs):GHCRの認証とPAT(classic)の
read:packages(Q5) -
Sealed Secrets:
kubesealによるSecretの封印(Q5)
その他
-
CloudNativePG: Kubernetes upgrade and maintenance:
nodeMaintenanceWindowとenablePDB(Q6) - kured - Kubernetes Reboot Daemon:ノードの自動再起動
