Skip to content

Split View: k3s 폐쇄망 설치 완전 가이드 — 이미지 tarball 반입부터 사설 레지스트리, 에이전트 조인까지

✨ Learn with Quiz
|

k3s 폐쇄망 설치 완전 가이드 — 이미지 tarball 반입부터 사설 레지스트리, 에이전트 조인까지

들어가며 — 심의를 통과한 파일 세 개로 클러스터를 세운다

폐쇄망 설치가 어려운 이유는 쿠버네티스가 어려워서가 아닙니다. 설치 도구 대부분이 "필요하면 그때 내려받는다"를 전제로 설계되어 있는데, 폐쇄망에는 그 "그때"가 존재하지 않기 때문입니다. get.k3s.io 스크립트를 그냥 실행하면 첫 줄에서 curl이 타임아웃 나고 끝납니다.

그래서 폐쇄망 설치는 순서가 반대입니다. 먼저 무엇이 반입되어야 하는지 목록을 확정하고, 연결망 구간에서 그 목록을 정확히 채우고, 체크섬을 붙여 심의에 올리고, 매체가 안쪽에 들어온 다음에야 설치를 시작합니다. 목록에서 하나라도 빠지면 안쪽에서는 되돌릴 방법이 없고, 다음 반입 심의까지 며칠에서 몇 주를 기다려야 합니다.

이 글은 k3s를 대상으로 그 목록과 절차를 명령어 단위로 적은 문서입니다. 검증 기준 버전은 아래와 같습니다.

항목확인 시점확인한 출처
k3s 최신 안정판v1.36.2+k3s1 (2026-06-24 릴리스)2026-07-31k3s 릴리스 페이지
공식 문서 예시v1.33.3+k3s12026-07-31k3s Air-Gap Install
조건부 임포트v1.33.1+k3s1 이상2026-07-31k3s Air-Gap Install
인증서 자동 갱신만료 120일 이내 (재기동 시)2026-07-31k3s 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가 실패 지점이 하나 적습니다.

이 문서의 이후 명령은 전부 .tar.zst를 씁니다. 매체 용량이 빠듯한 현장이 더 흔해서인데, 위 권고대로 .tar.gz를 골랐다면 파일명을 그대로 바꿔 읽으면 됩니다. 달라지는 곳은 두 군데뿐입니다. 무결성 확인은 zstd -t 대신 gzip -t k3s-airgap-images-amd64.tar.gz를 쓰고, 내용 확인은 zstd -dc 대신 gzip -dc를 씁니다. /var/lib/rancher/k3s/agent/images/에 놓는 방식과 그 뒤 과정은 형식과 무관하게 동일합니다.

서버 노드 설치 — 이미지 배치와 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/barregistry.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 확인
서버를 늘리려는데 조인이 안 됨데이터스토어가 SQLitek3s 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주를 막아 줍니다.

참고 자료

The Complete Guide to Installing k3s in an Air-Gapped Network — From Image Tarball Transfer to a Private Registry and Agent Joins

Opening — you build a cluster out of three files that cleared review

Air-gapped installation is hard not because Kubernetes is hard. It is hard because most install tooling is designed around the assumption of "we will download it when we need it," and in an air-gapped network that "when" never arrives. Run the get.k3s.io script as-is and curl times out on the very first line, and that is the end of it.

So air-gapped installation runs in the opposite order. First you finalize the list of what has to be brought in, fill that list exactly on the connected side, attach checksums and submit it for review, and only after the medium is inside do you begin installing. If a single item is missing from the list there is no way to undo that from the inside, and you wait anywhere from days to weeks for the next transfer review.

This article is that list and that procedure, written out command by command for k3s. The versions it was verified against are below.

ItemValueChecked onSource consulted
Latest stable k3sv1.36.2+k3s1 (released 2026-06-24)2026-07-31k3s releases page
Example in the official docsv1.33.3+k3s12026-07-31k3s Air-Gap Install
Conditional importv1.33.1+k3s1 and later2026-07-31k3s Air-Gap Install
Automatic certificate renewalWithin 120 days of expiry (on restart)2026-07-31k3s Certificate

