インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

CKA編④ kubeadmでHAクラスタ構築

公開更新

新卒インフラエンジニア向け「Kubernetes 実践教科書 ② CKA クラスタ構築・運用編」(全16回)の第4回です。第3回では、k8s-lb VM に HAProxy を構築して k8s-lb:6443 という 1 つの入口を用意し、その名前を HAProxy へ付け替えました。その時点では存在する cp-01 だけを HAProxy の backend に登録し、stats でも cp-01 が UP でした。今回はいよいよ、cp-02・cp-03 を Control Plane Node として、そして k8s-wl-01・k8s-wl-02 を Workload Node として join し、5 ノードの HA クラスタを完成させます。最後に、第1巻で作った fanclub-api をこの本番クラスタへ引っ越します。本巻最大の山場です。

本回で使う VM は多いので、先に役割を整理します。プロンプトの # は root、$ は一般ユーザー developer を表し、各 VM へは手元のマシンから developer で SSH ログインし、root が必要な作業は sudo -i で root シェルに切り替えます。

  • k8s-cp-01192.168.1.125)― 第2回で構築した 1 台目の Control Plane Node。ここで join 用のトークンと証明書を再発行します。
  • k8s-cp-02192.168.1.126)/k8s-cp-03192.168.1.127)― 今回追加する 2・3 台目の Control Plane Node。
  • k8s-wl-01192.168.1.128)/k8s-wl-02192.168.1.129)― 今回追加する Workload Node(ワークロードを実行するノード)。
  • k8s-lb192.168.1.124)― 第3回で構築した HAProxy。join 先の k8s-lb:6443 はここを指します。
  • k8s-registry192.168.1.123)― 第1巻から使っているコンテナレジストリ。fanclub のイメージ置き場です。
  • k8s-ops192.168.1.122)― 第1巻から使っている作業端末。kubectl と helm はここから実行します。
目次
  1. 今ここマップ(全 16 回中の現在地)
  2. この回のゴール
  3. 今回作る 5 ノード HA の全体像
  4. 追加する 4 ノードを kubeadm 向けに準備する
    1. 下ごしらえ・containerd・kubeadm を一括で流す
    2. ノードの役割に応じて firewalld を開ける
  5. join 用のトークンと証明書を再発行する
  6. Control Plane Node を 2 台追加する(cp-02・cp-03)
  7. Workload Node を 2 台追加する(wl-01・wl-02)
  8. 5 ノード Ready と HA の健全性を確認する
  9. fanclub-api を HA クラスタへ移行する
    1. Workload Node にレジストリからの pull を許可する
    2. docker.io のイメージ CDN を whitelist に許可する
    3. 既定の StorageClass を用意する(local-path-provisioner)
    4. Helm で HA クラスタへデプロイする
  10. port-forward でブラウザ動作確認する
  11. admin.conf を k8s-ops の正式な kubeconfig にする
  12. やってみよう
  13. まとめ
  14. 理解度チェック(○×形式・全 9 問)
  15. 次回予告

今ここマップ(全 16 回中の現在地)

本シリーズは 6 部構成です。現在地は第1部「クラスタ構築」の第4回です。

  • 第1部 クラスタ構築(第1〜5回)← 今ここ
  • 第2部 ワークロード管理(第6〜8回)
  • 第3部 ネットワーク(第9〜11回)
  • 第4部 ストレージ(第12回
  • 第5部 監視・運用(第13〜14回)
  • 第6部 トラブルシュート(第15〜16回)
  • AlmaLinux 10.2(kernel 6.12.0-211.22.1.el10_2)
  • kubeadm・kubelet・kubectl v1.35.6(pkgs.k8s.io・v1.35 系にピン留め)
  • containerd.io v2.2.6
  • Calico v3.32.0
  • Pod CIDR 10.244.0.0/16
  • HAProxy 3.0.5
  • Helm v4.1.4
  • 確認日 2026-07-10

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • kubeadm join --control-plane で追加の Control Plane Node(cp-02・cp-03)を参加させられる
  • kubeadm join(無印)で Workload Node(wl-01・wl-02)を参加させられる
  • kubectl get nodes で 5 ノード全員が Ready であることを確認できる
  • 第1巻の fanclub-api を HA クラスタへ再デプロイし、kubectl port-forward でブラウザから CRUD を確認できる

今回作る 5 ノード HA の全体像

完成形は、Control Plane Node が 3 台(cp-01〜03)、Workload Node が 2 台(wl-01・02)、その前段に HAProxy(k8s-lb)という構成です。役割の違いをもう一度確認しておきます。

  • Control Plane Node(cp-01〜03)― kubeadm init または kubeadm join --control-plane で構成。API Server・スケジューラ・コントローラマネージャに加え、etcd も同居します(Stacked etcd)。3 台で etcd の quorum(過半数=2)を満たし、1 台落ちても動き続けます。
  • Workload Node(wl-01・02)― kubeadm join--control-plane なし)で構成。kubelet だけが動き、実際のワークロード(Pod)を載せます。control-plane components も etcd も持ちません。
5 ノード HA クラスタの全体図。手元のマシンや各ノードが k8s-lb:6443(HAProxy)に接続し、HAProxy が cp-01・cp-02・cp-03 の 3 台の Control Plane Node(それぞれ stacked etcd と control-plane components を持つ)へ TCP で振り分ける。その下に k8s-wl-01・k8s-wl-02 の 2 台の Workload Node(kubelet のみ)が並び、fanclub-api の Pod がそこに配置される構成を示す図
図1:5 ノード HA クラスタの全体像(Control Plane 3・Workload 2)

ここまでで揃っているのは cp-01(第2回)と HAProxy(第3回)だけです。cp-02・cp-03・wl-01・wl-02 の 4 台は、まだ何も入っていない素の AlmaLinux です。そこでまず、この 4 台を kubeadm で参加できる状態まで準備します。

