상태: 실측 (2026-10-31). TT Lab 실습 VM 안에 k0s v1.37.1+k0s.0 을 k0sctl v0.33.1 로 세워 잰 값입니다. 2대 시나리오는 VM 을 새로 만들어 처음부터 두 번 돌렸고, 두 번 모두 표에 적었습니다. 폐쇄망을 만든 방법은 1편과 같습니다. 노드 주소는 예시 대역(192.0.2.0/24)으로 바꿔 적었습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.
목차
1. 결론부터
- 2대로는 제어면 고가용성이 되지 않습니다. etcd 멤버 2개는 과반이 2라서 하나만 죽어도 쓰기가 멈춥니다. 그래서 2대는 컨트롤러 1 + 워커 1 로 세웠습니다.
- 컨트롤러가 죽어도 워커의 서비스는 계속됩니다. 컨트롤러를 90초 멈추고 5초 간격으로 18번 재는 동안 API 는 18번 모두 실패했지만, 워커의 nginx 는 18번 모두 200 을 돌려줬습니다. 컨트롤러를 다시 켜자 API 는 3~4초 만에 돌아왔습니다.
- 멈추는 것은 「바꾸는 일」 입니다. 배포, 스케일, 워커가 죽었을 때의 재배치가 모두 컨트롤러를 거칩니다. 2대 구성의 위험은 서비스 정지가 아니라 그동안 손을 쓸 수 없다는 데 있습니다.
- 노드에 NIC 가 둘이면 k0sctl 의
privateAddress만으로는 모자랍니다. API 주소가 기본 경로 NIC 에서 정해져 워커 조인이 172초 뒤 실패했고,spec.api.onlyBindToAddress: true로 풀었습니다. - 그 밖의 실측: 2대 설치 1분58초~2분44초, 워커 더하기 1분42초~2분31초, drain 과
reset: true로 빼기 24~39초,k0sctl backup16~32초(416~440KB). 백업에서 되살리는 복원은 재지 않았습니다(미실측).
2. 왜 2대는 「컨트롤러 1 + 워커 1」 인가
k0s 의 다중 컨트롤러 구성은 etcd 를 씁니다. etcd 는 멤버의 과반이 동의해야 쓰기를 받아들입니다. etcd FAQ의 표를 옮기면 이렇습니다.
| 멤버 수 | 과반 | 견디는 장애 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
멤버 2개는 1개와 견디는 장애 수가 같고, 죽을 수 있는 서버만 하나 늘어납니다. 그래서 「둘 다 컨트롤러」 는 1대 컨트롤러보다 오히려 나쁩니다. 점검하려고 한 대를 내리면 남은 한 대의 API 까지 쓰기를 멈춥니다. 2대에서 실제로 고를 것은 컨트롤러 1 + 워커 1 이고, 남는 질문은 컨트롤러가 워커를 겸할지입니다.
- 겸하면 워크로드를 놓을 노드가 하나 늘어납니다.
- 대신 워크로드의 메모리 압박이 API 와 etcd 를 흔들고, 그 노드를 점검하려 내리면 제어면과 워크로드를 함께 잃습니다.
이번에는 겸하지 않았습니다. 겸임은 노드가 셋일 때 씁니다(3편). 같은 문제를 데이터베이스와 검색 엔진에서 잰 글로 서버 2대로는 왜 HA 가 안 되나 시리즈가 있습니다.
3. k0sctl 로 세우기
1대는 손으로 깔았지만 2대부터는 k0sctl을 씁니다. 반입 서버에서 SSH 로 각 노드에 바이너리와 묶음을 올리고, 설정을 쓰고, 조인 토큰을 만들어 워커를 붙이는 일을 파일 하나로 선언합니다. 폐쇄망에서 쓴 설정은 이렇습니다.
apiVersion: k0sctl.k0sproject.io/v1beta1
kind: Cluster
metadata: {name: k0s-two}
spec:
hosts:
- role: controller
ssh: {address: 192.0.2.3, user: root, keyPath: /root/.ssh/id_ed25519}
hostname: ctrl01
privateInterface: eth1
privateAddress: 192.0.2.3
uploadBinary: true
k0sBinaryPath: /opt/airgap/bin/k0s-v1.37.1+k0s.0-amd64
- role: worker
ssh: {address: 192.0.2.4, user: root, keyPath: /root/.ssh/id_ed25519}
hostname: worker01
privateInterface: eth1
privateAddress: 192.0.2.4
uploadBinary: true
k0sBinaryPath: /opt/airgap/bin/k0s-v1.37.1+k0s.0-amd64
installFlags: [--kubelet-extra-args=--node-ip=192.0.2.4]
files:
- {name: k0s-airgap-bundle, src: /opt/airgap/images/k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
- {name: apps-bundle, src: /opt/airgap/images/apps-bundle.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
k0s:
version: v1.37.1+k0s.0
config:
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
metadata: {name: k0s}
spec:
api: {onlyBindToAddress: true} # NIC 가 둘이면 필요하다(3.1)
network: {provider: kuberouter, podCIDR: 172.20.0.0/16, serviceCIDR: 172.21.0.0/16}
images: {default_pull_policy: Never}
telemetry: {enabled: false}
폐쇄망에서 의미가 있는 줄만 짚습니다.
uploadBinary: true+k0sBinaryPath. 경로를 주지 않으면 k0sctl 은 반입 서버에서 GitHub 릴리스를 받으려 합니다. 반입해 둔 바이너리를 가리켜야 바깥에 나가지 않습니다. 경로를 주면 k0sctl 이 「이 호스트는 판 확인을 하지 않는다」 는 경고를 남기는데, 무해합니다.files는 워커에만 둡니다. 순수 컨트롤러는 파드를 띄우지 않으므로 이미지 묶음이 필요 없습니다.src가 URL 이면 그것도 바깥으로 나가는 길이 되므로 로컬 경로를 씁니다.installFlags의--node-ip. kubelet 이 사내망 주소로 자신을 알리게 합니다. k0sctl 은privateAddress가 있으면 이 깃발을 스스로 더하지만 (host.go), 무엇이 쓰이는지 파일에서 바로 보이도록 적어 두었습니다.
반입 서버에서 k0sctl apply 를 돌리자 두 회차에 각각 1분58초, 2분44초(k0sctl 보고) 만에 끝났습니다. 같은 설정으로도 회차마다 이만큼 흩어졌고, 그 원인은
따로 보지 않았습니다. 순수 워커는 k0sctl 이 「waiting for node to become ready」 로 Ready 까지 기다려 주므로 apply 가 끝나면 워커는 이미
Ready 였습니다. 이 기다림이 컨트롤러 겸 워커에는 없다는 점은 3편에서 다시 나옵니다.
3.1 함정: 조인 토큰이 엉뚱한 주소를 가리켰다
처음에는 onlyBindToAddress 없이 privateInterface·privateAddress 만 적었습니다. 사내망 주소를 쓰라고 분명히 적었으니 될 줄 알았는데, 워커 조인이
172초 동안 매달리다가 이렇게 끝났습니다.
failed to connect to kubernetes api using the join token ...
The connection to the server <NAT 주소>:6443 was refused
<NAT 주소> 는 이 시험 VM 들의 기본 경로 NIC 주소입니다. KubeVirt 의 masquerade 네트워크는 모든 VM 에 같은 NAT 주소를 주므로, 워커가
그 주소의 6443 으로 가면 자기 자신에게 갑니다. 그곳에는 API 가 없으니 거절됩니다.
왜 토큰에 그 주소가 들어갔는지는 k0sctl 소스에서 확인했습니다. configure_k0s.go는
privateAddress 를 etcd 피어 주소(spec.storage.etcd.peerAddress)에 쓰지만, spec.api.address 는 onlyBindToAddress 가 true 일 때만 채웁니다.
비어 있으면 k0s 가 정하는데, 설정 문서에 따르면 기본값은 「노드에서 처음 찾은 비루프백 주소」 이고 이
VM 에서는 기본 경로 NIC 의 NAT 주소였습니다. 조인 토큰은 이 주소를 담습니다.
spec.api.onlyBindToAddress: true 를 주면 두 가지가 함께 바뀝니다. k0sctl 이 호스트마다 address 를 그 호스트의 privateAddress 로 채우고, API 서버는 모든
인터페이스가 아니라 그 주소에만 bind 합니다. k0s 설정 문서도 이 옵션을 「multi-homed control plane nodes」 에 필요할 수 있다고 적습니다. 고친 뒤 2대
설치는 1분58초에 rc=0 으로 끝났습니다.
3편의 컨트롤러 셋에서는 이 방법이 사실상 유일한 길입니다. k0sctl 의 config 는 클러스터에 하나뿐이라 컨트롤러마다 다른 address 를 적을 자리가
없습니다. 같은 문제를 다룬 k0sctl 이슈가 셋 열려 있습니다.
#1026(HA 컨트롤러마다 다른 주소를 줄 수 없다),
#1035(워커가 API 에 사설 주소로 붙지 않는다),
#760(privateInterface 가 맞는 주소를 고르지 않는다).
4. 컨트롤러를 90초 멈추면
4.1 재는 방법
워커에 nginx 두 벌(Deployment web)과 NodePort Service(30080)를 띄운 뒤, 컨트롤러의 k0scontroller 서비스를 멈췄습니다. 반입 서버에서 약 5초마다 두 가지를
적었습니다.
- API:
kubectl get nodes가 성공하는가. - 서비스:
curl http://<워커>:30080/의 상태 코드.
20초쯤 워커에서 nginx 의 가장 오래된 프로세스, 곧 마스터 프로세스를 죽였습니다(pkill -o -x nginx). API 가 없을 때 컨테이너가 죽으면 누가 다시
띄우는지 보려는 것입니다. 90초가 지나면 컨트롤러를 다시 켜고 API 가 돌아오기까지의 초를 쟀습니다.
4.2 결과
| 회차 | API | 워커 nginx | nginx 마스터 kill 뒤 재시작 횟수 | 컨트롤러 재시작 → API 복귀 |
|---|---|---|---|---|
| 1 | 18/18 실패 | 18/18 = 200 | 0 → 1(API 가 돌아온 뒤 보고됨) | 4초 |
| 2(최종) | 18/18 실패 | 18/18 = 200 | 0 → 0 | 3초 |
워커의 k0sworker 서비스는 내내 active 였습니다.
서비스가 계속된 이유. 요청이 지나는 길은 전부 워커 안에 있습니다. NodePort 를 파드로 넘기는 kube-proxy 규칙, 컨테이너를 돌리는 containerd, 파드를 지키는 kubelet 이 모두 워커에서 돌고, 컨트롤러는 「무엇을 어디에 띄울지」 를 정할 때만 필요합니다. 그래서 이미 뜬 파드는 컨트롤러가 없어도 응답합니다.
죽은 컨테이너를 다시 띄운 것. 1회차에서는 nginx 마스터를 죽인 뒤 재시작 횟수가 0 에서 1 로 올랐습니다. kubelet 은 이미 받아 둔 파드 정의와 재시작 정책을 가지고 있으므로 API 없이도 컨테이너를 다시 띄웁니다. 다만 그 사실을 API 에 보고하지 못해서, 숫자는 API 가 돌아온 뒤에야 보였습니다. 최종 회차에서는 같은 일을 했는데 재시작 횟수가 0 으로 남았습니다. 그동안 nginx 응답은 18번 모두 200 이었습니다. 왜 이번에는 재시작으로 세지지 않았는지는 확인하지 못했습니다(원인 미확인). 그래서 「kubelet 이 API 없이 재시작한다」 는 두 번 중 한 번 관측한 사실로만 적습니다.
멈춘 것. 컨트롤러가 없는 동안에는 다음이 모두 안 됩니다.
- 새 배포, 스케일, 설정 바꾸기. 모두 API 를 거칩니다.
- 워커가 죽었을 때 파드를 다른 곳에 다시 띄우는 일. 2대 구성에서는 애초에 옮길 워커도 없습니다.
- 상태 보고. 재시작 횟수처럼 그동안 일어난 일이 API 가 돌아온 뒤에야 보입니다.
2대 구성의 위험은 「서비스가 멈춘다」 가 아니라 「멈춘 동안 아무것도 바꿀 수 없고, 그동안 워커가 죽으면 되살릴 주체가 없다」 입니다. 그래서 2대에서는 컨트롤러의 빠른 복구, 곧 백업(6절)이 고가용성보다 먼저입니다.
5. 워커를 더하고 빼기
더하기. k0sctl.yaml 의 hosts 에 worker02 를 더하고 apply 했습니다. k0sctl 은 선언형이라 이미 있는 노드는 건드리지 않고 「Install workers」 단계만
돌았고, 1분42초 · 2분31초 만에 worker02 가 Ready 가 되었습니다. 이것이 3편에서 비교하는 「컨트롤러 1 + 워커 2」 구성입니다.
빼기. 순서가 중요합니다.
kubectl drain worker01 --ignore-daemonsets --delete-emptydir-data로 파드를 worker02 로 옮깁니다. 옮길 곳이 있어야 하므로 더하기를 먼저 했습니다.- k0sctl.yaml 의 worker01 호스트에
reset: true를 적고 apply 합니다. k0sctl 이 「Reset workers」 단계에서 그 노드의 k0s 를 지웠고, 노드 목록에서도 worker01 이 사라졌습니다. 24초 · 39초 걸렸습니다.
nginx 두 벌은 worker02 에서 계속 가용했습니다. reset 때 failed to remove existing configuration /etc/k0s/k0s.yaml: file does not exist 경고가 나오는데,
워커에는 원래 k0s.yaml 이 없으므로 무해합니다.
6. 백업
k0sctl backup --config k0sctl.yaml
| 회차 | 걸린 시간 | 파일 |
|---|---|---|
| 1 | 16초 | k0s_backup_<epoch>.tar.gz 416KB |
| 2 | 32초 | 440KB |
k0sctl 은 컨트롤러에서 k0s backup 을 돌리고 결과를 반입 서버로 가져옵니다. k0s 백업 문서에 따르면 안에는
etcd 스냅숏과 인증서·키가 들어 있습니다. 클러스터 CA 의 개인 키도 들어 있으므로 백업 파일은 비밀값처럼 보관해야 합니다. 폐쇄망이라고 해서 이 파일을
아무 공유 폴더에 두면 안 됩니다.
복원(k0sctl apply --restore-from)은 이번에 재지 않았습니다(미실측). 같은 문서는 복원을 컨트롤러에서 해야 하고, 다른 IP 의 노드로 복원할 때는 주소를 맞추는
절차가 따로 있다고 적습니다. 2대 구성에서 백업이 고가용성을 대신한다고 말하려면 복원 시간을 재 두는 것이 다음 일입니다.
7. 정리
| 점검 | 이유(이 글의 실측) |
|---|---|
| 2대면 컨트롤러는 하나 | etcd 2멤버는 견디는 장애가 0, 하나만 내려도 쓰기가 멈춘다 |
NIC 가 둘이면 onlyBindToAddress: true |
없으면 조인 토큰이 기본 경로 주소를 담아 172초 뒤 실패 |
k0sBinaryPath 와 로컬 files |
경로가 없거나 URL 이면 반입 서버가 바깥을 찾는다 |
| 컨트롤러 장애는 「변경 불가」 로 대비 | API 18/18 실패 동안 서비스는 18/18 정상, 복귀 3~4초 |
빼기는 drain 다음 reset: true |
순서를 지키면 web 두 벌이 끊기지 않았다 |
| 백업 파일은 비밀값처럼 | etcd 스냅숏과 CA 키가 들어 있다 |
| 복원 시간은 따로 잰다 | 이번에는 미실측, 2대에서는 이것이 복구 계획의 전부다 |
티티 한마디. 반장(컨트롤러)이 잠깐 자리를 비워도 수업(서비스)은 계속돼요. 다만 그동안은 자리를 바꾸거나 새 친구를 들일 수 없어요. 그리고 반장을 둘 뽑으면 더 든든할 것 같지만, 둘이 다 찬성해야 하는 규칙이면 하나만 아파도 아무것도 못 정한답니다!