TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Closed-network k0s multi-node actual measurement 1: How to prove that the internet is blocked, and 1 node

Status: Actual measurement (2026-10-31). These are values measured by setting up k0s v1.37.1+k0s.0 inside a TT Lab practice VM (Ubuntu 24.04 on KubeVirt). The 1-node scenario is the value from a run executed from start to finish using the fixed correct answer key, and sealing and importing were repeated in the exact same manner across all 1-, 2-, and 3-node scenarios. Node addresses have been replaced with example ranges (192.0.2.0/24). Writing convention: Actual measured data is the default; unmeasured items are marked as "Unmeasured", and interpretations whose causes could not be verified are marked as "Estimated".

Table of Contents

1. Starting with the Conclusion

Servers in places like public institutions, financial companies, and factories often cannot reach the internet. To install Kubernetes in such environments, you must bring in the necessary files from the outside (importing). This much is widely known, but doing it in practice leaves trickier questions: Did it really not receive anything from the outside during installation? If you practice in a lab that can reach the outside, you won't even notice if someone secretly downloads a missed image from the internet. That loophole reveals itself only on the day you enter the actual site.

How workers behave when the controller dies in a 2-node setup is covered in Part 2, and 3-node HA along with Rook-Ceph and CloudNativePG running on top of it are covered in Part 3.

2. How the Closed Network Was Built

Since we did not have separate closed network equipment for experiments, we blocked external connections using nftables inside a VM. There are two reasons why we did not block it using the network policies of the outer cluster wrapping the VM: that policy did not have a mode like "no internet", and learners needed to be able to read and fix the sealing table themselves for it to be a practical exercise. Instead, there is a clear limitation. Since root inside the VM can delete this table, the sealing is a learning and testing mechanism, not a security boundary. During the test, no one deleted it, and we re-measured at each step whether the sealing table remained intact and whether it was still blocked.

반입 서버 하나와 봉인된 노드들 시험 환경의 구성 그대로, 주소는 예시 인터넷 반입 단계에서만 씀 반입 서버 받은 뒤 스스로도 봉인한다 받기 공식 릴리스 해시 대조 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 를 열어 둔 까닭은 본문)
Figure: A bastion server and sealed nodes: The bastion server seals itself after finishing receiving, hash verification, bundling, and mirroring, and the nodes are sealed from their first boot.

The configuration is as follows.

The core of the sealing script is as follows. Only items to keep are written, and other new connections are 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

Reasons are noted for a few items.

2.1 Trap: Chain name fwd is a reserved word

The initial sealing table named the chain out·fwd . nft -f rejected it like this (Ubuntu 24.04, nftables 1.0.9).

Error: syntax error, unexpected fwd, expecting string or last

And subsequent lines were followed by errors such as unexpected type, unexpected policy . Because a single initial error caused the parser to lose its way, looking only at the lower part of the output makes the type filter hook forward line appear incorrect, making it difficult to find the cause. You must look at the topmost error.

The cause lies in the fact that fwd is a statement keyword in nftables. The nft manual's FWD STATEMENT section explains that fwd to device is a statement that sends packets to another interface (used only in ingress and egress hooks of the netdev family), and the lexical rules of the same manual state that "Identifiers that coincide with keywords must be enclosed in double quotes." Instead of quotes, we changed the name to airgap_out·airgap_fwd . Whether "fwd" enclosed in quotes is accepted in this version was not tested (Unmeasured).

There is another reason why the damage was severe. The sealing ran inside the VM's first boot recipe (cloud-init, set -e), and when it failed there, the entire recipe stopped, so the "VM Ready" marker was never printed. The testing tool announced failure only after waiting 1,200 seconds. Now, we check the syntax first using nft -c -f(check only, do not apply) before inserting it.

2.2 Trap: "Allowing only outgoing traffic to the internal network NIC" kills the k0s CNI

The initial sealing table left only oifname "eth1" accept . We thought it would be sufficient to allow outgoing traffic to the corporate network NIC and reject the rest. However, when we installed 1 node, the kube-system pods ultimately did not become Ready. Tracing it revealed the following chain of events.

인터페이스로만 열면 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
Figure: When kube-router calls the API with a Service address, the destination after DNAT is the node's own address, but the filter hook's log recorded it as a packet going out to the default route NIC.
  1. k0s's default CNI, kube-router, runs in the host network and calls the API via kubernetes the Service address (172.21.0.1:443).
  2. kube-proxy DNATs this address to the node's own API address (e.g., 192.0.2.3:6443).
  3. However, the log recorded in the OUTPUT filter hook was OUT=enp1s0 SRC=<NAT 주소> DST=192.0.2.3 DPT=6443 . The destination had changed to the corporate network address, but the outgoing NIC appeared to be the default route NIC. Therefore, it was rejected without hitting oifname "eth1" .
  4. kube-router left failed to synchronize cache: 1m0s timeout and restarted every minute, failing to write the CNI configuration file, causing all pods to get stuck in no IP ranges specified .

We could not verify up to the kernel's route recalculation order why the NIC appeared that way in step 3. We presume that the exit initially determined by the Service address route (default route) remained at the filter hook stage (Estimated). The fix is definite. By adding the rule fib daddr type local accept, meaning if the destination is this node's own address, allow it regardless of the NIC, kube-router connected and all 4 system pods became Ready. For cases going to other nodes' APIs, ip daddr 192.0.2.0/24 accept is also left in place.

The lesson is this: Closed network rules should be written based on "where it goes," not "which NIC it goes out of." On Kubernetes nodes, packet appearances change across hooks due to DNAT.

3. What to Bring In

The version was fixed to the latest stable release as of the test day (2026-10-31).