追加する 4 ノードを kubeadm 向けに準備する

cp-02・cp-03・wl-01・wl-02 の 4 台は、第2回で cp-01 に施したのと同じ準備(下ごしらえ・containerd・kubeadm ツール群)が必要です。join する前に、各ノードでこの準備を済ませておかなければなりません。準備の一つひとつの「なぜ」は第2回で説明したので、ここでは要点だけをまとめ、1 台分(cp-02)を通して示します。残る 3 台も同じ手順です。

whitelist の CDN 許可(第2回で追加)は alma-proxy の Squid で全ノード共通に効くため、ここで再追加する必要はありません。

下ごしらえ・containerd・kubeadm を一括で流す

第2回では各手順を分けて実行しましたが、同じ内容を 4 台に手打ちで繰り返すのは現実的ではありません。複数ノードの準備は、手作業ではなくスクリプトや構成管理ツール(Ansible など)で自動化するのが本番の定石です。ここでは第2回の手順を 1 本のスクリプトにまとめ、cp-02 で実行します。まず cp-02 に root で入り、次のスクリプトを作成します。

実行コマンド(k8s-cp-02・root):

# cat <<'EOF' > /root/prep-node.sh
#!/bin/bash
set -euo pipefail
PROXY="http://192.168.1.121:3128"

swapoff -a
sed -i.bak '/swap/s/^/#/' /etc/fstab

cat > /etc/modules-load.d/k8s.conf <<'MOD'
overlay
br_netfilter
MOD
modprobe overlay
modprobe br_netfilter

cat > /etc/sysctl.d/k8s.conf <<'SYS'
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
SYS
sysctl --system

grep -q ',k8s-lb' /etc/profile.d/proxy.sh || sed -i 's/\.cluster\.local"/.cluster.local,k8s-lb,k8s-cp-01,k8s-cp-02,k8s-cp-03,k8s-wl-01,k8s-wl-02,alma-proxy,k8s-registry"/' /etc/profile.d/proxy.sh

curl -x "$PROXY" -sSL https://download.docker.com/linux/centos/docker-ce.repo -o /etc/yum.repos.d/docker-ce.repo
dnf install -y containerd.io
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
mkdir -p /etc/systemd/system/containerd.service.d
cat > /etc/systemd/system/containerd.service.d/http-proxy.conf <<'PRX'
[Service]
Environment="HTTP_PROXY=http://192.168.1.121:3128"
Environment="HTTPS_PROXY=http://192.168.1.121:3128"
Environment="NO_PROXY=127.0.0.1,localhost,192.168.1.0/24,10.0.10.0/24,10.96.0.0/12,10.244.0.0/16,.svc,.cluster.local,k8s-lb,k8s-cp-01,k8s-cp-02,k8s-cp-03,k8s-wl-01,k8s-wl-02,k8s-registry"
PRX
systemctl daemon-reload
systemctl enable --now containerd

cat > /etc/yum.repos.d/kubernetes.repo <<'REPO'
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.35/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.35/rpm/repodata/repomd.xml.key
REPO
dnf install -y kubelet-1.35.6 kubeadm-1.35.6 kubectl-1.35.6 python3-dnf-plugin-versionlock
dnf versionlock add kubelet kubeadm kubectl
systemctl enable kubelet
echo "=== prep done: $(kubeadm version -o short) / $(containerd --version | awk '{print $3}') ==="
EOF
# bash /root/prep-node.sh

実行結果(末尾):

=== prep done: v1.35.6 / v2.2.6 ===

このスクリプトは、第2回で cp-01 に対して行った「swap 無効化・カーネルモジュール・sysctl・containerd 導入(SystemdCgroup = true・プロキシ drop-in)・kubeadm/kubelet/kubectl v1.35.6 の導入と versionlock」を 1 本にまとめたものです。第2回と同じく SELinux は Enforcing のまま触りません。containerd 用のプロキシ drop-in の NO_PROXY には、第2回に加えて k8s-registry も含めています(後で fanclub のイメージをこのレジストリから取得するため、その通信をプロキシへ回さないようにするためです)。

加えて、それとは別にシェルの no_proxy/etc/profile.d/proxy.sh)にもノード名(k8s-lb・各ノード名・k8s-registry)を追記しています。これは重要な一手です。既定の no_proxy は IP レンジ(192.168.1.0/24 など)だけで、ホスト名の k8s-lb は CIDR に一致せず「プロキシ経由」と判定されます。これを足さないと、後の kubeadm join k8s-lb:6443 が Squid の CONNECT403 Forbidden となり join できません(第2回で cp-01 に施したのと同じ対処です)。no_proxy はログイン時に読み込まれるため、この変更を今のシェルへ反映するには、join に進む前に root で入り直す(いったん exit して再度 sudo -i)か、source /etc/profile.d/proxy.sh を実行してください。

1 点補足します。kubeadmkubeletkubectlversionlock で v1.35.6 に固定していますが、containerd.io はバージョンを固定していないため、実行した時点で docker-ce リポジトリにある最新のパッチ版(本書の再検証時点は v2.2.6)が入ります。4 ノードを同じタイミングで準備すれば版は揃いますし、cp-01 を先に第2回で作っている場合はパッチ版が新しくなって混在することもありますが、いずれも Kubernetes の動作には影響しません。表示されるパッチ番号が本書と多少違っても、そのまま読み進めて問題ありません。

cp-03・wl-01・wl-02 でも同じ準備を行います。各ノードに root で入り、上とまったく同じ cat <<'EOF' > /root/prep-node.shEOF のブロックでスクリプトを作成し(ヒアドキュメントなので、同じコマンドブロックをそのまま貼り付けられます)、続けて bash /root/prep-node.sh を実行してください。スクリプトは各ノードの /root/prep-node.sh(root 所有)に作られます。手順は 4 台とも共通で、差が出るのは次の firewalld の設定だけです。なお実務では、この「同じ準備を全ノードへ配って実行する」作業こそ Ansible などの構成管理ツールでまとめて行うのが定石です。

