Share Notes

chundev

View the Project on GitHub latteouka/share-notes

從 k3s 到 kubeadm — Single-Node 自建的六個踩坑筆記

日期:2026-04-10 環境:Ubuntu 24.04 Server / containerd 2.2 / Kubernetes 1.29 / Calico 3.26.1


TL;DR

為了對齊上游廠商的發行版,把熟悉的 k3s 換成 kubeadm 自建 single-node K8s,從 OS 初始化跑到 control plane Ready 的過程中踩了六個坑:LVM 預設 LV 不吃滿 VGUFW 把 Pod → Host 流量擋掉kubeadm preflight 2 vCPU 硬性限制Calico manifest Pod CIDR 寫死 192.168.0.0/16kubectl wait --all 的 race conditionkubeadm cert 把 control plane IP 烤進憑證。每一個都不難解,但每一個都會在你以為快好了的時候絆你一跤。


背景

很多人上手 K8s 是從 k3s 開始,一行 install script 就能拿到可用叢集。但在企業場景裡常會遇到「上游廠商的安裝包是基於標準 kubeadm 做的」— 這種情況下要硬套 k3s 風險高,因為兩者在 API server、kube-proxy、kubelet 啟動方式、CNI 安裝介面等細節上有差異,廠商的 Helm chart / Operator 可能在 k3s 上踩到奇怪的 bug。

折衷做法是自建 kubeadm single-node 叢集。這比 k3s 麻煩但比 multi-node 簡單,可以完整複製廠商架構,又不需要一堆機器。本篇記錄這條路上遇到的幾個非直覺陷阱。


坑 1:Ubuntu Server LVM 預設 LV 不吃滿 VG

症狀

在 hypervisor 把 virtual disk 從 100 GB 擴到 300 GB 後,VM 裡卻只看到 / 還是 98 GB:

$ lsblk
NAME                      SIZE
sda                       300G   ← virtual disk 擴大成功
├─sda1                      1M
├─sda2                      2G   /boot
└─sda3                    298G   ← partition 也跟上了
  └─ubuntu--vg-ubuntu--lv 100G   /   ← 但 LV 還是原本的 100 GB

$ df -h /
Filesystem                         Size  Used Avail Use%
/dev/mapper/ubuntu--vg-ubuntu--lv   98G  6.7G   87G   8%

$ sudo vgs
  VG        #PV #LV #SN Attr   VSize    VFree
  ubuntu-vg   1   1   0 wz--n- <298.00g <198.00g   ← 198 GB 閒置在 VG 裡!

根因

Ubuntu Server 的 Subiquity 安裝器有個容易被忽略的行為:用 LVM guided partition 時,它預設只把 VG 的一部分(通常是 100 GB 或 50%)分配給 ubuntu-lv。這個設計是「留彈性給之後建 snapshot / 其他 LV」,但對只跑單一 workload 的 VM 來說,這 198 GB 就是浪費。

hypervisor 擴 disk 之後,partition 和 PV 會自動跟上(或手動 growpart / pvresize),但 LV 不會自動擴 — LVM 是有意識的資源管理器,它不會自己去吃 VG free space。

解法

三步線上擴展,ext4 支援 online resize,不用 unmount、不用 reboot:

# 1. 把 VG 所有 free space 分給 LV
sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv

# 2. 擴展 ext4 檔案系統(on-line)
sudo resize2fs /dev/ubuntu-vg/ubuntu-lv

# 3. 驗證
df -h /
sudo vgs   # VFree 應該是 0

-l +100%FREE(小寫 L)比 -L +198G(大寫 L)好 — 不用人肉算剩餘空間、不會踩進位誤差。

一次到位的預防

在 Subiquity 的 partition 畫面,手動把 ubuntu-lv 的 Size 拉到 VG 上限,就不會遇到這個坑。


坑 2:UFW 在 Single-Node 會把 Pod → Host 流量擋掉

症狀

