TT Lab
블로그

한국어

시작하기
블로그

블로그

폐쇄망 k0s 멀티노드 실측 2: 노드 2대, 컨트롤러가 90초 멈춘 동안 워커는 계속 응답했다

상태: 실측 (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. 왜 2대는 「컨트롤러 1 + 워커 1」 인가

k0s 의 다중 컨트롤러 구성은 etcd 를 씁니다. etcd 는 멤버의 과반이 동의해야 쓰기를 받아들입니다. etcd FAQ의 표를 옮기면 이렇습니다.

멤버 수 과반 견디는 장애
1 1 0
2 2 0
3 2 1

멤버 2개는 1개와 견디는 장애 수가 같고, 죽을 수 있는 서버만 하나 늘어납니다. 그래서 「둘 다 컨트롤러」 는 1대 컨트롤러보다 오히려 나쁩니다. 점검하려고 한 대를 내리면 남은 한 대의 API 까지 쓰기를 멈춥니다. 2대에서 실제로 고를 것은 컨트롤러 1 + 워커 1 이고, 남는 질문은 컨트롤러가 워커를 겸할지입니다.

이번에는 겸하지 않았습니다. 겸임은 노드가 셋일 때 씁니다(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}

폐쇄망에서 의미가 있는 줄만 짚습니다.

반입 서버에서 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
조인 토큰이 엉뚱한 주소를 가리킨 이유 k0sctl v0.33.1, 노드 주소는 예시 노드 VM 마다 NIC 가 둘 enp1s0(기본 경로) NAT 주소, 모든 VM 이 같다 eth1(사내망) 192.0.2.3 privateAddress 만 줌 k0sctl: etcd 피어·node-ip 에만 spec.api.address 는 비워 둠 k0s: 첫 비루프백 주소를 고름 → NAT 주소 조인 토큰도 NAT 주소:6443 → 172초 뒤 connection refused onlyBindToAddress: true k0sctl 이 호스트마다 address 를 privateAddress 로 → 192.0.2.3 API 는 eth1 주소에만 bind 조인 토큰도 사내망 주소 → 2대 설치 1분58초, rc=0
그림: privateAddress 만 주면 API 주소는 k0s 가 기본 경로 NIC 에서 고르고, onlyBindToAddress 를 주면 k0sctl 이 호스트마다 사내망 주소로 채운다

<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초마다 두 가지를 적었습니다.

20초쯤 워커에서 nginx 의 가장 오래된 프로세스, 곧 마스터 프로세스를 죽였습니다(pkill -o -x nginx). API 가 없을 때 컨테이너가 죽으면 누가 다시 띄우는지 보려는 것입니다. 90초가 지나면 컨트롤러를 다시 켜고 API 가 돌아오기까지의 초를 쟀습니다.

4.2 결과

컨트롤러가 90초 멈춘 동안 컨트롤러 1 + 워커 1, 약 5초 간격 18번(최종 회차, 실측) 0초 30초 60초 90초 22초: 워커의 nginx 마스터 kill API 18/18 실패 nginx 18/18 = 200 다시 켜자 API 는 3초(첫 회차 4초) 만에 돌아왔다 계속 된 것 이미 뜬 파드의 응답 워커의 kube-proxy 규칙 kubelet 의 로컬 재시작 (첫 회차에서 관측) 멈춘 것 배포 · 스케일 · 설정 바꾸기 워커가 죽었을 때 재배치 상태 보고(재시작 횟수 등) API 가 돌아온 뒤 반영
그림: 컨트롤러 정지 90초 동안 18번 잰 API 는 모두 실패, 워커의 nginx 는 모두 200. 22초에 nginx 마스터를 죽였다
회차 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 없이 재시작한다」 는 두 번 중 한 번 관측한 사실로만 적습니다.

멈춘 것. 컨트롤러가 없는 동안에는 다음이 모두 안 됩니다.

2대 구성의 위험은 「서비스가 멈춘다」 가 아니라 「멈춘 동안 아무것도 바꿀 수 없고, 그동안 워커가 죽으면 되살릴 주체가 없다」 입니다. 그래서 2대에서는 컨트롤러의 빠른 복구, 곧 백업(6절)이 고가용성보다 먼저입니다.

5. 워커를 더하고 빼기

더하기. k0sctl.yaml 의 hosts 에 worker02 를 더하고 apply 했습니다. k0sctl 은 선언형이라 이미 있는 노드는 건드리지 않고 「Install workers」 단계만 돌았고, 1분42초 · 2분31초 만에 worker02 가 Ready 가 되었습니다. 이것이 3편에서 비교하는 「컨트롤러 1 + 워커 2」 구성입니다.

빼기. 순서가 중요합니다.

  1. kubectl drain worker01 --ignore-daemonsets --delete-emptydir-data 로 파드를 worker02 로 옮깁니다. 옮길 곳이 있어야 하므로 더하기를 먼저 했습니다.
  2. 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대에서는 이것이 복구 계획의 전부다

티티 한마디. 반장(컨트롤러)이 잠깐 자리를 비워도 수업(서비스)은 계속돼요. 다만 그동안은 자리를 바꾸거나 새 친구를 들일 수 없어요. 그리고 반장을 둘 뽑으면 더 든든할 것 같지만, 둘이 다 찬성해야 하는 규칙이면 하나만 아파도 아무것도 못 정한답니다!

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

댓글

아직 댓글이 없습니다.

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