Container Runtime và CRI: Cài containerd Trên Worker

K
Kai··10 min read

Phần control plane đã khép lại ở Bài 9: ba api-server sau một load balancer, kubectl từ laptop điều khiển được, RBAC cho api-server gọi xuống kubelet đã sẵn. Nhưng cluster vẫn chưa chạy được một pod nào: get nodes trả về rỗng, vì chưa worker nào tham gia. Phần còn lại của series dựng worker, và việc đầu tiên trên một worker không phải là kubelet, mà là thứ kubelet dựa vào để chạy container: một container runtime.

Bài này dừng lại ở lớp nền đó. Ta xem kubelet nói chuyện với runtime qua giao diện nào, ai làm việc gì trong chồng, rồi cài containerdrunc lên hai worker và kiểm chứng bằng crictl, chưa cần tới kubelet.

kubelet không chạy container, nó ra lệnh cho runtime

Một hiểu lầm phổ biến là kubelet tự tạo container. Nó không làm vậy. kubelet quan sát api-server, biết những pod nào cần chạy trên node của mình, rồi yêu cầu một runtime tạo và quản lý các container đó. Việc tạo namespace, cgroup, gắn rootfs, chạy tiến trình là phần của runtime.

Giao diện giữa hai bên là CRI — Container Runtime Interface. Đó là một API gRPC do Kubernetes định nghĩa, gồm hai dịch vụ:

  • RuntimeService — vòng đời pod sandbox và container: tạo, khởi động, dừng, xóa, liệt kê.
  • ImageService — quản lý image: pull, list, xóa.

kubelet là client, runtime là server. Miễn một runtime nói đúng giao thức CRI, kubelet không quan tâm bên dưới nó là gì. Đó là lý do dockershim bị gỡ khỏi kubelet (1.24) mà không ảnh hưởng chức năng: Docker chỉ là một lựa chọn, còn containerd thì nói CRI trực tiếp.

containerd hiện thực CRI bằng một plugin tích hợp, lắng nghe trên một Unix socket: /run/containerd/containerd.sock. Đó là socket mà kubelet sẽ trỏ tới ở Bài 11. Còn bây giờ, ta dùng crictl (một CLI nói thẳng CRI tới socket đó) để kiểm tra runtime mà không cần kubelet.

Ai làm gì: containerd, runc, OCI

containerd không phải là cái cuối cùng chạy container. Nó điều phối: kéo image, quản lý snapshot rootfs, lưu trạng thái, gọi runtime cấp thấp hơn để thực sự tạo container. Cái cấp thấp đó là runc — một OCI runtime.

OCI (Open Container Initiative) chuẩn hóa hai thứ: định dạng image và định dạng runtime. runc đọc một OCI bundle (một thư mục rootfs cộng file config.json mô tả container) rồi dùng các nguyên thủy Linux — namespaces, cgroups, capabilities — để dựng container và chạy tiến trình bên trong. Tạo xong, runc thoát; container vẫn sống.

Giữa containerd và runc còn một mảnh: containerd-shim (containerd-shim-runc-v2). Mỗi container có một shim làm cha trực tiếp. Shim giữ cho container tồn tại độc lập với containerd, nhờ vậy bạn có thể restart hay nâng cấp containerd mà các container đang chạy không bị giết theo.

Ghép lại thành một chuỗi ủy thác, mỗi tầng làm đúng một việc:

   api-server
       │  (kubelet xem pod nào thuộc node mình)
       ▼
   kubelet ──CRI (gRPC qua unix socket)──► containerd
                                              │  kéo image, dựng rootfs,
                                              │  giữ trạng thái
                                              ▼
                                   containerd-shim-runc-v2   (1 shim / container)
                                              │
                                              ▼
                                            runc   (OCI runtime)
                                              │  tạo namespaces + cgroups,
                                              │  chạy tiến trình rồi thoát
                                              ▼
                                       [ container đang chạy ]

Ta sẽ cài từ dưới lên: runc (OCI runtime), containerd (đã kèm sẵn shim trong bản phát hành), crictl (công cụ kiểm tra), và bộ plugin CNI (containerd cần khi dựng mạng pod; phần cấu hình mạng để dành Bài 14).

Bước 1 — Tải binary lên worker-0

Pin phiên bản theo mốc giữa 2026 để bài viết không lệch theo thời gian: containerd v2.3.1, runc v1.4.2, CNI plugins v1.9.1, crictl v1.36.0 (khớp minor với Kubernetes v1.36). Tất cả là binary tĩnh, tải thẳng từ GitHub release.

# trên worker-0
cd /tmp
CONTAINERD_VER=2.3.1
RUNC_VER=1.4.2
CNI_VER=1.9.1
CRICTL_VER=1.36.0

curl -fsSL -O https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VER}/containerd-${CONTAINERD_VER}-linux-amd64.tar.gz
curl -fsSL -O https://github.com/opencontainers/runc/releases/download/v${RUNC_VER}/runc.amd64
curl -fsSL -O https://github.com/containernetworking/plugins/releases/download/v${CNI_VER}/cni-plugins-linux-amd64-v${CNI_VER}.tgz
curl -fsSL -O https://github.com/kubernetes-sigs/cri-tools/releases/download/v${CRICTL_VER}/crictl-v${CRICTL_VER}-linux-amd64.tar.gz

ls -la *.tar.gz *.tgz runc.amd64
-rw-rw-r-- 1 ubuntu ubuntu 55418181 cni-plugins-linux-amd64-v1.9.1.tgz
-rw-rw-r-- 1 ubuntu ubuntu 34541786 containerd-2.3.1-linux-amd64.tar.gz
-rw-rw-r-- 1 ubuntu ubuntu 19263420 crictl-v1.36.0-linux-amd64.tar.gz
-rw-rw-r-- 1 ubuntu ubuntu 12233104 runc.amd64

-f trong curl -fsSL khiến lệnh trả mã lỗi khi server trả 404 thay vì lặng lẽ lưu một trang HTML lỗi thành file .tar.gz. Ở các bài control plane ta đã vài lần dính binary tải cụt mà không báo gì; với worker, cứ liếc kích thước file sau khi tải (ls -la) là cách rẻ nhất để bắt lỗi sớm.

Bước 2 — Cài vào đúng chỗ

Bản phát hành containerd giải nén thẳng vào /usr/local (binary nằm trong bin/). runc là một file đơn, đặt vào /usr/local/sbin. CNI plugins vào /opt/cni/bin, đường dẫn mặc định containerd tìm. crictl vào /usr/local/bin.

# containerd -> /usr/local/bin/{containerd,containerd-shim-runc-v2,ctr,...}
sudo tar Cxzvf /usr/local containerd-2.3.1-linux-amd64.tar.gz

# runc -> OCI runtime
sudo install -m 755 runc.amd64 /usr/local/sbin/runc

# CNI plugins -> /opt/cni/bin
sudo mkdir -p /opt/cni/bin
sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.9.1.tgz

# crictl -> /usr/local/bin
sudo tar Cxzvf /usr/local/bin crictl-v1.36.0-linux-amd64.tar.gz

Verify từng cái; sau mỗi lần tải, bước này cũng bắt luôn binary cụt:

containerd --version
runc --version | head -1
crictl --version
ls /opt/cni/bin
containerd github.com/containerd/containerd/v2 v2.3.1 64b425cf570b3b8dd1d4cc46da7c1fce65c6651a
runc version 1.4.2
crictl version v1.36.0

bandwidth  bridge  dhcp  dummy  firewall  host-device  host-local  ipvlan
loopback   macvlan  portmap  ptp  sbr  static  tap  tuning  vlan  vrf

Trong đống plugin CNI kia, hai cái ta thực sự sẽ cấu hình ở Bài 14 là bridge (tạo bridge và cấp IP cho pod) và loopback (interface lo bên trong mỗi pod). Số còn lại là tùy chọn cho các mô hình mạng khác.

Bước 3 — Sinh và chỉnh config của containerd

containerd chạy được mà không cần file config, nhưng plugin CRI thì cần vài tinh chỉnh. Cách sạch nhất là để chính containerd in ra config mặc định rồi sửa hai chỗ:

sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
grep -E '^version' /etc/containerd/config.toml
version = 4

containerd 2.x sinh config version 4, với các khóa plugin đặt theo kiểu mới là io.containerd.cri.v1.imagesio.containerd.cri.v1.runtime (khác với io.containerd.grpc.v1.cri của dòng 1.x). Nếu bạn copy config từ một hướng dẫn cũ, để ý chỗ này, vì khóa sai sẽ bị containerd bỏ qua mà không báo lỗi.

Chỉnh thứ nhất, pause image (đã đúng sẵn). Mỗi pod có một container ẩn tên pause: nó được tạo trước tiên, giữ các namespace mạng/IPC của pod để những container thật chia sẻ, và không làm gì ngoài việc ngủ. Image của nó được "ghim" trong config:

    [plugins.'io.containerd.cri.v1.images'.pinned_images]
      sandbox = 'registry.k8s.io/pause:3.10.2'

containerd 2.3.1 đã ghim pause:3.10.2, đúng phiên bản đi cùng Kubernetes 1.36, không cần đổi. Điểm cần nhớ: pause image được khai báo ở đây, trong runtime, chứ không phải trong kubelet. Nếu sau này pod kẹt ở trạng thái không tạo nổi sandbox, đây là một trong những chỗ đầu tiên nên kiểm tra.

Chỉnh thứ hai, cgroup driver. Đây là chỗ bắt buộc phải sửa. Mặc định containerd dùng cgroup driver cgroupfs:

grep -n 'SystemdCgroup' /etc/containerd/config.toml
88:            SystemdCgroup = false

Ubuntu khởi động bằng systemd, và systemd muốn là thành phần duy nhất quản lý cây cgroup. Nếu runtime tự ghi vào cgroup theo kiểu cgroupfs trong khi systemd cũng quản lý cùng cây đó, hai bên dẫm chân nhau, và dưới áp lực tài nguyên, node có thể mất ổn định. Quy tắc: kubelet và container runtime phải dùng cùng một cgroup driver, và trên host systemd thì cả hai chọn systemd. Ta bật nó cho containerd ngay bây giờ; phía kubelet sẽ đặt cgroupDriver: systemd ở Bài 11.

sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
grep -n 'SystemdCgroup' /etc/containerd/config.toml
88:            SystemdCgroup = true

Cờ này nằm trong khối tùy chọn của runtime runc, runtime mặc định mà containerd dùng để chạy container:

          [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
            ...
            SystemdCgroup = true

Bước 4 — systemd unit và khởi động

Bản phát hành containerd không kèm file service systemd; lấy nó từ repo, ghim đúng tag để khớp binary:

curl -fsSL https://raw.githubusercontent.com/containerd/containerd/v2.3.1/containerd.service \
  | sudo tee /etc/systemd/system/containerd.service >/dev/null

sudo systemctl daemon-reload
sudo systemctl enable --now containerd
systemctl is-active containerd
active

Bước 5 — Kiểm chứng bằng crictl

crictl mặc định không biết socket nào, nên khai báo một lần trong /etc/crictl.yaml:

sudo tee /etc/crictl.yaml >/dev/null <<'EOF'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
EOF

Giờ kiểm tra theo đúng đường mà kubelet sẽ đi, qua CRI tới containerd:

sudo crictl version
Version:  0.1.0
RuntimeName:  containerd
RuntimeVersion:  v2.3.1
RuntimeApiVersion:  v1

crictl version không đọc binary containerd; nó gọi gRPC tới socket và hỏi runtime tự khai báo. Trả về RuntimeApiVersion: v1 nghĩa là plugin CRI đang phục vụ đúng phiên bản API mà kubelet 1.36 cần. Thử kéo một image, đây là ImageService của CRI hoạt động:

sudo crictl pull registry.k8s.io/pause:3.10.2
sudo crictl images
Image is up to date for sha256:4a83b15d3ecfe0d916b2d0a7991bc2854a629b8097017c2ee1ff65b30ae4c07c

IMAGE                   TAG                 IMAGE ID            SIZE
registry.k8s.io/pause   3.10.2              4a83b15d3ecfe       321kB

Cả hai dịch vụ của CRI đều phản hồi. Một thứ chưa sẵn sàng, và đúng như vậy mới phải:

sudo crictl info | grep -iE 'lastCNILoadStatus|SystemdCgroup'
            "SystemdCgroup": true
  "lastCNILoadStatus": "cni config load failed: no network config found
    in /etc/cni/net.d: cni plugin not initialized: failed to load cni config"

SystemdCgroup: true xác nhận tinh chỉnh ở Bước 3 đã ăn. Còn dòng lastCNILoadStatus báo lỗi vì /etc/cni/net.d rỗng: ta đã cài binary CNI vào /opt/cni/bin nhưng chưa viết cấu hình mạng nào. Runtime chạy được, kéo image được, dựng container đơn lẻ được; nhưng để một pod có IP và nói chuyện ra ngoài thì cần CNI config, và đó là việc của Bài 14. Tách lớp như vậy cũng phản ánh đúng kiến trúc: runtime và mạng là hai mối quan tâm độc lập, nối với nhau qua CNI.

Bước 6 — Lặp lại trên worker-1

Toàn bộ các bước trên áp y hệt cho worker-1. Gói lại thành một mạch để khỏi gõ tay từng lệnh:

# trên worker-1
cd /tmp
CONTAINERD_VER=2.3.1; RUNC_VER=1.4.2; CNI_VER=1.9.1; CRICTL_VER=1.36.0
for u in \
  https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VER}/containerd-${CONTAINERD_VER}-linux-amd64.tar.gz \
  https://github.com/opencontainers/runc/releases/download/v${RUNC_VER}/runc.amd64 \
  https://github.com/containernetworking/plugins/releases/download/v${CNI_VER}/cni-plugins-linux-amd64-v${CNI_VER}.tgz \
  https://github.com/kubernetes-sigs/cri-tools/releases/download/v${CRICTL_VER}/crictl-v${CRICTL_VER}-linux-amd64.tar.gz ; do
  curl -fsSL -O "$u"
done

sudo tar Cxzf /usr/local containerd-2.3.1-linux-amd64.tar.gz
sudo install -m 755 runc.amd64 /usr/local/sbin/runc
sudo mkdir -p /opt/cni/bin && sudo tar Cxzf /opt/cni/bin cni-plugins-linux-amd64-v1.9.1.tgz
sudo tar Cxzf /usr/local/bin crictl-v1.36.0-linux-amd64.tar.gz

sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
curl -fsSL https://raw.githubusercontent.com/containerd/containerd/v2.3.1/containerd.service \
  | sudo tee /etc/systemd/system/containerd.service >/dev/null
sudo tee /etc/crictl.yaml >/dev/null <<'EOF'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
EOF

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

Kiểm chứng:

containerd --version
systemctl is-active containerd
sudo crictl version | grep RuntimeVersion
containerd github.com/containerd/containerd/v2 v2.3.1 64b425cf570b3b8dd1d4cc46da7c1fce65c6651a
active
RuntimeVersion:  v2.3.1

Hai worker giờ đều có runtime sẵn sàng nhận lệnh CRI.

🧹 Dọn dẹp

containerd là dịch vụ thường trú trên worker, để nguyên. Chỉ dọn các file tải về trong /tmp:

# trên mỗi worker
rm -f /tmp/containerd-*.tar.gz /tmp/runc.amd64 \
      /tmp/cni-plugins-*.tgz /tmp/crictl-*.tar.gz

Image pause đã kéo về thì giữ lại, kubelet sẽ cần nó cho mọi pod, và nó chỉ nặng 321kB. Nếu bạn tạm dừng cụm EC2 để đỡ tốn, containerd đã enable nên sẽ tự chạy lại sau khi khởi động.

Script đầy đủ cho cả hai worker nằm ở github.com/nghiadaulau/kubernetes-from-scratch, thư mục 10-container-runtime.

Tổng kết

Worker đã có lớp nền: containerd v2.3 nói CRI qua /run/containerd/containerd.sock, runc làm OCI runtime bên dưới, cgroup driver đặt systemd cho khớp kubelet, pause image ghim đúng phiên bản. Điều đáng mang theo không phải các lệnh tar, mà là hình dung được chuỗi ủy thác kubelet → containerd → shim → runc: khi sau này một pod kẹt, biết tầng nào chịu trách nhiệm gì sẽ rút ngắn việc dò lỗi rất nhiều.

crictl cho thấy runtime đã sẵn sàng, nhưng nó vẫn đứng yên chờ: chưa ai bảo nó chạy pod nào. Người ra lệnh đó là kubelet. Bài 11 cài kubelet lên hai worker: phân phối certificate cho từng node, viết KubeletConfiguration, trỏ nó vào socket containerd ta vừa dựng, và xem lần đầu hai worker hiện ra trong kubectl get nodes, tuy chưa Ready vì còn thiếu đúng mảnh CNI mà ta vừa cố ý gác lại.

Related Posts