按慣例設 UFW 最小權限,只開必要 port(SSH、6443、Ingress 80/443、NodePort range),Pod 起得來、kubectl 也通,但一跑需要掛 NFS 的 workload 就卡住

MountVolume.SetUp failed for volume "nfs-pv" :
  mount failed: mount.nfs: Connection timed out

根因

很多人以為 single-node 的「Pod 打 host 上的服務」是 loopback 流量,其實不是。Calico(或 flannel / cilium)在 host 上建立 veth 介面給 Pod 用,Pod 送出去的封包長這樣:

Pod (10.244.x.x) ──[calico veth]──> Host network namespace ──> Host 服務 (port 2049)
                                                                    ↑
                                                       UFW 看到這是「incoming」
                                                       source = Pod IP
                                                       → 預設 deny incoming 擋掉

從 Linux kernel 的封包流向看,Pod 透過 veth 進入 host network namespace 之後,對 iptables / nftables / UFW 而言就是「incoming packet」。UFW 的預設策略 deny incoming 會把它擋掉,除非有對應的 allow 規則。

loopback(lo)是例外 — 但只限於真正的 127.0.0.1127.0.0.1 流量。Pod 的 source IP 是 Pod CIDR 裡的 IP,不是 loopback。

解法:放行 Pod CIDR 和 Service CIDR

# Pod CIDR(Calico 預設 或 kubeadm --pod-network-cidr 指定值)
sudo ufw allow from 10.244.0.0/16

# Service CIDR(kubeadm 預設 10.96.0.0/12)
sudo ufw allow from 10.96.0.0/12

這兩條不是「放行整個內網網段」的寬鬆規則,只是放行 K8s 內部虛擬網段進入 host — 它們本來就跟實體網段分離,不會跟 192.168.x.x 之類的辦公網段衝突。

不要這樣做

sudo ufw allow from 10.0.0.0/8 — 如果你的辦公內網剛好在 10.x,等於對整個辦公網段放行。用 /16/12 的精確網段就好。

❌ 為 NFS 個別開 port 2049 — 能動但維護成本高;之後任何新增的 host service(metrics exporter、DB、log collector)都要再開一次。從 Pod CIDR 放行是一次到位。


坑 3:kubeadm preflight 的 2 vCPU 是硬性限制

症狀

VM 給 1 vCPU 跑 kubeadm init

[preflight] Running pre-flight checks
error execution phase preflight: [preflight] Some fatal errors occurred:
  [ERROR NumCPU]: the number of available CPUs 1 is less than the required 2

「偷繞」方法存在,但不要用

kubeadm init --ignore-preflight-errors=NumCPU  ...

這會讓 init 通過,但會換來一個更難 debug 的問題:control plane 在 1 vCPU 上會 CPU throttle。

kube-apiserver + etcd + controller-manager + scheduler + kubelet + containerd + CNI agent,這些東西加起來至少要 1 個核專門跑控制面。1 vCPU 的情況下,它們互相搶 CPU time、API server 回應變慢、kubelet healthcheck 偶發 timeout、Pod 排程延遲幾十秒才發生 — 你會看到「機器還在、但一切都很慢」的詭異症狀,debug 成本遠高於直接給 2 vCPU。

建議最低規格

項目 最低 理由
vCPU 2 kubeadm preflight 寫死、control plane 需要
RAM 16 GB K8s + CNI + 監控 + 一到兩個 workload 的基準
Disk 150 GB image cache + log + 實驗空間

k3s 的最低需求比這低很多(k3s 1 vCPU / 512 MB 就能跑)— 因為 k3s 把 control plane 塞進單一 binary、etcd 換成 sqlite(預設)。這是 k3s 和 kubeadm 設計哲學的根本差異。


坑 4:Calico 3.26.1 manifest 的 Pod CIDR 預設寫死 192.168.0.0/16

症狀

kubeadm init--pod-network-cidr=10.244.0.0/16,然後按官方文件 kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml,結果 Pod 起不來、kubectl get nodes 一直 NotReady。

根因