Every command below is written against v1.36.2+k3s1. If you use a different version you only change the version string, but the archive and the binary must be the same version. The reason comes later.

Working out what to transfer — what you download on the connected side

An air-gapped k3s installation needs at least three files. On top of those come your workload images and your deployment manifests.

#!/usr/bin/env bash
# collect-k3s.sh — run this on a staging machine that has internet access
set -euo pipefail

K3S_VERSION="v1.36.2+k3s1"
ARCH="amd64"
# The + in a URL path has to be encoded as %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) System image archive (zstd recommended, saves space on the medium)
curl -fL -o "k3s-airgap-images-${ARCH}.tar.zst" \
  "${BASE}/k3s-airgap-images-${ARCH}.tar.zst"

# 2) k3s binary
curl -fL -o k3s "${BASE}/k3s"

# 3) Install script (use it with SKIP_DOWNLOAD so it touches no network at run time)
curl -fL -o install.sh https://get.k3s.io

# 4) Checksum file (when the release publishes one)
curl -fL -o "sha256sum-${ARCH}.txt" "${BASE}/sha256sum-${ARCH}.txt" || \
  echo "WARN: the release has no sha256sum-${ARCH}.txt. Substitute your own computed values."

ls -la

For the sha256sum-amd64.txt asset, I could not establish from the release page asset list whether it exists as of this check. If it is there, use it; if it is not, it is safer to treat a manifest computed by the person doing the transfer as the authoritative copy, as shown below. Air-gap review is going to want your own manifest anyway, the one that proves "who fetched this file, when, and from where."

# Build your own checksum manifest (connected segment)
cd "./k3s-airgap-v1.36.2+k3s1"
sha256sum k3s k3s-airgap-images-amd64.tar.zst install.sh > MANIFEST.sha256
cat MANIFEST.sha256

On RHEL-family nodes with SELinux enabled you also have to bring in the k3s-selinux RPM. The official docs state that this RPM must be installed manually before k3s on an SELinux-enabled node. Leave this file out and the install itself still succeeds, then blows up much later in the form of containers that cannot read their volumes.

Transfer checklist

FileRequired?What happens if you miss it
k3s (binary)RequiredThe install script tries to download and times out
k3s-airgap-images-amd64.tar.zstRequiredEvery system Pod goes ErrImagePull
install.shRequiredYou hand-build the systemd unit and the symlinks
MANIFEST.sha256Effectively requiredNo way to tell from the inside whether the medium is corrupt
k3s-selinux RPMConditionalVolume access fails on SELinux-enabled nodes
Your own workload image archiveRequiredOnly the application goes ErrImagePull — the most common accident
Helm charts / manifestsRequiredYou wait for another transfer review at deployment time

The last two rows are the heart of this table. The k3s archive contains only the images k3s needs to bring itself up. That is roughly CoreDNS, Traefik, local-path-provisioner, metrics-server and pause. Your internal application images are obviously not in there, and neither is Prometheus, nor a replacement Ingress controller. The failure modes section comes back to this.

Medium transfer and integrity verification

Once the medium is inside, verification always comes before installation. If a compressed archive arrives quietly corrupted, k3s lets the import failure slide past as a single log line and starts up anyway — and then you find out several minutes later, when you look at Pod status.

# On the air-gapped node
cd /opt/staging/k3s-airgap-v1.36.2+k3s1

# 1) Checksum verification — if this fails, go no further
sha256sum -c MANIFEST.sha256

# 2) Confirm the archive itself opens (needs zstd)
zstd -t k3s-airgap-images-amd64.tar.zst && echo "archive OK"

# 3) Check the list of images the archive holds
zstd -dc k3s-airgap-images-amd64.tar.zst | tar -tf - | grep -E 'manifest|repositories' | head

