本文へ移動
購読
GUIDE#infra更新 2026.09.24

k3sで作る自宅Kubernetesクラスタ:ミニPC3台で始める高可用構成

Ubuntu 24.04のミニPC3台をk3sの組み込みetcdでHA構成にし、kube-vipのVIP、MetalLB、Ingress、永続ストレージ、etcdスナップショットからの復旧までを組み立てます。

level
中級
time
約40分
stack
k3s / Ubuntu Server 24.04 / ミニPC×3
author
@ryoma_t
// 前提条件
Linuxサーバーの基本操作 / Dockerでコンテナを動かした経験 / 固定IPを3つ以上確保できるLAN

k3sとは、SUSE(旧Rancher Labs)が開発するCNCF認定の軽量Kubernetesディストリビューションで、containerd・flannel・CoreDNS・Traefik・ServiceLB・local-pathストレージが1つのバイナリに同梱され、組み込みetcdを使えば3ノードの高可用(HA)クラスタを追加ソフトなしで組めます。Kubernetes(k8s)の標準APIをそのまま持つので、ここで覚えた操作は会社のEKSやAKSでもそのまま通用します。

ミニPC3台(Proxmox上のVM3台でも同じ)にUbuntu Server 24.04を入れ、3台すべてをserver(コントロールプレーン兼ワーカー)にする構成で、インストールからkube-vipのVIP、MetalLB、Ingress、永続ストレージ、etcdスナップショットまでを通します。所要時間は40分ほどです。

この記事でわかること

  • k3sと本家Kubernetesの違いと、自宅で3台すべてをserverにする理由(etcdのクォーラム)
  • /etc/rancher/k3s/config.yaml と --cluster-init、K3S_TOKEN、--tls-san の役割
  • kube-vipでのVIP、MetalLBのL2モード、TraefikのIngressの組み合わせ方
  • local-path / Longhorn / NFS の選び方と、etcdスナップショットからの復旧手順
  • x509エラー、node password rejected、EXTERNAL-IPがpendingのままなど実際に出るエラーの直し方

Kubernetesとは何か、なぜk3sを選ぶのか

Kubernetesは、複数のマシンにまたがってコンテナを配置・監視・復旧するオーケストレーターです。「nginxを2つ動かす」という望ましい状態(Deployment)を宣言すると、コントローラーが差分を埋め続け、ノードが落ちれば残りのノードでPodを作り直します。Docker Composeが「1台の上での並べ方」なら、Kubernetesは「複数台での置き場所と直し方」です。

観点本家Kubernetes(kubeadm)k3s
配布形態kubelet、kube-apiserver等を個別に導入単一バイナリ(100MB未満)に全部入り
データストアetcdを別途構築SQLite(単体)/ 組み込みetcd(HA)/ 外部DB
同梱物CNI・Ingress・LBは自分で選ぶflannel、CoreDNS、Traefik、ServiceLB、local-path、metrics-server
メモリ(server 1台)目安2GB以上目安512MB〜1GB

自宅ではk3sを選びます。etcd・証明書・CNIという「動かす前の作業」が省け、壊しても k3s-uninstall.sh 一発で消せるからです。kubeadmで組む価値があるのは、CKAのようなkubeadm前提の試験対策と、本番と同一構成の検証に限られます。

構成:3ノード・組み込みetcd・VIP

ホスト名IP役割
k3s-1192.168.10.11server(cluster-init)+ ワークロード
k3s-2192.168.10.12server + ワークロード
k3s-3192.168.10.13server + ワークロード
VIP192.168.10.100kube-vipがAPIサーバー用に保持
MetalLBプール192.168.10.200〜220LoadBalancer Serviceに配る

3台すべてをserverにするのは、組み込みetcdが過半数(クォーラム)で動くからです。3台なら1台落ちても書き込みが続き、2台だと1台落ちた時点で過半数を失って書き込み不能になり、1台構成より弱くなります。etcdは奇数台が原則です。k3sのserverは既定でワークロードも受け付けるので、家庭では「server 3台」が「server 1台 + agent 2台」より単純で強い構成になります。機材が1台しかないなら、SQLiteのserver単体で始めて後からetcdに移行できます。

ノード間で開けておくポートは、6443/tcp(APIサーバー)、2379-2380/tcp(etcd、server間のみ)、8472/udp(flannelのVXLAN)、10250/tcp(kubelet)です。ufwを有効にするなら、これに加えてPodネットワーク 10.42.0.0/16 とServiceネットワーク 10.43.0.0/16 からの通信を許可します。

