TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Closed-network k0s multi-node measurement 2: While 2 nodes and the controller stopped for 90 seconds, workers kept responding

Status: Measured (2026-10-31). These are values measured by setting up k0s v1.37.1+k0s.0 with k0sctl v0.33.1 inside a TT Lab practice VM. For the 2-node scenario, we created new VMs and ran it twice from scratch, recording both times in the table. The method for creating the closed network is the same as in Part 1. Node addresses have been rewritten with example ranges (192.0.2.0/24). Writing rules: Measured data is the default; unmeasured items are marked as "Unmeasured", and interpretations whose causes could not be verified are marked as "Estimated".

Table of Contents

1. Starting with the Conclusion

2. Why 2 Nodes Mean "1 Controller + 1 Worker"

Multi-controller configurations in k0s use etcd. etcd requires a majority of members to agree to accept writes. Translating the table from the etcd FAQ, it looks like this.

Number of Members Majority Tolerated Failures
1 1 0
2 2 0
3 2 1

Two etcd members tolerate the same number of failures as 1, while only increasing the number of servers that can die. Therefore, "both as controllers" is actually worse than a 1-controller setup. If you take down one node for inspection, the API on the remaining node also stops accepting writes. The actual choice for 2 nodes is 1 controller + 1 worker , and the remaining question is whether the controller should also double as a worker.

This time, we did not combine them. Combining roles is used when there are three nodes (Part 3). There is a series of articles measuring the same problem with databases and search engines titled Why HA Doesn't Work with 2 Servers .

3. Setting Up with k0sctl

We installed a single node manually, but for 2 or more nodes, we use k0sctl. From the transfer server, we declare everything in a single file: uploading binaries and bundles to each node via SSH, writing configurations, generating join tokens, and attaching workers. The configuration used in the closed network is as follows.

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}

We will only point out lines that are meaningful in a closed network.

When we ran k0sctl apply on the transfer server, it finished in 1 minute 58 seconds and 2 minutes 44 seconds(reported by k0sctl) for each of the two runs. Even with the same configuration, it varied this much between runs, and we did not look into the cause separately. For pure workers, k0sctl waits until Ready with "waiting for node to become ready", so when apply finished, the worker was already Ready. The fact that this waiting does not exist for controller-cum-workers will come up again in Part 3.

3.1 Pitfall: Join Token Pointed to the Wrong Address

At first, we wrote only onlyBindToAddress without privateInterface·privateAddress . We thought it would work because we explicitly specified to use the corporate network address, but the worker join hung for 172 seconds and ended like this.

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
Figure: When only privateAddress is given, the API address is chosen by k0s from the default route NIC; when onlyBindToAddress is given, k0sctl populates it with the corporate network address for each host

<NAT 주소> is the default route NIC address of these test VMs. Since KubeVirt's masquerade network gives the same NAT address to all VMs, when a worker goes to 6443 at that address, it goes to itself. There is no API there, so it is rejected.

We verified why that address went into the token from the k0sctl source code. configure_k0s.gouses privateAddress for the etcd peer address (spec.storage.etcd.peerAddress), but populates spec.api.address only when onlyBindToAddress is true . If left empty, k0s determines it; according to the configuration documentation, the default is "the first non-loopback address found on the node", which in this VM was the NAT address of the default route NIC. The join token contains this address.

spec.api.onlyBindToAddress: true Providing address changes two things together. k0sctl populates privateAddress for each host with that host's

, and the API server binds only to that address, not all interfaces. The k0s configuration documentation also notes that this option may be necessary for "multi-homed control plane nodes". After fixing this, the 2-node installation finished in 1 minute 58 seconds with rc=0. config In the 3-controller setup of Part 3, this method is practically the only way. k0sctl's address is unique to the cluster, so there is no place to write different #1026for each controller. Three k0sctl issues addressing the same problem are open: #1035(Cannot give different addresses to each HA controller), #760(Workers do not connect to the API with private addresses),

(privateInterface does not pick the right address).

4. Stopping the Controller for 90 Seconds

4.1 Measurement Method webAfter running two replicas of nginx on the worker (Deployment k0scontroller ) and a NodePort Service (30080), we stopped the controller's

Around 20 seconds in, we killed the oldest nginx process on the worker, i.e., the master process (pkill -o -x nginx). This was to see who restarts the container when it dies in the absence of an API. After 90 seconds passed, we turned the controller back on and measured the seconds until the API recovered.

4.2 Results

