目标:
git push一条命令 → 云端自动构建镜像 → 推送制品库 → SSH 部署到服务器 → 健康检查 → 失败自动回滚。 示例环境:CNB (cnb.cool) 作为代码托管 + CI + Docker 制品库;腾讯云轻量服务器(OpenCloudOS,宝塔面板)作为运行环境,运行时用 containerd + nerdctl。 全文使用占位符,套用时全局替换成你自己的值即可:
<org>/<repo>:CNB 组织/仓库名(如myteam/myapp)<服务器IP>:服务器公网 IP<项目名>:项目名(用于部署目录、systemd 服务名等)- 端口示例用
3000,按你的应用改。
开发者 git push (master)
│
▼
cnb.cool(代码托管 + CI + 制品库,三合一)
│ .cnb.yml 自动触发
├─ 阶段1 构建并推送镜像(CNB 云端,services: docker)
│ 基础镜像:docker.cnb.cool/<org>/<repo>/node:22 ← 预先镜像到自己的制品库
│ docker build → 推送双标签:
│ docker.cnb.cool/<org>/<repo>:<commit> (不可变,审计/回滚用)
│ docker.cnb.cool/<org>/<repo>:prod (可变生产指针)
│
└─ 阶段2 部署(cnbcool/ssh 插件 → 云服务器)
ssh root@服务器
nerdctl compose pull && nerdctl compose up -d
健康检查 localhost:3000(30s)
失败 → 强制重建 → 仍失败 → 回滚 :prev
云服务器(纯运行时,零构建):
containerd + nerdctl compose 拉 :prod 镜像运行
五条设计原则
pull + run,不碰源码、不装构建工具链。<commit> 标签永不覆盖,用于溯源回滚;prod 是可变指针,服务器永远只认它。CNB_TOKEN;服务器拉镜像用独立的只读部署令牌;SSH 用专用部署密钥。三者互不共享。为什么选 containerd + nerdctl 而不是 Docker?
nerdctl CLI 与 docker 几乎完全一致,nerdctl compose 平替 docker compose。| 项 | 说明 |
| --- | --- |
| CNB 账号 + 仓库 | cnb.cool 新建仓库,记住默认分支(master/main,决定 .cnb.yml 顶层键) |
| CNB 部署令牌 | 个人设置 → 访问令牌,生成只读/拉取权限令牌,服务器登录制品库用 |
| 云服务器 | 任意 Linux(本文 OpenCloudOS,RPM 系用 dnf),放行业务端口(安全组 + 系统防火墙都要) |
| SSH 部署密钥 | 服务器上生成专用密钥对(空密码,否则 CI 自动登录会卡住):ssh-keygen -t ed25519 -f ~/.ssh/cnb_deploy_key,公钥追加进 ~/.ssh/authorized_keys,私钥后面填进密钥仓库 |
| 部署目录 | mkdir -p /www/wwwroot/<项目名>,后续放 docker-compose.yml 和 .env |
若从旧 CI(如 Gitee Go + TCR)迁移,先清理旧容器/镜像/Agent/凭证:
docker rm -f旧容器 →docker rmi旧镜像 →docker logout旧仓库 → 卸载旧 Runner → 从authorized_keys删旧部署公钥(⚠️ 别误删日常登录密钥)→ 可选卸载 Docker 再装 containerd。
以下在服务器 root 执行。RPM 系(OpenCloudOS/TencentOS/CentOS)用 dnf;Debian 系换 apt。
dnf install -y containerd
mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl enable --now containerd
⚠️ 实战教训:境内服务器直连 GitHub 大概率
curl: (56)失败,ghproxy 也可能挂。最稳做法:本地电脑下载好,scp上传到服务器/tmp再解压。
# 本地下载这两个文件(浏览器/代理均可),版本号按 release 页最新:
# https://github.com/containerd/nerdctl/releases → nerdctl-<版本>-linux-amd64.tar.gz
# https://github.com/containernetworking/plugins/releases → cni-plugins-linux-amd64-v<版本>.tgz
# 本地 PowerShell 上传:
# scp nerdctl-2.0.4-linux-amd64.tar.gz cni-plugins-linux-amd64-v1.6.0.tgz root@<服务器IP>:/tmp/
# 服务器解压安装
tar -C /usr/local/bin -xzf /tmp/nerdctl-2.0.4-linux-amd64.tar.gz nerdctl
chmod +x /usr/local/bin/nerdctl
# CNI 插件——重点!nerdctl compose 的 bridge 网络/端口映射必需,缺了容器起不来或端口不通
mkdir -p /opt/cni/bin
tar -C /opt/cni/bin -xzf /tmp/cni-plugins-linux-amd64-v1.6.0.tgz
# 验证
nerdctl version
nerdctl info # 能看到 CNI 插件路径 /opt/cni/bin 即正常
buildctl not found警告可忽略——服务器只运行不构建。
# 用户名是字面量 cnb(不是你的账号名!),密码是部署令牌
nerdctl login docker.cnb.cool -u cnb -p <你的只读部署令牌>
firewall-cmd --permanent --add-port=3000/tcp && firewall-cmd --reload
同时在云控制台放行入站 TCP 3000(腾讯云轻量是「防火墙」标签页,CVM 是「安全组」)。
⚠️ 实战教训:宝塔面板 / 腾讯云控制台显示已放行 ≠ 规则真正生效。若外网仍不通且
iptables -L -n | grep 3000查不到规则,手动加规则还不行时——重启实例,规则才会真正下发(详见坑 #6)。
为什么必须做:CNB 构建环境拉 Docker Hub 直连不稳定,且 docker.cnb.cool/docker.io/library/... 这种"代理路径"不存在(会报 403,CNB 制品库不是 Docker Hub 代理)。大厂在 CNB 上的标准玩法是:把基础镜像预先推到自己的制品库,构建全程只跟 docker.cnb.cool 打交道,零外部依赖。
一次性操作(在已登录 CNB 的服务器上执行最方便):
# 1. 从境内可用源拉基础镜像(DaoCloud 实测可用)
nerdctl pull docker.m.daocloud.io/library/node:22
# 2. 打上自己制品库的标签
nerdctl tag docker.m.daocloud.io/library/node:22 docker.cnb.cool/<org>/<repo>/node:22
# 3. 推送(约 1.1GB,几分钟)
nerdctl push docker.cnb.cool/<org>/<repo>/node:22
# 4. 清理源标签
nerdctl rmi docker.m.daocloud.io/library/node:22
之后 Dockerfile 里 FROM docker.cnb.cool/<org>/<repo>/node:22 即可。基础镜像升级时重复这 4 步换个版本号。
镜像源实测记录(2026-07,CNB 构建环境):
| 源 | 结果 |
| --- | --- |
| docker.m.daocloud.io/library/node:22 | ✅ 可用 |
| docker.cnb.cool/docker.io/library/node:22 | ❌ 403(CNB 不是镜像代理) |
| 阿里云 / 腾讯 mirror 域名 | ❌ insufficient_scope |
| 南大 / 百度 / 网易 mirror | ❌ 403 / no such host |
| Docker Hub 直连 | ❌ 不稳定,不建议赌 |
Dockerfile(多阶段构建,Next.js standalone 示例)# syntax=docker/dockerfile:1.4
# 多阶段构建:build 在 Linux 容器内完成,无需本地 WSL / Linux 环境。
# 阶段 1:依赖安装(基础镜像已镜像到 CNB 制品库,构建零外部依赖)
FROM docker.cnb.cool/<org>/<repo>/node:22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# BuildKit 缓存挂载加速 npm 重复安装(层缓存之外的额外加速)
RUN --mount=type=cache,target=/root/.npm \
npm config set registry https://registry.npmmirror.com && npm ci
# 阶段 2:构建
FROM docker.cnb.cool/<org>/<repo>/node:22 AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
RUN mkdir -p /app/public # Git 不跟踪空目录,防 COPY 失败
COPY . .
RUN npm run build
# 阶段 3:运行(非 root 用户 + 精简产物)
FROM docker.cnb.cool/<org>/<repo>/node:22 AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN groupadd -r nodejs --gid=1001 && useradd -r nextjs --uid=1001 --gid=nodejs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
要点:
HOSTNAME=0.0.0.0 必须设,否则 Next.js 只监听 127.0.0.1,外网永远不通;volumes: node_modules 缓存只适用于「在 CI 容器里直接 npm ci」的流水线;我们是 docker build 场景,Docker 层缓存 + BuildKit --mount=type=cache 才是正确等价物。docker-compose.yml(放服务器,也进仓库留档)services:
web:
# 服务器只拉制品库镜像运行,不构建、不碰源码
image: docker.cnb.cool/<org>/<repo>:prod
ports:
- "3000:3000"
# 运行期凭证从同级 .env 注入(服务器手动创建,不进镜像、不进仓库)
env_file:
- .env
restart: unless-stopped # 注意:containerd+nerdctl 下不完全可靠,需 systemd 兜底(第九节)
.cnb.yml(CNB 流水线,放仓库根目录)# 顶层键 = 触发分支。仓库默认分支是 master 就写 master:,是 main 就写 main:,写错不触发!
master:
push:
- services:
- docker
# 从密钥仓库导入部署变量(私钥等敏感信息不进代码库),见第六节
imports:
- https://cnb.cool/<org>/secrets/-/blob/main/deploy.yml
stages:
- name: 构建并推送镜像
image: docker:24
script:
# CNB_TOKEN / CNB_TOKEN_USER_NAME(=cnb) / CNB_DOCKER_REGISTRY /
# CNB_REPO_SLUG_LOWERCASE / CNB_COMMIT 均由 CNB 自动注入,无需配置
- docker login -u "${CNB_TOKEN_USER_NAME}" -p "${CNB_TOKEN}" "${CNB_DOCKER_REGISTRY}"
- docker build
-t "${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:${CNB_COMMIT}"
-t "${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:prod"
.
- docker push "${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:${CNB_COMMIT}"
- docker push "${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:prod"
- name: 部署到服务器
image: cnbcool/ssh
settings:
host: ${DEPLOY_HOST}
port: 22
username: ${DEPLOY_SSH_USER}
key: ${DEPLOY_SSH_KEY}
command_timeout: 3m
# ⚠️ 最大的坑:settings.script 会先被 CNB 做变量插值(${DEPLOY_HOST} 就是这么生效的),
# 脚本里自定义的 shell 变量($C、${REG}、$i 等)会被当成流水线变量替换为【空】,
# 例如 `$C compose pull` 会变成 `compose pull` 报 command not found。
# 对策:脚本内全部写字面量,一个 shell 变量都不要用。
script: |
set -eu
# 服务器重启后 /run/user/0 可能不存在,nerdctl 会报错,先补
mkdir -p /run/user/0 && chmod 700 /run/user/0
export XDG_RUNTIME_DIR=/run/user/0
cd /www/wwwroot/<项目名>
test -f docker-compose.yml || { echo "ERROR: docker-compose.yml 不存在,请先 scp 上来"; exit 1; }
test -f .env || { echo "ERROR: .env 不存在,请先在服务器创建"; exit 1; }
nerdctl tag docker.cnb.cool/<org>/<repo>:prod docker.cnb.cool/<org>/<repo>:prev 2>/dev/null || true
nerdctl compose pull
nerdctl compose up -d
# 健康检查(用 timeout+until 规避 for $i 计数器被插值清空的问题)
if timeout 30 sh -c 'until curl -fsS http://localhost:3000 >/dev/null 2>&1; do sleep 1; done'; then
echo "deploy OK"
else
echo "health check failed -> force recreate"
nerdctl compose down || true
nerdctl compose up -d
if timeout 30 sh -c 'until curl -fsS http://localhost:3000 >/dev/null 2>&1; do sleep 1; done'; then
echo "recreate OK"
else
echo "still failing -> rollback to :prev"
nerdctl tag docker.cnb.cool/<org>/<repo>:prev docker.cnb.cool/<org>/<repo>:prod
nerdctl compose down || true
nerdctl compose up -d
exit 1
fi
fi
分支策略说明:只有顶层键写了的分支才触发。日常在 feature/develop 分支开发不会碰生产,合到 master 才发布——单人项目这就是最简也最安全的姿势。需要分支构建验证时另加:
develop:
push:
- stages:
- name: 构建验证
script: npm ci && npm run build
CNB 没有「仓库设置 → 变量/密钥」界面(个人设置里的「个人环境变量」只对云原生开发环境生效,对 CI 构建无效,别配错地方)。官方给敏感信息用的机制是密钥仓库(secret repo):
https://cnb.cool/new/repos,仓库类型选 「秘钥仓库」(不是普通仓库),命名如 <org>/secrets。deploy.yml(只能 Web 编辑、禁止 clone,内容不进代码/日志):# 可选:限制只有指定仓库能引用这份密钥
allow_slugs:
- <org>/<repo>
DEPLOY_HOST: "<服务器IP>"
DEPLOY_SSH_USER: "root"
DEPLOY_SSH_KEY: |
-----BEGIN OPENSSH PRIVATE KEY-----
(服务器 /root/.ssh/cnb_deploy_key 私钥全文,逐行粘贴)
-----END OPENSSH PRIVATE KEY-----
.cnb.yml 里 imports 引用它(见 5.3)。注意 imports URL 里的分支名(main/master)要和密钥仓库默认分支一致。# 本地上传 compose 文件
scp docker-compose.yml root@<服务器IP>:/www/wwwroot/<项目名>/
# 服务器创建运行期环境变量文件
vim /www/wwwroot/<项目名>/.env
# NODE_ENV=production
# 其余站点自定义变量...
git remote add cnb https://cnb.cool/<org>/<repo>.git
git add .
git commit -m "ci: cnb pipeline"
git push cnb master
deploy OK;:prod 和 :<commit> 双标签;nerdctl ps 有 web 容器 Up;http://<服务器IP>:3000。⚠️ CNB 页面的「重试」按钮跑的是旧 commit 的配置。改了
.cnb.yml必须 push 新 commit 才生效。
:prev 指回 :prod 并重启,流水线标红。# 服务器上执行,<旧commit> 从 CNB 制品库标签列表里选
nerdctl pull docker.cnb.cool/<org>/<repo>:<旧commit>
nerdctl tag docker.cnb.cool/<org>/<repo>:<旧commit> docker.cnb.cool/<org>/<repo>:prod
cd /www/wwwroot/<项目名> && nerdctl compose up -d
或直接 push 旧 commit 触发流水线重打 prod。
restart: unless-stopped 是 Docker Engine 语义,containerd + nerdctl 下服务器重启后容器不会自动拉起,且宝塔重置防火墙会把 nerdctl 端口转发干掉("假 Up")。用 systemd 兜底:
cat > /etc/systemd/system/<项目名>.service <<'EOF'
[Unit]
Description=<项目名> via nerdctl compose
After=network-online.target containerd.service
Wants=network-online.target containerd.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/www/wwwroot/<项目名>
Environment=XDG_RUNTIME_DIR=/run/user/0
ExecStartPre=/usr/bin/mkdir -p /run/user/0
ExecStart=/usr/local/bin/nerdctl compose up -d
ExecStop=/usr/local/bin/nerdctl compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable <项目名>
systemctl start <项目名>
之后:服务器重启自动拉起;宝塔动了防火墙导致不通时,一条 systemctl restart <项目名> 恢复。
按实际搭建时踩坑的顺序整理,每条都是真实付出过调试时间的。
docker.cnb.cool/docker.io/library/node:22 拉取 403403 Forbidden。docker.cnb.cool/<自己org>/cmpdocker/... 基础镜像)。insufficient_scope,南大 403,百度/网易 no such host。docker.m.daocloud.io)可用,但也只拿它做"一次性搬运工"(拉一次推到 CNB),日常构建不依赖任何公共源。cnbcool/ssh 的 settings.script 变量插值把 shell 变量清空(最隐蔽)$C compose pull 报 bash: line 16: compose: command not found。bash -c '...' 包裹——无效,因为变量在发到服务器之前就被清空了。settings.script 和其他 settings 一样先经 CNB 变量插值(${DEPLOY_HOST} 正是靠这个生效)。脚本里自定义的 $C、${REG}、$i 不是流水线变量 → 全部替换为空字符串。timeout 30 sh -c 'until ...; do sleep 1; done' 代替 for i in $(seq ...)。ssh-keygen 时 passphrase 直接回车(空密码)。CI 无人值守,没法输密码。0.0.0.0:3000->3000、curl localhost:3000 返回 200,但公网访问超时。nerdctl ps 端口映射 → iptables -L -n | grep 3000(注意 CNI 的 FORWARD DROP 规则)→ 云防火墙/安全组 → 都对不上就重启实例。iptables -I INPUT/-I FORWARD -p tcp --dport 3000 -j ACCEPT 也没救。nerdctl ps 显示 Up、端口映射正常,但宿主机 ss -tlnp | grep 3000 为空,curl 127.0.0.1:3000 connection refused。compose up -d 是空操作(容器"在跑"就不动它)。nerdctl compose down && nerdctl compose up -d 强制重建。部署脚本已内置该兜底(健康检查失败先重建再回滚)。stat /run/user/0: no such file or directorynerdctl compose up -d 直接报错。/run/user/0 是 systemd 登录会话创建的临时目录,重启后消失。mkdir -p /run/user/0 && chmod 700 /run/user/0 && export XDG_RUNTIME_DIR=/run/user/0。部署脚本和 systemd 单元都已内置。restart: unless-stopped 对 containerd + nerdctl 不可靠| 坑 | 对策 |
| --- | --- |
| CNB 制品库登录用户名 | 是字面量 cnb,不是账号名;密码是访问令牌 |
| .cnb.yml 顶层键写错分支名 | 不报错、纯粹不触发,push 后毫无动静先查这里 |
| CNB 页面「重试」 | 跑旧 commit 配置,改了 .cnb.yml 必须 push 新 commit |
| GitHub 二进制下载失败 | 本地下载 + scp 上传最稳,别死磕 ghproxy |
| nerdctl 缺 CNI 插件 | 端口映射静默失效,装 /opt/cni/bin 必不可少 |
| buildctl not found 警告 | 服务器只运行不构建,忽略 |
| 私钥粘贴 | 必须完整(含 BEGIN/END 行),YAML 里用 | 块标量逐行粘 |
| 健康检查依赖 curl | 系统无 curl 时改 wget -qO- 探测 |
新项目套用本方案,按顺序做一遍:
.cnb.yml 顶层键authorized_keyspull DaoCloud → tag → push 到 docker.cnb.cool/<org>/<repo>/<基础镜像>:<tag>deploy.yml 写 DEPLOY_HOST/DEPLOY_SSH_USER/DEPLOY_SSH_KEY,allow_slugs 加上新仓库mkdir 部署目录 + scp docker-compose.yml + 建 .envgit push cnb <默认分支> → 验证流水线全绿 + 公网可访问