chundev
日期:2026-04-10 環境:Ubuntu 24.04 Server / containerd 2.2 / Kubernetes 1.29 / Calico 3.26.1
為了對齊上游廠商的發行版,把熟悉的 k3s 換成 kubeadm 自建 single-node K8s,從 OS 初始化跑到 control plane Ready 的過程中踩了六個坑:LVM 預設 LV 不吃滿 VG、UFW 把 Pod → Host 流量擋掉、kubeadm preflight 2 vCPU 硬性限制、Calico manifest Pod CIDR 寫死 192.168.0.0/16、kubectl wait --all 的 race condition、kubeadm 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 簡單,可以完整複製廠商架構,又不需要一堆機器。本篇記錄這條路上遇到的幾個非直覺陷阱。
在 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 上限,就不會遇到這個坑。
按慣例設 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.1 → 127.0.0.1 流量。Pod 的 source IP 是 Pod CIDR 裡的 IP,不是 loopback。
# 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 放行是一次到位。
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 設計哲學的根本差異。
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-cidr 是 10.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.yaml 的 Installation 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 版比較簡單。
kubectl wait --all 的 race conditionAnsible / 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 的資源。
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 不匹配)、整個叢集陷入半死不活狀態。
預防面(部署時):
kubeadm init 時加 --apiserver-advertise-address=<靜態 IP> 明確指定kubeadm init 時加 --apiserver-cert-extra-sans=<其他預期會用到的 IP/DNS>補救面(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 | 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 plan → apply → upgrade 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- |
lvextend -l +100%FREE + resize2fs,線上擴展不用停機。kubectl wait --all 是一份執行瞬間的快照,不是持續等待。剛 apply 的資源還沒被 scheduler 處理時,它根本不在 wait 名單裡。要等新資源就用 kubectl rollout status 對具體 Deployment / DaemonSet 等。--pod-network-cidr。sed 一下或改用 tigera-operator。lvextend man page — -l vs -L 的差異與 +100%FREE 語法