The zstd binary may not be on the node. Air-gapped Linux images are often minimal installs, and on some distributions zstd is not a default package. You have two options here. Install zstd from an OS package mirror first, or fetch the .tar.gz or the uncompressed .tar asset on the connected side to begin with. The release publishes all three forms: k3s-airgap-images-amd64.tar, .tar.gz and .tar.zst. Even if the size hurts, .tar.gz has one fewer failure point for a first installation.

Installing the server node — placing images and SKIP_DOWNLOAD

Now the actual install. Order matters. Place the images first and run the install script second. Do it the other way around and k3s starts up, fails to find the images and drops into a retry loop.

#!/usr/bin/env bash
# install-k3s-server.sh — air-gapped server node
set -euo pipefail

STAGE=/opt/staging/k3s-airgap-v1.36.2+k3s1

# 1) Place the system image archive
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) Enable the conditional import cache (v1.33.1+k3s1 and later)
#    With this file present, a restart skips re-import unless the archive changed
sudo touch /var/lib/rancher/k3s/agent/images/.cache.json

# 3) Place the binary
sudo cp "${STAGE}/k3s" /usr/local/bin/k3s
sudo chmod +x /usr/local/bin/k3s

# 4) Run the install script — tell it to skip the download
sudo chmod +x "${STAGE}/install.sh"
sudo INSTALL_K3S_SKIP_DOWNLOAD=true "${STAGE}/install.sh"

It is barely an exaggeration to say INSTALL_K3S_SKIP_DOWNLOAD=true is the whole procedure. Without it the script tries to reach the release server. To pass server options along, use 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

Managing options in a config file beats stringing them out on the command line. In an air-gapped network reinstalls and node additions are frequent, and every time, somebody drops one option.

# /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

Confirm startup like this.

sudo systemctl status k3s --no-pager
sudo k3s kubectl get nodes -o wide
sudo k3s kubectl -n kube-system get pods

# Check the containerd store directly to see whether the images really imported
sudo k3s ctr images ls | awk '{print $1}' | sort -u | head -20

If k3s ctr images ls comes back empty, the import failed. At that point look at the containerd log rather than journalctl -u k3s -n 200. The path is /var/lib/rancher/k3s/agent/containerd/containerd.log.

Agent joins and an embedded etcd HA setup

Agent nodes follow the same order. Place the image archive and the binary, then run the install script with the join details passed as environment variables.

# Read the token on the server node
sudo cat /var/lib/rancher/k3s/server/node-token
#!/usr/bin/env bash
# install-k3s-agent.sh — air-gapped agent node
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"

If you need HA, you have to pick the datastore up front. The k3s default datastore is SQLite, and SQLite cannot scale to more than one server node. To go with embedded etcd, bring the first server up with --cluster-init and attach the rest with --server.

# Server 1 — initialize the cluster (identical to putting cluster-init: true in config.yaml)
sudo INSTALL_K3S_SKIP_DOWNLOAD=true \
  K3S_TOKEN="shared-secret" \
  INSTALL_K3S_EXEC="server --cluster-init --tls-san 10.10.20.9" \
  ./install.sh

# Servers 2 and 3 — join the existing server
sudo INSTALL_K3S_SKIP_DOWNLOAD=true \
  K3S_TOKEN="shared-secret" \
  INSTALL_K3S_EXEC="server --server https://10.10.20.10:6443 --tls-san 10.10.20.9" \
  ./install.sh

The official docs state that an embedded etcd cluster needs an odd number of server nodes to maintain quorum. Quorum for n servers is (n/2)+1. A two-node setup tolerates exactly as many failures as a one-node setup while adding operational complexity, so it buys you nothing. Network flags such as --cluster-dns, --cluster-domain, --cluster-cidr and --service-cidr also have to be identical on every server node. When you add nodes one at a time in an air-gapped network, these values drift apart easily.

Even if you already brought up a single server on SQLite, there is a way out. According to the official docs, restarting the existing server with the --cluster-init flag switches it to etcd. That said, this is not the kind of operation you attempt in production with no backup.