File Version Size (Bytes) Remarks
k0s-v1.37.1+k0s.0-amd64 v1.37.1+k0s.0 271,493,593 A single binary contains the controller, worker, and containerd
k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar 〃 417,884,160 OCI bundle of 13 system images
k0sctl-linux-amd64 v0.33.1 19,796,128 Used in 2 and 3 nodes
apps-bundle.tar nginx 1.30.6-alpine, busybox 1.36.1-1 29,202,432 App image bundle (created by bastion server)

The 13 items in the airgap bundle matched the official list (airgap-images-linux-amd64.txt): pause, konnectivity agent, kube-proxy, coredns, metrics-server, pushgateway, CNI plugin, kube-router, the Calico trio, envoy for NLLB, and traefik, all of which are located under quay.io/k0sproject/ .

The airgapped installation of k0s works like this. According to the official documentation, if you place the OCI bundle file in the worker's /var/lib/k0s/images/ , k0s loads it into containerd when starting the worker. Also, if you specify spec.images.default_pull_policy: Never in the cluster configuration, the components managed by k0s's imagePullPolicy becomes Never , so it does not even attempt to pull from the internet (measured: coredns policy is Never). You must note that this setting only applies to system pods launched by k0s. Apps you deploy directly follow their own policies, so you either include the app images in the bundle together (this time's apps-bundle.tar) or provide an internal registry as in part 3. The app bundle was created on the import server as k0s airgap bundle-artifacts and took 7 seconds. Because this command produces a different result file hash every time it is run, only for the app bundle we record the newly generated value instead of the official hash and compare it on the nodes.

The time on the import server side was as follows (1- and 2-node scenarios, measured):

Stage Time
Tool installation on import server (apt) 43-49 seconds
Downloading binaries, bundles, and k0sctl, and hash verification 13-15 seconds
Creating the app bundle 7 seconds
Total (until sealing) 68-72 seconds

4. Single Node: k0s install controller --single

4.1 When to Use

This is a configuration where the controller and worker are placed together on a single node. It is suitable when you do not need fault tolerance, such as for edge devices, demos, or CI disposable clusters, and want to keep imports lightweight. You just move one binary and one bundle and run two commands.

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

We explicitly defined three things in the configuration:

4.2 Measurement

Item Value
Installation start -> node Ready 144 seconds
node Ready → kube-system 파드 4개(coredns·kube-proxy·kube-router·metrics-server) Ready +49 seconds
coredns's imagePullPolicy Never(default_pull_policy is applied)
Node images 15 (13 system + 2 app), all from the bundle
Node (4 vCPU, 4GiB) memory usage 1.0/3.9GiB
/var/lib/k0s 1.7GiB

4.3 --single limitation

--single is convenient but an irreversible choice.

5. Proof of Being an Air-Gapped Network

Simply succeeding at installation does not prove it was an air-gapped network. This time, three things were measured.

  1. The outside is blocked. When attempting HTTPS using github.com·quay.io·registry-1.docker.io on all nodes and the import server, the HTTP code is 000, curl exit code 7 (connection refused), taking 0.014 seconds. Every time a lab step was graded, we re-measured to ensure it was still blocked.
  2. What is not imported truly cannot be used. If you create a pod with an docker.io/library/redis:8.2-alpine that has not been imported, dial tcp <docker.io 주소>:443: connect: connection refused results in ErrImagePull . At the same time, pods started with imported images were all in the Running state. The seal counter for the single-node run recorded 262 refusals of attempts to reach the outside.
  3. After importing, nothing attempted to go outside. Items 1 and 2 only prove that "it was blocked." To demonstrate that "it was unnecessary," we need to look at who tried to go out. Inserting a logging rule right before the sealing rule revealed that the only thing the node tried to send outside was the intentionally triggered redis pull from step 2 (8 addresses on docker.io, TCP 443). While installing and backing up with k0sctl on the import server, there were 0 outgoing TCP connections, and the only blocked traffic was UDP 123 for time synchronization (chrony).

The reason why point 3 is important is that firewalls in field environments are typically configured to drop packets. External attempts (such as telemetry and update checks) that fail immediately with a reject in a lab environment can cause installation to stall in the field while waiting for timeouts. Demonstrating in advance that there are zero external attempts eliminates that concern.

Additionally, k0sctl v0.33.1 does not have the --disable-telemetry·--disable-upgrade-check flag. If you copy it based on an older post, flag provided but not defined causes apply to finish in 0 seconds. Even without the flag, external connection attempts were zero.

6. Conclusion

Checklist Reason (Measured in this post)
Import server also sealed after import This proves nothing unauthorized was downloaded during installation
Hashes checked twice: on the import server and on the nodes One verifies the source, the other verifies integrity during transfer
Sealing rules used as the destination Restricting traffic strictly to the NIC blocked DNAT-ed API calls, breaking the CNI
Before putting in nft, nft -c -f fwd the entire table was rejected as a whole and the boot recipe stopped
Comparing service/pod ranges with internal addresses The default service range overwrote the VM's DNS address
Count external attempts with logs "Blocked" and "Unnecessary" are different proofs
If you plan to scale, --single avoid You cannot attach nodes, nor can you use CPLB or NLLB

The actual measurements in this post can be reproduced in the first module, "1 Node," of TT Lab's hands-on course "Air-gapped k0s: 1, 2, and 3 Nodes with Ceph and PostgreSQL" (site path /course/k0s-airgap-multinode), following the exact same board and import manifest. The import server sealing and --single installation are performed on actual VMs, with the grader verifying the results at each step.

TT's Word. Locking the door is not the end. You have to count even the footprints (logs) to see if there was a friend trying to sneak out before you can say "truly no one went out." And if you hang a nameplate that says "fwd," the whole house won't open!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment