← 回文章列表

2024年6月18日

從 Docker 換到 containerd + nerdctl:架構差異、安裝 runc 與 CNI,以及移除 Docker 後的網路問題

containerdnerdctl容器LinuxDevOps

為什麼不用 Docker 了

以前部署容器,第一步一定是裝 Docker。但這次換掉它有兩個原因:

  • Docker 太肥大了:這些主機只需要「拉映像檔、跑容器」,Docker 卻附帶了 dockerd 常駐程式、自己的網路與儲存管理、Swarm 等一整套功能,多一層元件就多一份要維護、要更新、要擔心的東西
  • 和公司的 Harbor 不相容:公司的 Harbor 一直沒有升版,在我們的環境裡,安裝 docker-ce 18.06 以上的版本就無法從 Harbor 拉取映像檔。但為了配合舊的 Harbor 一直停在舊版 Docker,也不是長久之計

而 Docker 底層真正在管理容器的,其實就是 containerd。Kubernetes 從 1.24 開始移除了 dockershim,也已經直接使用 containerd 這類 CRI 執行環境。所以只要把 Docker 拿掉、直接用 containerd,再搭配一個指令和 Docker 幾乎一樣的 CLI——nerdctl——就能用更少的元件做到一樣的事。

Docker 和 containerd 是什麼關係

Docker 與 containerd + nerdctl 的架構比較:使用 Docker 時,docker CLI 呼叫常駐的 dockerd,dockerd 再呼叫 containerd,由 containerd-shim 啟動 runc 建立容器,網路由 dockerd 自己管理 docker0 橋接網卡與 iptables 規則;改用 nerdctl 後,nerdctl 直接呼叫 containerd,少了 dockerd 這一層,容器同樣由 containerd-shim 與 runc 建立,網路則交給 CNI plugins 建立 nerdctl0 橋接網卡與 iptables 規則;Kubernetes 的 kubelet 也是透過 CRI 直接呼叫同一個 containerd

一個 docker run 背後其實經過好幾層:

元件負責什麼
docker CLI使用者下指令的工具
dockerdDocker 的常駐程式,處理 API、建置映像檔、管理網路與 Volume
containerd管理容器的生命週期:拉映像檔、管理快照、建立與監控容器
containerd-shim每個容器一個,讓 containerd 重啟時容器不會跟著停
runc真正呼叫 Linux 核心(namespace、cgroups)建立容器的低階執行環境,遵循 OCI 規範
CNI plugins負責容器的網路:建立橋接網卡、分配 IP、設定 Port 對應

改用 containerd + nerdctl 之後,dockerd 這一層就不見了,nerdctl 直接呼叫 containerd。容器的建立方式完全一樣,還是 containerd-shim 加 runc;差別在於網路:Docker 的網路是 dockerd 自己管的,containerd 本身不管網路,所以要另外安裝 CNI plugins,由 nerdctl 呼叫它們來建立網路。

這也是安裝時需要三樣東西的原因:

  1. containerd:管理容器
  2. runc:建立容器
  3. CNI plugins:容器網路

和 containerd 互動的三種 CLI

containerd 有三種常見的命令列工具,用途不同:

工具定位適合
ctrcontainerd 內建的除錯工具低階除錯,指令和 Docker 差很多,也不處理網路
crictl透過 CRI 介面操作,是給 Kubernetes 用的在 K8s 節點上查看 kubelet 建立的 Pod 與容器
nerdctl和 Docker CLI 相容的工具取代 docker 指令,支援 run -p、compose、login 等

日常使用選 nerdctl 就對了:docker run 改成 nerdctl run,大部分的參數都一樣,連 nerdctl compose up 都可以直接吃原本的 docker-compose.yml。

安裝 containerd

加入 Docker 的 apt 套件庫

containerd 的 containerd.io 套件是由 Docker 的 apt 套件庫提供的,先加入金鑰與套件庫:

Shell
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

安裝 containerd

只安裝 containerd.io,不要安裝 docker-ce:

Shell
sudo apt-get update
sudo apt-get install containerd.io

產生設定檔並啟動

containerd.io 附的設定檔是給 Docker 用的精簡版,建議改用 containerd 的完整預設設定,之後要調整 Registry、cgroup 等設定才有地方改:

Shell
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null

sudo systemctl daemon-reload
sudo systemctl enable --now containerd
sudo systemctl status containerd

安裝 runc

containerd.io 套件其實已經附帶了 runc,可以先確認:

Shell
runc --version

如果是從 GitHub 下載 containerd 的二進位檔安裝(不透過 apt),或是需要更新 runc 版本,再手動安裝。先確認系統架構:

Shell
dpkg --print-architecture   # 例如 amd64、arm64

再到 runc Releases 選擇版本下載:

Shell
RUNC_VERSION=1.1.12
ARCH=$(dpkg --print-architecture)

wget https://github.com/opencontainers/runc/releases/download/v${RUNC_VERSION}/runc.${ARCH}
sudo install -m 755 runc.${ARCH} /usr/local/sbin/runc
runc --version

Releases 頁面上也會列出 -rc 結尾的預覽版本(例如 v1.2.0-rc.1),正式環境請選擇沒有 -rc 的穩定版。

安裝 CNI plugins

containerd 不管網路,nerdctl run -p 8080:80 這種 Port 對應、容器之間的網路,都是由 CNI plugins 處理。containerd.io 沒有附帶 CNI,一定要自己安裝,到 CNI plugins Releases 選擇版本:

Shell
CNI_VERSION=1.5.0
ARCH=$(dpkg --print-architecture)

wget https://github.com/containernetworking/plugins/releases/download/v${CNI_VERSION}/cni-plugins-linux-${ARCH}-v${CNI_VERSION}.tgz
sudo mkdir -p /opt/cni/bin
sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-${ARCH}-v${CNI_VERSION}.tgz

/opt/cni/bin 是 nerdctl 預設尋找 CNI plugins 的位置。裡面會有 bridge(建立橋接網卡)、host-local(分配 IP)、portmap(Port 對應)、firewall 等執行檔,nerdctl 建立容器時會依序呼叫它們。

安裝 nerdctl

到 nerdctl Releases 下載。nerdctl 提供兩種版本:

  • nerdctl-x.y.z-linux-amd64.tar.gz:只有 nerdctl 本身,containerd、runc、CNI 要自己裝(也就是本篇的做法)
  • nerdctl-full-x.y.z-linux-amd64.tar.gz:一次附帶 containerd、runc、CNI plugins、BuildKit 等所有相依元件,適合全新的主機一次裝好
Shell
NERDCTL_VERSION=1.7.6
ARCH=$(dpkg --print-architecture)

wget https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-${ARCH}.tar.gz
sudo tar Cxzvf /usr/local/bin nerdctl-${NERDCTL_VERSION}-linux-${ARCH}.tar.gz nerdctl

nerdctl version

nerdctl version 同時顯示 Client 與 Server(containerd)的版本,兩者都有出現代表 nerdctl 已經成功連上 containerd。

驗證

Shell
sudo nerdctl run -d --name web -p 8080:80 nginx:alpine
sudo nerdctl ps
curl http://localhost:8080

連線公司的 Harbor

登入

和 Docker 一樣:

Shell
sudo nerdctl login harbor.example.com
sudo nerdctl pull harbor.example.com/realty/house-api:1.3.0

自簽憑證

公司內部的 Harbor 常用自簽憑證,拉取時會出現 x509: certificate signed by unknown authority。nerdctl 會讀取 /etc/containerd/certs.d/ 底下的設定,把公司的 CA 憑證放進對應的資料夾:

Shell
sudo mkdir -p /etc/containerd/certs.d/harbor.example.com
sudo cp company-ca.crt /etc/containerd/certs.d/harbor.example.com/ca.crt
toml
# /etc/containerd/certs.d/harbor.example.com/hosts.toml
server = "https://harbor.example.com"

[host."https://harbor.example.com"]
  ca = "/etc/containerd/certs.d/harbor.example.com/ca.crt"

測試環境如果只是暫時要略過驗證,可以加 --insecure-registry,但正式環境不要這樣用。

和 Docker 不一樣的地方

nerdctl 雖然和 Docker CLI 相容,還是有幾個地方容易卡住:

  • containerd 有 Namespace:nerdctl 預設使用 default 這個 Namespace,而 Kubernetes 的 kubelet 使用 k8s.io。在 K8s 節點上用 nerdctl pull 拉下來的映像檔,kubelet 看不到;要查看 K8s 建立的容器,也要加 --namespace k8s.io(或 -n k8s.io)
  • nerdctl build 需要 BuildKit:nerdctl 本身不會建置映像檔,要另外安裝並啟動 buildkitd(nerdctl-full 版本有附)。這幾台主機只負責執行容器、建置交給 CI,所以沒有安裝
  • 沒有常駐程式也代表沒有自動重啟管理員:--restart=always 是由 nerdctl 搭配 containerd 的機制處理,主機重開機後要確認容器有自動起來
  • 預設需要 root:上面的指令都加了 sudo。nerdctl 也支援 Rootless 模式,但需要額外設定
  • 網卡名稱不同:Docker 的預設橋接網卡是 docker0,nerdctl 是 nerdctl0,網段也不一樣。防火牆規則、監控設定如果有寫死 docker0 要一起改

錯誤紀錄:移除 Docker 後外部連不進容器

症狀

