GitHub Actions + Docker + ACR + GHCR 自动化构建与发布
GitHub Actions + Docker + 阿里云 ACR + GitHub Container Registry(GHCR)自动化构建与发布工作流。
一、先理解 CI/CD 到底是什么
- CI(持续集成):代码提交后,自动检查代码是否有问题、能否构建、基本功能能否运行。
- CD(持续交付 / 持续部署):把验证通过的程序交付出去,例如发布 Docker 镜像;如果进一步自动更新生产服务器,才是完整的自动部署流程。
你的这个工作流已经包含了自动测试和镜像发布,但从这份 YAML 来看,它没有直接登录你的生产服务器,也没有执行服务器上的 docker compose pull 或 docker compose up -d。所以它完成的是“构建、测试、发布镜像”,不是自动更新运行中的服务。
GitHub Actions 的核心机制是:事件触发工作流,工作流运行一个或多个 Job,每个 Job 再依次执行 Steps。
二、你的工作流具体做了什么?
查看这份文件。它只有一个 Job,但包含多个按顺序执行的步骤。
| 阶段 |
你的工作流做什么 |
目的 |
| 1. 触发 |
监听 main 分支的 push,也支持手动运行 |
自动启动流水线 |
| 2. 获取代码 |
Checkout 仓库 |
把代码下载到临时机器 |
| 3. 前置检查 |
检查文件和 ACR 配置 |
提前发现配置错误 |
| 4. 代码检查 |
设置 Python 3.12,检查 Python 语法 |
避免低级错误 |
| 5. 构建应用镜像 |
构建 Flask/Python 应用镜像 |
验证应用能否打包 |
| 6. 冒烟测试 |
启动应用容器,访问 /health |
验证应用基本可用 |
| 7. 构建 Nginx 镜像 |
构建 Web 入口镜像 |
验证 Nginx 能否打包 |
| 8. 配置检查 |
执行 nginx -t |
检查 Nginx 配置 |
| 9. 镜像发布 |
登录两个仓库,推送镜像 |
发布可供部署的镜像 |
这里有一个特别值得学习的设计:测试通过之前,不会执行最后的镜像发布步骤。 如果前面的步骤失败,后续普通步骤默认会跳过。
1. 为什么是两个镜像?
应用镜像(app)
包含 Python 应用及依赖,负责文件服务器的后端逻辑。
示例:file-server-app-ci:版本号
Web 镜像(nginx)
包含 Nginx 和相关配置,负责 Web 入口、静态页面或反向代理。
示例:file-server-nginx-ci:版本号
这两个镜像会分别推送到两个仓库,因此按这份脚本的逻辑,最终有 4 个镜像仓库路径,每个路径 3 个标签,共 12 次镜像标签推送操作。这不代表构建了 12 个不同的镜像内容:同一个镜像的不同标签通常指向相同的镜像内容。
三、理解最重要的几个概念
1. on:什么情况下执行?
1 2 3 4 5
| on: push: branches: - main workflow_dispatch:
|
push:有人向仓库推送代码时触发。
branches: - main:只响应 main 分支的 push。
workflow_dispatch:允许你在 GitHub 的 Actions 页面手动点击运行。
注意:这并不意味着所有分支的代码都会自动发布。比如你在 dev 分支提交代码,默认不会触发这个工作流;合并到 main 后才会触发。
2. jobs 和 steps:流水线与具体任务
1 2 3 4 5 6
| jobs: build-test-publish: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v4
|
可以这样理解:
jobs:定义需要执行的任务。
build-test-publish:Job 的内部标识符。
runs-on:指定运行环境,这里是 GitHub 托管的 Ubuntu Linux 机器。
steps:任务中的具体步骤。
uses:调用别人已经编写好的 Action。
run:直接执行 Shell 命令。
例如,actions/checkout@v4 就是一个可复用的 Action,负责将仓库代码检出到工作目录。
3. ${{ ... }} 和 $VARIABLE 有什么区别?
这是阅读 GitHub Actions 时很容易混淆的地方。
| 写法 |
由谁处理 |
示例 |
${{ github.sha }} |
GitHub Actions 表达式引擎 |
获取本次提交的完整 SHA |
${{ vars.ACR_IMAGE }} |
GitHub Actions 表达式引擎 |
读取仓库变量 |
${{ secrets.ACR_PASSWORD }} |
GitHub Actions 表达式引擎 |
读取密码 Secret |
$ACR_IMAGE |
Bash Shell |
读取已注入环境的变量 |
${GITHUB_SHA} |
Bash Shell |
读取当前提交 SHA 对应的环境变量 |
"$TAG" |
Bash Shell |
读取标签变量,并防止普通空格导致参数拆分 |
比如:
1 2 3 4 5 6
| {% raw %} env: ACR_IMAGE: ${{ vars.ACR_IMAGE }} run: | docker push "${ACR_IMAGE}:latest" {% endraw %}
|
GitHub Actions 先把仓库变量注入环境,Bash 执行命令时再读取 $ACR_IMAGE。
记住:${{ ... }} 是工作流表达式,$VARIABLE 是 Shell 变量。 两者处于不同的处理阶段。
4. vars、secrets 和 GITHUB_TOKEN
你的工作流使用了这几种配置:
| 名称 |
类型 |
用途 |
vars.ACR_REGISTRY |
仓库变量 |
阿里云 ACR 的 Registry 地址 |
vars.ACR_IMAGE |
仓库变量 |
应用镜像的完整仓库路径 |
secrets.ACR_USERNAME |
Secret |
阿里云仓库用户名 |
secrets.ACR_PASSWORD |
Secret |
阿里云仓库密码或访问凭证 |
secrets.GITHUB_TOKEN |
GitHub 提供的令牌 |
工作流登录 GHCR 时使用 |
变量与 Secret 的关键区别是:普通配置通常放在 vars,敏感凭证放在 secrets。不要把真实密码直接写进 YAML。
你的配置还包含:
1 2 3
| permissions: contents: read packages: write
|
contents: read 允许工作流读取仓库内容;packages: write 允许对应的 GITHUB_TOKEN 写入 GitHub Packages,包括 GHCR 镜像发布所需的权限。阿里云 ACR 的认证则另外使用 ACR_USERNAME 和 ACR_PASSWORD。
四、 docker-ci.yml 添加详细中文注释
下面是根据你当前文件整理的完整中文注释版。我保留了原有的触发条件、构建参数、测试逻辑、镜像名称和推送规则,主要增加注释,便于你逐行学习。
有一点要注意:这是一份学习用的注释版,不是已经写回 GitHub 仓库的修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
| {% raw %}
name: File Server CI and Publish
on: push: branches: - main
workflow_dispatch:
permissions: contents: read packages: write
concurrency: group: file-server-publish-main cancel-in-progress: false
jobs: build-test-publish: name: Build, test and publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout source uses: actions/checkout@v4
- name: Validate repository and registry configuration shell: bash
env: ACR_REGISTRY: ${{ vars.ACR_REGISTRY }} ACR_IMAGE: ${{ vars.ACR_IMAGE }}
run: | # -e:命令失败时退出 # -u:使用未定义变量时退出 # -o pipefail:管道中任意命令失败,管道结果也视为失败 set -euo pipefail
test -f app/Dockerfile test -f app/app.py test -f app/requirements.txt test -f nginx/Dockerfile test -f nginx/nginx.conf test -f html/index.html test -f docker-compose.yml
test -n "$ACR_REGISTRY" || { echo "::error::Set Actions variable ACR_REGISTRY first" exit 1 }
test -n "$ACR_IMAGE" || { echo "::error::Set Actions variable ACR_IMAGE first" exit 1 }
case "$ACR_IMAGE" in "$ACR_REGISTRY"/*) ;; *) echo "::error::ACR_IMAGE must begin with ACR_REGISTRY/" exit 1 ;; esac
- name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12"
- name: Check Python syntax run: python -m py_compile app/app.py
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: driver: docker-container
- name: Build application image uses: docker/build-push-action@v6 with: context: ./app
file: ./app/Dockerfile
tags: file-server-app-ci:${{ github.sha }}
load: true
push: false
cache-from: type=gha,scope=file-server-app
cache-to: type=gha,mode=max,scope=file-server-app
- name: Smoke test application container shell: bash run: | # 启用严格 Shell 检查 set -euo pipefail
docker run -d \ --name file-server-ci-test \ -p 127.0.0.1:5000:5000 \ -e SECRET_KEY=ci-only-test-secret \ -e FILE_PASSWORD=ci-only-test-password \ file-server-app-ci:${GITHUB_SHA}
cleanup() { docker rm -f file-server-ci-test >/dev/null 2>&1 || true }
trap cleanup EXIT
success=0
for i in $(seq 1 30); do if curl --fail --silent http://127.0.0.1:5000/health; then echo success=1 break fi sleep 2 done
if [ "$success" -ne 1 ]; then docker logs file-server-ci-test echo "::error::Container health check failed" exit 1 fi
- name: Build Nginx web image uses: docker/build-push-action@v6 with: context: .
file: ./nginx/Dockerfile
tags: file-server-nginx-ci:${{ github.sha }}
load: true
push: false
cache-from: type=gha,scope=file-server-nginx cache-to: type=gha,mode=max,scope=file-server-nginx
- name: Validate Nginx configuration shell: bash run: | set -euo pipefail
mkdir -p "$RUNNER_TEMP/nginx-certs"
openssl req -x509 -nodes -newkey rsa:2048 \ -keyout "$RUNNER_TEMP/nginx-certs/server.key" \ -out "$RUNNER_TEMP/nginx-certs/server.crt" \ -days 1 -subj "/CN=localhost"
docker run --rm \ --add-host app:127.0.0.1 \ -v "$RUNNER_TEMP/nginx-certs:/etc/nginx/certs:ro" \ file-server-nginx-ci:${GITHUB_SHA} nginx -t
- name: Generate readable China-time tags id: image-tags shell: bash run: | set -euo pipefail
TAG="$(TZ=Asia/Shanghai date +'%Y%m%d-%H%M%S')"
SHORT_SHA="${GITHUB_SHA:0:7}"
echo "timestamp=$TAG" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Image timestamp tag: $TAG"
- name: Login to Alibaba Cloud ACR uses: docker/login-action@v3 with: registry: ${{ vars.ACR_REGISTRY }}
username: ${{ secrets.ACR_USERNAME }} password: ${{ secrets.ACR_PASSWORD }}
- name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag and publish both images to both registries shell: bash
env: ACR_IMAGE: ${{ vars.ACR_IMAGE }}
GHCR_IMAGE: ghcr.io/${{ github.repository }}
TIMESTAMP_TAG: ${{ steps.image-tags.outputs.timestamp }} SHORT_SHA: ${{ steps.image-tags.outputs.short_sha }}
run: | set -euo pipefail
publish_image() { local SOURCE="$1" local IMAGE="$2"
for TAG in "$TIMESTAMP_TAG" "$SHORT_SHA" latest; do
docker tag "$SOURCE" "${IMAGE}:$TAG"
docker push "${IMAGE}:$TAG" done
echo "Published image: $IMAGE (tags: $TIMESTAMP_TAG, $SHORT_SHA, latest)" }
publish_image "file-server-app-ci:${GITHUB_SHA}" "$ACR_IMAGE"
publish_image "file-server-nginx-ci:${GITHUB_SHA}" "${ACR_IMAGE}-nginx"
publish_image "file-server-app-ci:${GITHUB_SHA}" "$GHCR_IMAGE"
publish_image "file-server-nginx-ci:${GITHUB_SHA}" "${GHCR_IMAGE}-nginx"
{ echo "### Published Docker images" echo echo "Tags: $TIMESTAMP_TAG, $SHORT_SHA, latest" echo echo "ACR app: $ACR_IMAGE" echo "ACR web: ${ACR_IMAGE}-nginx" echo "GHCR app: $GHCR_IMAGE" echo "GHCR web: ${GHCR_IMAGE}-nginx" } >> "$GITHUB_STEP_SUMMARY" {% endraw %}
|
五、这份工作流中最值得理解的 5 个细节
1. 为什么先构建,再推送?
你的两个构建步骤都设置了:
这表示先将构建结果加载到本地 Docker 镜像库,不立即发布到远程仓库。接下来启动容器或检查 Nginx 配置,验证成功后才登录仓库并推送。
这是一个很实用的原则:先验证,再发布。
2. 为什么使用三个标签?
你的发布函数会给每个镜像创建三个标签。
| 标签 |
示例 |
用途 |
| 时间戳 |
20261009-184500 |
方便按发布时间查找版本 |
| 短 SHA |
a1b2c3d |
方便追溯对应的代码提交 |
latest |
latest |
表示当前发布的最新版本 |
实际时间戳和 SHA 会根据每次运行动态生成。
需要注意,latest 会被新版本覆盖,所以生产环境如果需要稳定回滚,最好使用时间戳、提交 SHA 或镜像摘要,而不是只依赖 latest。
3. GITHUB_OUTPUT 是什么?
你的代码中有:
1 2
| echo "timestamp=$TAG" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
它将当前步骤生成的数据传递给后面的步骤。后面通过:
1 2 3 4
| {% raw %} TIMESTAMP_TAG: ${{ steps.image-tags.outputs.timestamp }} SHORT_SHA: ${{ steps.image-tags.outputs.short_sha }} {% endraw %}
|
读取这些值。
其中 image-tags 是步骤的 id,timestamp 和 short_sha 是步骤输出的名称。
这是一种很常见的工作流数据传递方式。
4. set -euo pipefail 为什么常见?
这条命令让 Shell 脚本更加严格:
-e:大多数未被处理的命令失败会导致脚本退出。
-u:使用未定义变量时退出。
pipefail:管道中的命令失败不会轻易被后续成功命令掩盖。
它可以避免脚本在某个关键操作失败后,仍然继续推送镜像。
不过,Shell 的错误处理有一些例外情况,例如 if 条件中执行的命令、使用 || 处理的失败等,因此它不能替代明确的错误判断。
5. 冒烟测试不等于完整测试
你当前的 Python 检查:
1
| python -m py_compile app/app.py
|
主要验证语法是否正确。
而 /health 检查主要验证容器是否能够启动并返回成功响应。它不能证明登录、文件上传、分片合并、下载、权限控制等业务功能都正确。