下準備:固定IP・ホスト名・swap無効・時刻同期

3台それぞれで固定IPとホスト名を決めます。ホスト名はノード名としてetcdに登録されるので、重複させると後述の「Node password rejected」の原因になります。

/etc/netplan/50-cloud-init.yaml
network:
  version: 2
  ethernets:
    eno1:
      dhcp4: false
      addresses: [192.168.10.11/24]
      routes:
        - to: default
          via: 192.168.10.1
      nameservers: {addresses: [192.168.10.1]}
terminal (3台それぞれ)
sudo netplan apply
sudo hostnamectl set-hostname k3s-1        # k3s-2, k3s-3 も同様
sudo swapoff -a
sudo sed -i '/\sswap\s/s/^/#/' /etc/fstab   # 再起動後もswapを無効に
timedatectl                                 # System clock synchronized: yes を確認
sudo ufw disable                            # 有効にするなら上記ポートを許可

k3sはswapが有効でも起動しますが、メモリ不足時にPodがswapへ追い出されて「遅いのに落ちない」状態になるので無効にします。時刻がずれると証明書検証とetcdのリース更新が失敗するため、同期状態は必ず見ておきます。

1台目のインストールとトークン

設定はインストール時のフラグではなく /etc/rancher/k3s/config.yaml に書きます。フラグと同じ名前で -- を外すだけで、再インストールや更新のときに同じ設定が読まれます。トークンは自分で長い乱数を作って3台で共有します。

/etc/rancher/k3s/config.yaml (k3s-1)
cluster-init: true
token: "ここに openssl rand -hex 32 で作った値"
tls-san:
  - "192.168.10.100"
  - "k3s.home.lan"
disable:
  - servicelb
write-kubeconfig-mode: "0644"

cluster-init: true がSQLiteではなく組み込みetcdで初期化する指示、tls-san はAPIサーバー証明書に含める追加の名前で、VIPやDNS名でアクセスするなら最初から入れておかないと x509 エラーになります。servicelb を無効にするのは、後でMetalLBを入れるためです。

terminal (k3s-1)
sudo mkdir -p /etc/rancher/k3s
# config.yaml を上の内容で作成してから
curl -sfL https://get.k3s.io -o install-k3s.sh
less install-k3s.sh                          # 中身を確認してから実行する
sudo sh install-k3s.sh server
sudo cat /var/lib/rancher/k3s/server/node-token
sudo kubectl get nodes

node-token は「K10(CAのハッシュ)::server:(トークン)」という形で、ここに含まれるCAハッシュで参加ノードがサーバーの真正性を確認します。config.yamlに書いたトークンは /var/lib/rancher/k3s/server/token にも保存されます。

2台目・3台目の追加(serverとagent)

2台目以降のserverは、cluster-init の代わりに server: で1台目を指します。それ以外(token、tls-san、disable)は1台目と同じ内容にします。

/etc/rancher/k3s/config.yaml (k3s-2, k3s-3)
server: https://192.168.10.11:6443
token: "1台目と同じ値"
tls-san:
  - "192.168.10.100"
  - "k3s.home.lan"
disable:
  - servicelb
write-kubeconfig-mode: "0644"
terminal (k3s-2, k3s-3)
sudo sh install-k3s.sh server
# agentとして足すなら config.yaml なしで:
# K3S_URL=https://192.168.10.11:6443 K3S_TOKEN=トークン sh install-k3s.sh

agentはetcdもAPIサーバーも持たないワーカー専用ノードで、4台目以降やRaspberry Piのような非力な機械を混ぜるときに使います。

kubectlの設定:手元のPCから操作する

kubeconfigを手元のPCにコピーし、接続先をVIP(設定前なら1台目のIP)に書き換えます。

terminal (手元のPC)
mkdir -p ~/.kube
scp [email protected]:/etc/rancher/k3s/k3s.yaml ~/.kube/config
sed -i 's/127.0.0.1/192.168.10.100/' ~/.kube/config
chmod 600 ~/.kube/config
kubectl get nodes -o wide

kube-vipでAPIサーバーにVIPを付ける

kubeconfigが1台目のIPを指したままだと、その1台が落ちた瞬間に kubectl が使えなくなります。kube-vipはARPモードで動くDaemonSetで、リーダー選出された1台がVIP(192.168.10.100)を自分のNICに付け、落ちれば別のノードが引き継ぎます。/var/lib/rancher/k3s/server/manifests/ に置いたマニフェストはk3sが自動で適用するので、そこにRBACとDaemonSetを置きます。

