写到这一篇,前面的零件都齐了:Dockerfile 会写了(见 Dockerfile 入门篇),多架构构建会玩了,私有仓库也搭得起来,自动更新也有 Watchtower 盯着。还差最后一件事:把这些零件串成一条自动流水线。
手动部署过项目的人都懂那种重复劳动:改一行代码,然后 SSH 上服务器,git pull、构建、停容器、起容器、看日志,十分钟没了,一天来上三五回,人就成了部署工具人。CI/CD 要解决的就是这件事:代码 push 到 GitHub,剩下的构建、推送、上线全部自动完成,你只负责看结果。
这一篇的主线如下:
- 先看全景:五个环节各自在干什么;
- 选镜像仓库:GHCR、Docker Hub、自建 Registry 三者怎么选;
- 写一份完整的 GitHub Actions 工作流,逐段拆解;
- 定标签策略与 Secrets 管理这两条纪律;
- 服务器侧用 Watchtower 衔接自动上线,并与 SSH 部署路线做对比;
- 上线后验证、失败处理与回滚预案;
- 最后是排障小节。
读完这一篇,你的部署动作将只剩一个:git push。
一、全景图:五个环节各自干什么
先把整条链路在脑子里过一遍,后面每一节都是在填其中一个环节的细节。
| 环节 | 发生在哪 | 干什么 | 用到的工具 |
|---|---|---|---|
| 1. 提交 | 本地电脑 | 改代码、git push 到 GitHub | Git |
| 2. 构建 | GitHub 的云端机器 | 拉代码、按 Dockerfile 构建镜像 | GitHub Actions + Buildx |
| 3. 推送 | GitHub 的云端机器 | 把镜像推到镜像仓库存好 | GHCR / Docker Hub / 自建 Registry |
| 4. 上线 | 你的服务器 | 发现新镜像、拉下来、换掉旧容器 | Watchtower |
| 5. 验证 | 服务器与你 | 健康检查通过、服务可访问;失败则回滚 | curl 健康检查 / 回滚命令 |
有几个关键点先说清楚:
- 构建不在你的服务器上做。构建吃 CPU 和内存,小配置 VPS 构建一次能卡到服务无响应。GitHub Actions 的公共仓库免费额度足够个人项目用,构建在云端跑完,服务器只负责拉现成镜像,又快又稳。
- 服务器不存代码、不存密钥。服务器上只有一个 Compose 文件和一个 Watchtower,它甚至不需要知道 GitHub 上发生了什么,只盯着镜像仓库看标签有没有变。
- 整条链路是单向的。代码从本地流向 GitHub,再流向镜像仓库,最后流向服务器。任何一环出问题,都不会把脏东西带回上一环。
这套结构还有一个隐形好处:部署过程全部留痕。哪次上线对应哪个 commit、哪个镜像标签,在 GitHub 的 Actions 页面和镜像仓库里都查得到,出了问题甩不了锅,也赖不掉账。
二、镜像推哪:GHCR、Docker Hub 与自建 Registry
构建出来的镜像得有个地方存。三个主流选择,先给结论再给对比:
| 对比项 | GHCR(GitHub Container Registry) | Docker Hub | 自建 Registry |
|---|---|---|---|
| 地址 | ghcr.io | docker.io | 你自己的域名 |
| 费用 | 公共镜像免费;私有镜像按 GitHub 套餐额度 | 免费版有限制,私有仓库数量受限 | 只花服务器与流量成本 |
| 登录凭证 | GitHub Token(Actions 里自动有) | Docker Hub 账号密码 / Access Token | 自己配的账号密码 |
| 与 GitHub 的衔接 | 原生一体,权限跟着仓库走 | 需要手动配 Secrets | 需要手动配 Secrets |
| 拉取速度 | 海外节点,国内 VPS 视线路而定 | 同上,且有匿名拉取限流历史 | 完全自己控制,同机房最快 |
| 运维成本 | 零 | 零 | 自己维护、自己备份、自己保可用性 |
怎么选,一句话原则:项目代码在 GitHub 上,优先用 GHCR;镜像要公开给所有人拉,Docker Hub 更有知名度;镜像不想出内网、或者拉取速度是硬要求,再上自建 Registry。
自建 Registry 的搭建方法在私有镜像仓库篇里完整写过(见 私有镜像仓库:Registry 与 Harbor 部署实战),包括认证、HTTPS 与垃圾回收,这一节不重复。本篇的实战以 GHCR 为主线,因为它和 GitHub Actions 是同一套账号体系,Secrets 都可以省掉大半,第一次搭流水线最不容易卡壳。
三、完整 Workflow 实战:逐段拆解
在项目仓库根目录建 .github/workflows/docker.yml,下面是全文。先整体看一遍,后面逐段拆。
name: Build and Push Image
on: push: branches: - main paths: - "src/**" - "Dockerfile" - ".github/workflows/docker.yml"
permissions: contents: read packages: write
jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4
- name: Set up Buildx uses: docker/setup-buildx-action@v3
- name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}} type=sha,format=short type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max3.1 触发条件:on.push 的分支与 paths
on: push: branches: - main只有推到 main 分支才触发。特性分支、实验分支随便推,不会污染正式镜像。
再进一步的是 paths 过滤:
paths: - "src/**" - "Dockerfile" - ".github/workflows/docker.yml"只有这些路径的文件变了才构建。改个 README、改个文档,不再白白跑一次构建。这是个小习惯,但流水线跑多了,省下的时间和额度都是真金白银。注意 paths 要按你项目的实际结构改,源码目录不叫 src 的就换成自己的。
还有一个常见搭配是给版本打 tag 时触发:
on: push: tags: - "v*"推 v1.2.0 这样的 tag 就自动构建发版。两种触发可以写在同一个 workflow 里,也可以拆成两个文件,新手建议先用分支触发跑通,再加 tag 触发。
3.2 actions/checkout:把代码拉下来
- name: Checkout uses: actions/checkout@v4Actions 的运行环境是一台全新的空机器,什么都没有。第一步永远是把仓库代码 checkout 下来。@v4 是这个 Action 的大版本号,跟着官方大版本走即可,不要写死小版本号,也不要用 @main 这种飘忽不定的引用。
3.3 docker/setup-buildx-action:准备构建器
- name: Set up Buildx uses: docker/setup-buildx-action@v3Buildx 是 Docker 的增强构建工具,支持构建缓存导出、多平台构建等现代特性。GitHub 的 runner 自带 Docker,但显式装一遍 Buildx 能保证版本与行为一致,后面要加多架构构建时也是靠它。这一篇里它的主要作用是把构建缓存接入 GitHub Actions 的缓存系统,第二次构建能快一大截。
3.4 docker/login-action:登录镜像仓库
- name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}推镜像之前必须先登录。GHCR 的最大便利在这里:用户名就是触发流水线的 GitHub 账号,密码直接用 Actions 环境自动提供的 GITHUB_TOKEN,不需要你额外申请、额外保管。配合文件顶部的权限声明:
permissions: contents: read packages: write这是告诉 GitHub:这个 workflow 里的 GITHUB_TOKEN 只给两个权限——读代码、写镜像包,其他一律不给。权限最小化不是形式主义,万一某个第三方 Action 被投毒,Token 能干的坏事也被锁死在这个范围里。
如果换成 Docker Hub,这一段要改成用你自己配的 Secrets:
- name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }}注意密码一栏填的是 Docker Hub 的 Access Token,不是账号登录密码,这两个东西不要混。
3.5 docker/metadata-action:自动生成标签
这一步是整份 workflow 里最容易被跳过、又最不该跳过的一步。它根据本次提交的上下文,自动算出该给镜像打哪些标签,下一节标签策略会细讲。images 写镜像全名,tags 里三行分别对应版本号、commit 短哈希、latest 三种标签。
顺带说一句,github.repository 的值形如 你的用户名/仓库名,GHCR 要求镜像名全小写,仓库名里有大写字母时这里要留意,metadata-action 一般会处理,但推送报错时先查这一项。
3.6 docker/build-push-action:构建并推送
- name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max逐个参数看:
| 参数 | 含义 |
|---|---|
context: . | 构建上下文是仓库根目录,Dockerfile 默认也在根目录找 |
push: true | 构建完直接推送;关掉它就只构建不推,适合先做 PR 检查 |
tags | 用上一步 metadata 算出来的标签列表,一次构建打多个标签 |
labels | 给镜像写入来源仓库、版本等元信息,方便日后溯源 |
cache-from: type=gha | 从 GitHub Actions 缓存里读上一轮的构建缓存 |
cache-to: type=gha,mode=max | 把本轮所有中间层缓存都存回去,mode=max 表示连最终镜像用不到的中间层也缓存,换构建速度 |
本地手动构建时你敲的是 docker build 加 docker push 两条命令,这里一个 Action 全包了。至此,push 一次代码,几分钟后 GHCR 里就会躺着一个带版本号、带 commit 哈希的新镜像。
四、标签策略:版本号、latest 与 commit sha
标签不是随手打的,它是你日后回滚和排障的唯一线索。本系列的立场很明确,一次构建同时打三种标签:
| 标签 | 例子 | 管什么 |
|---|---|---|
| 版本号标签 | v1.2.0 | 对外发布的正式版本,回滚时指哪打哪 |
| latest | latest | 永远指向主分支最新构建,给尝鲜和 Watchtower 盯梢用 |
| commit sha | a1b2c3d | 精确对应某一次提交,排障时定位到底是哪行代码进的镜像 |
为什么生产环境不能只靠 latest?因为 latest 是个会动的指针。今天它指向好版本,明天一次坏构建推上去,它就指向坏版本了。更要命的是出了问题你想退回去,却发现旧镜像没有独立标签,没有版本号标签的镜像,等于没有存档点的游戏,坏了只能从头来过。
版本号标签负责稳定,sha 标签负责精确,latest 负责方便,三者分工明确。服务器上线时,重要服务建议在 Compose 里写死版本号标签,比如 image: ghcr.io/你的用户名/项目名:v1.2.0;个人小项目图省事可以跟 latest,但前提是仓库里每个版本都有独立标签存着,随时能退。
还有一个细节:标签一旦发布就不要覆盖重推。同一个 v1.2.0 今天是这份代码、明天换成另一份代码,这个版本号就废了。真要修,版本号往上加,发 v1.2.1。
五、Secrets 管理:密钥只进保险箱
凡是 workflow 里要用到的密码、Token,一律放进仓库的 Secrets,路径是仓库页面 Settings → Secrets and variables → Actions → New repository secret。引用时写 ${{ secrets.名字 }},GitHub 在日志里还会自动把这些值打码。
纪律只有三条,但条条要命:
WARNING绝不把任何密码、Token、私钥直接写进 workflow 文件或提交进仓库。仓库历史是永久的,写进去删掉也还在 git 记录里,等于公开广播。真发生了,第一时间去吊销那个凭证换新的,光删文件没有任何用。
- 能用自动 Token 就不用手动凭证。推 GHCR 用
GITHUB_TOKEN就是最优解,它随任务生、随任务死,泄漏面最小。 - 手动凭证要限权、限期。Docker Hub 用 Access Token 而不是主密码,Token 只给读写镜像的权限;自建 Registry 的账号也应该是专用账号,不要拿管理员账号到处填。
- 定期轮换。Secrets 不是配一次管一辈子,隔几个月换一轮,尤其是多人协作的仓库,有人退出就立刻换。
再补一句容易被忽略的:fork 来的 PR 默认拿不到 Secrets,这是 GitHub 的保护机制,不是配置错了。给外部 PR 跑构建检查时,设计成只构建不推送(push: false),别为了让它跑通去拆这道防线。
六、服务器侧衔接:Watchtower 自动上线
镜像推上去了,最后一段路是服务器自己把新镜像换上。这正是自动更新篇的主角 Watchtower(见 如何在 Linux 自动更新 Docker 容器),这里只讲它在流水线里怎么对接,部署细节不重复。
核心就一件事:只让 Watchtower 更新本项目的容器,千万别让它把整台机器的容器全卷进来。做法是靠 label 精确圈定范围。被流水线管理的服务,在 Compose 里加上启用标签:
services: app: image: ghcr.io/你的用户名/项目名:latest labels: - "com.centurylinklabs.watchtower.enable=true"Watchtower 这一侧则开启 label 模式,只盯带标签的容器:
services: watchtower: image: nickfedor/watchtower:latest volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_LABEL_ENABLE=true - WATCHTOWER_POLL_INTERVAL=300 - WATCHTOWER_CLEANUP=true这样对接完成后,链路是全自动的:你 push 代码,Actions 构建推送,Watchtower 在轮询周期内(上面配的是 300 秒)发现 latest 变了,拉新镜像、用原参数重建容器、清掉旧镜像。整个过程你只需要打开网站看一眼新版本在不在。
私有镜像 Watchtower 也拉得到,前提是服务器上先 docker login ghcr.io 登录一次,凭证会存在服务器的 Docker 配置里,Watchtower 挂载 docker.sock 时会沿用。登录用的 Token 在 GitHub 里生成一个只带 read:packages 权限的就够了,服务器侧只拉不推,权限给多了都是风险。
6.1 另一条路线:SSH 远程部署,与 Watchtower 对比
除了 Watchtower 轮询,还有一条常见路线:Actions 构建完之后,通过 SSH 登录服务器执行部署命令。大致长这样:
ssh deploy@你的服务器 "cd /opt/app && docker compose pull && docker compose up -d"两条路线对比着看:
| 对比项 | Watchtower 轮询 | SSH 远程部署 |
|---|---|---|
| 实时性 | 有轮询间隔,分钟级延迟 | 构建完立刻执行,秒级 |
| 部署动作 | 只会拉镜像换容器,动作固定 | 任意命令都能跑,可带迁移、清缓存等步骤 |
| 复杂度 | 服务器装一个 Watchtower 就完事 | 要配 SSH 密钥、部署账号、脚本健壮性自己兜底 |
| 安全面 | 服务器不开放任何新入口 | 要把 SSH 私钥放进 GitHub Secrets,服务器多一个可登录账号 |
| 失败感知 | 它只管换容器,上没上线要自己验证 | 命令返回值直接进 Actions 日志,成败一目了然 |
| 适合场景 | 个人项目、小服务、更新即部署 | 部署步骤复杂、要编排多条命令的项目 |
选型建议:个人项目默认 Watchtower,省心且攻击面小;等部署流程复杂到「换容器之外还要做好几步」时,再升级到 SSH 路线,而且 SSH 路线务必用专用低权限账号加专用密钥,不要把 root 密钥放进 Secrets——GitHub Secrets 里躺着服务器 root 密钥,等于把家门钥匙挂在别人家墙上。
七、上线后验证、失败处理与回滚
自动化不等于甩手不管,链路的最后一环是确认新版本真的活着。
7.1 健康检查:别只看容器在跑
容器状态是 Up 不代表服务正常,进程可能在里面反复崩溃重启。靠谱的做法是等一个健康接口返回 200。服务自己有 /health 接口最好,没有的话用首页或任意稳定路径凑合。验证脚本的思路如下,可以放在服务器上手动跑,也可以接进通知流程:
#!/bin/bashURL="http://127.0.0.1:8080/health"for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w "%{http_code}" "$URL") if [ "$code" = "200" ]; then echo "OK: 服务已就绪" exit 0 fi echo "第 $i 次检查,状态码 $code,继续等……" sleep 5doneecho "超时:服务未在 150 秒内就绪"exit 130 次、每次隔 5 秒,给足两分半的启动窗口。检查不过,先看容器日志再下结论:
docker compose logs --tail=100 app7.2 Actions 失败怎么看日志
构建或推送挂了,去仓库的 Actions 页点开那次运行,红叉标在哪个 step 就展开哪个 step 看尾部日志,报错基本都在最后几十行。几个高频点位:
- checkout 之后就挂,多半是 workflow 的 YAML 缩进写错,先在线校验一遍语法;
- build 阶段挂,八成是 Dockerfile 在本地就构建不过,先在本地
docker build复现,别在云端反复试; - push 阶段挂,看下一节排障,多半是权限和登录问题。
还有个习惯值得养成:失败的运行不要急着删,重跑之前先把日志看完。Actions 支持 Re-run jobs,但同样的错重跑一百遍还是错。
7.3 回滚:把标签改回去,就这么简单
新版本上线后发现致命问题,不用慌,这正是第四节坚持打版本号标签的回报。回滚不需要重新构建,只是把部署指回上一个好版本:
cd /opt/app# 把 compose 里的镜像标签改回上一个版本sed -i 's|image: ghcr.io/你的用户名/项目名:.*|image: ghcr.io/你的用户名/项目名:v1.1.0|' docker-compose.ymldocker compose pulldocker compose up -d三条命令,一分钟回到旧版本。如果你的 Compose 跟的是 latest,回滚前先把 latest 对应的好版本镜像在仓库里确认好,再手动 docker pull 指定版本号标签并改 Compose 钉死版本。等故障处理完、根因找到了,再发新版本号重新走流水线。切记:回滚是改标签重新部署,不是去服务器上改代码救火,在服务器上热改的东西下次部署全被覆盖,还会让线上版本和仓库代码对不上账,后患无穷。
八、进阶:一句带过的两个提速与扩展点
多架构构建。想让镜像同时支持 x86 和 ARM(比如还要跑在树莓派或 ARM 云主机上),在 build-push-action 里加一行 platforms 即可:
with: platforms: linux/amd64,linux/arm64原理、QEMU 模拟构建的注意事项和 manifest 清单的验证方法,在多平台构建篇里完整讲过(见 多平台镜像构建实战:buildx 双架构),这里不展开。
构建缓存用 type=gha。第三节 workflow 里已经埋了这两行:
cache-from: type=gha cache-to: type=gha,mode=max它把 Docker 的层缓存存进 GitHub Actions 自带的缓存空间,下次构建直接复用没变的层。依赖多、构建慢的项目,第一次构建五分钟、第二次可能只要几十秒。注意 Actions 缓存有容量上限(默认 10 GB,超了自动淘汰最旧的),缓存偶尔失效重跑一次全量构建属于正常现象,不用排查。
再往前一步,还可以给 workflow 加上 PR 触发的只构建不推送检查、镜像漏洞扫描等环节,但那都是锦上添花。主链先跑稳,再谈扩展。
九、排障小节
1. login 失败:unauthorized 或 permission_denied
按顺序查三件事:一是 workflow 顶部有没有写 permissions: packages: write,没写的话 GITHUB_TOKEN 默认权限可能不给推包;二是镜像名是不是全小写,GHCR 对大小写很较真;三是如果用的是个人访问令牌(PAT),确认它勾了 write:packages 权限且没有过期。用 Docker Hub 或自建仓库的,则检查 Secrets 里的用户名和 Token 有没有复制串行、Token 有没有被吊销。
2. build-push-action 推不上去:构建成功但 push 报错
先分清是构建挂了还是推送挂了,日志里 ERROR: failed to push 才是推送问题。常见原因:登录步骤被跳过或失败但没中断(检查 login-action 那一步是不是绿的);镜像名和仓库对不上,比如 metadata-action 生成的名字和 login 的 registry 不是同一家;网络抽风偶发失败,直接 Re-run 一次能过的大多是这类。
3. 服务器没更新:镜像推上去了,容器纹丝不动
这是 Watchtower 对接最经典的坑,逐项对:
- 服务的 Compose 里 label 写了吗?键名必须是
com.centurylinklabs.watchtower.enable=true,拼错一个字母都不认; - Watchtower 自己有没有开
WATCHTOWER_LABEL_ENABLE=true,一边开一边没开等于没对接; - 容器跟的标签对不对:Watchtower 只认你部署时用的那个标签有没有新版,Compose 里钉的是版本号标签,那推
latest推一万次它也不会动; - 私有镜像有没有在服务器上
docker login过,没登录它拉不到,只会在日志里默默报错,去看 Watchtower 自己的日志:
docker logs watchtower --tail=504. 镜像拉下来了但容器反复重启
这不是流水线的问题,是新版本本身起不来。看应用日志定位,别回滚了事也别放着不管:确认是代码 bug 就修完发新版,是配置缺失(环境变量、数据卷没跟上)就补齐部署配置。实在顶不住先按第七节回滚保命,再慢慢查。
5. Actions 额度或缓存异常
公共仓库的 Actions 分钟数免费且宽裕,私有仓库有月度额度,跑超了流水线会被暂停,去 Settings → Billing 看用量。缓存突然全失效一般是缓存被容量淘汰,属正常现象。
小结
这一篇把整个系列收了个尾,回顾一下链路:本地 git push 触发 GitHub Actions,checkout、Buildx、登录、打标签、构建推送一气呵成,镜像躺进 GHCR 并带着版本号、latest、commit sha 三种标签;服务器上的 Watchtower 盯着标签把新容器换上;健康检查确认服务活着,出事就改标签一分钟回滚。
真正要带走的其实是三条纪律:构建放云端、服务器只拉镜像;标签必须留存档、生产别只靠 latest;密钥只进 Secrets、权限只给刚好够用的。这三条守住,你的部署就从手艺活变成了流水线,改代码只管改,上线交给机器。
至此 Docker 系列从安装、更新、命令、数据、网络、Compose、Dockerfile、日志、安全、反代,一路写到自动化流水线,一条完整的路走通了。下一篇可以横向扩展到监控与编排,或者就停在这里,把这条流水线先在自己项目里跑上一个月,体感会比多看十篇教程都实在。