状態: 実測 (2026-10-31)。 TT Labの仮想マシン(VM)環境にk0s v1.37.1+k0s.0をk0sctl v0.33.1で構築して測定した値です。2台のシナリオはVMを新規作成して 最初から2回実行し、どちらの結果も表に記載しています。閉鎖網の構築方法は 第1部と同様です。ノードのアドレスは例文用の 帯域(192.0.2.0/24)に書き換えています。作成ルール: 実測データを基本とし、測定できなかったものは「未測定」、原因を確認できなかった解釈は「推定」と表記します。
目次
- 1. 結論から
- 2. なぜ2台構成は「コントローラー1台+ワーカー1台」なのか
- 3. k0sctlによる構築
- 4. コントローラーを90秒間停止させると
- 5. ワーカーの追加と削除
- 6. バックアップ
- 7. まとめ
1. 結論から
- 2台構成ではコントロールプレーンの高可用性は実現できません。 etcdメンバー2個は過半数が2であるため、1台が停止するだけで書き込みが停止します。そのため、2台構成はコントローラー1台+ワーカー1台として 構築しました。
- コントローラーが停止しても、ワーカーのサービスは継続します。 コントローラーを90秒間停止し、5秒間隔で18回測定した間、APIは18回すべて失敗しましたが、ワーカーの nginxは18回すべて200を返しました。コントローラーを再起動すると、APIは3〜4秒で復帰しました。
- 停止するのは「変更する作業」です。 デプロイ、スケール、ワーカーが停止した際の再配置はいずれもコントローラーを経由します。2台構成の危険性は、サービス停止ではなく、 その間に対処ができないという点にあります。
- ノードにNICが2つある場合、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台増えるだけです。そのため「どちらもコントローラー」にする構成は、1台のコントローラーよりもむしろ劣ります。点検のために1台を停止させると、残る1台のAPIまで書き込みが停止します。2台構成で実際に選択すべきなのは コントローラー1台+ワーカー1台 であり、残る疑問はコントローラーがワーカーを兼任するかどうかです。
- 兼任する場合、ワークロードを配置できるノードが1台増えます。
- その代わり、ワークロードによるメモリの圧迫がAPIやetcdに悪影響を及ぼし、そのノードを点検するために停止させると、コントロールプレーンとワークロードを同時に失うことになります。
今回は兼任させませんでした。兼任はノードが3台以上の場合に用います(第3部)。同様の問題をデータベースと検索エンジンの観点から測定した記事として、 サーバー2台でHAが実現できない理由 シリーズがあります。
3. k0sctlによる構築
1台目は手動で構築しましたが、2台目以降は k0sctlを使用します。持ち込み用サーバーからSSH経由で各ノードにバイナリやバンドルを転送し、設定を書き込み、参加トークンを作成してワーカーを紐付ける作業を1つのファイルで宣言します。閉鎖網で使用した設定は以下の通りです。
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 を実行したところ、2回の実行でそれぞれ 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のマスカレードネットワークはすべての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 を指定すると、2つの要素が同時に変更されます。k0sctlがホストごとに address をそのホストの privateAddress で埋め、APIサーバーはすべてのインターフェースではなくそのアドレスのみにバインドします。k0sの設定ドキュメントでも、このオプションは「multi-homed control plane nodes」において必要になる場合があると記載されています。修正後、2台のインストールは1分58秒、rc=0で完了しました。
第3部の3台のコントローラー構成では、この方法が実質唯一の手段となります。k0sctlの config はクラスター内で1つしか存在しないため、コントローラーごとに異なる address を指定する箇所がありません。同じ問題を扱ったk0sctlのissuesが3件オープンされています。
#1026(HAコントローラーごとに異なるアドレスを指定できない)、
#1035(ワーカーがAPIにプライベートアドレスで接続できない)、
#760(privateInterfaceが適切なアドレスを選択しない)。
4. コントローラーを90秒間停止させると
4.1 測定方法
ワーカー上でnginxの2つのインスタンス(Deployment web)とNodePort Service(30080)を起動した後、コントローラーの k0scontroller サービスを停止しました。持ち込み用サーバーから約5秒間隔で以下の2点を記録しました。
- API:
kubectl get nodesが成功するかどうか。 - サービス:
curl http://<워커>:30080/のステータスコード。
経過時間20秒ほどで、ワーカー上のnginxの最も古いプロセス、すなわちマスタープロセスを強制終了しました(pkill -o -x nginx)。APIが存在しない状態でコンテナが終了した場合、誰が再起動させるのかを確認するためです。90秒経過したらコントローラーを再起動し、APIが復帰するまでの秒数を測定しました。
4.2 結果
| 実行回数 | API | ワーカーnginx | nginxマスター強制終了後の再起動回数 | コントローラー再起動 → 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回中1回観測された事実としてのみ記載します。
停止するもの。 コントローラーが存在しない間は、以下の操作がすべて不可能になります。
- 新規デプロイ、スケール、設定変更。いずれも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 の2つは worker02 で引き続き利用可能でした。リセット時に 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台の場合コントローラーは1つ | etcd 2メンバーが耐えられる障害は0、1つ落としただけで書き込みが停止する |
NIC が2つなら onlyBindToAddress: true |
がないと参加トークンがデフォルト経路のアドレスを含み、172秒後に失敗 |
k0sBinaryPath とローカル files |
経路がないか、URL の場合、取り込みサーバーが外部を探す |
| コントローラーの障害は「変更不可」で備える | API 18/18失敗の間もサービスは18/18正常、復帰3〜4秒 |
削除は drain の次に行う reset: true |
手順を守れば web の2つが途切れない |
| バックアップファイルはシークレットのように | etcd スナップショットと CA キーが含まれている |
| 復元時間は別途測定する | 今回は未実測、2台構成ではこれが復旧計画のすべてである |
この記事の実測は TT Lab の実習コース「閉域網 k0s:ノード1・2・3台とその上の Ceph・PostgreSQL」(サイトパス /course/k0s-airgap-multinode)の第2モジュール「ノード2台」にて、同じバージョン、同じ取り込みリストで実際に試すことができます。コントローラーの停止、ワーカーの追加・削除、バックアップを実際の VM 上で行い、採点機が段階ごとに結果を確認します。
TT の一言。 学級委員(コントローラー)がちょっと席を外しても授業(サービス)は続きますよ。ただしその間は席を替えたり新しいお友達を入れたりすることはできません。そして学級委員を2人選んだらもっと心強そうに思えますが、2人とも賛成しなければならないルールだと、1人が体調を崩しただけで何も決められなくなってしまいます!