5599 字
28 分钟
CI/CD 全流水线:从代码提交到自动上线
2026-11-15

写到这一篇,前面的零件都齐了:Dockerfile 会写了(见 Dockerfile 入门篇),多架构构建会玩了,私有仓库也搭得起来,自动更新也有 Watchtower 盯着。还差最后一件事:把这些零件串成一条自动流水线。

手动部署过项目的人都懂那种重复劳动:改一行代码,然后 SSH 上服务器,git pull、构建、停容器、起容器、看日志,十分钟没了,一天来上三五回,人就成了部署工具人。CI/CD 要解决的就是这件事:代码 push 到 GitHub,剩下的构建、推送、上线全部自动完成,你只负责看结果。

这一篇的主线如下:

  1. 先看全景:五个环节各自在干什么;
  2. 选镜像仓库:GHCR、Docker Hub、自建 Registry 三者怎么选;
  3. 写一份完整的 GitHub Actions 工作流,逐段拆解;
  4. 定标签策略与 Secrets 管理这两条纪律;
  5. 服务器侧用 Watchtower 衔接自动上线,并与 SSH 部署路线做对比;
  6. 上线后验证、失败处理与回滚预案;
  7. 最后是排障小节。

读完这一篇,你的部署动作将只剩一个:git push。

一、全景图:五个环节各自干什么#

先把整条链路在脑子里过一遍,后面每一节都是在填其中一个环节的细节。

环节发生在哪干什么用到的工具
1. 提交本地电脑改代码、git push 到 GitHubGit
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.iodocker.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=max

3.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@v4

Actions 的运行环境是一台全新的空机器,什么都没有。第一步永远是把仓库代码 checkout 下来。@v4 是这个 Action 的大版本号,跟着官方大版本走即可,不要写死小版本号,也不要用 @main 这种飘忽不定的引用。

3.3 docker/setup-buildx-action:准备构建器#

- name: Set up Buildx
uses: docker/setup-buildx-action@v3

Buildx 是 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对外发布的正式版本,回滚时指哪打哪
latestlatest永远指向主分支最新构建,给尝鲜和 Watchtower 盯梢用
commit shaa1b2c3d精确对应某一次提交,排障时定位到底是哪行代码进的镜像

为什么生产环境不能只靠 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 登录服务器执行部署命令。大致长这样:

Terminal window
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/bash
URL="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 5
done
echo "超时:服务未在 150 秒内就绪"
exit 1

30 次、每次隔 5 秒,给足两分半的启动窗口。检查不过,先看容器日志再下结论:

Terminal window
docker compose logs --tail=100 app

7.2 Actions 失败怎么看日志#

构建或推送挂了,去仓库的 Actions 页点开那次运行,红叉标在哪个 step 就展开哪个 step 看尾部日志,报错基本都在最后几十行。几个高频点位:

  • checkout 之后就挂,多半是 workflow 的 YAML 缩进写错,先在线校验一遍语法;
  • build 阶段挂,八成是 Dockerfile 在本地就构建不过,先在本地 docker build 复现,别在云端反复试;
  • push 阶段挂,看下一节排障,多半是权限和登录问题。

还有个习惯值得养成:失败的运行不要急着删,重跑之前先把日志看完。Actions 支持 Re-run jobs,但同样的错重跑一百遍还是错。

7.3 回滚:把标签改回去,就这么简单#

新版本上线后发现致命问题,不用慌,这正是第四节坚持打版本号标签的回报。回滚不需要重新构建,只是把部署指回上一个好版本:

Terminal window
cd /opt/app
# 把 compose 里的镜像标签改回上一个版本
sed -i 's|image: ghcr.io/你的用户名/项目名:.*|image: ghcr.io/你的用户名/项目名:v1.1.0|' docker-compose.yml
docker compose pull
docker 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 自己的日志:
Terminal window
docker logs watchtower --tail=50

4. 镜像拉下来了但容器反复重启

这不是流水线的问题,是新版本本身起不来。看应用日志定位,别回滚了事也别放着不管:确认是代码 bug 就修完发新版,是配置缺失(环境变量、数据卷没跟上)就补齐部署配置。实在顶不住先按第七节回滚保命,再慢慢查。

5. Actions 额度或缓存异常

公共仓库的 Actions 分钟数免费且宽裕,私有仓库有月度额度,跑超了流水线会被暂停,去 Settings → Billing 看用量。缓存突然全失效一般是缓存被容量淘汰,属正常现象。

小结#

这一篇把整个系列收了个尾,回顾一下链路:本地 git push 触发 GitHub Actions,checkout、Buildx、登录、打标签、构建推送一气呵成,镜像躺进 GHCR 并带着版本号、latest、commit sha 三种标签;服务器上的 Watchtower 盯着标签把新容器换上;健康检查确认服务活着,出事就改标签一分钟回滚。

真正要带走的其实是三条纪律:构建放云端、服务器只拉镜像;标签必须留存档、生产别只靠 latest;密钥只进 Secrets、权限只给刚好够用的。这三条守住,你的部署就从手艺活变成了流水线,改代码只管改,上线交给机器。

至此 Docker 系列从安装、更新、命令、数据、网络、Compose、Dockerfile、日志、安全、反代,一路写到自动化流水线,一条完整的路走通了。下一篇可以横向扩展到监控与编排,或者就停在这里,把这条流水线先在自己项目里跑上一个月,体感会比多看十篇教程都实在。

CI/CD 全流水线:从代码提交到自动上线
https://blog.anexns.com/posts/docker-cicd-pipeline/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0