ノードの役割に応じて firewalld を開ける

第2回では cp-01 に対して API Server の 6443・kubelet の 10250 と、Pod・Service の CIDR を trusted に加えました。HA では複数ノードが etcd で合意を取り合い、Calico がノードをまたいで通信するため、ここでは追加のポートを開けたうえで、ノード間通信のために信頼するサブネットも足します。まず Control Plane Node(cp-02)で設定します。

実行コマンド(k8s-cp-02・root):

# firewall-cmd --permanent --add-port=6443/tcp
# firewall-cmd --permanent --add-port=2379-2380/tcp
# firewall-cmd --permanent --add-port=10250/tcp
# firewall-cmd --permanent --add-port=10257/tcp
# firewall-cmd --permanent --add-port=10259/tcp
# firewall-cmd --permanent --zone=trusted --add-source=10.244.0.0/16
# firewall-cmd --permanent --zone=trusted --add-source=10.96.0.0/12
# firewall-cmd --permanent --zone=trusted --add-source=10.0.10.0/24
# firewall-cmd --reload
# firewall-cmd --list-ports

実行結果:

2379-2380/tcp 6443/tcp 10250/tcp 10257/tcp 10259/tcp

追加した各ポートの役割は、6443=API Server、2379-2380=etcd(クライアント通信とノード間のピア通信)、10250=kubelet、10257=コントローラマネージャ、10259=スケジューラです。加えて、trusted ゾーンに 10.0.10.0/24 を足している点が今回の要です。本シリーズの VM は 2 枚の NIC を持ち、Calico はノード間の Pod 通信を 内部ネットワーク(10.0.10.0/24)上の IPIP トンネルで運びます(ルート配布は BGP)。この内部サブネットを trusted にしないと、ノードをまたぐ Pod 間通信が firewalld で落ち、後で fanclub の Pod が別ノードの DB へ届かなくなります。Pod・Service の CIDR に加えて、この内部サブネットも忘れず信頼済みにしてください。cp-03 でも同じ firewalld 設定を実行してください。

Workload Node(wl-01・wl-02)は etcd もスケジューラも持たないため、開けるポートが異なります。kubelet の 10250、NodePort 用の 30000-32767、そして Pod・Service CIDR と内部サブネット(10.0.10.0/24)の trusted 追加です。内部サブネットの trusted は、ワークロードが実際に載る Workload Node こそ重要です(Pod がここに配置され、別ノードの Pod と IPIP で通信するため)。wl-01・wl-02 の両方で実行します。

実行コマンド(k8s-wl-01・k8s-wl-02・root):

# firewall-cmd --permanent --add-port=10250/tcp
# firewall-cmd --permanent --add-port=30000-32767/tcp
# firewall-cmd --permanent --zone=trusted --add-source=10.244.0.0/16
# firewall-cmd --permanent --zone=trusted --add-source=10.96.0.0/12
# firewall-cmd --permanent --zone=trusted --add-source=10.0.10.0/24
# firewall-cmd --reload
# firewall-cmd --list-ports

実行結果:

10250/tcp 30000-32767/tcp

最後に、すでに稼働している cp-01 にも etcd のピア通信ポート(2379-2380)とコントロールプレーン用ポート、そして内部サブネットの trusted を追加します。第2回では単一ノードだったため etcd のピア通信もノードをまたぐ Pod 通信も不要でしたが、これから cp-02・cp-03 が etcd クラスタに加わり、ノード間通信が発生するので、cp-01 側も受け入れられるようにします。これを忘れると、追加した Control Plane の etcd が cp-01 と合意できず join が進みません。

実行コマンド(k8s-cp-01・root):

# firewall-cmd --permanent --add-port=2379-2380/tcp
# firewall-cmd --permanent --add-port=10257/tcp
# firewall-cmd --permanent --add-port=10259/tcp
# firewall-cmd --permanent --zone=trusted --add-source=10.0.10.0/24
# firewall-cmd --reload

join 用のトークンと証明書を再発行する

第2回の kubeadm init の末尾には join コマンドが表示されていましたが、そこに含まれるトークンは 24 時間、証明書キー(--certificate-key)は 2 時間で失効します。第2回から時間が経っているため、まず cp-01 で発行し直します。2 つのコマンドを使います。

1 つ目は、追加の Control Plane が証明書を受け取れるよう、証明書をクラスタ内へ再アップロードして新しい証明書キーを得るコマンドです。

実行コマンド(k8s-cp-01・root):

# kubeadm init phase upload-certs --upload-certs

実行結果:

[upload-certs] Storing the certificates in Secret "kubeadm-certs" in the "kube-system" Namespace
[upload-certs] Using certificate key:
<証明書キー>

最終行に表示される長い 16 進文字列が証明書キーです(control-plane join のときに使います)。2 つ目は、join コマンドの本体(トークンとハッシュ入り)を出力するコマンドです。

実行コマンド(k8s-cp-01・root):

# kubeadm token create --print-join-command

実行結果:

kubeadm join k8s-lb:6443 --token <トークン> --discovery-token-ca-cert-hash sha256:<ハッシュ>

この出力が Workload Node 用の join コマンドです。そして Control Plane Node 用は、この末尾に --control-plane --certificate-key <証明書キー> を足したものになります。トークン・ハッシュ・証明書キーはクラスタごと・発行ごとに異なる機密値なので、本文では伏字にしています。実際にはご自身の環境で出力された値をそのまま使ってください。

Control Plane Node を 2 台追加する(cp-02・cp-03)