The private registry path — registries.yaml and a self-signed CA

The standard way to distribute images in an air-gapped network is to stand up an internal registry and make the nodes look only at it. In k3s you configure that through /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: 'replace-at-transfer-time'
    tls:
      ca_file: /etc/rancher/k3s/certs/internal-ca.crt

If you want to funnel every registry into a single endpoint, use a wildcard entry. The official docs state that an asterisk entry can be used as the default configuration on both the mirrors and the configs side, and that the asterisk has to be wrapped in quotes.

# Send every registry to the internal registry (wildcard)
mirrors:
  '*':
    endpoint:
      - 'https://registry.internal.example:5000'

configs:
  'registry.internal.example:5000':
    tls:
      ca_file: /etc/rancher/k3s/certs/internal-ca.crt

rewrite changes the leading part of the path. On a registry like Harbor, where namespacing by project is mandatory, you cannot use the original paths as-is without it. The example above sends ghcr.io/foo/bar to registry.internal.example:5000/mirror/ghcr/foo/bar.

If you use a self-signed CA, trust has to be configured in two places. About half of all attempts trip here.

# 1) For containerd — the location that ca_file in registries.yaml points at
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 trust store — for other clients such as helm, skopeo, crictl and curl
#    RHEL family
sudo cp /opt/staging/internal-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract
#    Debian family
sudo cp /opt/staging/internal-ca.crt /usr/local/share/ca-certificates/internal-ca.crt
sudo update-ca-certificates

# 3) registries.yaml changes take effect only after a restart — on every node
sudo systemctl restart k3s        # server node
sudo systemctl restart k3s-agent  # agent node

The official docs call out that third step explicitly: "k3s must be restarted on each node for configuration changes to take effect." And this file has to be distributed to every node that pulls images. Putting it on the servers and forgetting the agents is a common mistake.

Verification looks like this.

# Confirm the registry configuration reached containerd
sudo k3s ctr --namespace k8s.io images pull \
  registry.internal.example:5000/library/busybox:1.36

# If it fails, read the containerd log (not the kubelet message)
sudo tail -n 100 /var/lib/rancher/k3s/agent/containerd/containerd.log

Redistributing images between nodes — the embedded registry mirror

k3s ships an embedded distributed registry mirror built on Spegel. It lets one node hand an image it already has to another node without an external registry. That is useful before you have stood up an internal registry, or when edge nodes cannot even reach the internal registry.

# /etc/rancher/k3s/config.yaml (every server node)
embedded-registry: true
# /etc/rancher/k3s/registries.yaml (every node)
mirrors:
  '*':

Per the official docs, nodes must be able to reach each other on their internal IPs over TCP 5001 (the P2P network that shares the list of available images) and 6443 (the local OCI registry each node hosts). In an air-gapped network with tight firewalls 5001 is often blocked, so open it in advance.

One misconception is worth correcting. This feature only redistributes images that already exist on some node. There is no push capability for injecting images into it. The initial entry still has to come from the airgap archive or from k3s ctr images import.

Here is where you get stuck — air-gapped k3s failure modes

A document that only writes down the happy path is useless in an air-gapped network. Here are the points where you actually get stranded.

SymptomThe real causeHow to confirm
System Pods come up but only my app is ErrImagePullThe airgap archive does not contain my imageCheck whether that image is absent from k3s ctr images ls
The ErrImagePull message points at docker.ioOnly the result of the default registry endpoint fallback is surfacedFind the actual first attempt in containerd.log
I edited registries.yaml and nothing changedNo restart, or it was never distributed to the agentsRetry after systemctl restart, confirm the file exists on every node
helm works but only containerd fails TLSOnly ca_file was set and the OS trust store was missed (or the reverse)Try reaching the registry with curl and ctr images pull separately
External domain lookups stall for 5 secondsThe CoreDNS upstream is unreachableCheck the forward target in the CoreDNS ConfigMap and the node resolv.conf
Trying to add servers and the join failsThe datastore is SQLiteCheck k3s kubectl get nodes and the server startup flags
System Pods try to pull after a restartArchive version and binary version do not matchCompare k3s -v against the version in the archive filename
Containers cannot read volumes (RHEL family)The k3s-selinux RPM is not installedgetenforce and rpm -q k3s-selinux

Failure mode 1 — an image that is not in the archive

The most common and the most deflating failure. The k3s airgap archive holds only the images k3s itself needs. Your internal applications, the Ingress controller you swapped in, the monitoring stack — all of those have to be transferred separately.

If you do not have an internal registry yet, you can import directly onto the node as a stopgap.

# On the connected segment: bundle the images you need into a single archive
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

# On the air-gapped node: option A — drop it in the image directory and restart
sudo cp myapp-images.tar /var/lib/rancher/k3s/agent/images/
sudo systemctl restart k3s

# Option B — import immediately, without a restart
sudo k3s ctr --namespace k8s.io images import myapp-images.tar
sudo k3s ctr --namespace k8s.io images ls | grep myapp

When you use an image loaded via option B, check the imagePullPolicy in the Pod spec. If the tag is latest, the default policy becomes Always, so the node tries to pull even though the image is right there, and fails. In an air-gapped network always use explicit version tags, and pin imagePullPolicy: IfNotPresent where you need to.

Failure mode 2 — the false lead the default endpoint fallback creates

containerd has a behavior where, as a last attempt, it connects to the original registry regardless of the mirror configuration in registries.yaml. In an air-gapped network that last attempt always fails, and the error kubelet shows the user is the result of that last attempt. So you cannot tell whether the mirror is misconfigured, whether the mirror does not have the image, or whether the mirror is not being used at all.

There are two responses.

# Response A — the real cause is in the containerd log
sudo grep -iE 'failed|error' /var/lib/rancher/k3s/agent/containerd/containerd.log | tail -40
# Response B — /etc/rancher/k3s/config.yaml
# Turns off the default endpoint fallback for registries that have a mirror configured
disable-default-registry-endpoint: true

The official docs describe this option as disabling containerd default registry endpoint fallback when a mirror is configured for that registry. It was introduced as an experimental feature in a January 2024 release, and it applies only to registries that have a mirror entry in registries.yaml. Registries with no mirror configured still fall back. Turning this on makes the error message point at the actual failure point, which cuts debugging time dramatically.

Failure mode 3 — CoreDNS cannot find anything

The symptom is that in-cluster names (kubernetes.default.svc.cluster.local) resolve fine, but internal or external domain lookups stall for 5 seconds and then fail. The default CoreDNS Corefile forwards queries outside the cluster domain to the node resolver, and if that resolver points at a public DNS server that is unreachable from the air-gapped network (8.8.8.8, say), every query waits out a timeout.

Start by checking the actual configuration. Do not guess — read it from the cluster.

# Check what CoreDNS is using as its forward target
sudo k3s kubectl -n kube-system get configmap coredns -o yaml

# Check the node resolver
cat /etc/resolv.conf
sudo resolvectl status 2>/dev/null | head -30

# Check from the node itself whether internal DNS actually answers
dig @10.10.10.53 registry.internal.example +short

There are two branches to the response. First, clean up the node /etc/resolv.conf so it points only at internal DNS. On a node managed by systemd-resolved a symlink means editing the file directly gets reverted, so you have to change the resolved configuration instead. Second, if touching the node file is awkward, give k3s a separate resolver file for kubelet.

# Keep a separate resolver file just for the air-gapped network
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

The --resolv-conf flag is documented in the official CLI reference as "Kubelet resolv.conf file" and can also be set through the K3S_RESOLV_CONF environment variable. In an environment with no internal DNS at all, you are better off pinning the forward target in the CoreDNS Corefile to an internal resolver, or nailing down just the names you need with the hosts plugin. options timeout:1 at least caps the delay of the queries that still leak out to one second.

Version drift and post-install verification

