はじめに
この記事はClaude Codeによって生成されており、一部人間の手で修正されております。
人間の感想
Claudeの勉強のために今回の作業を実施してみました。
Claudeには事前に行動計画やルールブックを作らせたりして他のタスクにも共通するようにしてみました。
トラブルシュートも自分で試行錯誤していたので、改めて便利な世の中になったなと感じました。
放置する時間が多かったので自分は他の事していたのですが、確認待ちに気が付かず放置したことでトークンのリミットが更新されていた。
なのでトークン切れで待つ事はなかったです。
目的
自宅の検証用マシンでWindowsに仮想環境を構築しての検証・開発環境として使っていました。
最近、出張などが増えたため、外出先からも自宅の環境にリモートアクセスしたいと考えるようになりました。
そこで、
Ubuntu Server + KVMをベースにしたホスト環境に作り直し、Tailscale経由でネットワーク越しにアクセスできる構成に切り替えることにしました。
その環境構築の一環として、OpenShift(OCP)のSingle Node OpenShift(SNO)環境を2種類 —— Assisted Installer版とAirgap版 —— 構築してみました。
- 全てAnsible Playbookで自動化(作業単位ごとにPlaybook・ラッパースクリプト・単体テストをセットで用意)
- 構築・設計はClaude Codeと対話しながらClaude Codeが主体となり作業を進行
- 本記事はアーキテクチャの紹介と、実機で構築する過程で実際にハマった技術的なポイントのまとめです
- この記事も作業の過程で適宜修正し、最終的に人による指摘を経てアップしております。
2種類のSNO環境について
-
sno-assisted(Assisted Installer版、OCP 4.22.4) -
sno-airgap(Agent-based Installer版、アップデート検証目的で4.18.47でインストール)
物理マシン構成
-
ワークステーション(Ubuntu)
- CPU Xeon E5-2697 v3 14コア 28スレッド
- RAM 48GB
- SSD 500GB
- HDD 1TB
- 電源はスマートプラグ経由で供給(リモートでの電源断/再投入用)
-
HP ノートPC
- Windows上のWSL2でRHEL9を動かし、これを管理ホストとして使用(oc/kubectl・ミラーホスト・ミラーレジストリはすべてこのRHEL9上)
全体アーキテクチャ
[管理ホスト] (RHEL9。HPノートPC上のWindows→WSL2内で稼働。持ち出し可能)
├── oc / kubectl ............. 両SNOクラスタへのAPIアクセス
├── ミラーホスト .............. oc-mirrorでOCPイメージをインターネットから取得
└── ミラーレジストリ .......... 取得イメージを保持(mirror-registry、quayベース)
│
│ Tailscale(WireGuardメッシュVPN)
▼
[ワークステーション] (Ubuntu Server 26.04 LTS。自宅設置、KVMホスト)
├── Cockpit ................... Web UIからのKVM操作
├── KVM / libvirt .............仮想化基盤
├── libvirtネットワーク(通常網) → VM: sno-assisted (Assisted Installerでインストール)
└── libvirtネットワーク(隔離網) → VM: sno-airgap (Agent-based Installerでインストール、擬似Airgap)
ポイントは以下の4つです。
- 管理ホストが「ミラーホスト」と「ミラーレジストリ」を兼務している(個人の練習環境なので、専用サーバーは別途立てずに同居)
- Tailscaleで管理ホストとワークステーションを繋ぎ、ポート開放不要でリモートアクセスできるようにしている
- Airgap版SNOは、物理的な完全隔離ではなくKVM上の隔離ネットワーク+ファイアウォールで「ミラーレジストリ宛の通信のみ許可」という擬似的なAirgap環境を作っている
- リモートワークではネットワークだけでなく物理的な電源も操作できる必要があるため、ワークステーションの電源をスマートプラグ経由で供給し、BIOS側で「AC電源投入時に自動的に電源ON」する設定を有効化している(サーバー機の
iDRACのような帯外管理を、個人ラボ向けに簡易的に代替する構成)
採用したツール
仮想化基盤・ホスト側
| ツール | 用途 |
|---|---|
| Ansible | 全工程の自動化 |
| KVM / QEMU / libvirt | 仮想化基盤(virsh・virt-install・community.libvirt) |
| Cockpit | ブラウザからのKVM操作・VMコンソール |
| nftables | libvirtネットワークの遮断/許可ルール調整(隔離網の実装に利用) |
ネットワーク・リモートアクセス
| ツール | 用途 |
|---|---|
| Tailscale | 管理ホスト⇔ワークステーション間の疎通(ポート開放不要) |
| dnsmasq(libvirt内蔵) | 隔離網内でのミラーレジストリ名前解決 |
| chrony/chronyd | 隔離網向けNTPサーバー(オフライン環境でのホスト時刻検証をパス) |
WSL2(wsl.conf) |
管理ホスト側での/etc/hosts自動生成無効化など |
OpenShiftインストール・構築
| ツール | 用途 |
|---|---|
| mirror-registry | プライベートコンテナレジストリ(Airgap用、Quayベース) |
| oc-mirror (v2) | OCPイメージ・Operatorカタログのミラーリング |
| podman | レジストリ認証まわり |
| Assisted Installer | 通常網側SNOのインストール(console.redhat.comのSaaS) |
| Agent-based Installer | 隔離網側SNOのオフラインインストール |
| oc / kubectl | クラスタ操作全般 |
クラスタ構成(Day0・Operator)
| ツール | 用途 |
|---|---|
| cert-manager Operator | HostPath Provisionerのwebhook証明書発行 |
| HostPath Provisioner(HPP) | 永続ストレージ(ノードのrootFSを利用) |
| OLM(Subscription/CatalogSource) | Operatorインストール、Airgap側はミラーカタログに切替 |
| KubeletConfig / MachineConfigPool | ノードリソース割当変更とロールアウト管理 |
リポジトリ構成(ざっくり)
「作業単位ごとにPlaybook・ロール・ラッパースクリプト・単体テストをセットで用意する」という規約で作っているので、ディレクトリ構成はこんな感じになっています(一部抜粋)。
ocp-sno-lab-setup/
├── ansible.cfg
├── inventory/
│ └── hosts.ini # mgmt(管理ホスト自身) + workstation
├── playbooks/
│ ├── 01_kvm_libvirt_setup.yml # KVM/libvirt導入
│ ├── 02_libvirt_networks_setup.yml # libvirtネットワーク構築(通常網・隔離網)
│ ├── 03_mirror_registry_setup.yml # ミラーレジストリ構築
│ ├── 04_mirror_sync.yml # OCPイメージのミラーリング
│ ├── 05_sno_assisted_vm_provision.yml
│ ├── 06_sno_assisted_install_check.yml
│ ├── 07_sno_airgap_vm_provision.yml
│ ├── 08_sno_airgap_install_check.yml
│ ├── 09_api_access_verify.yml
│ ├── 10_mgmt_access_setup.yml # 管理ホスト→VMサブネットの直接到達性
│ └── 12_mirror_ntp.yml # 隔離網向けNTPサーバー
├── roles/ # 各Playbookに対応するロール(tasks/defaults/templates)
│ ├── kvm_libvirt/
│ ├── libvirt_networks/
│ ├── mirror_registry/
│ ├── mirror_sync/
│ ├── mirror_ntp/
│ ├── sno_assisted_vm/ sno_assisted_install/
│ ├── sno_airgap_vm/ sno_airgap_install/
│ ├── mgmt_access/ tailscale_accept_routes/
│ └── api_access_verify/
├── scripts/ # Playbookのラッパー(ログ保存+単体テスト自動実行)
│ ├── lib/common.sh
│ └── 01_kvm_libvirt_setup.sh 〜 12_mirror_ntp.sh
├── tests/ # 1 script = 1 test(test_NN_*.sh)
├── logs/ # 実行ログ(自動生成)
├── secrets/ # 認証情報(0600/0700で権限を絞る)
├── kubeconfigs/<cluster>/kubeconfig # クラスタごとのkubeconfig
└── docs/
├── basic-design.md # 全体構成・設計判断とその理由
├── detailed-design.md # 各作業の詳細仕様(実機で判明した注意点も追記していく)
├── action-plan.md # フェーズ一覧・実行順序
└── required-tools.md
もう一つポイントとして、「Playbookを実行 → ログをlogs/に保存 → 対応する単体テストを自動実行 → 失敗したら"Claudeへの調査依頼コマンド"を出力する」というラッパースクリプト(scripts/NN_*.sh)を全作業単位に用意しています。単体テストはget pvcがBoundになっているか、virsh net-infoがActiveを返すか、といった実際の動作確認をするようにしていて、「Ansibleがokと言った」だけでは満足しないようにしています。
構築の流れ
- Ubuntu Server 26.04 LTSインストール
- Tailscale導入(管理ホスト⇔ワークステーション間の疎通確立)
- Cockpit導入
- KVM/libvirt導入
- libvirtネットワーク構築(通常網・隔離網)
- ミラーレジストリ構築
- ミラーリング実行(OCPリリースイメージをミラーレジストリへ同期)
- sno-assisted用VM作成・Assisted Installerでのインストール
- sno-airgap用VM作成・Agent-based Installerでのインストール
- 両クラスタのAPI疎通確認
以降ではその過程で実際にハマったポイントをまとめます。
各フェーズはAnsible Playbookに分割し、実行スクリプトが「コマンド実行 → ログ保存 → 単体テストで実際に機能しているか検証」まで自動でやってくれる構成にしています(単体テストの考え方は前述の通りです)。
ハマったポイント集
ここからが本題です。実機で構築を進める中で実際に踏んだ落とし穴と、その対処法をまとめます。
1. Ubuntu 26.04 LTSではqemu-kvmが仮想パッケージになっていた
Ubuntu 24.04以前の感覚でapt install qemu-kvmしようとすると失敗します。
E: Package 'qemu-kvm' has no installation candidate
Package qemu-kvm is a virtual package provided by:
qemu-system-x86-hwe
qemu-system-x86
qemu-kvmは実体のない仮想パッケージになっており、具体的なパッケージ名(qemu-system-x86)を明示的に指定する必要がありました。
2. AnsibleのlibvirtモジュールにはPython側の依存パッケージが要る
community.libvirt.virt_netを実行すると以下のエラーで落ちました。
"msg": "The `lxml` module is not importable. Check the requirements."
libvirt-daemon-systemをインストールすればpython3-libvirtは依存関係で入りますが、python3-lxmlは別途インストールが必要でした。Ansibleの実行対象ホスト側にPythonの依存パッケージが必要になる、というのはついOSパッケージだけ見て見落としがちなポイントです。
3. Ansibleのvirt_netモジュール、stateとautostartを同時指定するとautostartが無視される
# これだとautostartが反映されないことがある
- community.libvirt.virt_net:
name: my-net
state: active
autostart: true
タスクを分けて実行することで解決しました。
- community.libvirt.virt_net:
name: my-net
state: active
- community.libvirt.virt_net:
name: my-net
autostart: true
単体テストで「Active」だけでなく「Autostart」も明示的に確認するようにしてから発覚した問題で、テストの網羅性の大切さを再認識しました。
4. libvirtのisolatedネットワークは「例外を一切差し込めない」完全遮断だった
Airgap環境を擬似的に再現するために、最初は<forward>要素を持たないisolatedモードのlibvirtネットワークを作り、「ミラーレジストリ宛だけは別途nftablesルールで許可する」という設計にしていました。
しかし実機で試すと、libvirtが自動生成するFORWARDチェインに**宛先を一切問わない無条件のreject**が入っており、後から別テーブルで許可ルールを追加しても素通りしてくれません。
nftablesでは同一hook(forward)に複数のテーブルが登録されている場合、drop/rejectは即座にそのパケットの処理を終了させますが、acceptは「そのテーブルの判定が終わった」ことを意味するだけで、後続のテーブルには処理が引き継がれます。つまり「先に自分のテーブルでaccept」しても、libvirtの無条件rejectが後で効いてしまい、通信は結局落ちる、ということでした。
解決策: ネットワーク自体はforward mode='nat'にする(libvirt側は緩くaccept)。その代わりホスト側に、libvirtのip filterテーブル(優先度0)より先に評価される独自のnftablesテーブル(優先度-10)を用意し、「ミラーレジストリ宛はaccept、それ以外は即drop(終端)」というルールを先出しする、という構成に変更しました。
table ip sno_airgap_egress {
chain forward {
type filter hook forward priority -10;
iifname "virbr-airgap" ip daddr <ミラーレジストリのIP> tcp dport 8443 accept
iifname "virbr-airgap" drop
}
}
dropは即終端なので、この独自テーブルで弾かれなかった通信だけが後続のlibvirtの緩いacceptルールに到達する、という流れです。再起動後も同じ状態を再現するため、systemdのoneshotサービスとしても登録しています。
5. mirror-registryの初期パスワード、キャプチャし忘れると再取得不能
mirror-registry installは--initPasswordを指定しないと、ランダムなパスワードを生成してターミナルに一度だけ出力します。Ansibleのcommandモジュールでラップして実行した際、その標準出力を明示的に表示・保存する処理を入れていなかったため、生成されたパスワードを取りこぼしてしまいました。
解決策: gitea構築時と同じパターンで、先にパスワードを自前で生成してsecrets/配下に保存し、--initUser / --initPasswordに明示的に渡すようにしました。Playbookを再実行しても同じパスワードが使われる(冪等)ようにしてあります。
6. oc-mirror v2はREGISTRY_AUTH_FILEという環境変数名と衝突する
ミラーレジストリへの書き込み認証情報をREGISTRY_AUTH_FILE環境変数で渡そうとしたところ、以下のようなエラーで落ちました。
[ERROR] : parsing config error parsing local storage configuration :
parsing environment variable REGISTRY_AUTH_FILE: yaml: unmarshal errors:
line 1: cannot unmarshal !!str `/root/d...` into configuration.Parameters
oc-mirror v2は内部でDocker Distributionのレジストリサーバーを埋め込みで起動しており、そちらのサーバーはREGISTRY_から始まる環境変数を「自分自身の設定ファイルの上書き」として解釈します(distribution/distributionの標準機能)。そのためREGISTRY_AUTH_FILEという名前がまさにこの仕組みと衝突し、パス文字列をYAML設定としてパースしようとして落ちていました。
解決策: oc-mirror --helpにも書いてある通り、oc-mirrorは実は認証情報をpodmanの標準的な認証ファイルの場所($XDG_RUNTIME_DIR/containers/auth.json)から自動で読みます。環境変数を使うのをやめ、Pull Secretの内容とミラーレジストリの認証情報をこのファイルへ直接マージする方式に変更しました。
7. RHEL9向けのopenshift-installバイナリは中身の名前が違う
$ tar tzf openshift-install-rhel9-amd64.tar.gz
openshift-install-fips
RHEL9向け(FIPS対応ビルド)のtarballを展開すると、中に入っているバイナリの名前がopenshift-installではなくopenshift-install-fipsでした。単純にtar xzf ... openshift-installと決め打ちすると「Not found in archive」で失敗します。リネームして配置すれば動作は同じです。
8. WSL2でTailscaleに参加しても、Windows本体は別ネットワーク扱い
作業環境がWSL2(Claude Codeを動かしているRHEL9相当の環境)だったのですが、WSL2側でTailscaleに参加しただけでは、Windows本体のブラウザからはCockpitのURL(https://<ワークステーション>:9090)にアクセスできませんでした。
DNS_PROBE_FINISHED_NXDOMAIN
WSL2とWindows本体は別々のネットワークスタックを持っているため、WSL2側のTailscale参加は自動的にWindows本体には反映されません。Windows側にも別途Tailscaleクライアントをインストールし、同じアカウントでログインする必要がありました。
9. CockpitのVMコンソールが表示できない/CD-ROMアタッチ方法を変えるとPermission denied
VM作成直後、Cockpitのブラウザコンソールを開くと以下のメッセージだけが表示され、画面が見られませんでした。
This machine has a SPICE graphical console that can not be shown here.
virt-installはグラフィックデバイスを指定しないとデフォルトでSPICEを選びますが、CockpitのブラウザコンソールはSPICEに対応していません。--graphics vnc,listen=0.0.0.0を明示的に指定してVNCにすることで解決しました。
もう一つ、「CD-ROMの接続方法(バス種別)によって結果が変わることがある」という過去の経験から、手動でCD-ROMをUSB接続(model='usb-bot')やSCSI接続に変更して試したところ、今度はVM自体が起動に失敗するようになりました。
internal error: process exited while connecting to monitor: ...
Could not open '/var/lib/libvirt/images/iso/sno-assisted-discovery.iso': Permission denied
原因をvirsh dumpxmlで確認すると、ディスク定義がtype='volume'(ストレージプール経由)に変わっており、この構成だと権限解決がうまくいっていませんでした。結局、シンプルなtype='file'+bus='sata'(ファイルパス直接指定)に戻すことで解決しました。CD-ROMの接続方法を変えて改善するケースも実際にあるようですが、少なくとも今回の環境ではシンプルな構成が一番安定していました。
10. WSL2から特定サイズのパケットだけが確実にロストし、SSHが鍵交換の途中で無限にハングする
これが今回一番手強かった問題です。VM作成後、管理ホスト(WSL2上のRHEL9環境)からワークステーションへSSHしようとしたところ、TCP接続・SSHバナー交換までは成功するのに、鍵交換の途中で応答が返らず無期限にハングするようになりました。
debug1: kex: algorithm: curve25519-sha256
debug1: expecting SSH2_MSG_KEX_ECDH_REPLY
(ここで何十秒待っても応答なし)
ssh ubuntu@localhostのようにワークステーション自身からの接続は正常に動作するため、sshd自体は健全。原因の切り分けに以下を順番に試しましたが、いずれも外れでした。
-
OpenSSHの
PerSourcePenalties(接続元ペナルティ)機構: 何度も接続を試みてLoginGraceTime超過を繰り返した結果、実際に発動していた(srclimit_penalise: ... activating ipv4 penaltyのログを確認)。ただしPerSourcePenaltyExemptListで除外設定しsystemctl restart sshしても症状は変わらず。ペナルティは症状の結果であって原因ではなかった -
ホスト鍵の不一致説: サーバー再構築でホスト鍵が変わっていたのではと疑い、
known_hostsをクリアしてStrictHostKeyChecking=accept-newで再接続しても変化なし -
MTU超過によるKEXメッセージの断片化失敗説:
ping -M do -s 1400は試したものの、Tailscaleのインターフェース自体がMTU 1280に制限しているため「ローカルで即座に拒否」されるだけで、経路上の実態は分からず。サーバー側のホスト鍵をed25519のみに絞ってKEX応答を小さくしても症状は変わらず、この時点では「MTUの線は薄い」と判断していました
決め手になったtcpdump
サーバー側・クライアント側の両方でtcpdumpを仕掛けて接続を試みたところ、決定的な証拠が取れました。
# クライアント(WSL2)が送信しているセグメント
16:46:05 ... seq 22:1250, ... length 1228 ← KEXINITの前半(1228バイト、IPパケット全体で1280バイト)
16:46:05 ... seq 1250:1406, ... length 156 ← KEXINITの後半(156バイト)
# サーバー側の応答
... ack 22, ... sack 1 {1250:1406} ← 後半(156バイト)は受信済み、前半(1228バイト)は未受信のままSACK
クライアントはKEXINIT(約1384バイト)をMSS(1240)に従って2つのセグメントに分割して送信していました。ところがIPパケット全体でちょうど1280バイト(=tailscale0インターフェースのMTUぴったり)になる方のセグメントだけが、6回リトライしても毎回確実にロストするという、非常にピンポイントな症状でした。もう片方の156バイトのセグメントは毎回正常に届いていたので、単純な帯域・経路の問題ではなく、特定のパケットサイズに対する再現性のある不具合だと確定しました。
WSL2の仮想ネットワークアダプタ(Hyper-V仮想スイッチ)がインターフェースMTUちょうどのサイズのパケットを扱う際に何か問題を起こしている、というのが最も説明のつく仮説です。TCPオフロード機能(TSO/GSO/GRO)を無効化しても改善しませんでしたが、tailscale0のMTU自体を1280から1200へ下げることで解決しました。
ip link set tailscale0 mtu 1200
再起動のたびに設定が消えてしまうため、systemdのoneshotユニットとして恒久化しました。
[Unit]
Description=Lower tailscale0 MTU to 1200 (workaround for WSL2 packet loss at 1280-byte boundary)
After=tailscaled.service
Wants=tailscaled.service
Requires=tailscaled.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'for i in $(seq 1 20); do ip link show tailscale0 >/dev/null 2>&1 && break; sleep 0.5; done; ip link set tailscale0 mtu 1200'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
教訓: 「繋がったり繋がらなかったりする」「特定の操作の後だけハングする」ような症状に遭遇したら、ログや設定の見直しだけで仮説検証を続けるより、早い段階でtcpdumpを両端で取って実際のパケットを見た方が結局早いです。ペナルティ機構やホスト鍵といった「もっともらしい」候補に何度も飛びついて時間を溶かしてしまいましたが、パケットキャプチャを見た瞬間に一発で真因が分かりました。
11. Tailscaleサブネットルーティングを設定しても、libvirtのNATネットワークへの新規接続がrejectされる
SSH問題が解決した後、SNOノード(sno-assisted-net内の192.168.150.0/24)へ管理ホストから直接アクセスしたくなり、Tailscaleのサブネットルーティングを設定しました。
# ワークステーション側でサブネットをアドバタイズ
sudo tailscale up --advertise-routes=192.168.150.0/24,192.168.151.0/24
# Tailscale管理コンソールでルートを承認後、RHEL9側で受け入れを有効化
tailscale set --accept-routes
しかしこれだけではpingすら通らず、以下のICMPエラーが返ってきました。
From 100.78.62.89 icmp_seq=1 Destination Port Unreachable
原因は、libvirtがnatモードのネットワークに自動生成するFORWARDチェイン(LIBVIRT_FWI)が「ct state related,established以外の新規着信はreject」というルールになっていることでした。NATネットワークは「VM側から外に出た通信の戻り」しか通さない設計なので、外部(今回はTailscale経由の管理ホスト)から新規に接続を開始する通信は素通りできません。
ここでPitfall 4と同じ罠を踏みかけました。「別テーブルでacceptルールを追加すればいいはず」と考えて試しましたが、案の定うまくいきません。
# これだけでは効かない(別テーブルのacceptは後続のlibvirtのrejectを止められない)
nft add table ip mgmt_ingress
nft add rule ip mgmt_ingress forward iifname "tailscale0" ip daddr 192.168.150.0/24 accept
nftablesで複数テーブルが同一hookに登録されている場合、acceptは「そのテーブルの判定終了」を意味するだけで後続のテーブル評価は続行されてしまう、というのは前述の隔離ネットワークの件と全く同じ仕組みです。今回は「許可したい」側なのでdropで終端させる訳にもいかず、別の手段が必要でした。
解決策: libvirtが管理するtable ip filterのFORWARDチェイン自体の先頭に、直接ルールを挿入しました。
nft insert rule ip filter FORWARD iifname "tailscale0" ip daddr 192.168.150.0/24 accept
nft insert rule ip filter FORWARD iifname "tailscale0" ip daddr 192.168.151.0/24 accept
同じチェイン内であれば、上から評価されてacceptにヒットした時点で処理が終わるため、後続のLIBVIRT_FWIなどへのjumpまで到達しません。libvirtがネットワーク再起動時にこのチェインを再生成してルールを消してしまう可能性があるため、libvirtd.service起動後に冪等チェック付きで再投入するsystemdユニットとして恒久化しました。
教訓: 「別テーブルでaccept追加」は今回のようなlibvirt+nftablesの構成では効かないケースが多く、許可を確実にしたいなら管理対象のチェイン自体に割り込ませる必要があります。同じ落とし穴に2回(隔離ネットワークの時と今回)ハマったので、次からは最初からこのパターンを疑うようにします。
sno-airgap構築編: さらにハマったポイント集
sno-assistedが完成した後、いよいよsno-airgap(Agent-based Installer、擬似Airgap環境、OCP 4.18.47)の構築に進みました。こちらはこちらで、Assisted Installer版とは種類の違うハマりポイントが次々に出てきたので追記します。
12. virt-install --boot uefiのデフォルト(Secure Boot有効)でAgent ISOがクラッシュする
Agent ISOでVMを起動すると、GRUBのBooting 'RHEL CoreOS (Live)'というメッセージの直後にコンソールが完全にフリーズし、何十分待っても先に進みません。CPU使用率だけは高いまま動き続けているので、ただ遅いだけなのか、本当にハングしているのか最初は判断がつきませんでした。
シリアルコンソールを起動直後からキャプチャしてようやく実態がつかめました。
!!!! X64 Exception Type - 0E(#PF - Page-Fault) CPU Apic ID - 00000000 !!!!
ExceptionData - 0000000000000003 I:0 R:0 U:0 W:1 P:1 PK:0 SS:0 SGX:0
RIP - 0000000072359C30, ...
OVMFファームウェア自体がページフォルト例外でクラッシュしていました。virt-install --boot uefiはデフォルトでSecure Boot有効なOVMF(OVMF_CODE_4M.ms.fd)を選ぶのですが、Agent ISOのカーネル読み込み時にこの組み合わせでクラッシュするようです。
「じゃあSecure Bootを切ればいい」と--boot uefi,loader.secure=noを試したところ、今度はクラッシュダンプすら出ないまま無限にハングするようになりました。原因を調べると、このホストでは非Secure Boot指定時にAMD SEV専用ビルド(/usr/share/ovmf/OVMF.amdsev.fd)が誤って自動選択されており、SEV非対応環境でこのビルドが不安定になっていたようでした。
解決策: ローダーのパスを直接指定して、標準の非SEV・非Secure BootなOVMFを明示的に選ぶようにしました。
virt-install \
--boot loader=/usr/share/OVMF/OVMF_CODE_4M.fd,loader.readonly=yes,loader.type=pflash,loader.secure=no,nvram.template=/usr/share/OVMF/OVMF_VARS_4M.fd \
...
13. libvirtネットワークの再定義はvirt_netのdefineでは効かない、しかもVM稼働中に触るとブリッジから浮く
隔離網の設定(後述のDNS対応など)を追記するたびに、Ansibleのcommunity.libvirt.virt_netモジュールでcommand: defineしても内容が反映されない、という現象に遭遇しました。素のvirsh net-defineを実行しても「既に存在する」という趣旨のエラーになります。
解決策: 既存の場合はnet-destroy→net-undefine→net-define→net-startと明示的に作り直すようにしました。
ただし、この再定義操作をVMが接続されたまま行うと、VMのtapインターフェース(vnetXX)がブリッジから外れて孤立し、bridge link showに出てこなくなる/virbr-*がNO-CARRIER状態になる、という二次被害が発生することも分かりました。慌ててip link set vnetXX master virbr-airgapで手動再接続しましたが、恒久対策としては「ネットワーク定義を変更する作業はVM停止中に行う」をルール化しています。
14. 隔離網内でのミラーレジストリ名前解決は、専用DNSではなくlibvirtネットワーク組み込みのdnsmasqで完結させる
隔離網からミラーレジストリ(ホスト名rhel9-mgmt-host)にアクセスするには、当然ながら名前解決が必要です。egressはミラーレジストリ宛のTCP 8443のみに絞っているため、外部のDNSサーバーには一切問い合わせできません。
最初はRHEL9管理ホスト側に専用のdnsmasqを新設し、そのためにegressへUDP/TCP 53の穴を追加で開ける、という案で進めていました。動いたには動いたのですが、途中で「隔離網自体が持つlibvirtのdnsmasq(DHCPでゲートウェイ=DNSサーバーを配布している)に、静的なホストエントリを1件足すだけで済むのでは」と気づき、そちらに切り替えました。
<network>
<name>sno-airgap-net</name>
<forward mode="nat"/>
<dns>
<host ip="100.104.175.2">
<hostname>rhel9-mgmt-host</hostname>
</host>
</dns>
...
</network>
外部への新規通信経路を1つも増やさずに名前解決が完結するので、egressの「ミラーレジストリ宛のみ許可」という設計をそのまま維持できます。専用DNSサーバーを新設する案は不採用にして撤回しました。
15. mgmtが開始した接続の戻りパケットまで、隔離網のegressルールにdropされる
RHEL9管理ホストからsno-airgapのノードに直接pingやAPIアクセスをしたところ、Destination Host Unreachableになりました。mgmt→VMサブネットへの新規接続自体はPitfall 11の対応で許可済みのはずなのに、です。
原因は、隔離網のegressテーブル(優先度-10、table ip filterより先に評価される)が「ミラーレジストリ宛以外は全部drop」という設計になっていたことでした。mgmtが開始した接続に対するVM→mgmt方向の戻りパケットも、このテーブルからは単なる「VM発の通信」にしか見えないため、一緒にdropされていたのです。
解決策: このテーブルの先頭にct state established,related acceptを追加しました。
nft add rule ip sno_airgap_egress forward ct state established,related accept
これにより「VM自身が新規に外部接続を開始できる相手はミラーレジストリのみ」という制約は保ったまま、mgmt側が開始した接続の戻りだけを通せるようになりました。
16. Agent-based Installerのホスト検証でmachineNetworkとNTPが必須だった
install-config.yamlにnetworking.machineNetwork(VMサブネットのCIDR)を書き忘れていたところ、ホスト検証で以下のように失敗しました。
Host does not belong to machine network CIDRs.
さらに、隔離網はミラーレジストリ以外に到達できないため、パブリックNTPサーバーにも同期できず「Host couldn't synchronize with any NTP server」でも失敗します。
解決策: install-config.yamlにmachineNetworkを明示し、RHEL9管理ホストにchronydを立てて隔離網からの問い合わせを許可、agent-config.yamlのadditionalNTPSourcesにそのホスト名を指定しました。NTPも名前解決と同じく「新しい通信経路を増やさない」方針で、既存のミラーレジストリ用ホストを兼用しています。
17. ディスク書き込み完了後、VMは自動で再起動せず電源オフする
インストールが進み「Writing image to disk: 100%」まで達した後、VMがそのままshut offになって二度と起動してきませんでした。てっきりクラッシュしたのかと思いましたが、これはAssisted/Agent-based Installerの想定動作でした。実機のBMCが電源を入れ直すことを前提とした設計になっており、OS側は電源オフしかしません。
解決策: VMの状態を監視し、shut offを検知したらvirsh startで電源を入れ直すループを回すようにしました。1回のインストールで複数回発生することもあります。
18. openshift-install agent wait-forの進捗表示が、実態よりかなり遅れることがある
wait-for bootstrap-completeやwait-for install-completeを実行すると、コンソール上ではPodが次々に起動しているのに、ツールの出力は"Waiting for cluster install to initialize"のまま数十分停滞し、最終的にタイムアウトすることがありました。
解決策: ツールのテキスト出力を鵜呑みにせず、curl -k https://<ノードIP>:6443/healthzや、生成されたauth/kubeconfigでのoc get clusteroperatorsを直接叩いて実態を確認するようにしました。実際にはとっくにインストールが完了していた、ということが何度かありました。
19. ワークステーションのディスクが満杯になり、VMがI/Oエラーで一時停止する
Day0構成(後述)の作業中、突然クラスタに到達できなくなりました。調べると、ワークステーションのディスクが98GB中93GB使用・空き数MBという状態になっており、VMがNo space left on deviceのI/Oエラーでpaused(一時停止)していました。2台のVMのqcow2ファイル(それぞれ数十GB)に加えて、Image RegistryやMonitoringの永続化ボリュームも同じホストのルートファイルシステム上に乗っているため、想像以上のペースでディスクを消費していたようです。
幸い調べてみると、LVMのボリュームグループ(ubuntu-vg)には828GBもの未割り当て領域が残っていました(root論理ボリュームには当初100GBしか割り当てていなかった)。
sudo lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
これで914GBまで拡張し、virsh resumeで両VMとも復旧しました。個人ラボでSNOを複数台動かす場合、ディスク容量の見積もりは想像より厳しめに見ておくべきだと痛感しました。
20. WSL2を再起動するたびに/etc/hostsの手動エントリが消える
管理ホスト(WSL2)を再起動する機会があり、その後ocコマンドがことごとく名前解決エラーになりました。原因は、WSL2がデフォルトで起動のたびに/etc/hostsを自動生成し直す仕様で、手動で追加していたapi.<cluster>.sno.lab.local等のエントリが消えていたことでした。
解決策: /etc/wsl.confに以下を追記して恒久化しました。
[network]
generateHosts = false
Day0構成(cert-manager・ストレージ・Monitoring永続化など)について
両クラスタのインストール後、SNOで「これくらいは入れておくのが定番」という構成(いわゆるDay0)を、別プロジェクトとしてAnsible化しました。
- **HostPath Provisioner(HPP)**でImage Registry/Monitoring用の永続ストレージを用意(当初はLVM Storage Operatorで実装・検証まで完了していましたが、「追加の仮想ディスクを持ちたくない」という方針変更でHPP方式に作り直しました。HPPはWebhook証明書発行にcert-managerを必要とします)
- kubeadminをHTPasswd認証の管理者ユーザーに置き換え
- self-provisioner権限の無効化、KubeletConfigでのリソース自動割当
- Airgap版はさらに、cert-manager Operatorカタログと上記HPPが参照するコンテナイメージも追加でミラーリングし、デフォルトのOperatorカタログを無効化してミラーカタログに切り替え
ここでも「LVM Storage OperatorのStorageClassデフォルト化がoperatorの reconcile で毎回falseに巻き戻される」「self-provisionerの権限復元を防ぐアノテーションを忘れるとノード再起動のたびに権限が復活する」など、libvirtの隔離ネットワークで学んだのと同じ「コントローラーが外部からの変更を継続的に上書きしてしまう」パターンに何度か遭遇しました。
2つのクラスタへのアクセス切り替え(ocp-ops)
sno-assisted・sno-airgapという2つのクラスタを行き来しながら作業することが多いので、~/.kube/configに両方のcontextをマージし、切り替え用の小さなスクリプトを用意しています。
oc config get-contexts
oc config use-context sno-airgap
oc get nodes
これだけでも十分ですが、「今どっちに繋いでいるか」「切り替えた先のノード状態」を毎回打ち直すのが地味に面倒だったので、ラップしたスクリプトを作りました。
$ bash scripts/switch-cluster.sh sno-airgap
--- sno-airgap に切り替えました ---
current-context: sno-airgap
server: https://api.sno-airgap.sno.lab.local:6443
NAME STATUS ROLES AGE VERSION
52-54-00-79-b7-99 Ready control-plane,master,worker ... v1.31.14
このスクリプトは、クラスタの構築(ocp-sno-lab-setup)やDay0構成(ocp-day0-config)とは別に、ocp-opsという運用専用のプロジェクトディレクトリに切り出しています。Ansible Playbookでインフラを組み立てる系のプロジェクトと、日常的にちょこちょこ使う運用ツールを同じディレクトリに置いておくと、だんだん何がどこにあるか分からなくなってくるので、「構築(build)」「初期構成(Day0)」「運用(day-2 ops)」で明確にディレクトリを分けています。
Webコンソールへのログイン手順
管理ホスト(WSL2)からはocコマンドで両クラスタを操作できますが、GUI(Webコンソール)にはWindows側のブラウザからアクセスしています。手順は次の4段階です。
1. Windows側でTailscaleのサブネットルートを受け入れる
WSL2とWindows本体は同じPC上で動いていてもTailscale上では別ノードとして扱われるため、WSL2側で設定済みでもWindows側では別途受け入れが必要です。
tailscale set --accept-routes
ping 192.168.150.77 # 疎通確認
2. Windows自体のhostsファイルにクラスタごとのエントリを追記する
C:\Windows\System32\drivers\etc\hosts(WSL2内の/etc/hostsとは別ファイル)に、両クラスタ分のエントリを追記します。ベースドメイン(sno.lab.local)は共通ですが、クラスタ名部分のサブドメインでちゃんと区別されているので、2クラスタのURLが衝突することはありません。
192.168.xxx.xxx api.sno-assisted.sno.lab.local api-int.sno-assisted.sno.lab.local
192.168.xxx.xxx oauth-openshift.apps.sno-assisted.sno.lab.local console-openshift-console.apps.sno-assisted.sno.lab.local downloads-openshift-console.apps.sno-assisted.sno.lab.local
192.168.yyy.yyy api.sno-airgap.sno.lab.local api-int.sno-airgap.sno.lab.local
192.168.yyy.yyy oauth-openshift.apps.sno-airgap.sno.lab.local console-openshift-console.apps.sno-airgap.sno.lab.local downloads-openshift-console.apps.sno-airgap.sno.lab.local
3. 証明書警告を2ドメイン分突破する
https://console-openshift-console.apps.<cluster>.sno.lab.localにアクセスすると、ログイン時にoauth-openshift...へリダイレクトされます。クラスタ証明書は自己署名のため、ブラウザはドメインごとに個別に警告を出します。片方だけ「詳細設定」→「アクセスする」を選んで進むと、リダイレクト先で再度警告が出てそこで止まってしまい、一見「リダイレクトが失敗した」ように見えます。console-openshift-console...とoauth-openshift...の両方で警告を突破しておく必要があります。
4. HTPasswdログインフォームでユーザー名・パスワードを入力する
証明書警告を抜けるとHTPasswd認証のログインフォームが表示されます。ユーザー名・パスワードは、2クラスタを行き来する際にどちらに繋いでいるか一目でわかるよう、クラスタ名と同一の文字列にしています。
| クラスタ | ユーザー名 | パスワード |
|---|---|---|
| sno-assisted | sno-assisted |
sno-assisted |
| sno-airgap | sno-airgap |
sno-airgap |
個人の練習環境で、かつTailscale経由でないと到達すらできないネットワーク構成なので、強度よりも「今どっちのクラスタにログインしているか一発でわかる」という視認性を優先した意図的な選択です。kubeadminはこのHTPasswdユーザーへの置き換え完了時点で削除済みなので、ログイン手段はこのHTPasswdユーザー一本に統一されています。
まとめ
個人の練習環境とはいえ、実際に手を動かすと「ドキュメント上の設計」と「実機での挙動」がズレるポイントが数多く出てきました。特にlibvirtのネットワーク周り(隔離ネットワークの完全遮断、mgmt→VM方向の戻りパケット、ブリッジの孤立)、OVMFのSecure Boot、oc-mirrorの環境変数衝突、WSL2特有のMTU問題・/etc/hosts再生成あたりは、公式ドキュメントを読んだだけでは気づきにくいところでした。同じような構成(KVM上に複数SNO、Tailscaleでリモートアクセス、擬似Airgap環境)を検討している方の参考になれば幸いです。