cp-02 に root で入ります。ここで sudo -i で入り直すと、準備スクリプトが更新した no_proxyk8s-lb を含む)が読み込まれ、join が k8s-lb:6443 へ直接つながります(前掲の注意点。同じ root シェルを使い続けている場合は、先に source /etc/profile.d/proxy.sh を実行してください)。先ほどの join コマンドに --control-plane --certificate-key と、--apiserver-advertise-address を加えます。本シリーズの Control Plane Node は 2 枚の NIC を持つため、ここには External NIC の実 IP(cp-02 なら 192.168.1.126を指定します。これは HAProxy が backend として参照するアドレスで、ここを Internal NIC 側にすると HAProxy から API Server へ届かなくなります(第2回の cp-01 でホスト名が ::1 に解決される問題を避けて実 IP を明示したのと同じ考え方です)。

実行コマンド(k8s-cp-02・root):

# kubeadm join k8s-lb:6443 --token <トークン> \
    --discovery-token-ca-cert-hash sha256:<ハッシュ> \
    --control-plane --certificate-key <証明書キー> \
    --apiserver-advertise-address 192.168.1.126

実行結果(抜粋):

[preflight] Running pre-flight checks
[download-certs] Downloading the certificates in Secret "kubeadm-certs" in the "kube-system" Namespace
...
This node has joined the cluster and a new control plane instance was created:

* Certificate signing request was sent to apiserver and approval was received.
* The Kubelet was informed of the new secure connection details.
* Control plane label and taint were applied to the new node.
* The Kubernetes control plane instances scaled up.
* A new etcd member was added to the local/stacked etcd cluster.

To start administering your cluster from this node, you need to run the following as a regular user:

	mkdir -p $HOME/.kube
	sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
	sudo chown $(id -u):$(id -g) $HOME/.kube/config

A new etcd member was added to the local/stacked etcd cluster. の行が、etcd メンバーが 1 台から 2 台へ増えたことを示しています。出力の末尾にある mkdir -p $HOME/.kube … の 3 行は、この cp-02 から直接 kubectl を使いたい場合の kubeconfig 設定の案内です。本シリーズはクラスタ操作を作業端末 k8s-ops に集約しているため、cp-02 側でこれらを実行する必要はありません。続いて cp-03 も join します。--apiserver-advertise-address だけを cp-03 の実 IP に変え、他は同じ(同一のトークン・ハッシュ・証明書キー)で実行します。なお証明書キーは発行から 2 時間で失効します。cp-02 と cp-03 の join に時間が空き、証明書キー関連のエラーが出た場合は、cp-01 で kubeadm init phase upload-certs --upload-certs を実行し直して新しいキーを使ってください。

実行コマンド(k8s-cp-03・root):

# kubeadm join k8s-lb:6443 --token <トークン> \
    --discovery-token-ca-cert-hash sha256:<ハッシュ> \
    --control-plane --certificate-key <証明書キー> \
    --apiserver-advertise-address 192.168.1.127

2 台の join が終わると、etcd メンバーは 3 台になり、これで「1 台落ちても quorum(2)を保てる」HA が etcd レベルで成立します。k8s-ops から確認します(kubectl は第2回で用意した kubeadm 用 kubeconfig を使います。別セッションなら先に export KUBECONFIG=~/.kube/kubeadm.conf を実行してください)。

実行コマンド(k8s-ops・developer):

$ kubectl get nodes

実行結果:

NAME        STATUS   ROLES           AGE    VERSION
k8s-cp-01   Ready    control-plane   26h    v1.35.6
k8s-cp-02   Ready    control-plane   3m1s   v1.35.6
k8s-cp-03   Ready    control-plane   90s    v1.35.6

3 台の Control Plane がすべて Ready になりました。ただし、HAProxy の backend には第3回で cp-01 しか登録していないため、いまのままでは kubectl や各ノードの接続はすべて cp-01 へ振り分けられます。cp-02・cp-03 も振り分け先に加える作業は、Workload Node を join したあと、次の「5 ノード Ready と HA の健全性を確認する」でまとめて行います。

Workload Node を 2 台追加する(wl-01・wl-02)

次に、ワークロードを載せる Workload Node を join します。Workload Node には --control-plane--certificate-key も付けません。付けないことで「kubelet だけのノード」として参加します。wl-01 に root で入り、先ほどの Workload Node 用 join コマンド(トークンとハッシュだけ)を実行します。

実行コマンド(k8s-wl-01・root):

# kubeadm join k8s-lb:6443 --token <トークン> \
    --discovery-token-ca-cert-hash sha256:<ハッシュ>

実行結果(抜粋):

[preflight] Running pre-flight checks
[kubelet-start] Starting the kubelet
...
This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.

Run 'kubectl get nodes' on the control-plane to see this node join the cluster.

control-plane join のときと出力が違い、etcd メンバー追加やコントロールプレーンのスケールアップに関する行がありません。これが「kubelet だけが参加した」ことの表れです。wl-02 でも同じコマンドを実行してください。トークンは同じものを使い回せます(24 時間以内なら有効です)。

5 ノード Ready と HA の健全性を確認する

4 台の join が終わりました。k8s-ops から 5 ノードの状態を確認します。join 直後は、各ノードで Calico(CNI)が起動するまで数十秒ほど NotReady と表示されることがあります。少し待ってから確認すると、全ノードが Ready に揃います。

実行コマンド(k8s-ops・developer):

$ kubectl get nodes -o wide

実行結果:

NAME        STATUS   ROLES           AGE    VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION                  CONTAINER-RUNTIME
k8s-cp-01   Ready    control-plane   26h    v1.35.6   192.168.1.125   <none>        AlmaLinux 10.2 (Lavender Lion)   6.12.0-211.22.1.el10_2.x86_64   containerd://2.2.6
k8s-cp-02   Ready    control-plane   8m     v1.35.6   192.168.1.126   <none>        AlmaLinux 10.2 (Lavender Lion)   6.12.0-211.22.1.el10_2.x86_64   containerd://2.2.6
k8s-cp-03   Ready    control-plane   6m     v1.35.6   192.168.1.127   <none>        AlmaLinux 10.2 (Lavender Lion)   6.12.0-211.22.1.el10_2.x86_64   containerd://2.2.6
k8s-wl-01   Ready    <none>          3m     v1.35.6   192.168.1.128   <none>        AlmaLinux 10.2 (Lavender Lion)   6.12.0-211.22.1.el10_2.x86_64   containerd://2.2.6
k8s-wl-02   Ready    <none>          2m     v1.35.6   192.168.1.129   <none>        AlmaLinux 10.2 (Lavender Lion)   6.12.0-211.22.1.el10_2.x86_64   containerd://2.2.6

5 ノードが全員 Ready になりました。ROLES を見ると、cp-01〜03 が control-plane、wl-01・02 が <none>(=Workload Node)になっています。Workload Node に control-plane のロールが付かないのは、--control-plane を付けずに join したからです。次に、コントロールプレーンのコンポーネントが 3 台分そろっているかを確認します。

実行コマンド(k8s-ops・developer):

$ kubectl get pods -n kube-system -o wide | grep -E 'etcd|apiserver|scheduler|controller-manager'

実行結果:

etcd-k8s-cp-01                      1/1   Running   0   26h   192.168.1.125   k8s-cp-01
etcd-k8s-cp-02                      1/1   Running   0   8m    192.168.1.126   k8s-cp-02
etcd-k8s-cp-03                      1/1   Running   0   6m    192.168.1.127   k8s-cp-03
kube-apiserver-k8s-cp-01           1/1   Running   0   26h   192.168.1.125   k8s-cp-01
kube-apiserver-k8s-cp-02           1/1   Running   0   8m    192.168.1.126   k8s-cp-02
kube-apiserver-k8s-cp-03           1/1   Running   0   6m    192.168.1.127   k8s-cp-03
kube-controller-manager-k8s-cp-01  1/1   Running   1   26h   192.168.1.125   k8s-cp-01
kube-controller-manager-k8s-cp-02  1/1   Running   0   8m    192.168.1.126   k8s-cp-02
kube-controller-manager-k8s-cp-03  1/1   Running   0   6m    192.168.1.127   k8s-cp-03
kube-scheduler-k8s-cp-01           1/1   Running   1   26h   192.168.1.125   k8s-cp-01
kube-scheduler-k8s-cp-02           1/1   Running   0   8m    192.168.1.126   k8s-cp-02
kube-scheduler-k8s-cp-03           1/1   Running   0   6m    192.168.1.127   k8s-cp-03

etcd・API Server・スケジューラ・コントローラマネージャが、cp-01〜03 それぞれに 1 セットずつ、計 3 セット動いています。これが Stacked etcd の HA コントロールプレーンです。コントローラマネージャとスケジューラは、3 台で 1 台だけがリーダーとして働く「リーダー選出」方式ですが、Pod としては 3 台とも常駐します。RESTARTS が cp-01 で 1 になっているのは、リーダー選出の切り替え時に再起動が入ったためで、異常ではありません。

コントロールプレーンが 3 台そろったので、HAProxy の振り分け先にも cp-02・cp-03 を追加します。第3回では存在する cp-01 だけを backend に登録したため、いまのままでは接続がすべて cp-01 に集中し、cp-01 が落ちるとクラスタを操作できません。k8s-lb に root で入り、/etc/haproxy/haproxy.cfgbackend k8s-cp-api に次の 2 行を追加します。

  server k8s-cp-02 192.168.1.126:6443 check
  server k8s-cp-03 192.168.1.127:6443 check

追加後の backend k8s-cp-api は次のようになります。

backend k8s-cp-api
  mode tcp
  option tcp-check
  balance roundrobin
  server k8s-cp-01 192.168.1.125:6443 check
  server k8s-cp-02 192.168.1.126:6443 check
  server k8s-cp-03 192.168.1.127:6443 check

編集したら、設定の構文を検証してから reload で反映します。haproxy -c は問題がなければ何も出力せず、終了コードだけで妥当性を示します。reload は既存の接続を保ったまま設定を読み直すため、進行中の kubectl 操作を切りません。

実行コマンド(k8s-lb・root):

# haproxy -c -f /etc/haproxy/haproxy.cfg
# systemctl reload haproxy
# systemctl is-active haproxy

実行結果:

active

これで、生きている 3 台の Control Plane すべてが振り分け対象になりました。HAProxy stats で 3 台すべてが UP になったことを確認します。

実行コマンド(k8s-lb・root):

# curl -s 'http://127.0.0.1:9000/stats;csv' | awk -F, '$1=="k8s-cp-api"{print $2, $18}'

実行結果:

k8s-cp-01 UP
k8s-cp-02 UP
k8s-cp-03 UP
BACKEND UP

第3回では backend に cp-01 だけを登録していましたが、いま cp-02・cp-03 を追加したことで 3 台すべてが UP になりました。これで、どの Control Plane が落ちても HAProxy が残りへ振り分け、クラスタの操作を継続できます。なお reload 直後は、追加した cp-02・cp-03 が UP 1/3 のような遷移表示になることがあります。これはヘルスチェックの立ち上げ中を示すだけで、数秒待って再度確認すると UP に確定します。

fanclub-api を HA クラスタへ移行する

基盤が整ったので、第1巻で作った fanclub-api をこの本番クラスタへ引っ越します。第1巻では k8s-ops 上の kind クラスタ(Docker の中の Kubernetes)で動かしていました。まずその kind クラスタを畳みます。第1巻の学習成果は「Helm chart を作れること」であり、chart のディレクトリ(fanclub-chart/)さえ残っていれば、いつでも作り直せます。k8s-ops は 5 ノードクラスタの作業端末を兼ねるため、役目を終えた kind は畳んでリソースを空けます。

実行コマンド(k8s-ops・developer):

$ cd ~
$ kind delete cluster
$ ls fanclub-chart/

実行結果:

Deleting cluster "kind" ...
Deleted nodes: ["kind-control-plane"]
Chart.yaml  templates  values.yaml

kind が消え、chart ディレクトリは残っています。この fanclub-chart/ は第1巻で作成したもので、developer のホームディレクトリ(~/fanclub-chart)にあります。以降の k8s-ops での作業(マニフェスト取得や helm install)は、この ~(ホームディレクトリ)で行う前提で進めます。冒頭で cd ~ しておくと、相対パス(./fanclub-chart など)が迷わず解決できます。

もう 1 点、この節の kubectl・helm は、第2回で用意した kubeadm 用 kubeconfig(~/.kube/kubeadm.conf)に対して実行します。この後の StorageClass の手順で一度 export KUBECONFIG=~/.kube/kubeadm.conf を実行すれば、同じターミナル内ではその後の kubectl・helm すべてに引き継がれます(別のターミナルを開いた場合は、その都度 export をやり直してください)。~/.kube/config を正式な kubeconfig に昇格させるのは、この巻の最後の手順で行います。

Workload Node にレジストリからの pull を許可する

fanclub のイメージは、第1巻から使っている k8s-registry(192.168.1.123:5000)に置かれています。このレジストリは TLS を張らない HTTP のため、containerd は既定では「安全でないレジストリ」として拒否します。Pod が実際に動く Workload Node(wl-01・wl-02)の containerd に、このレジストリを許可する設定を加えます。

containerd v2 では、レジストリごとの設定ファイル(certs.d 方式)を参照するための config_path既定では空になっています。まずこの参照先を有効にし、そのうえでレジストリごとの hosts.toml を置きます。次の手順を wl-01・wl-02 の両方で実施します。

実行コマンド(k8s-wl-01・k8s-wl-02・root):

# sed -i "/cri.v1.images'.registry\]/,/config_path/ s|config_path = ''|config_path = '/etc/containerd/certs.d'|" /etc/containerd/config.toml
# mkdir -p /etc/containerd/certs.d/k8s-registry:5000
# cat <<'EOF' > /etc/containerd/certs.d/k8s-registry:5000/hosts.toml
server = "http://k8s-registry:5000"

[host."http://k8s-registry:5000"]
  capabilities = ["pull", "resolve"]
  skip_verify = true
EOF
# systemctl restart containerd
# sed -n "/cri.v1.images'.registry\]/,/config_path/p" /etc/containerd/config.toml | grep config_path

実行結果:

      config_path = '/etc/containerd/certs.d'

1 つ目の sed は、config.toml の中の [plugins.'io.containerd.cri.v1.images'.registry] セクションにある空の config_path/etc/containerd/certs.d に書き換えるものです(同名の設定が他にもあるため、セクション範囲を限定して置換しています)。参照先を有効にしたうえでレジストリごとの hosts.toml を置き、containerd を再起動すると、HTTP レジストリからの pull が許可されます。最後の sed -n ... grepconfig_path が設定されたことを確認できます。これで wl-01・wl-02 が k8s-registry から fanclub のイメージを取得できます。

docker.io のイメージ CDN を whitelist に許可する

fanclub の chart は、DB の起動待ちやログ収集に busybox、データベースに postgres を使います。これらは k8s-registry ではなく docker.io から取得されます。第2回で pkgs.k8s.io / registry.k8s.io のリダイレクト先 CDN を whitelist に足したのと同じ話がここでも起きます。Docker Hub はイメージ本体(blob)をリージョンに応じて CloudFront(AWS)や Cloudflare へ振り分けますが、本環境の whitelist には Cloudflare 側しか登録されておらず、CloudFront 側(.cloudfront.docker.com)が不足していました。これを足さないと、busybox や postgres の pull が blob 取得の段階で Forbidden になります。alma-proxy で追加します。

実行コマンド(alma-proxy・root):

# echo '.cloudfront.docker.com' >> /etc/squid/whitelist.txt
# systemctl reload squid
# grep cloudfront /etc/squid/whitelist.txt

実行結果:

.cloudfront.docker.com

先頭のドットにより、production.cloudfront.docker.com などのサブドメインをまとめて許可できます。「302 が返ってもリダイレクト先 CDN を個別に許可する必要がある」という第2回の教訓が、docker.io でもそのまま当てはまります。

既定の StorageClass を用意する(local-path-provisioner)

もう 1 つ、デプロイ前に片付けておくことがあります。fanclub-db(PostgreSQL)は永続ボリューム(PVC)を要求しますが、kubeadm で素から作ったクラスタには既定の StorageClass が存在しません。実際に確認すると空です。

実行コマンド(k8s-ops・developer):

$ export KUBECONFIG=~/.kube/kubeadm.conf
$ kubectl get sc

実行結果:

No resources found

既定 SC が無いと、PVC は割り当て先が決まらず Pending のまま止まり、DB Pod も起動できません。ストレージの本命は第12回で扱う分散ストレージ Longhorn ですが、そこまでの間、アプリを起動できる最小限の土台として軽量な local-path-provisioner を導入し、既定 StorageClass に設定します。これは各ノードのローカルディスク(既定で、Pod が載ったノードの /opt/local-path-provisioner/ 配下)にボリュームを作る素朴な provisioner で、第12回で fanclub-db のボリュームを Longhorn へ移行する際の「移行元」にもなります。作られる実体は root 所有のディレクトリで、その中に PostgreSQL のデータ(pgdata)が置かれます。マニフェストを取得して適用し、既定 SC の注釈を付けます。

実行コマンド(k8s-ops・developer):

$ curl -x http://192.168.1.121:3128 -sSLO https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.30/deploy/local-path-storage.yaml
$ kubectl apply -f local-path-storage.yaml
$ kubectl patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
$ kubectl get sc

実行結果:

storageclass.storage.k8s.io/local-path patched
NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  8s

local-path(default) が付きました。これで PVC は storageClassName を指定しなくても、この既定 SC からボリュームを得られます。VOLUMEBINDINGMODEWaitForFirstConsumer なのは、Pod が実際にスケジュールされるノードを待ってからそのノードにボリュームを作る、という意味です。

Helm で HA クラスタへデプロイする

いよいよデプロイです。第1巻の chart をそのまま使い、値だけを変えます。第1巻(kind)では Gateway(Traefik)経由で https://fanclub.local を公開していましたが、この HA クラスタにはまだ LoadBalancer(MetalLB・第9回)も Gateway 実装(Traefik・第11回)も入っていません。そこで今回は --set gateway.enabled=false で Gateway 関連を無効にしてデプロイし、動作確認は kubectl port-forward で行います。https://fanclub.local の外部公開は第11回で復活させます。

実行コマンド(k8s-ops・developer):

$ helm install fanclub ./fanclub-chart \
    --namespace fanclub --create-namespace \
    --set gateway.enabled=false \
    --set frontend.image.tag=1.0.0

実行結果(抜粋):

NAME: fanclub
LAST DEPLOYED: Fri Jul 10 21:32:52 2026
NAMESPACE: fanclub
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None

Pod が起動してくるのを待ってから状態を確認します。fanclub の Pod がどの Workload Node に配置されたかも -o wide で見えます。

実行コマンド(k8s-ops・developer):

$ kubectl get pods -n fanclub -o wide

実行結果:

NAME                                READY   STATUS    RESTARTS   AGE     IP               NODE
fanclub-backend-6896d48bd9-rjfrm    2/2     Running   0          2m50s   10.244.215.201   k8s-wl-01
fanclub-db-0                        1/1     Running   0          2m50s   10.244.119.140   k8s-wl-02
fanclub-frontend-7d9b4667f-j8zzf    1/1     Running   0          2m50s   10.244.215.200   k8s-wl-01
fanclub-logcollector-4xq4d          1/1     Running   0          2m50s   10.244.119.137   k8s-wl-02
fanclub-logcollector-tkqcc          1/1     Running   0          2m50s   10.244.215.199   k8s-wl-01

backend(アプリ本体とサイドカーで 2/2)・frontend・db(PostgreSQL 18 の StatefulSet)が Running になり、logcollector は各 Workload Node に 1 つずつ配置される DaemonSet です。NODE 列を見ると、いずれも Workload Node(wl-01・wl-02)に載っています。Control Plane Node には第2回で説明した taint(汚れ)が付いているため、通常の Pod はそちらに載らず、Workload Node に振り分けられます。なお、chart には DB スキーマを初期化する fanclub-db-migrate という Helm フック(Job)が含まれており、これはインストール時に一度だけ実行され、完了後は自動で削除されるため一覧には残りません。backend が 2/2(Ready)になるまでには、Payara Micro のアプリ展開に少し時間がかかります。

ストレージについても確認しておきます。fanclub-db が要求する PVC が、先ほど導入した local-path の既定 SC からボリュームを得られているかを見ます。

実行コマンド(k8s-ops・developer):

$ kubectl get pvc -n fanclub

実行結果:

NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data-fanclub-db-0   Bound    pvc-1e78fe5b-7ff5-438c-89b5-1be813716715   1Gi        RWO            local-path     <unset>                 2m50s

PVC が Boundlocal-path)になっています。この暫定ストレージは、第12回で分散ストレージ Longhorn へ移行します(そこで fanclub-db のボリュームを local-path から Longhorn へ載せ替える実演を行います)。