Calico calico.yaml(legacy 單檔 manifest 版,不是 tigera-operator 版)裡面有一個 CALICO_IPV4POOL_CIDR 環境變數,預設值是 192.168.0.0/16

- name: CALICO_IPV4POOL_CIDR
  value: "192.168.0.0/16"

這和 kubeadm 傳入的 --pod-network-cidr=10.244.0.0/16 不一致 — Calico 會用它自己的 192.168.0.0/16 分配 Pod IP,但 kube-controller-manager 的 --cluster-cidr10.244.0.0/16,兩邊對不上就各種詭異。

解法

下載 manifest、改 CIDR、再 apply:

# 下載
curl -LO https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml

# 改成跟 kubeadm --pod-network-cidr 一致
sed -i 's|192\.168\.0\.0/16|10.244.0.0/16|g' calico.yaml

# Apply
kubectl apply -f calico.yaml

或者 Ansible 版本:

- name: 下載 Calico manifest
  get_url:
    url: https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml
    dest: /tmp/calico.yaml

- name: 調整 Pod CIDR
  replace:
    path: /tmp/calico.yaml
    regexp: '192\.168\.0\.0/16'
    replace: '10.244.0.0/16'

- name: Apply
  command: kubectl apply -f /tmp/calico.yaml
  environment:
    KUBECONFIG: /etc/kubernetes/admin.conf

替代方案

tigera-operator 版本的 Calico 安裝(tigera-operator.yaml + custom-resources.yaml),CIDR 在 custom-resources.yamlInstallation CR 裡設定,語意更清楚:

apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  calicoNetwork:
    ipPools:
      - blockSize: 26
        cidr: 10.244.0.0/16
        encapsulation: VXLANCrossSubnet

trade-off:operator 版本多一層抽象、但比較現代。legacy manifest 版本比較「手動但直接」,如果要對齊上游廠商文件、或做 air-gapped 打包,legacy 版比較簡單。


坑 5:kubectl wait --all 的 race condition

症狀

Ansible / CI pipeline 裡 apply Calico 之後,馬上跑 kubectl wait --for=condition=Ready pods --all -n kube-system --timeout=300s命令回傳 0(success),但下一步 kubectl get pods -A 卻看到 Calico pods 還在 Init:0/3、CoreDNS 還在 Pending

kube-system   calico-node-24tph                          0/1     Init:0/3
kube-system   calico-kube-controllers-7ddc4f45bc-hkvs6   0/1     Pending
kube-system   coredns-76f75df574-csh94                   0/1     Pending
kube-system   etcd-k8s                                   1/1     Running
kube-system   kube-apiserver-k8s                         1/1     Running

等 60 秒之後再看就全綠,但 playbook 已經往下走了。

根因

kubectl wait --all 的語意容易誤解:它在執行那一瞬間對 API server 抓一份 pod 列表,然後等待那份列表裡的 pod 滿足條件。

關鍵時序:

t=0s  kubectl apply -f calico.yaml     # manifest 送進 API server
t=0s  scheduler 還在處理、Pod 物件還沒建出來
t=1s  kubectl wait --all              # 抓 pod 列表
      → 列表裡只有 etcd / apiserver / controller-manager / scheduler / kube-proxy
      → 這些 static pods 早就 Ready 了
      → wait 立刻 return 0 ✅(但 Calico pods 根本不在名單裡!)
t=3s  Calico pods 被 scheduler 建出來
t=60s Calico 全部 Ready

這個 bug 在新增資源後立刻 wait 的場景一定會踩到,因為抓列表的那個瞬間新資源還沒出現。

解法:用 kubectl rollout status 對具體資源等

# 對每個 DaemonSet / Deployment 等它的 rollout 完成
kubectl -n kube-system rollout status daemonset/calico-node --timeout=300s
kubectl -n kube-system rollout status deployment/calico-kube-controllers --timeout=300s
kubectl -n kube-system rollout status deployment/coredns --timeout=300s

# 最後再用 kubectl wait 把關
kubectl wait --for=condition=Ready pods --all -n kube-system --timeout=300s