主機原本裝過 Docker,移除後改用 containerd + nerdctl。nerdctl run -p 8080:80 啟動容器後:

  • 在主機上 curl localhost:8080 正常
  • 從外部其他機器連 http://主機IP:8080 卻一直沒有回應,最後逾時

相同的問題在 nerdctl 的 GitHub 也有人回報:containerd/nerdctl#2142。

原因

移除 Docker 後的 iptables 殘留示意圖:外部請求進入主機的 eth0,iptables nat 表的 PREROUTING 鏈中,Docker 留下的 DOCKER 鏈規則仍然存在並排在前面,指向已經不存在的 docker0 網段,封包被導到錯的地方而逾時;nerdctl 透過 CNI portmap 建立的 CNI-HOSTPORT-DNAT 規則排在後面,本應把 8080 轉到 nerdctl0 橋接網卡上的容器 10.4.0.2:80;從 localhost 連線走的是 OUTPUT 鏈,不受影響,所以本機正常、外部逾時;解法是刪除 Docker 殘留的 iptables 規則、鏈與 docker0 網卡

apt remove docker-ce 只會移除程式,Docker 在系統上建立的 iptables 規則與 docker0 網卡不會一起清掉。Docker 會在 nat 表裡建立自己的 DOCKER 鏈,並在 PREROUTING、OUTPUT 中插入規則指向它;nerdctl 透過 CNI 的 portmap 外掛建立的則是 CNI-HOSTPORT-DNAT 這組規則。

外部連進來的封包會先經過 PREROUTING,Docker 殘留的規則仍然排在前面,干擾了原本應該由 CNI 處理的轉送;而從主機本身用 localhost 連線,走的是另一條路徑,所以本機測試一切正常。這也是這個問題很難發現的原因:本機 curl 沒問題,只有從外面連才會出事。

解決辦法

清除 Docker 殘留的 iptables 規則與網卡。

⚠️ 修改 iptables 前,先確認自己不會把 SSH 連線一起擋掉,並且最好在有主控台可以登入的情況下操作。

1. 先備份目前的規則

Shell
sudo iptables-save > ~/iptables-backup-$(date +%Y%m%d).rules

2. 找出 Docker 相關的規則

Shell
# 列出 nat 表的規則與編號
sudo iptables -t nat -L -n --line-numbers

# 也可以直接篩選出和 Docker 有關的規則
sudo iptables-save | grep -i docker

3. 刪除規則

先刪除 PREROUTING、OUTPUT、POSTROUTING 中指向 DOCKER 的規則(用上一步看到的編號),再清空並刪除 DOCKER 鏈本身:

Shell
# 刪除指定編號的規則,例如 PREROUTING 的第 1 條(編號依實際情況)
sudo iptables -t nat -D PREROUTING 1

# 清空 DOCKER 鏈內的規則
sudo iptables -t nat -F DOCKER

# 沒有任何規則再引用它之後,才能刪除這條鏈
sudo iptables -t nat -X DOCKER

刪除一條規則後,後面規則的編號會往前移,所以每刪一條就重新列一次,或是從編號大的開始刪,避免刪錯(參考 iptables: Index of deletion too big)。filter 表中的 DOCKER、DOCKER-USER、DOCKER-ISOLATION-STAGE-1/2 等鏈也用同樣的方式處理。

4. 刪除 docker0 網卡

Shell
ip link show                     # 找到 docker0
sudo ip link set docker0 down
sudo ip link delete docker0

(舊的做法是 ifconfig docker0 down,但 ifconfig 在新版的 Ubuntu 已經不是預設安裝,建議改用 ip 指令;參考 How do I delete virtual interface in Linux。)

5. 重新建立容器並從外部驗證

Shell
sudo nerdctl rm -f web
sudo nerdctl run -d --name web -p 8080:80 nginx:alpine

# 確認 CNI 的 Port 對應規則存在
sudo iptables -t nat -L CNI-HOSTPORT-DNAT -n

# 從另一台機器測試
curl http://<主機IP>:8080

如果主機有用 iptables-persistent 之類的工具把規則存起來,開機時會自動載回來,記得清理後重新儲存(sudo netfilter-persistent save),不然重開機問題又回來了。

避免再發生

  • 新主機直接裝 containerd + nerdctl,不要先裝 Docker 再移除
  • 舊主機要換掉 Docker 時,把「清理 iptables 與 docker0」寫進移轉步驟,而不是等出問題才處理
  • 驗收時一定要從外部機器測試,不能只在主機上 curl localhost

參考資料

一句話總結

Docker 底層本來就是 containerd,拿掉 dockerd 之後,只要自己補上建立容器的 runc 與負責網路的 CNI plugins,再用和 Docker 指令相容的 nerdctl 操作,就能用更少的元件跑一樣的容器;但從 Docker 換過來的主機,記得清掉 Docker 殘留的 iptables 規則與 docker0,而且驗收時一定要從外部連線測試。