Split View: k0s 폐쇄망 설치와 k0sctl 자동화 — 이미지 번들 제작부터 다중 노드 업그레이드까지
k0s 폐쇄망 설치와 k0sctl 자동화 — 이미지 번들 제작부터 다중 노드 업그레이드까지
- 들어가며 — 바이너리 하나와 번들 하나로 끝내려면
- 반입 대상 산출 — k0s가 요구하는 파일
- 이미지 번들 제작 — list-images와 bundle-artifacts
- 수동 설치 — 단일 노드와 컨트롤러 플러스 워커
- k0sctl 자동화 — SSH로 번들과 바이너리를 밀어 넣는다
- 업그레이드 경로와 k0sctl의 한계
- 여기서 막힙니다 — k0s 폐쇄망 실패 모드와 k3s와의 비교
- 마치며 — 자동화 도구가 SSH를 쓴다는 사실이 전부입니다
- 참고 자료
들어가며 — 바이너리 하나와 번들 하나로 끝내려면
k0s의 설계 철학은 단순합니다. 단일 바이너리 하나가 컨트롤 플레인이자 워커이고, 설치 스크립트도 패키지 저장소도 필요 없습니다. 폐쇄망 관점에서 이건 큰 장점입니다. 반입 심의에 올릴 파일이 실질적으로 두 개로 줄어들기 때문입니다. k0s 바이너리 하나와 이미지 번들 하나입니다.
다만 이 단순함에는 대가가 있습니다. k3s는 릴리스에 올라온 에어갭 아카이브를 그대로 복사하면 끝나지만, k0s는 "무엇을 번들에 넣을지"를 사용자가 명시적으로 결정하는 구조에 가깝습니다. 그래서 자체 워크로드 이미지를 같은 번들에 합치기는 오히려 쉽고, 대신 번들 제작 단계에서 인터넷이 되는 장비에 k0s 바이너리를 먼저 갖다 놓아야 합니다.
검증 기준은 아래와 같습니다.
| 항목 | 값 | 확인 시점 | 확인한 출처 |
|---|---|---|---|
| k0s 최신 안정판 | v1.36.3+k0s.0 (2026-07-27) | 2026-07-31 | k0s 릴리스 |
| k0sctl 최신판 | v0.32.2 (2026-07-28) | 2026-07-31 | k0sctl 릴리스 |
| 번들 자동 임포트 | 데이터 디렉터리 하위 images | 2026-07-31 | k0s Airgap Install |
| 기본 데이터 디렉터리 | /var/lib/k0s | 2026-07-31 | k0s Airgap Install |
k0sctl v0.32.0에서 호스트 접속·원격 실행 계층이 rig v0.x에서 rig v2.0.0으로 통째로 교체되었습니다. 릴리스 노트가 접속·파일 전송·OS 감지 영역의 회귀를 주시하라고 명시했으니, 폐쇄망에 도입할 때는 스테이징에서 파일 업로드 경로를 반드시 먼저 검증하십시오.
반입 대상 산출 — k0s가 요구하는 파일
k0s 릴리스 페이지에는 폐쇄망에 필요한 자산이 이미 올라와 있습니다. v1.36.3+k0s.0 기준으로 확인한 주요 자산은 다음과 같습니다.
| 자산 | 크기(확인 시점) | 용도 |
|---|---|---|
| k0s-v1.36.3+k0s.0-amd64 | 약 250 MB | k0s 단일 바이너리 |
| k0s-airgap-bundle-v1.36.3+k0s.0-linux-amd64.tar | 약 390 MB | 기본 시스템 이미지 번들 (즉시 사용 가능) |
| k0s-airgap-bundle-v1.36.3+k0s.0-linux-arm64.tar | 약 357 MB | arm64 노드용 번들 |
| airgap-images 텍스트 파일 (amd64/arm/arm64) | 수 KB | 번들에 들어간 이미지 목록 |
| SHA256 체크섬 파일 | 수 KB | 무결성 검증 |
릴리스가 제공하는 번들을 그대로 쓰면 번들 제작 단계를 건너뛸 수 있습니다. 자체 이미지를 섞을 필요가 없는 최초 설치라면 이 경로가 가장 빠릅니다.
#!/usr/bin/env bash
# collect-k0s.sh — 인터넷이 되는 스테이징 장비
set -euo pipefail
K0S_VERSION="v1.36.3+k0s.0"
ARCH="amd64"
URLVER="${K0S_VERSION/+/%2B}"
BASE="https://github.com/k0sproject/k0s/releases/download/${URLVER}"
OUT="./k0s-airgap-${K0S_VERSION}"
mkdir -p "${OUT}" && cd "${OUT}"
curl -fL -o k0s "${BASE}/k0s-${K0S_VERSION}-${ARCH}"
chmod +x k0s
curl -fL -o "k0s-airgap-bundle-${K0S_VERSION}-linux-${ARCH}.tar" \
"${BASE}/k0s-airgap-bundle-${K0S_VERSION}-linux-${ARCH}.tar"
# 반입 매니페스트는 직접 만듭니다 — 안쪽에서 매체 손상을 판별할 유일한 근거입니다
sha256sum k0s "k0s-airgap-bundle-${K0S_VERSION}-linux-${ARCH}.tar" > MANIFEST.sha256
echo "${K0S_VERSION}" > VERSION
ls -la
이미지 번들 제작 — list-images와 bundle-artifacts
릴리스 번들을 쓰지 않고 직접 만들어야 하는 경우가 있습니다. 자체 워크로드 이미지를 같이 넣고 싶거나, 기본 CNI 대신 다른 것을 쓰거나, 사내 정책상 모든 이미지를 사내 레지스트리 경로로 태깅해서 반입해야 하는 경우입니다.
k0s는 필요한 이미지 목록을 스스로 뽑아 줍니다.
# 인터넷이 되는 장비에 k0s 바이너리를 두고 실행
./k0s airgap list-images --all > airgap-images.txt
wc -l airgap-images.txt
cat airgap-images.txt
목록이 나오면 번들로 묶습니다. 공식 문서가 제시하는 방법은 세 가지이고, 각각 전제 조건이 다릅니다.
# 방법 A — k0s 내장 도구 (도커도 실행 중인 k0s 도 필요 없음)
./k0s airgap bundle-artifacts -v -o image-bundle.tar < airgap-images.txt
# 방법 B — 이미 동작 중인 워커 노드의 containerd 저장소에서 내보내기
k0s ctr images export image-bundle.tar $(k0s airgap list-images | xargs)
# 방법 C — 도커가 있는 장비에서
k0s airgap list-images --all > airgap-images.txt
xargs -I{} docker pull {} < airgap-images.txt
docker image save -o image-bundle.tar $(xargs < airgap-images.txt)
세 방법의 실무적 차이는 이렇습니다.
| 방법 | 전제 조건 | 폐쇄망 반입 관점의 평가 |
|---|---|---|
| bundle-artifacts | k0s 바이너리와 인터넷 | 가장 깔끔. 도커 데몬 의존이 없어 심의용 스테이징 장비에 적합 |
| ctr images export | 이미 동작 중인 k0s 워커 노드 | 이미 검증된 이미지를 그대로 뜬다는 장점. 스테이징 클러스터가 필요 |
| docker save | 도커 데몬 | 익숙하지만 도커 설치가 필요. 멀티 아키텍처 처리가 가장 번거로움 |
방법 A를 기본으로 삼고, 스테이징 클러스터가 이미 있으면 방법 B로 "실제로 돌아간 이미지"를 뜨는 편이 안전합니다.
공식 문서는 k0s가 느슨한 플랫폼 매칭을 쓰기 때문에 멀티 아키텍처 번들이 서로 다른 플랫폼에서 동작한다고 명시합니다. amd64 노드와 arm64 엣지 노드가 섞인 폐쇄망에서 번들을 하나로 관리할 수 있다는 뜻이므로, 반입 단위를 줄이는 데 활용할 여지가 있습니다.
자체 워크로드 이미지를 같은 번들에 합치기
k0s 문서는 "k0s가 기본으로 쓰지 않는 컨테이너 이미지를 포함하도록 번들을 쉽게 커스터마이징할 수 있다"고 하며, 번들로 묶기 전에 이미지 목록을 검토하고 편집하라고 안내합니다. 실제로는 목록 파일에 줄을 더하기만 하면 됩니다.
# 1) k0s 시스템 이미지 목록을 뽑고
./k0s airgap list-images --all > airgap-images.txt
# 2) 사내 워크로드 이미지를 같은 목록에 덧붙입니다
cat >> airgap-images.txt <<'EOF'
registry.internal.example:5000/apps/api:1.4.2
registry.internal.example:5000/apps/worker:1.4.2
registry.internal.example:5000/infra/postgres:16.4
registry.internal.example:5000/infra/prometheus:v3.1.0
EOF
# 3) 중복 제거 후 한 번에 묶기
sort -u airgap-images.txt -o airgap-images.txt
./k0s airgap bundle-artifacts -v -o image-bundle.tar < airgap-images.txt
# 4) 매니페스트 갱신
sha256sum image-bundle.tar >> MANIFEST.sha256
이 목록 파일을 Git에 올려 두는 것을 강하게 권합니다. 폐쇄망 반입은 반복 작업이고, "이번 반입에 무엇이 들어갔는가"를 나중에 재구성할 수 있어야 합니다. 목록이 코드로 관리되면 다음 반입 때 diff만 보면 됩니다.
수동 설치 — 단일 노드와 컨트롤러 플러스 워커
반입이 끝났다면 설치 자체는 짧습니다. 번들을 데이터 디렉터리 하위 images 폴더에 두고 k0s를 기동하면, k0s가 그 폴더를 감시하다가 자동으로 임포트합니다.
#!/usr/bin/env bash
# install-k0s-single.sh — 폐쇄망 단일 노드
set -euo pipefail
STAGE=/opt/staging/k0s-airgap-v1.36.3+k0s.0
# 0) 무결성 검증부터
cd "${STAGE}" && sha256sum -c MANIFEST.sha256
# 1) 바이너리 배치
sudo install -m 0755 "${STAGE}/k0s" /usr/local/bin/k0s
k0s version
# 2) 이미지 번들 배치 — 기동 전에 넣어야 합니다
sudo mkdir -p /var/lib/k0s/images
sudo cp "${STAGE}"/k0s-airgap-bundle-*.tar /var/lib/k0s/images/image-bundle.tar
# 3) 단일 노드 설치
sudo k0s install controller --single
sudo k0s start
# 4) 상태 확인 (번들 임포트에 시간이 걸리므로 바로 Ready 가 아닐 수 있습니다)
sudo k0s status
sudo k0s kubectl get nodes -o wide
sudo k0s kubectl -n kube-system get pods
컨트롤러와 워커를 나누는 배치라면 다음과 같습니다. 이미지 번들은 워커 노드에 필요합니다. 실제로 이미지를 pull 하고 컨테이너를 띄우는 쪽이 워커이기 때문입니다. 컨트롤러가 워커를 겸하는 경우에는 컨트롤러에도 번들이 있어야 합니다.
# 컨트롤러 노드
sudo install -m 0755 /opt/staging/k0s /usr/local/bin/k0s
sudo k0s install controller --enable-worker --no-taints
sudo k0s start
sudo k0s status
# 워커 조인용 토큰 발급 (컨트롤러에서)
sudo k0s token create --role=worker > /opt/staging/worker.token
# 워커 노드
sudo install -m 0755 /opt/staging/k0s /usr/local/bin/k0s
sudo mkdir -p /var/lib/k0s/images
sudo cp /opt/staging/image-bundle.tar /var/lib/k0s/images/image-bundle.tar
sudo k0s install worker --token-file /opt/staging/worker.token
sudo k0s start
sudo k0s status
외부 pull 시도를 아예 막는다
번들이 제대로 들어갔는지 확인하는 가장 확실한 방법은, 외부에서 이미지를 가져오는 경로 자체를 차단해 보는 것입니다. k0s는 클러스터 설정으로 이를 지원합니다.
# k0s.yaml
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
spec:
images:
default_pull_policy: Never
공식 문서는 이 설정이 "Pod의 imagePullPolicy를 Never로 만들어 인터넷에서 이미지를 가져오지 않도록 보장한다"고 설명합니다. 폐쇄망에서는 이 값을 처음부터 켜 두는 편이 낫습니다. 그러면 번들에 빠진 이미지가 있을 때 몇 분 뒤 타임아웃이 아니라 즉시 실패하므로, 반입 목록의 누락을 그 자리에서 찾아냅니다.
다만 이 값을 켜면 사내 레지스트리에서 가져오는 이미지도 영향을 받는 구성이 될 수 있으니, 애플리케이션 Pod까지 일괄 적용할지는 클러스터 설정을 적용한 상태에서 실제 배포로 확인해야 합니다. 시스템 컴포넌트에만 적용되는 범위인지 워크로드 전체인지는 이번 확인 범위에서 문서 본문만으로 단정하지 못했으므로, 스테이징에서 직접 검증하십시오.
k0sctl 자동화 — SSH로 번들과 바이너리를 밀어 넣는다
노드가 세 대를 넘어가면 수동 절차는 곧 무너집니다. 누군가 한 노드에서 번들 복사를 빠뜨리고, 그 노드만 이상하게 동작합니다. k0sctl은 이 반복을 YAML 한 장으로 대체합니다.
핵심은 k0sctl이 폐쇄망 안쪽 점프 호스트에서 실행된다는 점입니다. k0sctl은 인터넷이 아니라 SSH로 대상 노드에 접속하므로, 점프 호스트에 k0s 바이너리와 이미지 번들만 있으면 나머지는 k0sctl이 각 노드로 전송합니다.
# 폐쇄망 점프 호스트에 k0sctl 바이너리 배치 (이것도 반입 대상입니다)
sudo install -m 0755 /opt/staging/k0sctl /usr/local/bin/k0sctl
k0sctl version
# 초기 설정 파일 생성
k0sctl init > k0sctl.yaml
# k0sctl.yaml — 폐쇄망 3노드 구성
apiVersion: k0sctl.k0sproject.io/v1beta1
kind: Cluster
metadata:
name: onprem-airgap
spec:
k0s:
version: v1.36.3+k0s.0
config:
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
spec:
images:
default_pull_policy: Never
hosts:
- role: controller
uploadBinary: true
k0sBinaryPath: /opt/staging/k0s
ssh:
address: 10.10.20.11
user: k0sadmin
keyPath: /home/k0sadmin/.ssh/id_ed25519
- role: worker
uploadBinary: true
k0sBinaryPath: /opt/staging/k0s
ssh:
address: 10.10.20.21
user: k0sadmin
keyPath: /home/k0sadmin/.ssh/id_ed25519
files:
- src: /opt/staging/image-bundle.tar
dstDir: /var/lib/k0s/images
perm: 0755
- role: worker
uploadBinary: true
k0sBinaryPath: /opt/staging/k0s
ssh:
address: 10.10.20.22
user: k0sadmin
keyPath: /home/k0sadmin/.ssh/id_ed25519
files:
- src: /opt/staging/image-bundle.tar
dstDir: /var/lib/k0s/images
perm: 0755
여기서 폐쇄망에 결정적인 두 필드가 있습니다.
uploadBinary: true— k0sctl이 각 노드에서 k0s를 내려받는 대신, 실행 호스트의 바이너리를 업로드합니다. 이 값이 없으면 노드가 릴리스 서버에 접속을 시도하고 폐쇄망에서는 그 자리에서 실패합니다. 공식 에어갭 문서의 예시도 이 필드를 씁니다.files— 임의의 파일을 노드의 지정 경로로 전송합니다. 에어갭 문서 예시가 그대로 이미지 번들을 이 방식으로 워커에 올립니다.
k0sBinaryPath로 업로드할 로컬 바이너리 경로를 명시하는 방식은 실무에서 널리 쓰이지만, 이번 확인 시점에 공식 에어갭 문서 본문에서 이 필드명 자체를 직접 확인하지는 못했습니다. uploadBinary만으로도 동작하므로, 필드명이 불확실하면 먼저 uploadBinary: true만 두고 k0sctl apply --debug로 어떤 바이너리가 어디서 올라가는지 확인한 뒤 추가하십시오. 같은 이유로, 에어갭 문서 예시의 kind 값이 렌더링에 따라 다르게 보이는 경우가 있어 여기서는 k0sctl 설치 문서 기준인 kind: Cluster를 썼습니다.
적용과 접속은 다음과 같습니다.
# 구성 적용 — 바이너리 업로드, 번들 전송, 클러스터 구성까지 한 번에
k0sctl apply --config k0sctl.yaml
# kubeconfig 회수
k0sctl kubeconfig --config k0sctl.yaml > kubeconfig
kubectl --kubeconfig kubeconfig get nodes -o wide
kubectl --kubeconfig kubeconfig -n kube-system get pods
SSH 접근 요건은 폐쇄망에서 오히려 까다로울 수 있습니다. 점프 호스트에서 각 노드의 22번 포트가 열려 있어야 하고, 키 기반 인증이 되어야 하며, 대상 사용자가 sudo를 비밀번호 없이 쓸 수 있어야 합니다. 이 세 가지 중 하나라도 사내 보안 정책과 충돌하면 k0sctl 경로는 포기하고 수동 설치로 가야 합니다. 도입 전에 먼저 확인할 항목입니다.
업그레이드 경로와 k0sctl의 한계
k0sctl의 가장 큰 실무 가치는 설치가 아니라 업그레이드입니다. 절차가 설치와 완전히 같습니다.
# 1) 새 버전 바이너리와 번들을 점프 호스트에 반입
sha256sum -c /opt/staging/k0s-v1.36.3/MANIFEST.sha256
# 2) k0sctl.yaml 의 버전 문자열과 파일 경로만 갱신
# spec.k0s.version: v1.36.3+k0s.0
# hosts[].files[].src: /opt/staging/k0s-v1.36.3/image-bundle.tar
# 3) 같은 명령으로 적용 — k0sctl 이 노드를 순서대로 처리합니다
k0sctl apply --config k0sctl.yaml
# 4) 검증
kubectl --kubeconfig kubeconfig get nodes -o wide
kubectl --kubeconfig kubeconfig get pods -A --field-selector=status.phase!=Running
새 이미지 번들을 먼저 올려야 한다는 점이 폐쇄망 특유의 순서입니다. files 항목이 앞에 있으므로 k0sctl이 번들을 전송한 뒤 k0s를 교체하지만, 번들 파일명이 이전 것과 같으면 덮어쓰기가 되고 다르면 두 개가 쌓입니다. 디스크가 빠듯한 엣지 노드에서는 이전 번들 정리를 별도 단계로 넣어야 합니다.
한계도 분명합니다. 공식 문서는 k0sctl이 노드를 추가할 수는 있지만 기존 노드를 제거할 수는 없다고 못 박습니다. 폐쇄망에서 하드웨어를 교체하거나 노드를 줄일 때는 k0sctl 설정에서 지운다고 클러스터에서 빠지지 않으므로, 별도의 절차가 필요합니다.
# 노드 제거는 수동입니다
kubectl --kubeconfig kubeconfig drain worker-03 --ignore-daemonsets --delete-emptydir-data
kubectl --kubeconfig kubeconfig delete node worker-03
# 해당 노드에서 k0s 정리
sudo k0s stop
sudo k0s reset
여기서 막힙니다 — k0s 폐쇄망 실패 모드와 k3s와의 비교
| 증상 | 원인 | 확인 방법 |
|---|---|---|
| k0s는 떴는데 모든 Pod가 ImagePullBackOff | 번들을 기동 후에 넣었거나 경로가 틀림 | ls /var/lib/k0s/images 와 k0s ctr images ls 대조 |
| 워커만 Pod가 안 뜸 | 번들을 컨트롤러에만 배치 | 각 워커에서 images 폴더 확인 |
| k0sctl apply 가 다운로드에서 멈춤 | uploadBinary 누락 | k0sctl apply --debug 로 전송 로그 확인 |
| k0sctl apply 가 SSH 단계에서 실패 | 키 인증 또는 무비밀번호 sudo 불가 | ssh 로 직접 접속 후 sudo -n true 시도 |
| 노드를 뺐는데 클러스터에 계속 보임 | k0sctl 은 노드 제거를 하지 않음 | kubectl delete node 와 k0s reset 을 수동 수행 |
| 임포트가 오래 걸려 Ready 가 늦음 | 번들 크기가 수백 MB — 정상 동작 | journalctl -u k0scontroller 또는 k0sworker 로 임포트 로그 확인 |
| arm64 노드에서만 실패 | 아키텍처별 번들을 잘못 배포 | 번들 파일명과 uname -m 대조 |
임포트 소요 시간에 대해 한마디 덧붙입니다. 공식 문서는 k0s가 images 폴더를 감시해 자동 임포트한다고만 하고, 소요 시간은 언급하지 않습니다. 실제로는 수백 MB 번들을 풀어 containerd 저장소에 넣는 작업이므로 디스크가 느린 엣지 하드웨어에서는 수 분이 걸릴 수 있습니다. 기동 직후 노드가 NotReady라고 해서 바로 실패로 판단하지 말고, 서비스 로그에서 임포트 진행을 먼저 확인하십시오.
sudo journalctl -u k0scontroller -f # 컨트롤러
sudo journalctl -u k0sworker -f # 워커
sudo k0s ctr images ls | wc -l # 임포트된 이미지 수가 늘어나는지
k3s 흐름과의 정직한 비교
| 축 | k3s | k0s |
|---|---|---|
| 이미지 반입 | 릴리스 아카이브를 이미지 폴더에 복사 | 릴리스 번들 복사 또는 list-images로 직접 제작 |
| 자체 이미지 합치기 | 별도 tar를 추가로 배치 | 목록 파일에 줄만 추가해 한 번들로 통합 — 더 깔끔 |
| 설치 진입점 | install.sh 와 환경변수 | k0s install 서브커맨드 — 스크립트 반입이 불필요 |
| 다중 노드 자동화 | 별도 도구 필요 (Ansible 등) | k0sctl 이 공식 도구로 존재 — 더 깔끔 |
| 외부 pull 차단 | disable-default-registry-endpoint (미러 설정 대상에만) | default_pull_policy Never — 의도가 더 직관적 |
| 사설 레지스트리 미러 | registries.yaml 로 rewrite 까지 지원 | containerd 설정을 직접 다뤄야 함 — 손이 더 감 |
| 노드 제거 | 수동 | 수동 (k0sctl 이 지원하지 않음) |
| 커뮤니티 자료 | 압도적으로 많음 | 상대적으로 적어 문제 해결이 느림 — 폐쇄망에서 체감이 큼 |
정리하면, 번들 제작과 다중 노드 자동화는 k0s가 낫고, 사설 레지스트리 미러링과 문제 해결 자료는 k3s가 낫습니다. 사내 레지스트리를 이미 운영 중이고 이미지 경로 재작성이 필요한 환경이면 k3s의 registries.yaml이 주는 이점이 큽니다. 반대로 레지스트리 없이 번들만으로 여러 노드를 반복 구축·업그레이드해야 하는 환경이면 k0sctl 한 장이 주는 이점이 큽니다.
마치며 — 자동화 도구가 SSH를 쓴다는 사실이 전부입니다
폐쇄망에서 k0sctl이 유효한 이유는 기능이 화려해서가 아니라, 인터넷이 아니라 SSH를 쓰기 때문입니다. 반입 심의를 통과한 파일이 점프 호스트에 놓이는 순간부터는 클러스터 구축이 순수한 내부 작업이 됩니다. 이 성질을 가진 도구인지 아닌지가 폐쇄망 도구 선택의 첫 번째 기준입니다.
그리고 목록 파일을 Git에 넣으십시오. 번들에 무엇이 들어갔는지 기록하지 않은 반입은 반드시 다음 반입 때 같은 조사를 처음부터 다시 하게 만듭니다.
참고 자료
k0s Air-Gapped Install and k0sctl Automation — From Building the Image Bundle to Multi-Node Upgrades
- Introduction — Getting It Down to One Binary and One Bundle
- Figuring Out What to Import — the Files k0s Requires
- Building the Image Bundle — list-images and bundle-artifacts
- Manual Install — Single Node, and Controller Plus Worker
- k0sctl Automation — Pushing Bundles and Binaries Over SSH
- The Upgrade Path and k0sctl's Limits
- Where This Gets Stuck — k0s Air-Gap Failure Modes, and a Comparison With k3s
- Closing — It All Comes Down to the Automation Tool Using SSH
- References
Introduction — Getting It Down to One Binary and One Bundle
k0s's design philosophy is simple. A single binary is both the control plane and the worker, with no install script and no package repository needed. From an air-gap standpoint, this is a real advantage — the number of files that have to go through import review effectively drops to two. One k0s binary, and one image bundle.
That simplicity comes at a cost, though. k3s just copies the air-gap archive attached to a release and you're done, but k0s is closer to a structure where you, the user, explicitly decide what goes into the bundle. That makes folding in your own workload images actually easier, but in exchange, at the bundle-building stage you first have to get the k0s binary onto a machine with internet access.
Here's what this was checked against.
| Item | Value | Checked on | Source checked |
|---|---|---|---|
| k0s latest stable | v1.36.3+k0s.0 (2026-07-27) | 2026-07-31 | k0s releases |
| k0sctl latest | v0.32.2 (2026-07-28) | 2026-07-31 | k0sctl releases |
| Bundle auto-import | The images directory under the data directory | 2026-07-31 | k0s Airgap Install |
| Default data directory | /var/lib/k0s | 2026-07-31 | k0s Airgap Install |
At k0sctl v0.32.0, the host connection and remote execution layer was swapped out entirely, from rig v0.x to rig v2.0.0. The release notes explicitly say to watch for regressions in the areas of connection, file transfer, and OS detection, so when adopting this in an air-gapped setting, verify the file upload path in staging first.
Figuring Out What to Import — the Files k0s Requires
The k0s release page already has the assets an air-gapped setup needs laid out. Here are the main assets as confirmed against v1.36.3+k0s.0.
| Asset | Size (at time of check) | Purpose |
|---|---|---|
| k0s-v1.36.3+k0s.0-amd64 | About 250 MB | The single k0s binary |
| k0s-airgap-bundle-v1.36.3+k0s.0-linux-amd64.tar | About 390 MB | The default system image bundle (ready to use as-is) |
| k0s-airgap-bundle-v1.36.3+k0s.0-linux-arm64.tar | About 357 MB | Bundle for arm64 nodes |
| airgap-images text files (amd64/arm/arm64) | A few KB | The list of images included in the bundle |
| SHA256 checksum files | A few KB | Integrity verification |
If you use the bundle the release provides as-is, you can skip the bundle-building stage entirely. If it's a fresh install with no need to mix in your own images, this is the fastest path.
#!/usr/bin/env bash
# collect-k0s.sh — a staging device with internet access
set -euo pipefail
K0S_VERSION="v1.36.3+k0s.0"
ARCH="amd64"
URLVER="${K0S_VERSION/+/%2B}"
BASE="https://github.com/k0sproject/k0s/releases/download/${URLVER}"
OUT="./k0s-airgap-${K0S_VERSION}"
mkdir -p "${OUT}" && cd "${OUT}"
curl -fL -o k0s "${BASE}/k0s-${K0S_VERSION}-${ARCH}"
chmod +x k0s
curl -fL -o "k0s-airgap-bundle-${K0S_VERSION}-linux-${ARCH}.tar" \
"${BASE}/k0s-airgap-bundle-${K0S_VERSION}-linux-${ARCH}.tar"
# Build the import manifest yourself — it's the only thing that lets you tell whether the media was damaged once you're inside.
sha256sum k0s "k0s-airgap-bundle-${K0S_VERSION}-linux-${ARCH}.tar" > MANIFEST.sha256
echo "${K0S_VERSION}" > VERSION
ls -la
Building the Image Bundle — list-images and bundle-artifacts
There are cases where you can't just use the release bundle and need to build your own. That happens when you want to fold in your own workload images, when you're swapping out the default CNI for something else, or when company policy requires every image to be re-tagged to an internal registry path before it's imported.
k0s can extract the list of images it needs on its own.
# Run on a machine with internet access, using the k0s binary
./k0s airgap list-images --all > airgap-images.txt
wc -l airgap-images.txt
cat airgap-images.txt
Once you have the list, bundle it up. The official documentation lays out three methods, and each has different preconditions.
# Method A — the k0s built-in tool (needs neither Docker nor a running k0s)
./k0s airgap bundle-artifacts -v -o image-bundle.tar < airgap-images.txt
# Method B — export from the containerd store of an already-running worker node
k0s ctr images export image-bundle.tar $(k0s airgap list-images | xargs)
# Method C — from a machine that has Docker
k0s airgap list-images --all > airgap-images.txt
xargs -I{} docker pull {} < airgap-images.txt
docker image save -o image-bundle.tar $(xargs < airgap-images.txt)
Here's the practical difference between the three methods.
| Method | Precondition | Assessment from an air-gap import standpoint |
|---|---|---|
| bundle-artifacts | The k0s binary and internet access | Cleanest. No dependency on a Docker daemon, suits a review staging device |
| ctr images export | An already-running k0s worker node | Advantage of pulling images that are already verified as working. Needs a staging cluster |
| docker save | A Docker daemon | Familiar, but requires a Docker install. Handling multi-architecture is the most tedious here |
Use Method A as your default, and if you already have a staging cluster, Method B is the safer bet for pulling "images that actually ran."
The official documentation states that because k0s uses loose platform matching, a multi-architecture bundle works across different platforms. That means in an air-gapped network with a mix of amd64 nodes and arm64 edge nodes, you can manage a single bundle for both, which is something you can use to shrink the number of separate import units.
Folding Your Own Workload Images Into the Same Bundle
The k0s documentation says you "can easily customize the bundle to include container images k0s doesn't use by default," and advises reviewing and editing the image list before bundling it up. In practice, all you have to do is add lines to the list file.
# 1) Extract the list of k0s system images
./k0s airgap list-images --all > airgap-images.txt
# 2) Append your in-house workload images to the same list
cat >> airgap-images.txt <<'EOF'
registry.internal.example:5000/apps/api:1.4.2
registry.internal.example:5000/apps/worker:1.4.2
registry.internal.example:5000/infra/postgres:16.4
registry.internal.example:5000/infra/prometheus:v3.1.0
EOF
# 3) Dedupe, then bundle it all in one shot
sort -u airgap-images.txt -o airgap-images.txt
./k0s airgap bundle-artifacts -v -o image-bundle.tar < airgap-images.txt
# 4) Update the manifest
sha256sum image-bundle.tar >> MANIFEST.sha256
I strongly recommend putting this list file into Git. An air-gap import is a recurring job, and you need to be able to reconstruct "what went into this particular import" later. Once the list is managed as code, all you need for the next import is the diff.
Manual Install — Single Node, and Controller Plus Worker
Once the import is done, the install itself is short. Put the bundle in the images folder under the data directory and start k0s, and k0s watches that folder and imports it automatically.
#!/usr/bin/env bash
# install-k0s-single.sh — single node, air-gapped
set -euo pipefail
STAGE=/opt/staging/k0s-airgap-v1.36.3+k0s.0
# 0) Integrity verification first
cd "${STAGE}" && sha256sum -c MANIFEST.sha256
# 1) Place the binary
sudo install -m 0755 "${STAGE}/k0s" /usr/local/bin/k0s
k0s version
# 2) Place the image bundle — needs to go in before startup
sudo mkdir -p /var/lib/k0s/images
sudo cp "${STAGE}"/k0s-airgap-bundle-*.tar /var/lib/k0s/images/image-bundle.tar
# 3) Single-node install
sudo k0s install controller --single
sudo k0s start
# 4) Check status (bundle import takes time, so it may not be Ready right away)
sudo k0s status
sudo k0s kubectl get nodes -o wide
sudo k0s kubectl -n kube-system get pods
For a layout that separates the controller and the worker, it looks like this. The image bundle is needed on the worker nodes. That's because the worker is the side actually pulling images and starting containers. When the controller doubles as a worker, the bundle needs to be on the controller too.
# controller node
sudo install -m 0755 /opt/staging/k0s /usr/local/bin/k0s
sudo k0s install controller --enable-worker --no-taints
sudo k0s start
sudo k0s status
# issue a worker join token (from the controller)
sudo k0s token create --role=worker > /opt/staging/worker.token
# worker node
sudo install -m 0755 /opt/staging/k0s /usr/local/bin/k0s
sudo mkdir -p /var/lib/k0s/images
sudo cp /opt/staging/image-bundle.tar /var/lib/k0s/images/image-bundle.tar
sudo k0s install worker --token-file /opt/staging/worker.token
sudo k0s start
sudo k0s status
Flatly Blocking Any Attempt at an External Pull
The most reliable way to confirm the bundle actually landed correctly is to block off the path for pulling images externally, and see what happens. k0s supports this through cluster configuration.
# k0s.yaml
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
spec:
images:
default_pull_policy: Never
The official documentation explains that this setting "makes the imagePullPolicy for Pods Never, guaranteeing that images are not pulled from the internet." In an air-gapped setting, it's better to turn this value on from the start. Do that, and if the bundle is missing an image, you get an immediate failure instead of a timeout several minutes later, so you catch a gap in your import list right on the spot.
That said, turning this value on can result in a configuration where images pulled from your in-house registry are affected too, so whether it applies across the board to application pods needs to be confirmed with an actual deployment while this cluster setting is active. Whether the scope is limited to system components or extends to workloads as a whole was something I could not settle from the documentation body alone within this check's scope, so verify it directly in staging.
k0sctl Automation — Pushing Bundles and Binaries Over SSH
Past three nodes or so, a manual procedure quickly falls apart. Someone forgets to copy the bundle on one node, and that one node just behaves strangely. k0sctl replaces this repetition with a single YAML file.
The key is that k0sctl runs from a jump host inside the air-gapped network. k0sctl connects to target nodes over SSH, not the internet, so as long as the jump host has the k0s binary and image bundle, k0sctl handles shipping the rest to each node.
# Place the k0sctl binary on the air-gapped jump host (this needs to be imported too)
sudo install -m 0755 /opt/staging/k0sctl /usr/local/bin/k0sctl
k0sctl version
# Generate the initial config file
k0sctl init > k0sctl.yaml
# k0sctl.yaml — a 3-node air-gapped configuration
apiVersion: k0sctl.k0sproject.io/v1beta1
kind: Cluster
metadata:
name: onprem-airgap
spec:
k0s:
version: v1.36.3+k0s.0
config:
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
spec:
images:
default_pull_policy: Never
hosts:
- role: controller
uploadBinary: true
k0sBinaryPath: /opt/staging/k0s
ssh:
address: 10.10.20.11
user: k0sadmin
keyPath: /home/k0sadmin/.ssh/id_ed25519
- role: worker
uploadBinary: true
k0sBinaryPath: /opt/staging/k0s
ssh:
address: 10.10.20.21
user: k0sadmin
keyPath: /home/k0sadmin/.ssh/id_ed25519
files:
- src: /opt/staging/image-bundle.tar
dstDir: /var/lib/k0s/images
perm: 0755
- role: worker
uploadBinary: true
k0sBinaryPath: /opt/staging/k0s
ssh:
address: 10.10.20.22
user: k0sadmin
keyPath: /home/k0sadmin/.ssh/id_ed25519
files:
- src: /opt/staging/image-bundle.tar
dstDir: /var/lib/k0s/images
perm: 0755
Two fields here are decisive for an air-gapped setting.
uploadBinary: true— instead of each node downloading k0s on its own, k0sctl uploads the binary from the execution host. Without this, nodes attempt to reach the release server and fail on the spot in an air-gapped setting. The official air-gap documentation's own example uses this field.files— sends arbitrary files to a specified path on the node. The air-gap documentation's own example uses exactly this mechanism to place the image bundle on the workers.
Specifying the local binary path to upload with k0sBinaryPath is widely used in practice, but as of this check, I could not directly confirm this exact field name in the body of the official air-gap documentation. Since uploadBinary alone is sufficient to make it work, if the field name is uncertain, set only uploadBinary: true first, run k0sctl apply --debug to confirm which binary is being uploaded from where, and add the rest after. For the same reason, since the kind value in the air-gap documentation's example renders inconsistently depending on how it's viewed, this post uses kind: Cluster, which is the value from the k0sctl install documentation.
Applying and connecting look like this.
# Apply the configuration — binary upload, bundle transfer, and cluster setup all in one shot
k0sctl apply --config k0sctl.yaml
# Retrieve kubeconfig
k0sctl kubeconfig --config k0sctl.yaml > kubeconfig
kubectl --kubeconfig kubeconfig get nodes -o wide
kubectl --kubeconfig kubeconfig -n kube-system get pods
SSH access requirements can actually be the tricky part in an air-gapped setting. Port 22 has to be open on each node from the jump host, key-based authentication has to work, and the target user has to be able to use sudo with no password. If even one of these three conflicts with your company's security policy, give up on the k0sctl path and go with manual install instead. This is something to confirm before adoption.
The Upgrade Path and k0sctl's Limits
k0sctl's biggest practical value isn't installation — it's upgrades. The procedure is exactly the same as install.
# 1) Import the new version's binary and bundle to the jump host
sha256sum -c /opt/staging/k0s-v1.36.3/MANIFEST.sha256
# 2) Update only the version string and file paths in k0sctl.yaml
# spec.k0s.version: v1.36.3+k0s.0
# hosts[].files[].src: /opt/staging/k0s-v1.36.3/image-bundle.tar
# 3) Apply with the same command — k0sctl processes the nodes in order
k0sctl apply --config k0sctl.yaml
# 4) Verify
kubectl --kubeconfig kubeconfig get nodes -o wide
kubectl --kubeconfig kubeconfig get pods -A --field-selector=status.phase!=Running
Needing to upload the new image bundle first is a sequencing detail specific to air-gapped settings. Because the files entry comes first, k0sctl transfers the bundle and then swaps in k0s, but if the bundle filename matches the previous one it gets overwritten, and if it doesn't, you end up with two sitting side by side. On an edge node tight on disk, cleaning up the old bundle needs to be a separate step.
There's a clear limit too. The official documentation states plainly that k0sctl can add nodes, but it cannot remove an existing one. When replacing hardware or scaling down in an air-gapped setting, deleting it from the k0sctl config doesn't remove it from the cluster, so a separate procedure is needed.
# Node removal is manual
kubectl --kubeconfig kubeconfig drain worker-03 --ignore-daemonsets --delete-emptydir-data
kubectl --kubeconfig kubeconfig delete node worker-03
# Clean up k0s on that node
sudo k0s stop
sudo k0s reset
Where This Gets Stuck — k0s Air-Gap Failure Modes, and a Comparison With k3s
| Symptom | Cause | How to check |
|---|---|---|
| k0s came up but every pod is ImagePullBackOff | The bundle was placed after startup, or the path is wrong | Compare ls /var/lib/k0s/images against k0s ctr images ls |
| Only the worker has no pods coming up | The bundle was placed only on the controller | Check the images folder on each worker |
| k0sctl apply stalls on a download | uploadBinary was missing | Check the transfer log with k0sctl apply --debug |
| k0sctl apply fails at the SSH stage | Key auth or passwordless sudo isn't working | Connect directly over ssh and try sudo -n true |
| Removed a node but it still shows up in the cluster | k0sctl doesn't do node removal | Do kubectl delete node and k0s reset by hand |
| Import takes a long time, so Ready comes late | The bundle is hundreds of MB — this is normal | Check the import log via journalctl -u k0scontroller or k0sworker |
| Failure only on arm64 nodes | The wrong per-architecture bundle got deployed | Cross-check the bundle filename against uname -m |
A word on import time. The official documentation only says k0s watches the images folder and auto-imports it — it doesn't say how long it takes. In practice, since it's the work of unpacking a bundle of several hundred MB into the containerd store, it can take several minutes on slow-disk edge hardware. Don't immediately treat a node showing NotReady right after startup as a failure — check the service logs first to confirm the import is progressing.
sudo journalctl -u k0scontroller -f # controller
sudo journalctl -u k0sworker -f # worker
sudo k0s ctr images ls | wc -l # is the count of imported images going up?
An Honest Comparison With the k3s Flow
| Axis | k3s | k0s |
|---|---|---|
| Image import | Copy the release archive into the image folder | Copy the release bundle, or build one directly with list-images |
| Folding in your own images | Place an extra tar separately | Just add lines to the list file and merge into one bundle — cleaner |
| Install entry point | install.sh and environment variables | The k0s install subcommand — no need to import a script |
| Multi-node automation | Needs a separate tool (Ansible, etc.) | k0sctl exists as an official tool — cleaner |
| Blocking external pulls | disable-default-registry-endpoint (applies only to configured mirrors) | default_pull_policy Never — the intent is more direct |
| Private registry mirroring | registries.yaml, with rewrite support | You have to work with containerd config directly — more manual work |
| Node removal | Manual | Manual (k0sctl doesn't support it) |
| Community resources | Overwhelmingly more | Relatively fewer, so troubleshooting is slower — noticeable in an air-gapped setting |
To sum up, k0s is better at building bundles and multi-node automation, while k3s is better at private registry mirroring and troubleshooting resources. If you're already running an in-house registry and need image-path rewriting, k3s's registries.yaml gives you a real edge. On the other hand, in an environment where you need to repeatedly build and upgrade multiple nodes using only a bundle, with no registry, a single k0sctl file gives you a real edge.
Closing — It All Comes Down to the Automation Tool Using SSH
k0sctl works in an air-gapped setting not because its feature set is flashy, but because it uses SSH instead of the internet. From the moment a file that passed import review lands on the jump host, building the cluster becomes a purely internal operation. Whether a tool has this property or not is the first criterion for choosing tools in an air-gapped setting.
And put the list file in Git. An import that doesn't record what went into the bundle always forces the same investigation to start over from scratch at the next import.