상태: 실측 (2026-10-31). TT Lab 실습 VM(KubeVirt 위 Ubuntu 24.04) 안에 k0s v1.37.1+k0s.0 을 세워 잰 값입니다. 노드 1대 시나리오는 고친 정답지로 처음부터 한 번 끝까지 돌린 회차의 값이고, 봉인과 반입은 1·2·3대 시나리오 모두에서 같은 방식으로 반복했습니다. 노드 주소는 예시 대역(192.0.2.0/24)으로 바꿔 적었습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.
목차
1. 결론부터
공공기관, 금융, 공장 같은 곳의 서버는 인터넷에 닿지 않는 경우가 많습니다. 그런 곳에 쿠버네티스를 설치하려면 필요한 파일을 밖에서 받아 들고 들어가야 합니다(반입). 여기까지는 많이 알려져 있는데, 실제로 해 보면 더 까다로운 질문이 남습니다. 설치하는 동안 정말 아무것도 바깥에서 받지 않았는가? 바깥에 닿을 수 있는 실험실에서 연습하면 빠뜨린 이미지 하나를 누군가 몰래 인터넷에서 받아 와도 모릅니다. 그 빈틈은 현장에 들어간 날에야 드러납니다.
- 폐쇄망은 만들기보다 증명하기가 어렵습니다. 이번 시험은 세 겹으로 확인했습니다. 바깥 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로 세운 노드에는 나중에 노드를 붙일 수 없습니다. - 봉인 스크립트에서 함정 둘을 밟았습니다. nftables 체인 이름
fwd는 예약어라 표 전체가 거부되었고, 「사내망 NIC 로 나가는 것만 허용」 으로 쓴 규칙은 k0s 의 CNI 를 죽였습니다. 둘 다 고친 방법과 이유를 2절에 적었습니다.
노드 2대에서 컨트롤러가 죽으면 워커가 어떻게 되는지는 2편, 노드 3대 HA 와 그 위의 Rook-Ceph·CloudNativePG 는 3편에서 봅니다.
2. 폐쇄망을 어떻게 만들었나
실험용 폐쇄망 장비가 따로 없어서 VM 안의 nftables 로 바깥 연결을 막았습니다. VM 을 감싼 바깥 클러스터의 네트워크 정책으로 막지 않은 까닭은 둘입니다. 그 정책에는 「인터넷 없음」 같은 모드가 없었고, 학습자가 직접 봉인 표를 읽고 고칠 수 있어야 실습이 되기 때문입니다. 대신 한계가 분명합니다. VM 안의 root 는 이 표를 지울 수 있으므로 봉인은 학습·시험 장치이지 보안 경계가 아닙니다. 시험 중에는 아무도 지우지 않았고, 단계마다 봉인 표가 그대로 있는지와 지금도 막혀 있는지를 다시 쟀습니다.
구성은 이렇습니다.
- 반입 서버(bastion) 한 대만 인터넷을 씁니다. 공식 릴리스에서 파일을 받고, 릴리스에 함께 올라온
sha256sums.txt의 값과 대조하고(다르면 지우고 다시 받고, 세 번 다 다르면 멈춥니다), 이미지 묶음을 만든 뒤 스스로도 봉인합니다. 반입이 끝난 뒤에는 반입 서버도 바깥에 닿지 않아야 「설치 중에 몰래 받은 것」 이 없다는 것을 보일 수 있기 때문입니다. - 노드는 첫 부팅부터 봉인됩니다. OS 이미지에 패키지를 하나도 더 깔지 않았습니다. 필요한 파일은 반입 서버가 사내망으로 넘겨 줍니다(scp 와
k0sctl 의 SSH). 노드에서 학습자가
sha256sum -c로 한 번 더 대조합니다. 반입 서버의 대조는 「인터넷에서 온 것이 공식 파일인가」 를, 노드의 대조는 「옮기는 동안 바뀌지 않았나」 를 확인합니다. - 노드마다 NIC 가 둘입니다. 기본 경로를 가진 NIC(
enp1s0, 바깥으로 나가는 길)와 노드끼리만 쓰는 사내망 NIC(eth1)입니다. 이 구성이 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 로 나가느냐」 가 아니라 「어디로 가느냐」 로 씁니다. 쿠버네티스 노드에서는 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 셋, 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 언제 쓰나
노드 하나에 컨트롤러와 워커를 함께 올리는 구성입니다. 엣지 장비, 데모, CI 의 일회용 클러스터처럼 장애에 버틸 필요가 없고 반입을 가볍게 하고 싶을 때 맞습니다. 바이너리 하나와 묶음 하나를 옮기고 명령 두 줄이면 끝납니다.
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
설정에서 세 가지를 직접 정했습니다.
api.address와--node-ip를 사내망 주소로 못 박았습니다. 적지 않으면 k0s 는 노드에서 처음 찾은 비루프백 주소를 쓰는데, NIC 가 둘인 이 노드에서는 기본 경로 NIC 의 주소가 잡힙니다. 노드 1대에서는 티가 나지 않지만, 2편에서 노드가 늘자 바로 조인 실패로 드러났습니다.- 서비스 대역을 바꿨습니다. k0s 의 서비스 대역 기본값(
serviceCIDR, 설정 문서)은 10 으로 시작하는 /12 대역인데, 이 시험의 VM 이 받은 DNS 서버 주소가 마침 그 안에 있었습니다. 겹치면 노드가 자기 DNS 서버로 가는 패킷을 쿠버네티스 Service 로 오인합니다. 현장의 사내 DNS·레지스트리 주소가 기본 서비스 대역이나 기본 파드 대역(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. 폐쇄망이었다는 증명
설치가 됐다는 것만으로는 폐쇄망이었다는 증명이 되지 않습니다. 이번에는 세 가지를 쟀습니다.
- 바깥은 막혀 있다. 모든 노드와 반입 서버에서
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. 정리
| 점검 | 이유(이 글의 실측) |
|---|---|
| 반입 서버도 반입 뒤 봉인 | 그래야 설치 중에 몰래 받은 것이 없다는 것을 보일 수 있다 |
| 해시는 반입 서버와 노드에서 두 번 | 하나는 출처, 하나는 옮기는 동안의 손상을 본다 |
| 봉인 규칙은 목적지로 쓴다 | NIC 로만 쓰면 DNAT 된 API 호출이 막혀 CNI 가 죽었다 |
nft 넣기 전에 nft -c -f |
fwd 하나로 표 전체가 거부되고 부팅 조리법이 멈췄다 |
| 서비스·파드 대역과 사내 주소 대조 | 기본 서비스 대역이 VM 의 DNS 주소를 덮었다 |
| 바깥 시도는 로그로 센다 | 「막혔다」 와 「필요 없었다」 는 다른 증명이다 |
키울 계획이면 --single 을 피한다 |
노드를 붙일 수 없고 CPLB·NLLB 도 못 쓴다 |
티티 한마디. 문을 잠갔다고 끝이 아니에요. 문밖에 몰래 나가려던 친구가 있었는지 발자국(로그)까지 세어 봐야 「정말 아무도 안 나갔다」 고 말할 수 있어요. 그리고 문패를 「fwd」 라고 달면 집 전체가 안 열린답니다!