terminal (k3s-1)
sudo curl -fsSL https://kube-vip.io/manifests/rbac.yaml \
  -o /var/lib/rancher/k3s/server/manifests/kube-vip-rbac.yaml
export VIP=192.168.10.100 INTERFACE=eno1 KVVERSION=v0.8.9   # 執筆時点の例。最新版は公式で確認
sudo k3s ctr image pull ghcr.io/kube-vip/kube-vip:$KVVERSION
sudo k3s ctr run --rm --net-host ghcr.io/kube-vip/kube-vip:$KVVERSION vip /kube-vip \
  manifest daemonset --interface $INTERFACE --address $VIP --inCluster \
  --controlplane --arp --leaderElection \
  | sudo tee /var/lib/rancher/k3s/server/manifests/kube-vip.yaml
ping -c 2 192.168.10.100

--services を付けていないのは、LoadBalancer ServiceのIP配布はMetalLBに任せるためです。両方に持たせると同じServiceに2つのIPが付いて混乱します。VIPが応答したら、kubeconfigの接続先をVIPに変えます。

LoadBalancerとIngress:MetalLBとTraefik

クラウドなら type: LoadBalancer のServiceを作るとロードバランサーが自動で用意されますが、自宅にはそれがありません。k3s同梱のServiceLBは各ノードのIPとポートをそのまま使う簡易実装で、80番を使うServiceを2つ作れません。MetalLBのL2モードは、指定したプールからIPを1つ選び、ARPで「このIPは私です」と答えることで、Serviceごとに専用IPを与えます。

terminal (手元のPC)
sudo snap install helm --classic
helm repo add metallb https://metallb.github.io/metallb && helm repo update
helm install metallb metallb/metallb -n metallb-system --create-namespace
kubectl -n metallb-system wait --for=condition=Ready pod --all --timeout=120s
metallb-pool.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: home-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.10.200-192.168.10.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: home-l2
  namespace: metallb-system
spec:
  ipAddressPools:
    - home-pool

kubectl apply -f metallb-pool.yaml の後、k3sが同梱するTraefikのService(kube-system/traefik)に自動でプール先頭の 192.168.10.200 が付きます。以後、HTTPのアプリはこのIP 1つにIngressのホスト名で振り分けるので、プールのIPを消費するのはHTTP以外(DNSやデータベース)だけです。プールの範囲はルーターのDHCP範囲から必ず外してください。

最初のアプリをデプロイする

hello.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  replicas: 2
  selector:
    matchLabels: {app: hello}
  template:
    metadata:
      labels: {app: hello}
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports: [{containerPort: 80}]
---
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  selector: {app: hello}
  ports: [{port: 80, targetPort: 80}]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello
spec:
  ingressClassName: traefik
  rules:
    - host: hello.home.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: {name: hello, port: {number: 80}}
terminal
kubectl apply -f hello.yaml
kubectl get pods -o wide            # 2つのPodが別ノードに配置される
curl -s -H "Host: hello.home.lan" http://192.168.10.200/ | grep title
# <title>Welcome to nginx!</title>

自宅のDNS(ルーターやPi-hole)に *.home.lan を 192.168.10.200 に向けるワイルドカードを1つ登録しておくと、以後はIngressにホスト名を書くだけで新しいアプリが公開できます。

永続ストレージの選択肢:local-path・Longhorn・NFS

方式データの場所ノード停止時向く用途
local-path(既定のStorageClass)そのノードの /var/lib/rancher/k3s/storage/Podは他ノードに移れず、復帰待ち検証、失っても困らないキャッシュ
Longhorn各ノードのディスクに複製(既定3レプリカ)別ノードのレプリカで継続DBや家計簿アプリなど止めたくない状態
NFS(NASから)NAS上どのノードからも同じデータ写真・動画・共有ファイル。複数Podの同時読み書き(RWX)

最初はlocal-pathと、TrueNASなどNASからのNFSの2本立てで十分です。Longhornは各ノードにSSDと2.5GbE以上のNICがあり、ノード障害時にもDBを止めたくなくなった段階で入れます。前提パッケージがないとアタッチに失敗するので、先に3台へ入れます。

terminal
# 3台それぞれ
sudo apt install -y open-iscsi nfs-common && sudo systemctl enable --now iscsid
# 手元のPC
helm repo add longhorn https://charts.longhorn.io && helm repo update
helm install longhorn longhorn/longhorn -n longhorn-system --create-namespace

更新とetcdスナップショット