port-forward でブラウザ動作確認する

Gateway を無効にしたため、外部からの入口はまだありません。動作確認は kubectl port-forward で行います。これは、手元の端末の特定ポートを、クラスタ内の Service へ一時的にトンネルする仕組みです。k8s-ops でフロントエンドの Service へトンネルを張ります。

実行コマンド(k8s-ops・developer):

$ kubectl port-forward -n fanclub svc/fanclub-frontend 8080:80

実行結果:

Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

このコマンドは実行したまま(フォアグラウンド)にしておきます。k8s-ops のブラウザから http://localhost:8080 にアクセスすると、fanclub-api の画面(タイトル「fanclub-api 会員管理」)が表示され、会員情報の登録・一覧・更新・削除(CRUD)が行えます。第1巻では https://fanclub.local でアクセスしていましたが、中身のアプリは同じものが、本番の HA クラスタ上で動いています。

ブラウザが無い場合や、動作を手早く確かめたい場合は、別のターミナルから curl で API を叩いても確認できます(port-forward は張ったままにしておきます)。frontend の Nginx は /api/ へのリクエストを backend へ中継するので、フロントエンド越しに backend・DB まで到達できます。まず会員一覧を取得します。

実行コマンド(k8s-ops・developer・別ターミナル):

$ curl -s http://localhost:8080/api/members

実行結果:

[{"createdAt":"2026-07-10T12:26:10.784723","email":"hanako@example.com","id":1,"name":"鈴木花子","plan":"standard"},{"createdAt":"2026-07-10T12:26:10.784723","email":"ichiro@example.com","id":2,"name":"佐藤一郎","plan":"free"}]

DB に初期投入された会員が JSON で返ってきました。frontend(Nginx)→ backend(Payara Micro・wl-01)→ PostgreSQL(wl-02)という、ノードをまたぐ経路が通っている証拠です。続いて会員を 1 件登録(POST)し、削除(DELETE)してみます。

実行コマンド(k8s-ops・developer・別ターミナル):

$ curl -s -X POST http://localhost:8080/api/members \
    -H 'Content-Type: application/json' \
    -d '{"name":"検証太郎","email":"kensho@example.com","plan":"premium"}' \
    -w '\nHTTP %{http_code}\n'
$ curl -s -X DELETE http://localhost:8080/api/members/3 -w 'HTTP %{http_code}\n'

実行結果:

{"createdAt":"2026-07-10T12:31:22.638903","email":"kensho@example.com","id":3,"name":"検証太郎","plan":"premium"}
HTTP 201
HTTP 204

登録は HTTP 201(Created)で新しい会員(id=3)が返り、削除は HTTP 204(No Content)で成功しています。作成・参照・削除が本番 HA クラスタ上で通り、第4回のゴール(fanclub-api を HA へ移行し、ブラウザ/API で CRUD できる)を達成しました。確認が終わったら、port-forward のターミナルで Ctrl+C を押してトンネルを閉じます。

なぜ今は port-forward なのかを押さえておきましょう。第1巻の kind には extraPortMappings という「コンテナのポートをホストへ通す」仕組みがあり、それで外部公開できていました。本番の kubeadm クラスタにはその仕組みがなく、外部公開には LoadBalancer(MetalLB・第9回で導入)と Gateway(Traefik・第11回で導入)が必要です。それらが揃うまでの間、最も手軽な疎通手段が port-forward です。

admin.conf を k8s-ops の正式な kubeconfig にする

第2回では、kind の kubeconfig(~/.kube/config)を残すため、kubeadm 用を別ファイル(~/.kube/kubeadm.conf)に置いて KUBECONFIG で切り替えていました。今回 kind を破棄したので、その使い分けはもう不要です。kubeadm 用を ~/.kube/config にすれば、毎回 KUBECONFIG を設定しなくても kubectl が HA クラスタを向きます。

実行コマンド(k8s-ops・developer):

$ cp ~/.kube/kubeadm.conf ~/.kube/config
$ chmod 600 ~/.kube/config
$ unset KUBECONFIG
$ kubectl config current-context

実行結果:

kubernetes-admin@kubernetes

これで、KUBECONFIG を設定しなくても kubectl は HA クラスタ(context 名 kubernetes-admin@kubernetes)を操作します。~/.kube/config は管理者権限の資格情報なので、パーミッションは 600(本人のみ読み書き)にしておきます。第1回で予告した「kubeconfig の所有と権限」を、ここで実践したことになります。alias k=kubectl も第1巻から引き続き使えます。

