TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

閉域網 k0s マルチノード実測 1: インターネットを遮断したことをどう証明するか、そしてノード1台

ステータス: 実測 (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 をインストールするには、必要なファイルを外部から取得して持ち込む必要があります(持ち込み)。ここまでは広く知られていますが、実際にやってみるとさらに厄介な疑問が残ります。 インストールの最中に本当に外部から何も取得しなかったか? 外部にアクセスできる実験室で検証していると、漏れていたイメージを誰かがこっそりインターネットからダウンロードしてきても気づきません。その抜け穴は、現場に持ち込んだ当日に初めて露呈します。

ノード2台構成でコントローラーが停止した場合にワーカーがどうなるかは パート2, ノード3台の HA とその上の Rook-Ceph・CloudNativePG については パート3で確認します。

2. 閉域網をどのように作成したか

検証用の閉域網機器が別になかったため、VM 内の nftables で外部接続を遮断しました。VM を包む外側のクラスターのネットワークポリシーで遮断しなかった理由は2つあります。そのポリシーには「インターネットなし」のようなモードがなく、学習者が自分で封鎖テーブルを読んで修正できなければ実習にならないためです。その代わり明確な制限があります。VM 内の root はこのテーブルを削除できるため、 封鎖は学習・テスト用の仕組みであり、セキュリティ境界ではありません。 テスト中には誰も削除せず、各ステップにおいて封鎖テーブルがそのまま維持されているか、今も遮断されているかを再測定しました。

반입 서버 하나와 봉인된 노드들 시험 환경의 구성 그대로, 주소는 예시 인터넷 반입 단계에서만 씀 반입 서버 받은 뒤 스스로도 봉인한다 받기 공식 릴리스 해시 대조 sha256sums 묶음·미러 OCI·레지스트리 봉인 nftables scp · k0sctl(SSH) · 미러에서 당기기 사내망(eth1), 노드끼리만 노드 1 첫 부팅부터 봉인 노드 2 첫 부팅부터 봉인 노드 3 첫 부팅부터 봉인 남긴 것(accept) 루프백 · 이미 맺은 연결 사내망 NIC · 사내망 대역 자기 주소(fib daddr type local) 세션망 VXLAN(UDP 4789) DNS · DHCP k0s 파드·서비스 대역 나머지 새 연결(reject) github.com · quay.io · docker.io → curl 000, rc=7, 0.014초 반입하지 않은 이미지 → ErrImagePull, 연결 거절 이름 풀이는 된다 (DNS 를 열어 둔 까닭은 본문)
図: 持ち込み用サーバー1台と封鎖されたノード群: 持ち込み用サーバーは取得、ハッシュ照合、バンドル・ミラー作成を終えた後に自らを封鎖し、ノードは初回の起動時から封鎖される

構成は以下の通りです。

封鎖スクリプトの核心は以下の通りです。保持するものだけを記述し、残りの新規接続は 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

いくつかの項目については理由を記載しておきます。

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 가 죽는다 노드 1대 설치에서 실측한 경로, 노드 주소는 예시 kube-router(호스트 네트워크) API 를 Service 주소로 부른다 172.21.0.1:443 kubernetes Service kube-proxy 가 DNAT 192.0.2.3:6443 이 노드 자신의 API OUTPUT 필터 훅의 로그 OUT=enp1s0 DST=192.0.2.3 DPT=6443 oifname "eth1" 만 허용 → reject kube-router 가 1분마다 재시작 모든 파드 no IP ranges fib daddr type local 추가 → accept 목적지가 자기 주소면 통과 kube-system 4개 Ready
図: kube-router が Service アドレス宛てに API を呼び出すと、DNAT 後の宛先はノード自身のアドレスになるが、フィルターフックのログにはデフォルトルート NIC に向かうパケットとして記録された
  1. k0s のデフォルト CNI である kube-router はホストネットワーク上で動作し、API を kubernetes Service アドレス(172.21.0.1:443)に対して呼び出します。
  2. kube-proxy がこのアドレスをノード自身の API アドレス(例: 192.0.2.3:6443)に DNAT します。
  3. しかし、OUTPUT フィルターフックに記録されたログは OUT=enp1s0 SRC=<NAT 주소> DST=192.0.2.3 DPT=6443 でした。宛先は社内ネットワークのアドレスに変更されているのに、出ていく NIC はデフォルトルート NIC として見えていたのです。そのため oifname "eth1" に引っかからず、reject されました。
  4. 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 つの項目を明示的に指定しました。

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 は手軽ですが、元に戻すことのできない選択です。

5. オフライン環境であったことの証明

インストールできたというだけでは、オフライン環境であったことの証明にはなりません。今回は 3 つの項目を計測しました。

  1. 外部は遮断されている。 すべてのノードと持ち込みサーバーから github.com·quay.io·registry-1.docker.io に対して HTTPS を試行すると、HTTP コード 000、curl の終了コード 7(接続拒否)、0.014 秒となります。実習ステップを採点するたびに、現在も遮断されているかを再計測しました。
  2. 持ち込んでいないものは確実に取り出せない。 持ち込んでいない docker.io/library/redis:8.2-alpine でポッドを作成すると、 dial tcp <docker.io 주소>:443: connect: connection refused により ErrImagePull が発生します。同じ時刻に持ち込んだイメージで起動したポッドはすべて Running でした。ノード 1 台の回の封鎖カウンターには、外部へ向かおうとした試みを 262 回拒否したと記録されています。
  3. 持ち込んだ後は外部へ向かおうとする通信が一切なかった。 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」にしてしまうと家全体が開かなくなってしまいますよ!

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます