ステータス: 実測 (2026-10-31). TT Lab の検証用 VM(KubeVirt 上の Ubuntu 24.04)内に k0s v1.37.1+k0s.0 を構築して測定した値です。ノード1台のシナリオは修正済みの正解構成で最初から最後まで1回通しで実行したときの値であり、封鎖と持ち込みは1・2・3台のすべてのシナリオで同じ手順で繰り返しました。ノードアドレスはダミーの帯域(192.0.2.0/24)に書き換えています。記述ルール: 実測データを基本とし、測定できなかったものは「未測定」、原因を確認できなかった解釈は「推定」と表記します。
目次
1. 結論から
公共機関、金融機関、工場などのサーバーはインターネットに接続されていないケースが多くあります。そのような環境に Kubernetes をインストールするには、必要なファイルを外部から取得して持ち込む必要があります(持ち込み)。ここまでは広く知られていますが、実際にやってみるとさらに厄介な疑問が残ります。 インストールの最中に本当に外部から何も取得しなかったか? 外部にアクセスできる実験室で検証していると、漏れていたイメージを誰かがこっそりインターネットからダウンロードしてきても気づきません。その抜け穴は、現場に持ち込んだ当日に初めて露呈します。
- 閉域網は、構築するよりも証明する方が難しいです。 今回のテストでは三重で確認を行いました。外部への HTTPS は 0.014秒で拒否され(curl rc=7)、持ち込んでいないイメージでは
ErrImagePullが発生し、封鎖ルールの手前にログルールを挿入して確認したところ、持ち込み用サーバーが k0sctl でのインストールとバックアップを実行している間に外部へ張った TCP 接続は 0件でした。 - k0s は持ち込むべき対象が少なくなっています。 単一のバイナリ(271,493,593バイト)とシステムイメージ13個が含まれる単一のバンドル(417,884,160バイト)があればノードが立ち上がります。持ち込み用サーバーが取得してハッシュを照合し、アプリイメージのバンドルまで作成するのに 68〜72秒かかりました。
- ノード1台の場合、インストールからノード Ready まで 144秒、システムポッド4個が Ready になるまでにさらに 49秒かかりました。ただし、
--singleで構築したノードには、後からノードを追加できません。 - 封鎖スクリプトで2つの罠を踏みました。 nftables のチェーン名
fwdは予約語であるためテーブル全体が拒否され、「社内向け NIC に向かうもののみを許可する」と記述したルールは k0s の CNI を破壊しました。両方を修正した方法と理由をセクション2に記載しています。
ノード2台構成でコントローラーが停止した場合にワーカーがどうなるかは パート2, ノード3台の HA とその上の Rook-Ceph・CloudNativePG については パート3で確認します。
2. 閉域網をどのように作成したか
検証用の閉域網機器が別になかったため、VM 内の nftables で外部接続を遮断しました。VM を包む外側のクラスターのネットワークポリシーで遮断しなかった理由は2つあります。そのポリシーには「インターネットなし」のようなモードがなく、学習者が自分で封鎖テーブルを読んで修正できなければ実習にならないためです。その代わり明確な制限があります。VM 内の root はこのテーブルを削除できるため、 封鎖は学習・テスト用の仕組みであり、セキュリティ境界ではありません。 テスト中には誰も削除せず、各ステップにおいて封鎖テーブルがそのまま維持されているか、今も遮断されているかを再測定しました。
構成は以下の通りです。
- 持ち込み用サーバー(bastion)1台のみがインターネットを使用します。 公式リリースからファイルを取得し、リリースに添付されている
sha256sums.txtの値と照合し(一致しない場合は削除して再取得し、3回とも一致しない場合は停止します)、イメージバンドルを作成した後に 自らも封鎖します。 持ち込みが終了した後は、持ち込み用サーバーも外部にアクセスできない状態にすることで、「インストールの最中にこっそり取得したもの」がないことを証明できるためです。 - ノードは初回の起動時から封鎖されます。 OS イメージにはパッケージを一切追加していません。必要なファイルは持ち込み用サーバーが社内ネットワーク経由で転送します(scp と k0sctl の SSH)。ノード上で学習者が
sha256sum -cを用いてもう一度照合します。持ち込み用サーバーでの照合は「インターネットから取得したものが公式ファイルか」を確認し、ノードでの照合は「転送途中に改ざんされていないか」を確認します。 - 各ノードには NIC が2つあります。 デフォルトルートを持つ NIC(
enp1s0、外へ出ていく経路)と、ノード間通信専用の社内ネットワーク NIC(eth1)です。この構成が、セクション2の2つ目の罠とパート2での結合失敗を引き起こします。
封鎖スクリプトの核心は以下の通りです。保持するものだけを記述し、残りの新規接続は reject します。
DNS=$(awk '/^nameserver/{print $2}' /run/systemd/resolve/resolv.conf | paste -sd, -)
VXLAN_PEERS=... # 시험 환경의 세션망 상대(바깥 클러스터의 주소라 생략)
nft -f - <<NFT
table inet airgap {
chain airgap_out { # 이름에 fwd 를 쓰지 않는다(2.1)
type filter hook output priority 0; policy accept;
oifname "lo" accept
ct state established,related accept
oifname "eth1" accept # 사내망 NIC
fib daddr type local accept # 목적지가 이 노드 자신의 주소(2.2)
ip daddr 192.0.2.0/24 accept # 사내망 대역
ip daddr $VXLAN_PEERS udp dport 4789 accept # 시험 환경의 세션망을 실어 나르는 VXLAN
ip daddr { $DNS } meta l4proto { tcp, udp } th dport 53 accept
udp dport 67 accept # DHCP
ip daddr { 172.20.0.0/16, 172.21.0.0/16 } accept # k0s 파드·서비스 대역
counter reject
}
chain airgap_fwd {
type filter hook forward priority 0; policy accept;
ct state established,related accept
oifname "eth1" accept
ip daddr { 172.20.0.0/16, 172.21.0.0/16, 192.0.2.0/24 } accept
counter reject
}
}
NFT
いくつかの項目については理由を記載しておきます。
- drop ではなく reject です。 drop は送信元にタイムアウトまで待たせます。reject は即座に拒否を返すため、遮断された試行が 0.014秒で失敗として判明し、どこで何が遮断されたのかがログとカウンターに即座に残ります。実際の現場のファイアウォールは drop であることが多いため、同じ試行が現場では数十秒間ハングアップする可能性があることも心に留めておくべき点です。
- DNS は開放しておきました。 テスト環境の社内ネットワークは VM 同士を VXLAN で接続したネットワークですが、近隣の外部アドレスを 30秒ごとに DNS で再解決します。DNS を遮断すると社内ネットワーク自体が崩壊します。そのため、このネットワークは「名前解決はできるが接続は拒否される」ネットワークです(実測:
getent hosts github.comはアドレスを返し、curlは rc=7)。実際の現場には通常、社内 DNS が存在するため、この違いがテスト結果を左右することはないと判断しました。 - 封鎖は systemd ユニット(
Type=oneshot,RemainAfterExit=yes)により起動の都度適用されます。 再起動後の動作全体については測定していません(未測定)。
2.1 罠: チェーン名 fwd は予約語である
最初に作成した封鎖テーブルでは、チェーン名を out·fwd に設定しました。 nft -f がこのように拒否しました(Ubuntu 24.04, nftables 1.0.9)。
Error: syntax error, unexpected fwd, expecting string or last
そしてその後の行ごとに unexpected type, unexpected policy のようなエラーが続きました。最初のエラーが原因でパーサーが道を見失ったものであり、出力の下の方だけを見ると
type filter hook forward 行目が間違っているように見えるため、原因の特定が難しくなります。 一番上のエラーを確認する必要があります。
原因は fwd が nftables のステートメント(statement)キーワードである点にあります。 nft のマニュアルの FWD STATEMENT のセクションでは、
fwd to device でパケットを別のインターフェースに送信するステートメントであると説明されており(netdev ファミリーの ingress・egress フックでのみ使用)、同マニュアルの語彙ルールには「キーワードと重複する識別子はダブルクォーテーションで囲む必要がある」と記載されています。私たちはクォーテーションの代わりに名前を airgap_out·airgap_fwd に変更しました。ダブルクォーテーションで囲んだ
"fwd" が今回のバージョンで受け入れられるかどうかは検証していません(未測定)。
被害が大きかった理由は別にあります。封鎖は VM の初回起動設定(cloud-init, set -e)内で実行され、ここで失敗したため設定全体が停止し、「VM 準備完了」のフラグが永久に出力されませんでした。テストツールは 1200秒待機した後にようやく失敗を通知しました。現在は適用する前に nft -c -f(構文チェックのみ行い適用しない)で構文から確認するようにしています。
2.2 罠: 「社内向け NIC に向かうもののみを許可する」と k0s の CNI が壊れる
最初の封鎖テーブルでは oifname "eth1" accept のみを残していました。社内向け NIC に向かうものは許可し、残りは拒否すればよいと考えていたのです。しかし、ノード1台をインストールしたところ、kube-system ポッドがいつまで経っても Ready になりませんでした。追跡してみると、次のような連鎖が発生していました。
- k0s のデフォルト CNI である kube-router はホストネットワーク上で動作し、API を
kubernetesService アドレス(172.21.0.1:443)に対して呼び出します。 - kube-proxy がこのアドレスをノード自身の API アドレス(例: 192.0.2.3:6443)に DNAT します。
- しかし、OUTPUT フィルターフックに記録されたログは
OUT=enp1s0 SRC=<NAT 주소> DST=192.0.2.3 DPT=6443でした。宛先は社内ネットワークのアドレスに変更されているのに、出ていく NIC はデフォルトルート NIC として見えていたのです。そのためoifname "eth1"に引っかからず、reject されました。 - kube-router が
failed to synchronize cache: 1m0s timeoutを残して1分ごとに再起動を繰り返し、CNI 設定ファイルを書くことができずにすべてのポッドがno IP ranges specifiedの状態で停止しました。
ステップ3でなぜ NIC がそのように見えたのかは、カーネルのルーティング再計算順序まで確認できていません。Service アドレスの経路(デフォルト経路)として最初に決定された出口が、フィルターフックの時点で残っていたものと推測されます(推定)。修正方法は確実です。 fib daddr type local accept, すなわち 宛先がこのノード自身のアドレスである場合は NIC に関係なく許可するというルールを追加したところ、kube-router が結合に成功し、システムポッド4個すべてが Ready になりました。他のノードの API にアクセスする場合に備えて
ip daddr 192.0.2.0/24 accept も一緒に残しておきます。
教訓は次の通りです。 閉域網のルールは「どの NIC から出ていくか」ではなく「どこへ向かうか」で記述します。 Kubernetes ノードでは DNAT のためにパケットの外見がフックごとに変化します。
3. 何を持ち込むか
バージョンはテスト当日(2026-10-31)時点の最新安定版に固定しました。
| ファイル | バージョン | サイズ(バイト) | 備考 |
|---|---|---|---|
k0s-v1.37.1+k0s.0-amd64 |
v1.37.1+k0s.0 | 271,493,593 | 単一のバイナリにコントローラー、ワーカー、containerd が含まれている |
k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar |
〃 | 417,884,160 | システムイメージ13個の OCI バンドル |
k0sctl-linux-amd64 |
v0.33.1 | 19,796,128 | 2・3台構成で使用 |
apps-bundle.tar |
nginx 1.30.6-alpine, busybox 1.36.1-1 | 29,202,432 | アプリイメージバンドル(持ち込み用サーバーが作成) |
airgap バンドルに含まれる 13 個は公式リスト(airgap-images-linux-amd64.txt)と同じでした。pause、konnectivity agent、kube-proxy、coredns、metrics-server、pushgateway、CNI プラグイン、kube-router、Calico の 3 つ、NLLB 用 envoy、traefik で、すべて quay.io/k0sproject/ の下にあります。
k0s のオフライン環境へのインストールは次のように動作します。 公式ドキュメントによると、OCI バンドルファイルをワーカーの
/var/lib/k0s/images/ に置いておくと、k0s がワーカーを起動するときに containerd に読み込ませてくれます。そして、クラスター設定の spec.images.default_pull_policy: Never を指定すると、 k0s が管理するコンポーネントの imagePullPolicy が Never になり、インターネットから取得しようとする試み自体を行わなくなります(実測:coredns のポリシーが Never)。
この設定は k0s が起動するシステムポッドにのみ適用される点に注意する必要があります。直接デプロイするアプリは独自のポリシーに従うため、アプリイメージをバンドルと一緒に配置するか(今回の apps-bundle.tar)、パート 3 のように社内レジストリを用意します。アプリバンドルは持ち込みサーバーで k0s airgap bundle-artifacts として作成し、7 秒かかりました。
このコマンドは作成のたびに結果ファイルのハッシュが異なるため、アプリバンドルのみ公式ハッシュではなく今回作成した値を記録しておき、ノードで照合します。
持ち込みサーバー側の所要時間は次のとおりでした(1 台・2 台シナリオ、実測)。
| ステップ | 時間 |
|---|---|
| 持ち込みサーバーへのツールインストール(apt) | 43~49 秒 |
| バイナリ・バンドル・k0sctl の取得とハッシュ照合 | 13~15 秒 |
| アプリバンドルの作成 | 7 秒 |
| 合計(封鎖まで) | 68~72 秒 |
4. ノード 1 台: k0s install controller --single
4.1 いつ使用するか
1 つのノードにコントローラーとワーカーを一緒に配置する構成です。エッジデバイス、デモ、CI の使い捨てクラスターのように耐障害性を必要とせず、持ち込みを軽量にしたい場合に適しています。バイナリ 1 つとバンドル 1 つを移動し、コマンド 2 行を実行するだけで完了します。
scp k0s-v1.37.1+k0s.0-amd64 node01:/usr/local/bin/k0s
scp k0s-airgap-bundle-*.tar apps-bundle.tar node01:/var/lib/k0s/images/
ssh node01 'cat > /etc/k0s/k0s.yaml' <<'YAML'
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
metadata: {name: k0s}
spec:
api: {address: 192.0.2.3, sans: [192.0.2.3, node01]}
network: {provider: kuberouter, podCIDR: 172.20.0.0/16, serviceCIDR: 172.21.0.0/16}
images: {default_pull_policy: Never}
telemetry: {enabled: false}
YAML
ssh node01 'k0s install controller --single -c /etc/k0s/k0s.yaml --kubelet-extra-args=--node-ip=192.0.2.3 && k0s start'
ssh node01 k0s kubeconfig admin > ~/.kube/config
設定で 3 つの項目を明示的に指定しました。
api.addressと--node-ipを社内網のアドレスに固定しました。 を指定しない場合、k0s はノードで最初に見つかった非ループバックアドレスを使用しますが、NIC が 2 つあるこのノードではデフォルトルートの NIC のアドレスが取得されます。ノード 1 台では影響が出ませんが、パート 2 でノードが増えるとすぐにジョイン失敗として表れました。- サービス CIDR を変更しました。 k0s のサービス CIDR のデフォルト(
serviceCIDR, 設定ドキュメント)は 10 で始まる /12 の帯域ですが、今回のテスト VM が取得した DNS サーバーのアドレスがたまたその中に含まれていました。重複すると、ノードが自身の DNS サーバー宛てのパケットを Kubernetes Service と誤認します。現場の社内 DNS・レジストリのアドレスがデフォルトのサービス CIDR またはデフォルトのポッド CIDR(podCIDR)に該当するかどうかは、インストール前に設定ドキュメントのデフォルト値と必ず照合する必要があります。 telemetry.enabled: falseを指定しました。オフライン環境ではどうせ拒否されますが、拒否された試みがログやカウンターを乱雑にしないようにするためです。
4.2 実測
| 項目 | 値 |
|---|---|
| インストール開始 → node Ready | 144 秒 |
| node Ready → kube-system 파드 4개(coredns·kube-proxy·kube-router·metrics-server) Ready | +49 秒 |
coredns の imagePullPolicy |
Never(default_pull_policy が機能する) |
| ノードのイメージ | 15 個(システム 13 + アプリ 2)、すべてバンドルから |
| ノード(4 vCPU・4GiB)のメモリ使用量 | 1.0/3.9GiB |
/var/lib/k0s |
1.7GiB |
4.3 --single の制限
--single は手軽ですが、元に戻すことのできない選択です。
- インストールドキュメントには、
--singleが「マルチノードに必要な機能を無効にする」と記載されています。そのため、 このように構築したクラスターには、後からノードを追加することはできません。 拡張する計画が少しでもあるなら、最初からコントローラーとワーカーを分離するか、controller --enable-workerで始める方が無難です(今回のテストで--enable-worker1 台構成は計測しませんでした)。 - CPLBと NLLB のドキュメントには、どちらも
--singleと併用できないと記載されています。パート 3 の高可用性コンポーネントはこの構成には組み込めません。 - 当然ながらノードが停止すると、API もワークロードも一緒に停止します。障害テストは実施していません。
5. オフライン環境であったことの証明
インストールできたというだけでは、オフライン環境であったことの証明にはなりません。今回は 3 つの項目を計測しました。
- 外部は遮断されている。 すべてのノードと持ち込みサーバーから
github.com·quay.io·registry-1.docker.ioに対して HTTPS を試行すると、HTTP コード000、curl の終了コード 7(接続拒否)、0.014 秒となります。実習ステップを採点するたびに、現在も遮断されているかを再計測しました。 - 持ち込んでいないものは確実に取り出せない。 持ち込んでいない
docker.io/library/redis:8.2-alpineでポッドを作成すると、dial tcp <docker.io 주소>:443: connect: connection refusedによりErrImagePullが発生します。同じ時刻に持ち込んだイメージで起動したポッドはすべて Running でした。ノード 1 台の回の封鎖カウンターには、外部へ向かおうとした試みを 262 回拒否したと記録されています。 - 持ち込んだ後は外部へ向かおうとする通信が一切なかった。 1 および 2 だけでは「遮断されていた」ことまでしか分かりません。「必要性すらなかった」ことを示すには、誰が外に出ようとしたのかを確認する必要があります。封鎖ルールの直前にログルールを挿入したところ、ノードが外部へ送信しようとしたのはステップ 2 で意図的に実行した redis の取得(docker.io の 8 つのアドレス、TCP 443)のみでした。持ち込みサーバーから k0sctl でインストールとバックアップを行っている間、外部へ接続した TCP 接続は 0 件であり、ブロックされたのは時刻同期(chrony)の UDP 123 のみでした。
3 番が重要な理由は、現場のファイアウォールが大抵 drop だからです。実験室では reject によって即座に失敗して先に進む外部への試み(テレメトリー、アップデート確認)が、現場ではタイムアウトを待つことでインストールの進行を止めてしまう可能性があります。外部への試みが 0 件であることを事前に示しておけば、その懸念を払拭できます。
さらに、k0sctl v0.33.1 には --disable-telemetry·--disable-upgrade-check フラグがありません。古い記事を見て付与すると、 flag provided but not defined により apply が 0 秒で終了します。フラグなしでも外部接続の試みは 0 件でした。
6. まとめ
| 確認項目 | 理由(本記事の実測) |
|---|---|
| 持ち込みサーバーも持ち込み後に封鎖する | そうすることで、インストール中にこっそりダウンロードされたものがないことを証明できる |
| ハッシュは持ち込みサーバーとノードで 2 回確認する | 1 つは取得元、もう 1 つは転送中の破損を確認するため |
| 封鎖ルールは宛先として使用する | NIC のみにすると DNAT された API 呼び出しがブロックされ CNI が停止した |
nft を追加する前に nft -c -f |
fwd の 1 つによりテーブル全体が拒否され、起動手順が停止した |
| サービス・ポッド CIDR と社内アドレスの照合 | デフォルトのサービス CIDR が VM の DNS アドレスを上書きした |
| 外部への試みはログでカウントする | 「遮断されていた」と「必要性すらなかった」は別の証明である |
拡張計画がある場合は --single を避ける |
ノードを追加できず、CPLB や NLLB も使用できない |
本記事の実測は TT Lab の実習コース「オフライン環境の k0s:ノード 1・2・3 台とその上の Ceph・PostgreSQL」(サイトパス /course/k0s-airgap-multinode)の最初のモジュール「ノード 1 台」にて、同一のバージョン、同一の持ち込みリストで実際に試すことができます。持ち込みサーバーの封鎖と --single のインストールを実際の VM で行い、採点機がステップごとに結果を確認します。
T.T. の一言。 戸締まりをしたからといって終わりではありません。戸外へこっそり出ようとした仲間がいなかったか、足跡(ログ)まで数えてこそ「本当に誰も外に出なかった」と言うことができます。そして、表札を「fwd」にしてしまうと家全体が開かなくなってしまいますよ!