- 들어가며 — 심의를 통과한 파일 세 개로 클러스터를 세운다
- 반입 대상 산출 — 연결망 구간에서 무엇을 내려받는가
- 서버 노드 설치 — 이미지 배치와 SKIP_DOWNLOAD
- 에이전트 조인과 임베디드 etcd HA 구성
- 사설 레지스트리 경로 — registries.yaml과 자체 서명 CA
- 여기서 막힙니다 — 폐쇄망 k3s 실패 모드
- 버전 드리프트와 설치 직후 검증
- 마치며 — 반입 목록이 곧 설계 문서입니다
- 참고 자료
들어가며 — 심의를 통과한 파일 세 개로 클러스터를 세운다
폐쇄망 설치가 어려운 이유는 쿠버네티스가 어려워서가 아닙니다. 설치 도구 대부분이 "필요하면 그때 내려받는다"를 전제로 설계되어 있는데, 폐쇄망에는 그 "그때"가 존재하지 않기 때문입니다. get.k3s.io 스크립트를 그냥 실행하면 첫 줄에서 curl이 타임아웃 나고 끝납니다.
그래서 폐쇄망 설치는 순서가 반대입니다. 먼저 무엇이 반입되어야 하는지 목록을 확정하고, 연결망 구간에서 그 목록을 정확히 채우고, 체크섬을 붙여 심의에 올리고, 매체가 안쪽에 들어온 다음에야 설치를 시작합니다. 목록에서 하나라도 빠지면 안쪽에서는 되돌릴 방법이 없고, 다음 반입 심의까지 며칠에서 몇 주를 기다려야 합니다.
이 글은 k3s를 대상으로 그 목록과 절차를 명령어 단위로 적은 문서입니다. 검증 기준 버전은 아래와 같습니다.
| 항목 | 값 | 확인 시점 | 확인한 출처 |
|---|---|---|---|
| k3s 최신 안정판 | v1.36.2+k3s1 (2026-06-24 릴리스) | 2026-07-31 | k3s 릴리스 페이지 |
| 공식 문서 예시 | v1.33.3+k3s1 | 2026-07-31 | k3s Air-Gap Install |
| 조건부 임포트 | v1.33.1+k3s1 이상 | 2026-07-31 | k3s Air-Gap Install |
| 인증서 자동 갱신 | 만료 120일 이내 (재기동 시) | 2026-07-31 | k3s Certificate |
아래 명령은 모두 v1.36.2+k3s1을 기준으로 적었습니다. 다른 버전을 쓸 경우 버전 문자열만 바꾸면 되지만, 아카이브와 바이너리의 버전은 반드시 같아야 합니다. 이유는 뒤에서 다룹니다.
반입 대상 산출 — 연결망 구간에서 무엇을 내려받는가
k3s의 폐쇄망 설치에 필요한 파일은 최소 세 개입니다. 여기에 워크로드 이미지와 배포 매니페스트가 추가됩니다.
#!/usr/bin/env bash
# collect-k3s.sh — 인터넷이 되는 스테이징 장비에서 실행
set -euo pipefail
K3S_VERSION="v1.36.2+k3s1"
ARCH="amd64"
# URL 경로에서 + 는 %2B 로 인코딩해야 합니다
URLVER="${K3S_VERSION/+/%2B}"
BASE="https://github.com/k3s-io/k3s/releases/download/${URLVER}"
OUT="./k3s-airgap-${K3S_VERSION}"
mkdir -p "${OUT}"
cd "${OUT}"
# 1) 시스템 이미지 아카이브 (zstd 권장, 매체 용량 절감)
curl -fL -o "k3s-airgap-images-${ARCH}.tar.zst" \
"${BASE}/k3s-airgap-images-${ARCH}.tar.zst"
# 2) k3s 바이너리
curl -fL -o k3s "${BASE}/k3s"
# 3) 설치 스크립트 (실행 시점에 네트워크를 쓰지 않도록 SKIP_DOWNLOAD 와 함께 사용)
curl -fL -o install.sh https://get.k3s.io
# 4) 체크섬 파일 (릴리스에 게시되는 경우)
curl -fL -o "sha256sum-${ARCH}.txt" "${BASE}/sha256sum-${ARCH}.txt" || \
echo "WARN: 릴리스에 sha256sum-${ARCH}.txt 가 없습니다. 자체 산출 값으로 대체하십시오."
ls -la
sha256sum-amd64.txt 자산은 이번 확인 시점에 릴리스 페이지 자산 목록에서 존재 여부를 단정하지 못했습니다. 있으면 그것을 쓰고, 없으면 아래처럼 반입 담당자가 직접 산출한 매니페스트를 정본으로 삼는 편이 안전합니다. 어차피 폐쇄망 심의에서는 "누가 언제 어디서 받은 파일인가"를 증명하는 자체 매니페스트가 필요합니다.
# 자체 체크섬 매니페스트 생성 (연결망 구간)
cd "./k3s-airgap-v1.36.2+k3s1"
sha256sum k3s k3s-airgap-images-amd64.tar.zst install.sh > MANIFEST.sha256
cat MANIFEST.sha256
SELinux가 켜진 RHEL 계열 노드라면 k3s-selinux RPM도 함께 반입해야 합니다. 공식 문서는 SELinux 활성 노드에서 k3s 설치 전에 이 RPM을 수동으로 설치하라고 명시합니다. 이 파일을 빠뜨리면 설치 자체는 되지만 컨테이너가 볼륨을 못 읽는 형태로 뒤늦게 터집니다.
반입물 체크리스트
| 파일 | 필수 여부 | 놓치면 생기는 일 |
|---|---|---|
| k3s (바이너리) | 필수 | 설치 스크립트가 다운로드를 시도하다 타임아웃 |
| k3s-airgap-images-amd64.tar.zst | 필수 | 시스템 Pod 전부 ErrImagePull |
| install.sh | 필수 | systemd 유닛과 심볼릭 링크를 직접 만들어야 함 |
| MANIFEST.sha256 | 사실상 필수 | 매체 손상 여부를 안쪽에서 판별할 수 없음 |
| k3s-selinux RPM | 조건부 | SELinux 활성 노드에서 볼륨 접근 실패 |
| 자체 워크로드 이미지 아카이브 | 필수 | 애플리케이션만 ErrImagePull — 가장 흔한 사고 |
| Helm 차트 / 매니페스트 | 필수 | 배포 단계에서 다시 반입 심의를 기다려야 함 |
마지막 두 줄이 이 표의 핵심입니다. k3s 아카이브에는 k3s가 스스로를 띄우는 데 필요한 이미지만 들어 있습니다. CoreDNS, Traefik, local-path-provisioner, metrics-server, pause 정도입니다. 사내 애플리케이션 이미지는 당연히 없고, Prometheus도 Ingress 컨트롤러 교체품도 없습니다. 이건 뒤의 실패 모드 절에서 다시 짚겠습니다.
매체 반입과 무결성 검증
매체가 안쪽으로 들어오면 설치 전에 반드시 검증부터 합니다. 압축 아카이브가 조용히 깨진 채로 들어오면 k3s는 임포트 실패를 로그 한 줄로 흘리고 그냥 기동해 버리는데, 그러면 몇 분 뒤 Pod 상태를 보고 나서야 알게 됩니다.
# 폐쇄망 노드에서
cd /opt/staging/k3s-airgap-v1.36.2+k3s1
# 1) 체크섬 검증 — 여기서 실패하면 더 진행하지 않습니다
sha256sum -c MANIFEST.sha256
# 2) 아카이브 자체가 열리는지 확인 (zstd 필요)
zstd -t k3s-airgap-images-amd64.tar.zst && echo "archive OK"
# 3) 아카이브에 들어 있는 이미지 목록 확인
zstd -dc k3s-airgap-images-amd64.tar.zst | tar -tf - | grep -E 'manifest|repositories' | head
zstd 바이너리가 노드에 없을 수 있습니다. 폐쇄망 리눅스 이미지는 최소 설치인 경우가 많고 zstd는 기본 패키지가 아닌 배포판이 있습니다. 이 경우 두 가지 선택지가 있습니다. OS 패키지 미러에서 zstd를 먼저 설치하거나, 애초에 연결망에서 .tar.gz 또는 무압축 .tar 자산을 받아 오는 것입니다. 릴리스에는 k3s-airgap-images-amd64.tar, .tar.gz, .tar.zst 세 형태가 모두 게시되어 있습니다. 용량이 아깝더라도 첫 설치에서는 .tar.gz가 실패 지점이 하나 적습니다.
서버 노드 설치 — 이미지 배치와 SKIP_DOWNLOAD
이제 실제 설치입니다. 순서가 중요합니다. 이미지를 먼저 배치하고 그다음에 설치 스크립트를 실행합니다. 반대로 하면 k3s가 기동하면서 이미지를 못 찾아 재시도 루프에 들어갑니다.
#!/usr/bin/env bash
# install-k3s-server.sh — 폐쇄망 서버 노드
set -euo pipefail
STAGE=/opt/staging/k3s-airgap-v1.36.2+k3s1
# 1) 시스템 이미지 아카이브 배치
sudo mkdir -p /var/lib/rancher/k3s/agent/images/
sudo cp "${STAGE}/k3s-airgap-images-amd64.tar.zst" /var/lib/rancher/k3s/agent/images/
# 2) 조건부 임포트 캐시 활성화 (v1.33.1+k3s1 이상)
# 이 파일이 있으면 아카이브가 바뀌지 않는 한 재기동 때 재임포트를 건너뜁니다
sudo touch /var/lib/rancher/k3s/agent/images/.cache.json
# 3) 바이너리 배치
sudo cp "${STAGE}/k3s" /usr/local/bin/k3s
sudo chmod +x /usr/local/bin/k3s
# 4) 설치 스크립트 실행 — 다운로드를 건너뛰도록 지시
sudo chmod +x "${STAGE}/install.sh"
sudo INSTALL_K3S_SKIP_DOWNLOAD=true "${STAGE}/install.sh"
INSTALL_K3S_SKIP_DOWNLOAD=true가 이 절차의 전부라고 해도 과언이 아닙니다. 이 값이 없으면 스크립트는 릴리스 서버에 접속을 시도합니다. 서버 옵션을 함께 주려면 INSTALL_K3S_EXEC를 씁니다.
sudo INSTALL_K3S_SKIP_DOWNLOAD=true \
INSTALL_K3S_EXEC="server --cluster-init --tls-san 10.10.20.10 --tls-san k8s-api.internal.example --write-kubeconfig-mode 0644 --disable traefik" \
/opt/staging/k3s-airgap-v1.36.2+k3s1/install.sh
옵션을 명령줄에 늘어놓는 대신 설정 파일로 관리하는 편이 낫습니다. 폐쇄망에서는 재설치와 노드 증설이 잦고, 그때마다 누군가 옵션 하나를 빠뜨립니다.
# /etc/rancher/k3s/config.yaml
cluster-init: true
tls-san:
- 10.10.20.10
- k8s-api.internal.example
write-kubeconfig-mode: '0644'
disable:
- traefik
node-label:
- topology.kubernetes.io/zone=dc-a
기동 확인은 아래와 같이 합니다.
sudo systemctl status k3s --no-pager
sudo k3s kubectl get nodes -o wide
sudo k3s kubectl -n kube-system get pods
# 이미지 임포트가 실제로 되었는지 containerd 저장소에서 직접 확인
sudo k3s ctr images ls | awk '{print $1}' | sort -u | head -20
k3s ctr images ls가 비어 있으면 임포트가 실패한 것입니다. 이때는 journalctl -u k3s -n 200 대신 containerd 로그를 봐야 합니다. 경로는 /var/lib/rancher/k3s/agent/containerd/containerd.log입니다.
에이전트 조인과 임베디드 etcd HA 구성
에이전트 노드도 같은 순서입니다. 이미지 아카이브와 바이너리를 배치한 뒤, 조인 정보를 환경변수로 넘겨 설치 스크립트를 실행합니다.
# 서버 노드에서 토큰 확인
sudo cat /var/lib/rancher/k3s/server/node-token
#!/usr/bin/env bash
# install-k3s-agent.sh — 폐쇄망 에이전트 노드
set -euo pipefail
STAGE=/opt/staging/k3s-airgap-v1.36.2+k3s1
SERVER_URL="https://10.10.20.10:6443"
JOIN_TOKEN="K10xxxxxxxx::server:xxxxxxxx"
sudo mkdir -p /var/lib/rancher/k3s/agent/images/
sudo cp "${STAGE}/k3s-airgap-images-amd64.tar.zst" /var/lib/rancher/k3s/agent/images/
sudo touch /var/lib/rancher/k3s/agent/images/.cache.json
sudo cp "${STAGE}/k3s" /usr/local/bin/k3s
sudo chmod +x /usr/local/bin/k3s
sudo INSTALL_K3S_SKIP_DOWNLOAD=true \
K3S_URL="${SERVER_URL}" \
K3S_TOKEN="${JOIN_TOKEN}" \
"${STAGE}/install.sh"
HA가 필요하면 데이터스토어 선택을 처음에 해야 합니다. k3s의 기본 데이터스토어는 SQLite이고, SQLite는 서버 노드를 늘릴 수 없습니다. 임베디드 etcd로 가려면 첫 서버를 --cluster-init으로 띄우고, 나머지 서버를 --server로 붙입니다.
# 서버 1 — 클러스터 초기화 (config.yaml 에 cluster-init: true 를 넣어도 동일)
sudo INSTALL_K3S_SKIP_DOWNLOAD=true \
K3S_TOKEN="공유-시크릿" \
INSTALL_K3S_EXEC="server --cluster-init --tls-san 10.10.20.9" \
./install.sh
# 서버 2, 3 — 기존 서버에 조인
sudo INSTALL_K3S_SKIP_DOWNLOAD=true \
K3S_TOKEN="공유-시크릿" \
INSTALL_K3S_EXEC="server --server https://10.10.20.10:6443 --tls-san 10.10.20.9" \
./install.sh
공식 문서는 임베디드 etcd 클러스터가 정족수를 유지하려면 서버 노드가 홀수여야 한다고 명시합니다. 서버 n대의 정족수는 (n/2)+1입니다. 2대 구성은 1대 구성과 장애 허용 능력이 같으면서 운영 복잡도만 올라가니 의미가 없습니다. 그리고 --cluster-dns, --cluster-domain, --cluster-cidr, --service-cidr 같은 네트워크 플래그는 모든 서버 노드에서 동일해야 합니다. 폐쇄망에서 노드를 한 대씩 증설하다 보면 이 값이 어긋나기 쉽습니다.
이미 SQLite로 단일 서버를 띄워 버린 뒤라도 방법은 있습니다. 공식 문서에 따르면 기존 서버를 --cluster-init 플래그와 함께 재기동하면 etcd로 전환됩니다. 다만 이건 프로덕션에서 아무 백업 없이 시도할 종류의 작업이 아닙니다.
사설 레지스트리 경로 — registries.yaml과 자체 서명 CA
폐쇄망에서 이미지를 배포하는 정공법은 사내 레지스트리를 두고 노드가 그쪽만 바라보게 만드는 것입니다. k3s는 /etc/rancher/k3s/registries.yaml로 이걸 설정합니다.
# /etc/rancher/k3s/registries.yaml
mirrors:
docker.io:
endpoint:
- 'https://registry.internal.example:5000'
registry.k8s.io:
endpoint:
- 'https://registry.internal.example:5000'
ghcr.io:
endpoint:
- 'https://registry.internal.example:5000'
rewrite:
'^(.*)': 'mirror/ghcr/$1'
configs:
'registry.internal.example:5000':
auth:
username: k3s-puller
password: '반입-시-교체'
tls:
ca_file: /etc/rancher/k3s/certs/internal-ca.crt
레지스트리 전부를 한 엔드포인트로 몰아넣고 싶으면 와일드카드 항목을 씁니다. 공식 문서는 mirrors와 configs 양쪽에서 별표 항목을 기본 설정으로 쓸 수 있다고 명시하며, 별표는 따옴표로 감싸야 합니다.
# 모든 레지스트리를 사내 레지스트리로 (와일드카드)
mirrors:
'*':
endpoint:
- 'https://registry.internal.example:5000'
configs:
'registry.internal.example:5000':
tls:
ca_file: /etc/rancher/k3s/certs/internal-ca.crt
rewrite는 경로 앞부분을 바꿔 줍니다. Harbor처럼 프로젝트 단위로 네임스페이스가 강제되는 레지스트리에서는 이게 없으면 원본 경로를 그대로 못 씁니다. 위 예시는 ghcr.io/foo/bar를 registry.internal.example:5000/mirror/ghcr/foo/bar로 보냅니다.
자체 서명 CA를 쓴다면 신뢰 설정이 두 군데 필요합니다. 여기서 절반쯤은 걸려 넘어집니다.
# 1) containerd 용 — registries.yaml 의 ca_file 이 가리키는 위치
sudo mkdir -p /etc/rancher/k3s/certs
sudo cp /opt/staging/internal-ca.crt /etc/rancher/k3s/certs/internal-ca.crt
sudo chmod 644 /etc/rancher/k3s/certs/internal-ca.crt
# 2) OS 트러스트 스토어 — helm, skopeo, crictl, curl 등 다른 클라이언트용
# RHEL 계열
sudo cp /opt/staging/internal-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract
# Debian 계열
sudo cp /opt/staging/internal-ca.crt /usr/local/share/ca-certificates/internal-ca.crt
sudo update-ca-certificates
# 3) registries.yaml 변경은 재기동해야 반영됩니다 — 모든 노드에서
sudo systemctl restart k3s # 서버 노드
sudo systemctl restart k3s-agent # 에이전트 노드
세 번째 단계를 공식 문서가 명시적으로 강조합니다. "설정 변경이 적용되려면 각 노드에서 k3s를 재시작해야 한다"입니다. 그리고 이 파일은 이미지를 pull 하는 모든 노드에 배포되어야 합니다. 서버에만 넣고 에이전트에 안 넣는 실수가 흔합니다.
검증은 이렇게 합니다.
# 레지스트리 설정이 containerd 에 반영되었는지 확인
sudo k3s ctr --namespace k8s.io images pull \
registry.internal.example:5000/library/busybox:1.36
# 실패하면 containerd 로그를 봅니다 (kubelet 메시지가 아니라)
sudo tail -n 100 /var/lib/rancher/k3s/agent/containerd/containerd.log
노드 간 이미지 재분배 — 임베디드 레지스트리 미러
k3s에는 Spegel 기반의 임베디드 분산 레지스트리 미러가 있습니다. 노드 하나에 이미 있는 이미지를 다른 노드가 외부 레지스트리 없이 가져갈 수 있게 해 줍니다. 폐쇄망에서 사내 레지스트리를 세우기 전 단계이거나, 엣지 노드가 사내 레지스트리에도 닿지 않는 경우에 유용합니다.
# /etc/rancher/k3s/config.yaml (모든 서버 노드)
embedded-registry: true
# /etc/rancher/k3s/registries.yaml (모든 노드)
mirrors:
'*':
공식 문서 기준으로 노드는 내부 IP로 서로 TCP 5001(가용 이미지 목록을 공유하는 P2P 네트워크)과 6443(각 노드가 호스팅하는 로컬 OCI 레지스트리)에 도달할 수 있어야 합니다. 방화벽이 촘촘한 폐쇄망에서는 5001이 막혀 있는 경우가 많으니 미리 열어야 합니다.
한 가지 오해를 짚어 둡니다. 이 기능은 이미 어느 노드엔가 존재하는 이미지만 재분배합니다. 여기에 이미지를 밀어 넣는 push 기능은 없습니다. 최초 진입은 여전히 에어갭 아카이브나 k3s ctr images import로 해야 합니다.
여기서 막힙니다 — 폐쇄망 k3s 실패 모드
행복 경로만 적은 문서는 폐쇄망에서 쓸모가 없습니다. 실제로 발이 묶이는 지점을 정리합니다.
| 증상 | 진짜 원인 | 확인 방법 |
|---|---|---|
| 시스템 Pod는 뜨는데 내 앱만 ErrImagePull | 에어갭 아카이브에는 내 이미지가 없음 | k3s ctr images ls 에 해당 이미지가 없는지 확인 |
| ErrImagePull 메시지가 docker.io 를 가리킴 | 기본 레지스트리 엔드포인트 폴백의 결과만 노출됨 | containerd.log 에서 실제 첫 시도 대상 확인 |
| registries.yaml 을 고쳤는데 그대로임 | 재기동을 안 함, 또는 에이전트에 배포 안 함 | systemctl restart 후 재시도, 모든 노드에 파일 존재 확인 |
| helm 은 되는데 containerd 만 TLS 실패 | ca_file 만 설정하고 OS 트러스트 스토어 누락 (또는 그 반대) | curl 로 레지스트리 접속과 ctr images pull 을 각각 시도 |
| 외부 도메인 조회가 5초씩 멈춤 | CoreDNS 업스트림이 도달 불가 | CoreDNS ConfigMap 의 forward 대상과 노드 resolv.conf 확인 |
| 서버를 늘리려는데 조인이 안 됨 | 데이터스토어가 SQLite | k3s kubectl get nodes 와 서버 기동 플래그 확인 |
| 재기동 후 시스템 Pod가 pull 을 시도함 | 아카이브 버전과 바이너리 버전 불일치 | k3s -v 와 아카이브 파일명의 버전 비교 |
| 컨테이너가 볼륨을 못 읽음 (RHEL 계열) | k3s-selinux RPM 미설치 | getenforce 와 rpm -q k3s-selinux |
실패 모드 1 — 아카이브에 없는 이미지
가장 흔하고 가장 허무한 실패입니다. k3s 에어갭 아카이브는 k3s 자신이 필요로 하는 이미지만 담고 있습니다. 사내 애플리케이션, 교체한 Ingress 컨트롤러, 모니터링 스택은 전부 별도로 반입해야 합니다.
사내 레지스트리가 아직 없다면 임시로 노드에 직접 임포트할 수 있습니다.
# 연결망 구간에서: 필요한 이미지를 한 아카이브로 묶기
docker pull myapp/api:1.4.2
docker pull myapp/worker:1.4.2
docker save -o myapp-images.tar myapp/api:1.4.2 myapp/worker:1.4.2
sha256sum myapp-images.tar >> MANIFEST.sha256
# 폐쇄망 노드에서: 방법 A — 이미지 디렉터리에 두고 재기동
sudo cp myapp-images.tar /var/lib/rancher/k3s/agent/images/
sudo systemctl restart k3s
# 방법 B — 재기동 없이 즉시 임포트
sudo k3s ctr --namespace k8s.io images import myapp-images.tar
sudo k3s ctr --namespace k8s.io images ls | grep myapp
방법 B로 넣은 이미지를 쓸 때는 Pod 스펙의 imagePullPolicy를 확인하십시오. 태그가 latest이면 기본 정책이 Always가 되어, 노드에 이미지가 있어도 pull을 시도하고 실패합니다. 폐쇄망에서는 태그를 항상 명시적 버전으로 쓰고, 필요하면 imagePullPolicy: IfNotPresent를 못 박습니다.
실패 모드 2 — 기본 엔드포인트 폴백이 만드는 거짓 단서
containerd에는 registries.yaml의 미러 설정과 무관하게 마지막 시도로 원래 레지스트리에 접속하는 동작이 있습니다. 폐쇄망에서는 이 마지막 시도가 반드시 실패하고, kubelet이 사용자에게 보여주는 에러는 이 마지막 시도의 결과입니다. 그래서 미러가 잘못 설정된 것인지, 미러에 이미지가 없는 것인지, 아예 미러를 안 타는 것인지 구분이 안 됩니다.
두 가지 대응이 있습니다.
# 대응 A — 진짜 원인은 containerd 로그에 있습니다
sudo grep -iE 'failed|error' /var/lib/rancher/k3s/agent/containerd/containerd.log | tail -40
# 대응 B — /etc/rancher/k3s/config.yaml
# 미러가 설정된 레지스트리에 대해 기본 엔드포인트 폴백을 끕니다
disable-default-registry-endpoint: true
공식 문서는 이 옵션을 "해당 레지스트리에 미러가 설정된 경우 containerd의 기본 레지스트리 엔드포인트 폴백을 비활성화한다"고 설명합니다. 2024년 1월 릴리스에서 실험적 기능으로 도입되었고, registries.yaml에 미러 항목이 있는 레지스트리에만 적용됩니다. 미러를 설정하지 않은 레지스트리는 여전히 폴백합니다. 이 옵션을 켜면 에러 메시지가 실제 실패 지점을 가리키게 되어 디버깅 시간이 크게 줄어듭니다.
실패 모드 3 — CoreDNS가 아무것도 못 찾는다
클러스터 내부 이름(kubernetes.default.svc.cluster.local)은 잘 풀리는데, 사내 도메인이나 외부 도메인 조회가 5초씩 멈추다가 실패하는 증상입니다. CoreDNS의 기본 Corefile은 클러스터 도메인 밖의 질의를 노드의 리졸버로 전달(forward)하는데, 그 리졸버가 폐쇄망에서 도달 불가한 공인 DNS(예: 8.8.8.8)를 가리키고 있으면 매 질의마다 타임아웃을 기다립니다.
먼저 실제 설정을 확인합니다. 추측하지 말고 클러스터에서 직접 읽으십시오.
# CoreDNS 가 무엇을 forward 대상으로 쓰고 있는지 확인
sudo k3s kubectl -n kube-system get configmap coredns -o yaml
# 노드의 리졸버 확인
cat /etc/resolv.conf
sudo resolvectl status 2>/dev/null | head -30
# 사내 DNS 가 실제로 응답하는지 노드에서 직접 확인
dig @10.10.10.53 registry.internal.example +short
대응은 두 갈래입니다. 첫째, 노드의 /etc/resolv.conf를 사내 DNS만 가리키도록 정리합니다. systemd-resolved가 관리하는 노드라면 심볼릭 링크 때문에 파일을 직접 고쳐도 되돌아가므로 resolved 설정을 바꿔야 합니다. 둘째, 노드 파일을 건드리기 어려우면 k3s에 kubelet용 리졸버 파일을 따로 지정합니다.
# 폐쇄망 전용 리졸버 파일을 별도로 둡니다
sudo tee /etc/rancher/k3s/resolv.conf > /dev/null <<'EOF'
nameserver 10.10.10.53
nameserver 10.10.10.54
search internal.example
options timeout:1 attempts:2
EOF
# /etc/rancher/k3s/config.yaml
resolv-conf: /etc/rancher/k3s/resolv.conf
--resolv-conf 플래그는 공식 CLI 문서에 "Kubelet resolv.conf file"로 기재되어 있고 환경변수 K3S_RESOLV_CONF로도 지정할 수 있습니다. 사내 DNS 자체가 아예 없는 환경이라면 CoreDNS Corefile에서 forward 대상을 사내 리졸버로 고정하거나, 필요한 이름만 hosts 플러그인으로 박아 넣는 편이 낫습니다. options timeout:1은 그래도 새는 질의의 지연을 1초로 묶어 줍니다.
버전 드리프트와 설치 직후 검증
아카이브와 바이너리가 어긋날 때
폐쇄망에서 몇 달에 걸쳐 여러 사람이 반입을 하다 보면 반드시 생기는 문제입니다. 지난달에 반입한 v1.35.6 아카이브가 이미지 디렉터리에 남아 있는데, 이번에 반입한 바이너리는 v1.36.2인 상황입니다.
이때 벌어지는 일은 이렇습니다. k3s v1.36.2는 자신에게 맞는 CoreDNS, pause, local-path-provisioner 태그를 요구하는데, 노드에 임포트된 이미지는 v1.35.6용 태그입니다. containerd 저장소에 태그가 없으니 pull을 시도하고, 폐쇄망이니 실패합니다. 로그에는 "이미지를 찾을 수 없다"만 남고, 버전 불일치라는 단서는 어디에도 없습니다.
진단과 정리는 이렇게 합니다.
# 1) 바이너리 버전
k3s -v
# 2) 이미지 디렉터리에 무엇이 쌓여 있는지
ls -la /var/lib/rancher/k3s/agent/images/
# 3) containerd 저장소의 시스템 이미지 태그
sudo k3s ctr --namespace k8s.io images ls | grep -E 'coredns|pause|local-path|metrics-server'
# 정리 — 옛 아카이브를 지우고 새 아카이브만 남긴 뒤 재기동
sudo systemctl stop k3s
sudo rm -f /var/lib/rancher/k3s/agent/images/k3s-airgap-images-amd64-v1.35.6.tar.zst
sudo rm -f /var/lib/rancher/k3s/agent/images/.cache.json
sudo cp /opt/staging/k3s-airgap-images-amd64.tar.zst /var/lib/rancher/k3s/agent/images/
sudo systemctl start k3s
.cache.json을 함께 지우는 이유는, v1.33.1+k3s1 이상에서 이 캐시 파일이 "이 아카이브는 이미 임포트했다"는 판단에 쓰이기 때문입니다. 아카이브 파일을 교체했는데 캐시가 남아 있으면 임포트를 건너뛸 수 있습니다. 공식 문서의 업그레이드 절차도 새 아카이브를 넣고 기존 아카이브를 삭제하라고 명시합니다.
드리프트를 애초에 막는 방법은 반입 단위를 버전으로 묶는 것입니다.
# 반입 디렉터리 이름과 파일 이름 양쪽에 버전을 박습니다
/opt/staging/k3s-v1.36.2+k3s1/
├── MANIFEST.sha256
├── VERSION # v1.36.2+k3s1 한 줄
├── install.sh
├── k3s
└── k3s-airgap-images-amd64.tar.zst
# 설치 스크립트에 버전 게이트를 넣습니다
EXPECTED="$(cat "${STAGE}/VERSION")"
ACTUAL="$(/usr/local/bin/k3s -v | awk '/^k3s version/{print $3}')"
if [ "${ACTUAL}" != "${EXPECTED}" ]; then
echo "FATAL: 바이너리 ${ACTUAL} 와 반입 번들 ${EXPECTED} 가 다릅니다" >&2
exit 1
fi
설치 직후 검증 스크립트
설치가 끝났다고 끝이 아닙니다. 폐쇄망에서는 "일단 떠 있는 것처럼 보이는" 상태와 "실제로 쓸 수 있는" 상태의 간격이 큽니다. 아래 스크립트를 설치 직후에 돌려 두면 며칠 뒤 사고가 크게 줍니다.
#!/usr/bin/env bash
# verify-airgap-k3s.sh
set -uo pipefail
ERR=0
K="sudo k3s kubectl"
echo "== 1. 노드 상태"
${K} get nodes -o wide
NR=$(${K} get nodes --no-headers | grep -cv " Ready ") || true
[ "${NR}" -gt 0 ] && { echo "FAIL: NotReady 노드 ${NR}대"; ERR=$((ERR+1)); }
echo "== 2. 시스템 Pod"
BAD=$(${K} -n kube-system get pods --no-headers | grep -cvE "Running|Completed") || true
[ "${BAD}" -gt 0 ] && { ${K} -n kube-system get pods | grep -vE "Running|Completed"; ERR=$((ERR+1)); }
echo "== 3. 외부 레지스트리로 나가려는 시도가 있는지"
if sudo grep -qiE 'docker\.io|registry\.k8s\.io|ghcr\.io' \
/var/lib/rancher/k3s/agent/containerd/containerd.log 2>/dev/null; then
echo "WARN: containerd 로그에 외부 레지스트리 접속 흔적이 있습니다"
echo " registries.yaml 미러 설정과 disable-default-registry-endpoint 를 확인하십시오"
fi
echo "== 4. DNS"
${K} run dnscheck --rm -i --restart=Never --image=registry.internal.example:5000/library/busybox:1.36 -- \
nslookup kubernetes.default.svc.cluster.local || { echo "FAIL: 클러스터 DNS"; ERR=$((ERR+1)); }
echo "== 5. 인증서 만료"
sudo k3s certificate check --output table
echo "== 6. 데이터스토어"
if [ -d /var/lib/rancher/k3s/server/db/etcd ]; then
echo "datastore: embedded etcd"
ls -la /var/lib/rancher/k3s/server/db/snapshots/ 2>/dev/null | tail -5
else
echo "datastore: sqlite (서버 노드 증설 불가)"
fi
echo "== 결과: 오류 ${ERR}건"
exit "${ERR}"
5번의 k3s certificate check --output table은 폐쇄망 운영에서 특히 중요합니다. k3s의 클라이언트·서버 인증서는 발급일로부터 365일 유효하고, 서비스 재기동 시 만료 120일 이내면 자동 갱신됩니다. 다만 재기동이 없으면 갱신도 없습니다. 몇 달간 아무도 건드리지 않는 폐쇄망 클러스터가 인증서 만료로 죽는 경로가 바로 여기입니다. 자세한 대응은 Day 2 운영 편에서 다룹니다.
마치며 — 반입 목록이 곧 설계 문서입니다
폐쇄망 k3s 설치에서 진짜 작업은 명령어가 아니라 목록입니다. 아카이브·바이너리·설치 스크립트·체크섬·CA·워크로드 이미지·차트·SELinux RPM까지, 안쪽에서 필요한 모든 바이트를 바깥에서 미리 세어 두는 일입니다. 명령어 자체는 열 줄이 안 됩니다.
그리고 그 목록은 한 번 만들고 끝나지 않습니다. 버전이 오르면 아카이브도 바이너리도 같이 올라야 하고, 둘 중 하나만 올라가는 순간 클러스터는 조용히 이상해집니다. 반입 단위에 버전을 박고 설치 스크립트에 게이트를 넣는 다섯 줄이, 다음 반입 심의를 기다리는 3주를 막아 줍니다.
참고 자료
현재 단락 (1/262)
폐쇄망 설치가 어려운 이유는 쿠버네티스가 어려워서가 아닙니다. 설치 도구 대부분이 "필요하면 그때 내려받는다"를 전제로 설계되어 있는데, 폐쇄망에는 그 "그때"가 존재하지 않기 때...