containerd与nerdctl实战:安装配置、Docker迁移与运维排障
1. 先把 containerd 和 nerdctl 的关系捋清楚
1.1 从 docker 命令为什么还能用说起
很多人第一次听说 containerd,是在某次排查容器运行时的时候,敲了个docker info,发现输出里赫然写着Server Version: containerd,然后就开始疑惑:我不是在跑 docker 吗,怎么变成 containerd 了?
这个答案其实不复杂。docker 从很早的版本开始就把底层的容器生命周期管理拆出去了,自己保留的是面向用户的命令行、镜像构建体验和一堆周边功能,真正负责「拉起进程、挂载文件系统、配置 namespace 和 cgroup」这一层的,是 containerd。再往下还有 runc 这种符合 OCI 规范的运行时,负责最终调用 clone() 和 execve()。
所以整条链路大致是:用户敲命令 → 前端工具 → containerd → runc → 内核。docker 是一个「全家桶」,containerd 是「发动机」,nerdctl 则是那套必须自己装的「方向盘和仪表盘」。
我见过不少人迁移到 containerd 之后,第一反应是打开ctr这个官方自带的命令行,然后就被劝退了。ctr的设计目标是给 containerd 的开发者和深度运维用的,它的抽象层级和 docker 完全不一样:ctr run要指定 snapshotter,要自己写 OCI spec,ctr image pull拉下来的镜像默认还不在你期望的 namespace 里,端口映射?不存在的,那得靠 CNI 自己配。用一句话形容,它更像是「调试工具」而不是「日常工具」。
nerdctl 就是在这个缝隙里长出来的。它的目标极其明确:把 docker 那套用惯了的命令,原封不动地搬到 containerd 上。nerdctl run -d -p 8080:80 nginx,跟docker run -d -p 8080:80 nginx长得一模一样,行为也对得上。它还顺带补了nerdctl compose、nerdctl build、nerdctl logs、nerdctl exec这些日常高频操作。
1.2 nerdctl 到底补了哪些坑
我把 nerdctl 相对原生 containerd 工具链的价值,总结成这么几块。
第一块是命令兼容性。你可能有一堆脚本、文档、CI 配置里全写着docker xxx,迁移的时候最省事的做法不是重写,而是做个别名或者软链,让docker指向nerdctl。当然这不代表所有行为 100% 等价,后面我会专门列一张差异表,但日常 80% 的操作是无感的。
第二块是端口映射和网络。ctr想做个端口发布,得手动写 CNI 配置、手动调 iptables,极其痛苦。nerdctl 内置了 bridge 网络模型,-p直接可用,还支持--network指定自定义网络,跟 docker 的体验基本一致。
第三块是镜像构建。containerd 本身不管构建,构建要靠 BuildKit。nerdctl 把 BuildKit 接进来,nerdctl build可以直接跑 Dockerfile,支持多阶段构建、--build-arg、--target、--platform,甚至能做--cache-from的远程缓存。这块体验比ctr强出一个数量级。
第四块是 compose。单机多容器编排这件事,docker compose 是事实标准。nerdctl compose直接读docker-compose.yml,不用改一行配置,这是迁移成本最低的一环。
还有一块容易被忽略的:rootless 和 namespace 隔离。nerdctl 对 rootless 模式的支持做得比 docker 原生更细,它把 containerd 的 namespace 概念暴露出来了,这对多租户或者需要跟 Kubernetes 共存的场景很有用。
1.3 什么场景该上,什么场景别硬上
我不建议所有人都去折腾这套东西。先说该上的情况。
一是你已经在跑 Kubernetes,节点上本来就有 containerd,那你顺手装个 nerdctl 直接用现成的 daemon,不用再额外跑一个 dockerd 占内存。这在资源紧张的边缘节点上很实际,省下来的几百兆内存不是小数目。
二是你对安全边界敏感,不想要 docker 那个以 root 跑的守护进程,想走 rootless。nerdctl + containerd 的 rootless 方案更干净。
三是你在做 CI 构建节点,构建任务重,BuildKit 的并行调度和缓存命中比老版 docker build 强很多,实测在大镜像上差距很明显。
反过来,下面这几种情况我劝你别折腾:团队所有人只会 docker 且没有运维能力去处理 containerd 的故障;依赖了 docker 特有的功能比如docker scan、docker 的 secret 管理、或者某些强绑定 docker socket 的第三方工具;再就是跑在桌面环境上只是想本地起个数据库玩玩的,那 docker desktop 或者 podman 都更省事。
提示:如果你机器上同时装了 docker 和 containerd,注意它们可能共用同一个 containerd 实例,也可能各自起各自的。生产环境上做迁移前先
systemctl status containerd看清楚到底在跑哪一份,别把正在服务的 daemon 覆盖掉。
2. 安装前的环境盘点和版本对齐
2.1 先确认内核和 cgroup 状态
这一步被无数人跳过,然后在后面各种莫名其妙的地方栽跟头。你先跑这两条:
uname -r mount | grep cgroup内核版本建议 5.4 以上,4.19 勉强能用但 overlayfs 的一些特性会打折。cgroup 这块重点看是 v1 还是 v2。现在主流发行版大多默认 cgroup v2,containerd 1.7 对 v2 支持已经比较成熟,但你需要确认SystemdCgroup这个参数配对,后面配置里我会专门讲。
再确认几个内核模块:
lsmod | grep -E "overlay|br_netfilter"overlay是 overlayfs 存储驱动要用的,br_netfilter是 bridge 网络下 iptables 转发要用的。缺了的话手动modprobe一下,并且写进/etc/modules-load.d/里保证开机加载。
还有两个 sysctl 参数,很多人忘了配,表现为容器内网络不通或者跨主机访问异常:
cat >> /etc/sysctl.d/99-k8s.conf <<'EOF' net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sysctl --system2.2 版本怎么搭配才不会互相打架
这是我踩过最深的坑之一。containerd、nerdctl、BuildKit、CNI 插件这四者的版本是有隐式耦合的,不是随便下最新版就能跑。
我的建议组合是 containerd 1.7.x 配 nerdctl 1.7.x。原因很实际:containerd 2.0 之后 CRI 的配置结构做了较大调整,很多网上流传的config.toml片段在 2.x 上直接报错;而 nerdctl 1.7 是当前文档最全、社区问题最多人回答的版本线。等你跑通了再考虑升级。
BuildKit 这块,如果你用nerdctl-full包,里面已经捆绑了匹配的 buildkitd,不用单独管版本。如果你是分开装的,那 buildkitd 建议 0.12 以上,因为低于这个版本对--platform多架构构建的支持有缺陷。
CNI 插件版本相对宽松,0.4.x 的配置格式用的人最多。但要注意nerdctl-full自带的 CNI 二进制放在/usr/local/libexec/cni,而传统做法是放/opt/cni/bin,路径不一样会导致 nerdctl 找不到插件,报failed to find plugin "bridge"。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| containerd | 1.7.x | CRI 配置结构稳定,资料最多 |
| nerdctl | 1.7.x | 与 containerd 1.7 配套,命令覆盖全 |
| runc | 1.1.x | 与 containerd 1.7 兼容性好 |
| BuildKit | 0.12+ | 低于此版本多架构构建有坑 |
| CNI plugins | 1.3+ | 注意二进制路径与 nerdctl 的查找顺序 |
2.3 两种安装方式怎么选
一种是「全家桶」:下nerdctl-full-<version>-linux-amd64.tar.gz,解压到/usr/local,里面一次性给你 containerd、runc、CNI 插件、buildkitd、nerdctl 全套。优点是省心,路径都帮你排好了,解压完就能跑。缺点是它会覆盖你系统里已有的 containerd,如果你机器上已经有一个被 Kubernetes 管着的 containerd,这一下就危险了。
另一种是「分件安装」:containerd 走自己的包,runc 单独装,nerdctl 单独下一个二进制放/usr/local/bin,BuildKit 单独起服务。麻烦一些,但每个组件你能控制版本,出问题也好定位。
我的实际选择是:全新机器用全家桶快速验证,生产机器一律分件安装。理由很直白,全家桶把版本绑死了,某天你只想升 nerdctl 一个组件,就得整体重来。
3. 从零装一遍:装完能跑起一个 nginx
3.1 containerd 的安装与关键配置项
先下二进制包。这里的版本号你自己替换成最新的稳定版:
VERSION="1.7.22" wget https://github.com/containerd/containerd/releases/download/v${VERSION}/containerd-${VERSION}-linux-amd64.tar.gz tar Cxzvf /usr/local containerd-${VERSION}-linux-amd64.tar.gz解压完你会看到/usr/local/bin/containerd、ctr、containerd-shim-runc-v2这些。接着装 runc:
wget https://github.com/opencontainers/runc/releases/download/v1.1.14/runc.amd64 install -m 755 runc.amd64 /usr/local/sbin/runc然后写 systemd unit。这个文件内容我建议直接抄,别自己改结构:
[Unit] Description=containerd container runtime Documentation=https://containerd.io After=network.target local-fs.target [Service] ExecStartPre=-/sbin/modprobe overlay ExecStart=/usr/local/bin/containerd Type=notify Delegate=yes KillMode=process Restart=always RestartSec=5 LimitNPROC=infinity LimitCORE=infinity LimitNOFILE=infinity TasksMax=infinity OOMScoreAdjust=-999 [Install] WantedBy=multi-user.targetDelegate=yes这个别删,它让 containerd 能自己管 cgroup 子树,删了之后容器里的资源限制可能不生效。OOMScoreAdjust=-999是让 containerd 在内存紧张时尽量不被杀,也是很实用的一个保护。
生成默认配置:
mkdir -p /etc/containerd containerd config default > /etc/containerd/config.toml生成完之后必须改两处,否则后面一定出问题。
第一处,cgroup driver。如果你的系统是 cgroup v2 + systemd,必须打开:
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml不改的后果是容器里nproc显示的 CPU 数不对,Java 应用会按宿主机核数去开线程池,然后内存直接爆掉。这个坑我在生产上遇到过,排查了半天才发现是 cgroup 驱动不匹配。
第二处,sandbox 镜像地址。默认配置里写的是registry.k8s.io/pause:3.9,这个地址国内拉取可能很慢甚至超时。如果你的环境拉不动,就换成自己仓库里的 pause 镜像副本:
sed -i 's|sandbox_image = ".*"|sandbox_image = "your-registry/pause:3.9"|' /etc/containerd/config.toml顺带把镜像加速也配上。1.7 版本支持两种写法,老写法是直接在 config.toml 里写 mirrors:
[plugins."io.containerd.grpc.v1.cri".registry.mirrors] [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"] endpoint = ["https://your-mirror.example.com"]新写法是hosts.toml,放在/etc/containerd/certs.d/docker.io/hosts.toml,更灵活,能按 host 配超时和认证:
server = "https://your-mirror.example.com" [host."https://your-mirror.example.com"] capabilities = ["pull", "resolve"] skip_verify = false两种写法不要同时配,会互相覆盖,行为很难预测。选定一种就用到底。
配完重载启动:
systemctl daemon-reload systemctl enable --now containerd systemctl status containerd看到active (running)就说明起来了。这时候你可以用ctr version验证一下,但不要急着用 ctr,我们下一步装 nerdctl。
注意:
containerd config default生成的配置里有些注释掉的可选块,改动的时候要小心 TOML 的层级关系。改完建议跑一次containerd config dump看看解析结果,如果有语法错误它会直接报行号,比等服务启动失败再去翻 journalctl 快得多。
3.2 nerdctl 与 BuildKit 的落地
只装 nerdctl 二进制是最轻的做法:
VERSION="1.7.6" wget https://github.com/containerd/nerdctl/releases/download/v${VERSION}/nerdctl-${VERSION}-linux-amd64.tar.gz tar Cxzvf /usr/local/bin nerdctl nerdctl --version但你会发现nerdctl build用不了,因为它连不上 buildkitd。所以要单独把 BuildKit 起来。
wget https://github.com/moby/buildkit/releases/download/v0.14.1/buildkit-v0.14.1.linux-amd64.tar.gz tar Cxzvf /usr/local buildkit-v0.14.1.linux-amd64.tar.gz mkdir -p /run/buildkitsystemd unit:
[Unit] Description=BuildKit Documentation=https://github.com/moby/buildkit After=network.target [Service] ExecStart=/usr/local/bin/buildkitd --addr unix:///run/buildkit/buildkitd.sock Restart=always RestartSec=5 [Install] WantedBy=multi-user.targetsystemctl daemon-reload systemctl enable --now buildkitnerdctl 默认会去/run/buildkit/buildkitd.sock找 BuildKit,路径对上了就不需要额外配置。如果你改过 socket 路径,记得设置环境变量BUILDKIT_HOST=unix:///your/path.sock,或者用nerdctl build --buildkit-host显式指定。
如果你图省事走的是全家桶路线,那nerdctl-full解压完就已经把 buildkitd 的 unit 文件放在/usr/local/lib/systemd/system/下了,直接systemctl enable --now buildkit就行,省掉手写 unit 的步骤。
3.3 CNI 插件别漏了
这一步是新手最容易忘的。容器能跑起来,但-p端口映射不生效,或者容器间 ping 不通,八成是 CNI 没配。
如果你用nerdctl-full,CNI 插件已经被解压到/usr/local/libexec/cni,配置文件在/etc/cni/net.d/nerdctl-bridge.conflist,nerdctl 会自动识别,什么都不用做。
如果你是分开装的,得自己补:
mkdir -p /opt/cni/bin wget https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz然后写一份 bridge 配置:
{ "cniVersion": "0.4.0", "name": "nerdctl", "type": "bridge", "bridge": "nerdctl0", "isGateway": true, "ipMasq": true, "hairpinMode": true, "ipam": { "type": "host-local", "ranges": [[{ "subnet": "10.4.0.0/24", "gateway": "10.4.0.1" }]], "routes": [{ "dst": "0.0.0.0/0" }] } }放在/etc/cni/net.d/10-nerdctl.conflist。ipMasq: true是让容器出网做 SNAT,没有它容器能拿到 IP 但访问不了外网。hairpinMode打开是为了让容器访问自己的映射端口能通,否则会出现「宿主机能访问,容器内访问自己 127.0.0.1 映射端口不行」的怪现象。
3.4 跑个容器验收
到这儿该装都装完了,跑一个最小验证:
nerdctl run -d --name web -p 8080:80 nginx:alpine nerdctl ps curl -I http://127.0.0.1:8080nerdctl ps输出格式跟 docker 几乎一样,有 CONTAINER ID、IMAGE、COMMAND、STATUS、PORTS、NAMES,唯一多出来的是 NAMESPACES 这一列——这是 nerdctl 的特色,后面单独讲。
curl 返回 200 就说明网络通了。再验证进容器和日志:
nerdctl logs --tail 20 web nerdctl exec -it web sh最后验证构建:
mkdir -p /tmp/demo && cd /tmp/demo cat > Dockerfile <<'EOF' FROM alpine:3.19 RUN echo hello > /opt/hi.txt CMD ["cat", "/opt/hi.txt"] EOF nerdctl build -t demo:1 . nerdctl run --rm demo:1nerdctl build走的是 BuildKit,输出是那种带进度条的分段式日志,跟老版 docker build 的逐层输出不一样,第一次看可能有点懵,但功能是对的。
4. nerdctl 与 docker 的命令对照与行为差异
4.1 高频命令对照表
这表我建议打印出来贴在显示器边上,迁移期天天要查。
| 场景 | docker | nerdctl | 备注 |
|---|---|---|---|
| 拉镜像 | docker pull nginx | nerdctl pull nginx | 行为一致 |
| 起容器 | docker run -d -p 80:80 nginx | nerdctl run -d -p 80:80 nginx | 一致 |
| 看容器 | docker ps -a | nerdctl ps -a | 多了 NAMESPACES 列 |
| 进容器 | docker exec -it c sh | nerdctl exec -it c sh | 一致 |
| 看日志 | docker logs -f c | nerdctl logs -f c | 一致 |
| 构建 | docker build -t a . | nerdctl build -t a . | 需 buildkitd |
| 推镜像 | docker push x | nerdctl push x | 一致 |
| 导出镜像 | docker save x > a.tar | nerdctl save x -o a.tar | 参数写法不同 |
| 导入镜像 | docker load < a.tar | nerdctl load -i a.tar | 参数写法不同 |
| 单机编排 | docker compose up | nerdctl compose up | 直接读同一份 yml |
| 系统信息 | docker info | nerdctl info | 输出字段有差异 |
除了表里这些,还有几个 nerdctl 独有或者语义不同的命令值得知道。
nerdctl namespace ls列出所有 containerd namespace。nerdctl --namespace k8s.io ps -a能看到 Kubernetes 管的容器。这个在排查「节点上到底跑了哪些容器」的时候特别有用,docker 时代你想看 kubelet 起的东西得去翻crictl。
nerdctl ps --no-trunc显示完整 ID,等价于 docker 的同名参数。nerdctl images --names只显示名字。
nerdctl container commit、nerdctl image convert、nerdctl ipfs这些是 docker 没有的能力,一般用不上但有需求时挺方便。
4.2 namespace 是 docker 没有的一层抽象
这个概念必须讲清楚,否则你会遇到「明明nerdctl images能看到镜像,nerdctl run却说镜像不存在」的诡异情况。
containerd 内部的资源是按 namespace 隔开的。镜像、容器、快照,全部挂在某个 namespace 下。nerdctl 默认用的是default这个 namespace,而 Kubernetes 用的是k8s.io,docker 用 containerd 后端时用的是moby。
也就是说,同一台机器上的三个 namespace 里,镜像可以是完全不同的三套。你在default里 pull 的 nginx,在k8s.io里是看不见的。
所以进集群节点上操作的时候,第一件事是确认 namespace:
nerdctl namespace ls nerdctl --namespace k8s.io images nerdctl --namespace k8s.io ps -a如果觉得每次敲--namespace麻烦,设置个环境变量:
export CONTAINERD_NAMESPACE=k8s.io或者在~/.config/nerdctl/nerdctl.toml里写死:
namespace = "k8s.io"这个机制在我看来是 nerdctl 比 docker 更清晰的体现。docker 时代所有东西混在一个池子里,本地开发和生产运维的镜像互相污染;有了 namespace,不同用途的资源天然隔开,清理的时候也方便——nerdctl namespace remove xxx一把清干净。
注意:删除 namespace 之前一定要确认里面没有正在运行的容器。正确的顺序是先
nerdctl ps -a看,nerdctl rm -f清容器,再nerdctl namespace remove。反过来的话会留下孤儿 snapshot,占着磁盘空间不放,排查起来很痛苦。
4.3 构建这块的真实差异
nerdctl build和docker build用起来像,但底层完全不同:一个走 BuildKit daemon,一个走 docker daemon 内置的 builder(新版也切 BuildKit 了,但集成方式不一样)。
实际用下来的差异有这几点。
并行度不一样。BuildKit 会分析 Dockerfile 的依赖图,把没有依赖关系的阶段并行跑。多阶段构建时这个优势特别明显。我实测过一个三阶段的 Node 项目,docker build 用了 4 分多钟,nerdctl build 两分半左右,主要省在依赖下载和编译阶段的重叠上。
缓存行为不一样。nerdctl build默认的缓存是本地的,要做跨机器共享得显式导出:
nerdctl build --cache-to type=local,dest=/tmp/buildcache -t app:1 . nerdctl build --cache-from type=local,src=/tmp/buildcache -t app:1 .如果你有 registry,用 registry 类型更方便:
nerdctl build \ --cache-from type=registry,ref=your-registry/app:buildcache \ --cache-to type=registry,ref=your-registry/app:buildcache,mode=max \ -t your-registry/app:1 .mode=max这个参数很关键,它会把中间层的缓存也推上去,不只是最终层。不带的话缓存命中率会低一大截。
多架构构建的写法不一样。docker 那边要docker buildx,nerdctl 直接:
nerdctl build --platform linux/amd64,linux/arm64 -t app:multi .前提是你的 buildkitd 配了对应的 QEMU binfmt 支持,否则只会构建出本机架构那一份。装 binfmt 最简单的办法是跑一个tonistiigi/binfmt镜像注册 handler,具体不展开了。
不允许的写法。有些 Dockerfile 里的老技巧在 BuildKit 下会报错,比如在RUN里用apt-get install不带-y、或者依赖交互式输入。迁移时遇到构建失败,先看是不是这类问题。
5. 日常运维实操:镜像、网络、存储、compose
5.1 镜像搬运与私有仓库对接
离线环境的镜像搬运是刚需,这块 nerdctl 的参数跟 docker 不太一样,容易记混。
docker 是管道式:
docker save myimage:1.0 | gzip > myimage.tar.gz docker load < myimage.tar.gznerdctl 用-o和-i:
nerdctl save -o myimage.tar.gz myimage:1.0 nerdctl load -i myimage.tar.gz多个镜像一起存:
nerdctl save -o bundle.tar img1:1 img2:1 img3:1私有仓库认证这块,nerdctl 复用 containerd 的凭据配置。最直接的做法是nerdctl login:
nerdctl login your-registry.example.com -u user -p password凭据会写到~/.docker/config.json或者$XDG_RUNTIME_DIR/containerd/下,具体位置取决于你的 nerdctl 版本和是否 rootless。如果登录后还是 401,检查一下是不是 containerd 的 CRI 配置里配了 hosts.toml 但没带认证:
[host."https://your-registry.example.com"] capabilities = ["pull", "resolve", "push"] [host."https://your-registry.example.com".header] authorization = "Basic base64编码"用 header 塞 Basic 认证是个兜底手段,能解决很多「明明登录了但拉不到」的玄学问题。
还有一个很实用的技巧:nerdctl image convert可以在本地做镜像格式转换和平台转换,比如把单架构镜像补上 arm64 的 manifest,或者把 OCI 格式转成 docker 格式(有些老 registry 只认 docker mediaType):
nerdctl image convert --oci --all-platforms myimage:1 myimage:oci5.2 网络这块要单独花时间
nerdctl run -p用起来简单,但你要理解它背后是 CNI + iptables 在干活,出问题时才知道往哪儿看。
先看默认 bridge 网络:
nerdctl network ls nerdctl network inspect nerdctl自定义网络:
nerdctl network create --subnet 10.5.0.0/24 mynet nerdctl run -d --network mynet --name db redis:7 nerdctl run -it --network mynet --rm alpine ping db同一自定义网络内的容器可以用容器名互相解析,这是靠内嵌的 DNS 实现的,跟 docker 一样。但注意默认的nerdctlbridge 网络不支持名字解析,只有用户创建的网络才支持。这点跟 docker 的默认 bridge 行为一致,但很多人不知道。
端口映射排障的顺序我一般是这样:
# 1. 确认容器里服务本身是活的 nerdctl exec web wget -qO- http://127.0.0.1 # 2. 看宿主机上的 iptables 规则 iptables -t nat -L CNI-DN-xxx -n # 3. 确认 sysctl 转发开着 sysctl net.ipv4.ip_forward # 4. 确认 br_netfilter 加载了 lsmod | grep br_netfilter第 2 步里的链名是 nerdctl 动态生成的,前缀一般是CNI-,具体名字能通过iptables -t nat -L -n | grep CNI找到。
还有一种情况:宿主机上跑过 Kubernetes,kube-proxy 的 iptables 规则和 nerdctl 的规则可能打架,表现为端口映射偶尔生效偶尔不生效。这种要么把两者网络段规划开,要么在节点上不做本地容器业务,别混用。
5.3 compose:迁移成本最低的一环
如果你有现成的docker-compose.yml,基本可以直接用:
cd /path/to/project nerdctl compose up -d nerdctl compose ps nerdctl compose logs -f nerdctl compose downnerdctl compose解析的是标准的 Compose 文件,services、networks、volumes、depends_on、healthcheck这些都支持。有几个细节要注意。
第一,build:字段会走 BuildKit,所以 compose 里的构建也依赖 buildkitd 在线。
第二,env_file的相对路径是按 compose 文件所在目录算的,跟 docker compose 一致。
第三,deploy段里的资源限制(limits、reservations)在nerdctl compose下的支持度不如docker compose完整,重要的话改成mem_limit、cpus这种旧字段更稳。
一个实际项目里的写法:
services: api: build: context: ./api dockerfile: Dockerfile ports: - "8000:8000" environment: - DB_HOST=db depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: - POSTGRES_PASSWORD=devonly healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 10 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:跑起来nerdctl compose up -d --build,跟 docker compose 的体验完全一样。
5.4 资源限制和开机自启
nerdctl run的资源参数跟 docker 基本对齐:
nerdctl run -d \ --memory 512m \ --memory-swap 512m \ --cpus 1.5 \ --pids-limit 200 \ --restart=always \ nginx:alpine--memory-swap跟--memory设成一样大是个小技巧,表示禁用 swap,避免容器被换出导致延迟抖动。
--restart=always在 nerdctl 下是支持的,但要注意它依赖 nerdctl 自己维护状态。重启宿主机后,containerd 会把容器恢复起来,但重启策略的执行者得是 nerdctl 或者你手动触发的。生产上更靠谱的做法是让容器由 systemd 管起来,或者干脆用 compose 包一层。
如果非要让单个容器开机自启,可以用nerdctl generate systemd:
nerdctl generate systemd --name web --restart always web它会吐出一段 unit 文件内容,你存到/etc/systemd/system/下 enable 就行。这个功能是从 podman 借鉴来的,很好用。
提示:
--restart和 systemd 管理二选一,别同时上。我见过两个机制同时生效导致容器被反复拉起又杀掉,日志里全是 exec 冲突的报错。
6. 踩坑实录与排查手册
6.1 高频问题速查表
下面这些是我和同事在迁移过程中真实撞到的问题,按出现频率排序。
| 现象 | 根因 | 处理方式 |
|---|---|---|
nerdctl run报镜像不存在,但nerdctl images能看到 | namespace 不一致 | 加--namespace或用CONTAINERD_NAMESPACE统一 |
| 端口映射不生效 | CNI 未安装或 conflist 缺失 | 检查/etc/cni/net.d/,确认 ipMasq 与 bridge 配置 |
| 容器内访问外网失败 | ip_forward或 br_netfilter 缺失 | 补 sysctl 与模块加载 |
nerdctl build报连不上 buildkitd | socket 路径不对或服务没起 | systemctl status buildkit,确认/run/buildkit/buildkitd.sock |
容器内nproc显示宿主机核数 | SystemdCgroup 未开启 | 改 config.toml 后重启 containerd |
| 拉镜像超时 | sandbox_image 或 mirror 未配 | 换 pause 镜像,配 hosts.toml |
| 容器重启后数据丢了 | 匿名卷未持久化 | 显式-v named:/path或用 compose volumes |
nerdctl compose up报端口占用 | 残留容器未清 | nerdctl ps -a找出来清掉,注意 namespace |
| 磁盘被吃满 | 孤儿 snapshot 或旧镜像 | nerdctl system prune -a,必要时清 snapshot |
| rootless 下端口 < 1024 失败 | 非特权用户无法绑定 | 用高段端口 + 反代,或配net.ipv4.ip_unprivileged_port_start |
6.2 三个印象最深的排查过程
第一个,镜像明明拉下来了却找不到。
当时是在一个已经跑了 Kubernetes 的节点上装 nerdctl,装完nerdctl pull nginx提示成功,nerdctl images也能看到。但nerdctl run nginx却报no such image。反复 pull、rm 好几次都一样。
后来nerdctl info一看,才发现pull和run用的不是同一个 namespace。原因是CONTAINERD_NAMESPACE这个环境变量在某个 shell 配置文件里被设置成了别的值,而我新开的窗口和原来的窗口环境不一样。改用nerdctl --namespace default pull和--namespace default run就统一了。
这件事的教训是:在多 namespace 环境里,所有操作都显式带 namespace,别指望默认值。我现在写脚本一律带参数,宁可多敲几个字符。
第二个,构建缓存越用越慢。
现象是同一个项目的nerdctl build,第一次 3 分钟,第二次反而 5 分钟,第三次 6 分钟,越跑越慢。看日志发现它每次都在重新拉 base image,缓存完全没命中。
排查下来是 Dockerfile 里FROM node:20没锁 digest,而 BuildKit 默认会去 registry 校验 tag 的 manifest。如果网络慢,这个校验就会拖时间。加上--pull=false之后速度恢复正常:
nerdctl build --pull=false -t app:1 .更根本的做法是把 base image 锁到 digest:
FROM node:20@sha256:xxxxxxxxxxxx这样 BuildKit 不用去远端校验,直接用本地缓存。
第三个,容器里的 Java 应用 OOM 但堆内存没超。
一个 Spring 服务,限制了--memory 1g,JVM 参数没显式设堆大小,结果容器被 OOM kill,但监控上看堆内存只用了 400 多兆。
这个问题有两层。第一层是SystemdCgroup没开,JVM 读到的 cgroup 限制是宿主机总内存,于是按宿主机内存去算默认堆大小(通常是 1/4),设了个巨大的堆。第二层是 JVM 需要开UseContainerSupport(Java 10 之后默认开)才能正确读 cgroup。
改完 cgroup driver 重启 containerd,问题消失。这件事之后我养成了习惯:不管什么语言的服务,容器化部署第一件事就是验证nproc、free -m、cat /sys/fs/cgroup/memory.max这三个值在容器里读到的是不是限制值。
6.3 几条踩出来的实操心得
关于清理。nerdctl system prune -a会清掉所有未使用的镜像、容器、网络。听起来很爽,但要注意它默认只清当前 namespace。生产节点上跑着 k8s 的时候,你不小心在k8s.io里执行一次,可能把节点上所有业务镜像都干掉了。我现在的做法是清理前先nerdctl images | wc -l数一下,确认数字在预期范围内再动手。
关于日志。nerdctl logs默认读的是 containerd 存的日志文件,路径在/var/log/pods/或者/var/lib/nerdctl/下。如果容器用了 json-file 之外的 log driver,nerdctl logs可能读不到。这时候直接去/var/lib/containerd/io.containerd.runtime.v2.task/<ns>/<id>/下面找。
关于镜像大小。nerdctl images显示的大小是解压后的大小,不是压缩传输的大小。如果你在评估传输耗时,用nerdctl image inspect看Size字段,或者直接看 registry 上的 manifest。
关于 rootless。如果打算走 rootless,containerd和buildkitd都要以非 root 用户身份启动,配置路径全部变成~/.config/containerd/和~/.config/buildkit/。rootless 下不能用-p 80:80(除非调ip_unprivileged_port_start),建议直接用 8080 这种高位端口加外层反向代理。另外 rootless 对 cgroup v2 有硬性要求,cgroup v1 下很多限制参数不生效,会静默忽略。
关于版本升级。升级 containerd 大版本之前,务必把/etc/containerd/config.toml备份出来,然后用新版重新config default生成一份,再把你的自定义项人工比对合并进去。直接拿旧配置启动新版本,大概率会因为字段废弃而启动失败。这个流程我在两个环境上都验证过,虽然麻烦但最稳。
最后一个不太技术但很重要的:迁移期保留 docker 一段时间。nerdctl 和 docker 可以共存,docker 的 socket 和 containerd 的 socket 是两个东西,互不干扰(前提是你没让 docker 也用同一个 containerd 实例)。团队里有人遇到 nerdctl 搞不定的场景,随时能切回 docker 应急,这个缓冲期能救很多急。等所有脚本都验证过一轮,再考虑彻底摘掉 docker。