やってみよう

HA の効きめを stats で読む

手元のマシンのブラウザで http://192.168.1.124:9000/stats を開き、k8s-cp-api の 3 サーバ(cp-01〜03)がすべて UP であることを確認してください。第3回では backend に cp-01 の 1 台だけが登録されていたことと見比べると、cp-02・cp-03 を追加して HA が成立した変化がはっきり分かります。「3 台のうち 1 台落ちても残り 2 台で quorum を保てる」状態が、今の正解です。

Pod の配置ノードを確認する

次のコマンドで、fanclub の各 Pod がどの Workload Node に載っているかを確認してください。Control Plane Node(cp-01〜03)には載っていないこと、Workload Node(wl-01・02)に分散していることを読み取れれば、taint とスケジューリングの効果を実機で確認できます。

実行コマンド(k8s-ops・developer):

$ kubectl get pods -n fanclub -o wide

control-plane join と worker join の違いを説明する

紙の上で答えてください。(1) Control Plane Node を追加する join コマンドに必要で、Workload Node には不要なオプションは何か(2 つ)。(2) Workload Node の ROLES<none> になるのはなぜか。(3) etcd メンバーが増えるのは、control-plane join と worker join のどちらか。答えは本文「Control Plane Node を 2 台追加する」「Workload Node を 2 台追加する」で確認できます。

まとめ

本回では、cp-02・cp-03 を kubeadm join --control-plane で、wl-01・wl-02 を kubeadm join で参加させ、5 ノードの HA クラスタを完成させました。トークンと証明書キーを再発行する理由、control-plane join と worker join の違い、そして HAProxy stats が 3 台とも UP に変わる様子を確認しました。さらに、第1巻で作った fanclub-api を kind から本番 HA クラスタへ移行し、port-forward でブラウザから CRUD を確認しました。第2巻の基盤がここに立ち上がったことになります。

理解度チェック(○×形式・全 9 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. control-plane join には --control-plane--certificate-key の両方が必要である。
  2. kubeadm の join トークンは一度発行すれば無期限に使える。
  3. Workload Node の join コマンドにも --control-plane を付ける。
  4. Control Plane を 3 台にすると、1 台が落ちても etcd の quorum を保てる。
  5. 第4回の時点で https://fanclub.local にブラウザからアクセスできる。
  6. fanclub-api は Gateway を無効(gateway.enabled=false)にしてデプロイする。
  7. fanclub の Pod は Control Plane Node に優先して配置される。
  8. kind を破棄したので、kubeadm 用の kubeconfig を ~/.kube/config にしてよい。
  9. kubeadm で素から作ったクラスタには、最初から既定の StorageClass が用意されている。
解答と解説

1=○/2=×(トークンは 24 時間、証明書キーは 2 時間で失効する)/3=×(Workload Node には付けない。付けないことで kubelet だけのノードになる)/4=○(quorum 2 を維持できる)/5=×(第4回は port-forward で確認。外部公開は第11回で復活)/6=○(MetalLB・Traefik 未導入のため)/7=×(Control Plane には taint があり、Workload Node に配置される)/8=○(kind 破棄で使い分けが不要になった)/9=×(既定 SC は無い。local-path-provisioner 等を導入して既定に設定する必要がある)

次回予告

次回・第5回では、この HA クラスタを「守り・育てる」運用へ進みます。etcd の snapshot saverestore によるバックアップとリストア、そして kubectl drainuncordon を使ったノードの追加・削除を学びます。クラスタは「作って終わり」ではなく、壊れても戻せること・ノードを安全に入れ替えられることが本番運用の要になります。

前の記事
次の記事