When the archive and the binary do not line up

This problem is guaranteed to appear once several people have been doing transfers over several months in an air-gapped network. The v1.35.6 archive somebody brought in last month is still sitting in the image directory, while the binary transferred this time is v1.36.2.

Here is what happens. k3s v1.36.2 wants the CoreDNS, pause and local-path-provisioner tags that match it, but the images imported on the node carry v1.35.6 tags. The tag is not in the containerd store, so it tries to pull, and since this is an air-gapped network it fails. All the log says is "image not found," and the clue about a version mismatch appears nowhere.

Diagnosis and cleanup go like this.

# 1) Binary version
k3s -v

# 2) What has piled up in the image directory
ls -la /var/lib/rancher/k3s/agent/images/

# 3) System image tags in the containerd store
sudo k3s ctr --namespace k8s.io images ls | grep -E 'coredns|pause|local-path|metrics-server'
# Cleanup — delete the old archive, leave only the new one, then restart
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

The reason you delete .cache.json alongside it is that from v1.33.1+k3s1 onward this cache file is what drives the judgement "this archive has already been imported." If you swap the archive file and the cache is still there, the import can be skipped. The upgrade procedure in the official docs likewise says to add the new archive and delete the existing one.

The way to prevent drift in the first place is to bind the transfer unit to a version.

# Pin the version into both the transfer directory name and the file names
/opt/staging/k3s-v1.36.2+k3s1/
├── MANIFEST.sha256
├── VERSION            # one line: v1.36.2+k3s1
├── install.sh
├── k3s
└── k3s-airgap-images-amd64.tar.zst
# Put a version gate in the install script
EXPECTED="$(cat "${STAGE}/VERSION")"
ACTUAL="$(/usr/local/bin/k3s -v | awk '/^k3s version/{print $3}')"
if [ "${ACTUAL}" != "${EXPECTED}" ]; then
  echo "FATAL: binary ${ACTUAL} differs from transfer bundle ${EXPECTED}" >&2
  exit 1
fi

A post-install verification script

Finishing the installation is not the end. In an air-gapped network the gap between "it looks like it is up" and "it is actually usable" is wide. Running the script below right after installation cuts incidents a few days later by a lot.

#!/usr/bin/env bash
# verify-airgap-k3s.sh
set -uo pipefail
ERR=0
K="sudo k3s kubectl"

echo "== 1. Node status"
${K} get nodes -o wide
NR=$(${K} get nodes --no-headers | grep -cv " Ready ") || true
[ "${NR}" -gt 0 ] && { echo "FAIL: ${NR} NotReady node(s)"; ERR=$((ERR+1)); }

echo "== 2. System Pods"
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. Any attempt to reach out to an external registry"
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: the containerd log shows traces of external registry access"
  echo "      check the registries.yaml mirror settings and 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: cluster DNS"; ERR=$((ERR+1)); }

echo "== 5. Certificate expiry"
sudo k3s certificate check --output table

echo "== 6. Datastore"
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 (cannot add server nodes)"
fi

echo "== Result: ${ERR} error(s)"
exit "${ERR}"

Item 5, k3s certificate check --output table, matters especially in air-gapped operations. k3s client and server certificates are valid for 365 days from issue, and on a service restart they renew automatically if they are within 120 days of expiry. But no restart means no renewal. That is exactly the path by which an air-gapped cluster nobody has touched for months dies of certificate expiry. The Day 2 operations article covers the response in detail.

Closing — the transfer list is the design document

In an air-gapped k3s installation the real work is not the commands, it is the list. Archive, binary, install script, checksums, CA, workload images, charts, SELinux RPM — counting every byte you will need on the inside while you are still on the outside. The commands themselves come to fewer than ten lines.

And that list is not something you build once and are done with. When the version goes up the archive and the binary have to go up together, and the moment only one of the two moves, the cluster quietly goes wrong. Five lines — pinning the version into the transfer unit and putting a gate in the install script — save you the three weeks of waiting for the next transfer review.

References