TT Lab
블로그

한국어

시작하기
블로그

블로그

폐쇄망 k0s 멀티노드 실측 1: 인터넷을 막았다는 것을 어떻게 증명하나, 그리고 노드 1대

상태: 실측 (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. 결론부터

공공기관, 금융, 공장 같은 곳의 서버는 인터넷에 닿지 않는 경우가 많습니다. 그런 곳에 쿠버네티스를 설치하려면 필요한 파일을 밖에서 받아 들고 들어가야 합니다(반입). 여기까지는 많이 알려져 있는데, 실제로 해 보면 더 까다로운 질문이 남습니다. 설치하는 동안 정말 아무것도 바깥에서 받지 않았는가? 바깥에 닿을 수 있는 실험실에서 연습하면 빠뜨린 이미지 하나를 누군가 몰래 인터넷에서 받아 와도 모릅니다. 그 빈틈은 현장에 들어간 날에야 드러납니다.

노드 2대에서 컨트롤러가 죽으면 워커가 어떻게 되는지는 2편, 노드 3대 HA 와 그 위의 Rook-Ceph·CloudNativePG 는 3편에서 봅니다.

2. 폐쇄망을 어떻게 만들었나

실험용 폐쇄망 장비가 따로 없어서 VM 안의 nftables 로 바깥 연결을 막았습니다. VM 을 감싼 바깥 클러스터의 네트워크 정책으로 막지 않은 까닭은 둘입니다. 그 정책에는 「인터넷 없음」 같은 모드가 없었고, 학습자가 직접 봉인 표를 읽고 고칠 수 있어야 실습이 되기 때문입니다. 대신 한계가 분명합니다. 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 를 열어 둔 까닭은 본문)
그림: 반입 서버 하나와 봉인된 노드들: 반입 서버는 받기, 해시 대조, 묶음·미러를 마친 뒤 스스로 봉인하고, 노드는 첫 부팅부터 봉인된다

구성은 이렇습니다.

봉인 스크립트의 핵심은 아래와 같습니다. 남기는 것만 적고, 나머지 새 연결은 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 로 나가느냐」 가 아니라 「어디로 가느냐」 로 씁니다. 쿠버네티스 노드에서는 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

설정에서 세 가지를 직접 정했습니다.

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. 폐쇄망이었다는 증명

설치가 됐다는 것만으로는 폐쇄망이었다는 증명이 되지 않습니다. 이번에는 세 가지를 쟀습니다.

  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. 정리

점검 이유(이 글의 실측)
반입 서버도 반입 뒤 봉인 그래야 설치 중에 몰래 받은 것이 없다는 것을 보일 수 있다
해시는 반입 서버와 노드에서 두 번 하나는 출처, 하나는 옮기는 동안의 손상을 본다
봉인 규칙은 목적지로 쓴다 NIC 로만 쓰면 DNAT 된 API 호출이 막혀 CNI 가 죽었다
nft 넣기 전에 nft -c -f fwd 하나로 표 전체가 거부되고 부팅 조리법이 멈췄다
서비스·파드 대역과 사내 주소 대조 기본 서비스 대역이 VM 의 DNS 주소를 덮었다
바깥 시도는 로그로 센다 「막혔다」 와 「필요 없었다」 는 다른 증명이다
키울 계획이면 --single 을 피한다 노드를 붙일 수 없고 CPLB·NLLB 도 못 쓴다

티티 한마디. 문을 잠갔다고 끝이 아니에요. 문밖에 몰래 나가려던 친구가 있었는지 발자국(로그)까지 세어 봐야 「정말 아무도 안 나갔다」 고 말할 수 있어요. 그리고 문패를 「fwd」 라고 달면 집 전체가 안 열린답니다!

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다