更新はインストールスクリプトをバージョン指定で再実行するだけで、config.yamlは読み直されます。serverは1台ずつ、前の1台が Ready に戻ってから進めます。etcdスナップショットは既定で12時間ごとに /var/lib/rancher/k3s/server/db/snapshots/ へ5世代残ります。ここをProxmoxのVMバックアップやNASにも逃がしておくと、3台とも壊れても復旧できます。

terminal
# 更新(serverを1台ずつ)
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.33.4+k3s1 sh -s - server
# etcdスナップショット
sudo k3s etcd-snapshot save --name before-upgrade
sudo k3s etcd-snapshot ls
# 復旧(全serverでk3sを停止し、1台目で実行 → 他の2台は db/ を消して再参加)
sudo systemctl stop k3s
sudo k3s server --cluster-reset \
  --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/before-upgrade-k3s-1-1758000000

上のバージョン番号は執筆時点の例で、実在する最新版は公式のリリース一覧で確認してください。マイナーバージョンは1つずつ上げるのがKubernetesの原則で、1.31から1.33へ一気に上げるとAPIの互換性で失敗します。

動作確認

terminal (手元のPC)
kubectl get nodes -o wide
# NAME    STATUS   ROLES                       AGE   VERSION
# k3s-1   Ready    control-plane,etcd,master   1h    v1.33.x+k3s1
# k3s-2   Ready    control-plane,etcd,master   ...
# k3s-3   Ready    control-plane,etcd,master   ...
kubectl get pods -A | grep -v Running          # ヘッダー以外何も出なければ正常
kubectl -n kube-system get svc traefik         # EXTERNAL-IP が 192.168.10.200
sudo systemctl stop k3s   # k3s-1 で実行し、手元から kubectl get nodes が通れば VIP と etcd の HA が効いている
sudo systemctl start k3s

1台を止める試験は、3台すべてがReadyで、etcdスナップショットを取った後に行ってください。2台同時に止めると過半数を失い、書き込み不能になります。

つまずきポイント

  • x509: certificate is valid for 127.0.0.1, 192.168.10.11, ..., not 192.168.10.100: tls-san にVIPが入っていません。config.yamlに追記して systemctl restart k3s すると証明書が再生成されます。
  • Node password rejected, duplicate hostname or contents of '/etc/rancher/node/password' may not match server node-passwd entry: 同じホスト名のノードを作り直したときに出ます。kubectl delete node k3s-2 と kubectl -n kube-system delete secret k3s-2.node-password.k3s を実行してから再参加します。
  • LoadBalancer Serviceの EXTERNAL-IP が <pending> のまま: IPAddressPoolとL2Advertisementが未適用か、servicelb を無効にし忘れて競合しています。kubectl -n metallb-system logs deploy/metallb-controller で確認します。
  • 2台目の参加が failed to get CA certs でタイムアウトする: 1台目の6443/tcpに届いていません。ufwやProxmoxのファイアウォール、server: のIPの打ち間違いを確認します。

よくある質問

k3sと本家Kubernetesの違いは?

APIは同じで、CNCFの適合認定も受けています。違いは配布形態で、単一バイナリにcontainerd・CNI・Ingress・ストレージを同梱し、クラウド向けのin-treeプロバイダーを省いてメモリを抑えています。マニフェストやHelmチャートはそのまま使えます。

ノードは何台必要ですか?

学習だけなら1台(SQLite)で全機能が動きます。HAにするなら組み込みetcdの過半数が要るので、serverは3台が最小です。2台はetcdの過半数を維持できず、1台より弱くなるので避けてください。ワーカー(agent)は0台でも4台でも構いません。

Raspberry Piでも動きますか?

arm64版があり、Raspberry Pi 4/5で動きます。Raspberry Pi OSでは /boot/firmware/cmdline.txt に cgroup_memory=1 cgroup_enable=memory を追記し、Ubuntuでは linux-modules-extra-raspi を入れる必要があります。etcdはSDカードを早く消耗させるので、serverはSSD起動にするか、Piはagentに回します。

まとめ

config.yamlに cluster-init・token・tls-san(VIP入り)・disable: servicelb を書いて3台をserverで組み、kube-vipでAPIサーバーに、MetalLBでServiceにそれぞれIPを与え、HTTPはTraefikのIngressに集約する。この形にしておけば、1台止めてもkubectlもアプリも動き続けます。ストレージはlocal-pathとNFSから始め、etcdスナップショットはクラスタの外にも置いてください。外出先からVIPに届かせたいときはTailscaleのSubnet Routerを1台目に置くのが最短です。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。