컨트롤러가 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 가 돌아온 뒤 반영
Figure: During 90 seconds of controller stoppage, APIs measured 18 times all failed, workers' nginx all returned 200. Nginx master killed at 22s
Run API Worker nginx Restart count after killing nginx master Controller restart → API recovery
1 18/18 failed 18/18 = 200 0 → 1 (reported after API came back) 4s
2 (final) 18/18 failed 18/18 = 200 0 → 0 3s

The worker's k0sworker service remained active throughout.

Why the service continued. All paths through which requests travel are entirely within the worker. kube-proxy rules routing NodePort to pods, containerd running containers, and kubelet protecting pods all run on the worker, and the controller is only needed when deciding "what to run where." Therefore, already running pods respond even without a controller.

Restarting dead containers. In the first run, after killing the nginx master, the restart count increased from 0 to 1. Since kubelet has already received the pod definition and restart policy, it restarts the container even without an API. However, because it could not report that fact to the API, the number was only visible after the API came back. In the final run, we did the same thing, but the restart count remained 0. During that time, nginx responses were 200 all 18 times. We could not verify why it was not counted as a restart this time (cause unverified). Therefore, we record "kubelet restarts without an API" only as an observed fact in one of out of two attempts.

What stopped. While the controller is missing, all of the following stop working:

The danger of a 2-node configuration is not that "the service stops," but "that nothing can be changed while it is stopped, and if a worker dies during that time, there is no entity to revive it." Therefore, in a 2-node setup, rapid controller recovery—namely backup (Section 6)—takes precedence over high availability.

5. Adding and Removing Workers

Adding. We added worker02 to hosts in k0sctl.yaml and ran apply. Since k0sctl is declarative, it did not touch existing nodes and only ran the "Install workers" step, taking 1 minute 42 seconds and 2 minutes 31 seconds later, worker02 became Ready. This is the "1 controller + 2 workers" configuration compared in Part 3.

removal. Order matters.

  1. kubectl drain worker01 --ignore-daemonsets --delete-emptydir-data to move pods to worker02. Since there needed to be a place to move them to, addition was done first.
  2. Write reset: true to the worker01 host in k0sctl.yaml and apply it. k0sctl deleted k0s on that node during the "Reset workers" stage, and worker01 disappeared from the node list as well. It took 24 seconds · 39 seconds .

Both nginx replicas remained available on worker02. A failed to remove existing configuration /etc/k0s/k0s.yaml: file does not exist warning appears during reset, but it is harmless since workers do not have k0s.yaml in the first place.

6. Backup

k0sctl backup --config k0sctl.yaml
Round Time taken File
1 16 seconds k0s_backup_<epoch>.tar.gz 416KB
2 32 seconds 440KB

k0sctl runs k0s backup on the controller and brings the result back to the import server. k0s backup documentationstates that the backup contains etcd snapshots, certificates, and keys. Since it also includes the cluster CA private key, backup files must be stored like secrets. Even in a dark site (air-gapped environment), this file should not be placed in just any shared folder.

Restoration (k0sctl apply --restore-from) was not measured this time (unmeasured). The same documentation notes that restoration must be performed on the controller, and there is a separate procedure to match addresses when restoring to a node with a different IP. To claim that backups substitute for high availability in a 2-node configuration, measuring the restoration time is the next task.

7. Summary

Checklist Reason (measured in this post)
If 2 nodes, only one controller An etcd 2-member cluster tolerates 0 failures; bringing down even one stops writes
If there are two NICs, onlyBindToAddress: true If missing, the join token contains the default route address, leading to failure after 172 seconds
k0sBinaryPath and local files If the path is missing or is a URL, the import server looks outward
Prepare for controller failures with "immutability" During API 18/18 failures, service was 18/18 normal; recovery took 3-4 seconds
Removal follows drain and reset: true Following this order prevented any interruption to the two web replicas
Backup files are like secrets Contains etcd snapshots and CA keys
Measure restoration time separately Unmeasured this time; in a 2-node setup, this is the entirety of the disaster recovery plan

The measurements in this post can be followed along using the same version and import list in TT Lab's hands-on course, "Air-Gapped k0s: 1, 2, and 3 Nodes with Ceph and PostgreSQL" (site path /course/k0s-airgap-multinode), under the second module, "2 Nodes". Controller stoppage, worker addition and removal, and backups are performed on actual VMs, and the grader checks the results at each step.

TT's Word. Even if the class president (controller) steps away briefly, the class (service) keeps going. However, during that time, you cannot swap seats or bring in a new friend. And while electing two class presidents might sound more secure, if the rule requires both to agree, when even one falls ill, nothing can be decided!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment