GitHub Actions + Docker + ACR + GHCR 自动化构建与发布

22 阅读8分钟

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:什么情况下执行?

on:
  push:
    branches:
      - main
  workflow_dispatch:
  • push:有人向仓库推送代码时触发。
  • branches: - main:只响应 main 分支的 push。
  • workflow_dispatch:允许你在 GitHub 的 Actions 页面手动点击运行。

注意:这并不意味着所有分支的代码都会自动发布。比如你在 dev 分支提交代码,默认不会触发这个工作流;合并到 main 后才会触发。

2. jobs 和 steps:流水线与具体任务

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_IMAGEBash Shell读取已注入环境的变量
${GITHUB_SHA}Bash Shell读取当前提交 SHA 对应的环境变量
"$TAG"Bash Shell读取标签变量,并防止普通空格导致参数拆分

比如:

env:
  ACR_IMAGE: ${{ vars.ACR_IMAGE }}
run: |
  docker push "${ACR_IMAGE}:latest"

GitHub Actions 先把仓库变量注入环境,Bash 执行命令时再读取 $ACR_IMAGE。

记住:${{ ... }} 是工作流表达式,$VARIABLE 是 Shell 变量。 两者处于不同的处理阶段。

4. vars、secrets 和 GITHUB_TOKEN

你的工作流使用了这几种配置:

名称类型用途
vars.ACR_REGISTRY仓库变量阿里云 ACR 的 Registry 地址
vars.ACR_IMAGE仓库变量应用镜像的完整仓库路径
secrets.ACR_USERNAMESecret阿里云仓库用户名
secrets.ACR_PASSWORDSecret阿里云仓库密码或访问凭证
secrets.GITHUB_TOKENGitHub 提供的令牌工作流登录 GHCR 时使用

变量与 Secret 的关键区别是:普通配置通常放在 vars,敏感凭证放在 secrets。不要把真实密码直接写进 YAML。

你的配置还包含:

permissions:
  contents: read
  packages: write

contents: read 允许工作流读取仓库内容;packages: write 允许对应的 GITHUB_TOKEN 写入 GitHub Packages,包括 GHCR 镜像发布所需的权限。阿里云 ACR 的认证则另外使用 ACR_USERNAME 和 ACR_PASSWORD。<Cite refs={["turn540972search2","turn540972search0"]}/>

四、 docker-ci.yml 添加详细中文注释

下面是根据你当前文件整理的完整中文注释版。我保留了原有的触发条件、构建参数、测试逻辑、镜像名称和推送规则,主要增加注释,便于你逐行学习。

有一点要注意:这是一份学习用的注释版,不是已经写回 GitHub 仓库的修改。

# ============================================================
# 工作流名称
# 在 GitHub 仓库的 Actions 页面中显示这个名称
# ============================================================
name: File Server CI and Publish

# ============================================================
# 触发条件:什么情况下启动 CI/CD
# ============================================================
on:
  # 当代码被 push 到指定分支时触发
  push:
    branches:
      - main  # 只监听 main 分支

  # 允许在 GitHub Actions 页面手动启动工作流
  workflow_dispatch:

# ============================================================
# 工作流权限
# 这里设置的是 GitHub 自动提供的 GITHUB_TOKEN 权限
# ============================================================
permissions:
  contents: read   # 允许读取仓库代码
  packages: write  # 允许向 GitHub Packages / GHCR 发布镜像

# ============================================================
# 并发控制
# 防止同一组工作流同时执行
# ============================================================
concurrency:
  group: file-server-publish-main  # 同组工作流使用相同的并发标识
  cancel-in-progress: false        # 新任务不会主动取消正在运行的旧任务

# ============================================================
# Jobs:定义工作流需要执行的任务
# 当前只有一个 Job,内部包含多个按顺序执行的 Step
# ============================================================
jobs:
  build-test-publish:
    name: Build, test and publish  # 在 Actions 页面显示的任务名称

    # 使用 GitHub 托管的 Ubuntu Linux 环境
    # 每次运行会分配干净的临时运行环境
    runs-on: ubuntu-latest

    # 整个 Job 最多运行 30 分钟,超时会被终止
    timeout-minutes: 30

    # ========================================================
    # Steps:具体执行步骤
    # 同一个 Job 内的步骤按顺序运行
    # 普通步骤失败后,后续步骤默认不会继续执行
    # ========================================================
    steps:

      # ------------------------------------------------------
      # 第 1 步:下载仓库代码
      # ------------------------------------------------------
      - name: Checkout source
        # 使用 GitHub 官方维护的 checkout Action
        uses: actions/checkout@v4

      # ------------------------------------------------------
      # 第 2 步:检查项目文件和镜像仓库配置
      # ------------------------------------------------------
      - name: Validate repository and registry configuration
        shell: bash

        # 将 GitHub 仓库变量传入当前 Shell 步骤
        env:
          ACR_REGISTRY: ${{ vars.ACR_REGISTRY }}
          ACR_IMAGE: ${{ vars.ACR_IMAGE }}

        # 多行 Shell 脚本
        run: |
          # -e:命令失败时退出
          # -u:使用未定义变量时退出
          # -o pipefail:管道中任意命令失败,管道结果也视为失败
          set -euo pipefail

          # 检查构建所需的文件是否存在
          # 任意文件不存在,test 就会返回非零状态
          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

          # 检查 ACR Registry 配置是否为空
          # 为空时打印 GitHub Actions 错误信息并退出
          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
          }

          # 检查应用镜像路径是否以 Registry 地址开头
          # 例如:
          # ACR_REGISTRY=registry.example.com
          # ACR_IMAGE=registry.example.com/my-project/file-server
          case "$ACR_IMAGE" in
            "$ACR_REGISTRY"/*) ;;
            *)
              echo "::error::ACR_IMAGE must begin with ACR_REGISTRY/"
              exit 1
              ;;
          esac

      # ------------------------------------------------------
      # 第 3 步:安装并配置 Python
      # ------------------------------------------------------
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          # 指定 CI 检查使用的 Python 版本
          python-version: "3.12"

      # ------------------------------------------------------
      # 第 4 步:检查 Python 代码语法
      # ------------------------------------------------------
      - name: Check Python syntax
        # py_compile 会检查语法,并生成字节码缓存文件
        # 注意:语法检查通过不等于应用业务逻辑没有问题
        run: python -m py_compile app/app.py

      # ------------------------------------------------------
      # 第 5 步:准备 Docker Buildx
      # Buildx 是 Docker 的构建工具,支持 BuildKit 和构建缓存
      # ------------------------------------------------------
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          # 使用 docker-container 驱动运行 BuildKit 构建器
          driver: docker-container

      # ------------------------------------------------------
      # 第 6 步:构建 Python 应用镜像
      # 这里仅构建镜像,不推送到远程仓库
      # ------------------------------------------------------
      - name: Build application image
        uses: docker/build-push-action@v6
        with:
          # 构建上下文目录
          # Dockerfile 中 COPY 等指令可使用这个目录内的文件
          context: ./app

          # 明确指定应用使用的 Dockerfile
          file: ./app/Dockerfile

          # 临时镜像标签使用完整提交 SHA
          # github.sha 是触发本次工作流的提交 ID
          tags: file-server-app-ci:${{ github.sha }}

          # 将构建结果加载到本机 Docker 镜像库
          # 后面的 docker run 才能使用这个本地镜像
          load: true

          # 这一阶段不推送镜像
          push: false

          # 尝试读取之前保存的 GitHub Actions 构建缓存
          cache-from: type=gha,scope=file-server-app

          # 保存本次构建缓存,供以后构建复用
          # mode=max 尽可能保存中间构建层
          cache-to: type=gha,mode=max,scope=file-server-app

      # ------------------------------------------------------
      # 第 7 步:启动应用容器,执行冒烟测试
      # 冒烟测试用于快速验证应用的基本功能是否可用
      # ------------------------------------------------------
      - name: Smoke test application container
        shell: bash
        run: |
          # 启用严格 Shell 检查
          set -euo pipefail

          # 后台启动刚刚构建的应用镜像
          docker run -d \
            --name file-server-ci-test \
            # 仅将容器的 5000 端口绑定到本机回环地址
            -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
          }

          # 当前 Shell 退出时执行清理
          # 即使后面的健康检查失败,也会尝试清理容器
          trap cleanup EXIT

          # success=0 表示尚未检测到成功
          success=0

          # 最多尝试 30 次,每次间隔 2 秒
          # 用来等待应用启动完成
          for i in $(seq 1 30); do
            # curl --fail:HTTP 错误状态返回失败
            # --silent:不显示常规进度信息
            if curl --fail --silent http://127.0.0.1:5000/health; then
              echo
              success=1
              break
            fi
            sleep 2
          done

          # 30 次尝试后仍然失败,则输出容器日志并终止任务
          if [ "$success" -ne 1 ]; then
            docker logs file-server-ci-test
            echo "::error::Container health check failed"
            exit 1
          fi

      # ------------------------------------------------------
      # 第 8 步:构建 Nginx Web 镜像
      # 同样只构建、不推送,先进行配置检查
      # ------------------------------------------------------
      - name: Build Nginx web image
        uses: docker/build-push-action@v6
        with:
          # Nginx 构建上下文是项目根目录
          # 这样 Dockerfile 可以访问根目录下允许使用的文件
          context: .

          # Nginx 镜像使用自己的 Dockerfile
          file: ./nginx/Dockerfile

          # 使用提交 SHA 作为临时镜像标签
          tags: file-server-nginx-ci:${{ github.sha }}

          # 加载到本地 Docker,供后续 nginx -t 使用
          load: true

          # 测试阶段不发布镜像
          push: false

          # Nginx 专属构建缓存,避免与应用镜像缓存混用
          cache-from: type=gha,scope=file-server-nginx
          cache-to: type=gha,mode=max,scope=file-server-nginx

      # ------------------------------------------------------
      # 第 9 步:检查 Nginx 配置是否有效
      # ------------------------------------------------------
      - name: Validate Nginx configuration
        shell: bash
        run: |
          set -euo pipefail

          # 在临时目录创建证书存放位置
          mkdir -p "$RUNNER_TEMP/nginx-certs"

          # 生成仅用于 CI 检查的自签名证书
          # -x509:生成自签名证书
          # -nodes:不使用密码加密私钥
          # -newkey rsa:2048:创建 2048 位 RSA 密钥
          # -days 1:证书有效期为 1 天
          # -subj:指定证书主题,避免交互式提问
          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"

          # 启动一个临时容器,只执行 Nginx 配置检查
          docker run --rm \
            # 让容器内的 app 主机名解析到 127.0.0.1
            --add-host app:127.0.0.1 \
            # 将临时证书目录以只读方式挂载到容器
            -v "$RUNNER_TEMP/nginx-certs:/etc/nginx/certs:ro" \
            # 使用刚刚构建的镜像执行 nginx -t
            file-server-nginx-ci:${GITHUB_SHA} nginx -t

      # ------------------------------------------------------
      # 第 10 步:生成镜像标签
      # 生成北京时间时间戳标签和短提交 SHA 标签
      # ------------------------------------------------------
      - name: Generate readable China-time tags
        id: image-tags
        shell: bash
        run: |
          set -euo pipefail

          # 使用上海时区生成时间戳
          # 例如:20261009-184500
          TAG="$(TZ=Asia/Shanghai date +'%Y%m%d-%H%M%S')"

          # 截取完整提交 SHA 的前 7 个字符
          SHORT_SHA="${GITHUB_SHA:0:7}"

          # 写入 GitHub Actions 的步骤输出文件
          # 后续步骤可以通过 steps.image-tags.outputs 读取
          echo "timestamp=$TAG" >> "$GITHUB_OUTPUT"
          echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"

          # 在日志中打印生成的时间标签
          echo "Image timestamp tag: $TAG"

      # ------------------------------------------------------
      # 第 11 步:登录阿里云 ACR
      # 使用 GitHub 仓库变量和 Secret 提供认证信息
      # ------------------------------------------------------
      - name: Login to Alibaba Cloud ACR
        uses: docker/login-action@v3
        with:
          # ACR 的 Registry 地址
          registry: ${{ vars.ACR_REGISTRY }}

          # 从 GitHub Secrets 读取用户名和密码
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      # ------------------------------------------------------
      # 第 12 步:登录 GitHub Container Registry
      # GHCR 的域名是 ghcr.io
      # ------------------------------------------------------
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io

          # 当前触发工作流的 GitHub 用户或应用身份
          username: ${{ github.actor }}

          # 使用 GitHub 自动提供的令牌认证
          password: ${{ secrets.GITHUB_TOKEN }}

      # ------------------------------------------------------
      # 第 13 步:给两个镜像打标签,并发布到两个仓库
      # 到这里,前面的构建和测试都已成功完成
      # ------------------------------------------------------
      - name: Tag and publish both images to both registries
        shell: bash

        # 将仓库地址和之前生成的标签输出传给 Shell
        env:
          # ACR 应用镜像完整路径
          ACR_IMAGE: ${{ vars.ACR_IMAGE }}

          # 使用当前 GitHub 仓库自动生成 GHCR 镜像路径
          # 例如 ghcr.io/owner/repository
          GHCR_IMAGE: ghcr.io/${{ github.repository }}

          # 引用第 10 步生成的时间戳和短 SHA
          TIMESTAMP_TAG: ${{ steps.image-tags.outputs.timestamp }}
          SHORT_SHA: ${{ steps.image-tags.outputs.short_sha }}

        run: |
          set -euo pipefail

          # 定义可重复使用的镜像发布函数
          # 参数 1:本地已有的源镜像
          # 参数 2:需要推送到的目标镜像路径
          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)"
          }

          # 发布应用镜像到阿里云 ACR
          publish_image "file-server-app-ci:${GITHUB_SHA}" "$ACR_IMAGE"

          # 发布 Nginx 镜像到 ACR
          # 通过 -nginx 区分应用镜像与 Web 镜像
          publish_image "file-server-nginx-ci:${GITHUB_SHA}" "${ACR_IMAGE}-nginx"

          # 发布应用镜像到 GHCR
          publish_image "file-server-app-ci:${GITHUB_SHA}" "$GHCR_IMAGE"

          # 发布 Nginx 镜像到 GHCR
          publish_image "file-server-nginx-ci:${GITHUB_SHA}" "${GHCR_IMAGE}-nginx"

          # --------------------------------------------------
          # 将发布摘要写入 GitHub Actions 的运行摘要
          # GitHub 会在本次工作流的 Summary 页面显示这些内容
          # --------------------------------------------------
          {
            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"

五、这份工作流中最值得理解的 5 个细节

1. 为什么先构建,再推送?

你的两个构建步骤都设置了:

load: true
push: false

这表示先将构建结果加载到本地 Docker 镜像库,不立即发布到远程仓库。接下来启动容器或检查 Nginx 配置,验证成功后才登录仓库并推送。

这是一个很实用的原则:先验证,再发布。

2. 为什么使用三个标签?

你的发布函数会给每个镜像创建三个标签。

标签示例用途
时间戳20261009-184500方便按发布时间查找版本
短 SHAa1b2c3d方便追溯对应的代码提交
latestlatest表示当前发布的最新版本

实际时间戳和 SHA 会根据每次运行动态生成。

需要注意,latest 会被新版本覆盖,所以生产环境如果需要稳定回滚,最好使用时间戳、提交 SHA 或镜像摘要,而不是只依赖 latest。

3. GITHUB_OUTPUT 是什么?

你的代码中有:

echo "timestamp=$TAG" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"

它将当前步骤生成的数据传递给后面的步骤。后面通过:

TIMESTAMP_TAG: ${{ steps.image-tags.outputs.timestamp }}
SHORT_SHA: ${{ steps.image-tags.outputs.short_sha }}

读取这些值。

其中 image-tags 是步骤的 id,timestamp 和 short_sha 是步骤输出的名称。

这是一种很常见的工作流数据传递方式。

4. set -euo pipefail 为什么常见?

这条命令让 Shell 脚本更加严格:

  • -e:大多数未被处理的命令失败会导致脚本退出。
  • -u:使用未定义变量时退出。
  • pipefail:管道中的命令失败不会轻易被后续成功命令掩盖。

它可以避免脚本在某个关键操作失败后,仍然继续推送镜像。

不过,Shell 的错误处理有一些例外情况,例如 if 条件中执行的命令、使用 || 处理的失败等,因此它不能替代明确的错误判断。

5. 冒烟测试不等于完整测试

你当前的 Python 检查:

python -m py_compile app/app.py

主要验证语法是否正确。

而 /health 检查主要验证容器是否能够启动并返回成功响应。它不能证明登录、文件上传、分片合并、下载、权限控制等业务功能都正确。

关于

www.oiox.cn/

www.oiox.cn/index.php/s…

CSDN、GitHub、知乎、开源中国、思否、掘金、简书、华为云、阿里云、腾讯云、哔哩哔哩、今日头条、新浪微博、个人博客

全网可搜《小陈运维》

文章主要发布于微信公众号:《Linux运维交流社区》