rollout status 的語意跟 wait --all 截然不同:它對指定資源持續輪詢,直到「期望副本數 = 實際 ready 副本數」才 return。即使 pods 還沒被建出來,它也會等到建出來再等它們 ready。

另一個常見替代方案

# 先等到至少一個 calico-node pod 出現
until kubectl -n kube-system get pods -l k8s-app=calico-node -o name | grep -q .; do
  sleep 2
done

# 再 wait
kubectl -n kube-system wait --for=condition=Ready pods -l k8s-app=calico-node --timeout=300s

比較冗長但不依賴 rollout status,適合那種用 raw Pod 而不是 Deployment/DaemonSet 的資源


坑 6:kubeadm cert 會把 control plane IP 烤進憑證

觀察

kubeadm init 的輸出仔細看會發現這一行:

[certs] apiserver serving cert is signed for DNS names [k8s kubernetes kubernetes.default
        kubernetes.default.svc kubernetes.default.svc.cluster.local]
        and IPs [10.96.0.1 <NODE_IP>]

Control plane IP(init 時這台 VM 的 primary IP)被寫進 kube-apiserver 的 serving cert 的 SAN (Subject Alternative Name) 欄位。

隱含限制

這台 VM 不能換 IP。

如果你:

那 kubelet 會連不上 API server(TLS cert 對 IP 不匹配)、整個叢集陷入半死不活狀態。

解法

預防面(部署時):

補救面(IP 已經換了):

# 1. 備份舊 PKI
sudo cp -r /etc/kubernetes/pki /etc/kubernetes/pki.bak

# 2. 刪掉只跟 apiserver 有關的 certs(保留 CA)
sudo rm /etc/kubernetes/pki/apiserver.*

# 3. 重新生成(會讀 kubeadm-config ConfigMap 裡的設定)
sudo kubeadm init phase certs apiserver --apiserver-advertise-address=<新 IP>

# 4. 重啟 kube-apiserver(靜態 Pod 透過改 manifest 檔案重啟)
sudo systemctl restart kubelet

並且所有 kubeconfig(admin.conf、kubelet.conf、controller-manager.conf、scheduler.conf)裡的 server: https://<舊 IP>:6443 都要手動改成新 IP。

k3s 使用者對照: k3s 預設行為是每次重啟都會動態更新 apiserver.crt 的 SAN,自動把當前 node IP 塞進去,所以 k3s 使用者很難踩到這個坑。kubeadm 不會做這件事,因為它的設計假設是「生產環境用 LB」,control plane 通過 LB 對外,LB 的 IP / DNS 才是關鍵,node IP 本身可以變動(但 cert 裡的 SAN 還是需要對齊)。


k3s 使用者視角對照表

概念 k3s kubeadm
安裝方式 curl -sfL https://get.k3s.io \| sh - 一行 OS 前置 + apt repo + kubeadm init + CNI 三階段
Control plane 單一 k3s binary 內嵌 apiserver/etcd/cm/scheduler 各自是 static Pod manifest 在 /etc/kubernetes/manifests/
CNI Flannel (預設) / 可換 要自己裝(官方不裝 CNI)
kube-proxy 預設用 iptables / 可換 IPVS 同,但是 DaemonSet 不是內嵌
etcd 預設 sqlite、可選 embedded etcd / 外部 DB 預設 embedded etcd
Node token /var/lib/rancher/k3s/server/node-token kubeadm token create(24h 過期)
升級 把 binary 換掉、重啟 service kubeadm upgrade planapplyupgrade node
最低資源 1 vCPU / 512 MB 2 vCPU / 2 GB(官方)/ 實務 4 GB 起
cert SAN 處理 啟動時動態更新 init 時一次性烤進去
Single-node 是否要移除 taint 不需要(k3s 預設 server 節點就能跑 workload) 需要 kubectl taint nodes --all node-role.kubernetes.io/control-plane:NoSchedule-

學到的事


參考資料