Karry's BlogKarry Space
开发

GitHub Actions 实现 CI/CD 自动部署

2026-07-31 14 min read 约 3,994 字 #CICD#GitHub-Actions#Docker#backend

CICD已经是软件工程中非常成熟的步骤了。成熟的项目都会有自己的CICD流程。CICD的实现有很多,这篇笔记主要分享一下通过GitHub Actions实现基础的CICD。

如果没有CICD,每次改完代码要部署,都得 SSH 上服务器,git pulldocker compose builddownup,再 curl 一下健康检查看看起没起来。步骤多、容易漏,多人协作时更乱——谁部署了、部署的是哪个版本,全靠口头对齐。

理想的状态是:合并到 test 分支,测试服自动更新;合并到 main 分支,正式服自动更新——测试、构建、部署、健康检查全程自动化。 这就是 CI/CD 想解决的事。这篇就记录一下我用 GitHub Actions 给项目接「双环境自动部署」的完整过程:从 GitHub 上配两套 Secret,到写 workflow 的 yml(重点是按目标分支选服务器的写法),到整条流程怎么跑,最后是结果。

整体流程

先看一眼整条链路,一句话概括:

开发提 PR → 合并到 test / main → GitHub Actions 自动跑测试 + 安全扫描 → 通过后按目标分支选服务器test → SSH 到测试服main → SSH 到正式服 → 拉取最新代码、用对应环境的 compose 重建容器、健康检查 → 完成。

用文字拆开就是:

  1. 本地开发,推分支,开 Pull Request;
  2. PR 合并到 testmain
  3. GitHub 的托管机器(ubuntu-latest)自动拉代码,跑 lint、单元测试、依赖与代码安全扫描;
  4. 测试和安全都通过后,deploy 任务按目标分支选一套服务器凭证——合并到 test 用测试服那套,合并到 main 用正式服那套——SSH 连到对应的服务器;
  5. 服务器上把代码对齐到刚合并的那个分支,用这台服务器对应的 docker-compose yml 重新 build / down / up 容器;
  6. 最后 curl 健康检查接口,返回 200 才算部署成功。

我的项目是个用 Docker + docker-compose 部署的后端服务。两台服务器配置不一样:测试服资源小、正式服资源大,所以两边用不同的 compose 文件docker-compose.test.yml / docker-compose.yml,差在内存/CPU 配额和网络名),这个选择由部署脚本按环境参数完成,后面会讲。

为什么用 GitHub Actions(对比 Jenkins)

说到 CI/CD,绕不开 Jenkins。它是这个领域的老牌工具,功能强、插件多、几乎什么流水线都能编排。但对我这种「代码已经在 GitHub 上、就想合并后自动部署」的场景,Jenkins 有点重——它是需要你自己搭一台服务器、自己维护的系统。两者放一起对比:

维度GitHub ActionsJenkins
部署形态GitHub 托管,开箱即用,无需自建需自建服务器,自己负责升级、备份、安全
与仓库集成原生集成,PR / push / Secret 直接可用要装插件、配 Webhook 才能联动
配置方式仓库里的 yml,声明式、跟代码一起版本化Jenkinsfile(Groovy)或界面点选,能力更强但更复杂
运行环境官方托管 runner(也支持 self-hosted)自己维护 agent / 节点
成本公共仓库免费、私有仓库有免费额度,超出额度要付费软件免费,但服务器和运维人力是隐性成本
上手 / 维护低,写个 yml 就跑高,要懂 Jenkins 本身那一套
适合谁中小项目、开源、代码已在 GitHub大型 / 高度定制流水线、需内网隔离、多仓库统一调度

一句话:Jenkins 强在「大而全、可深度定制、能完全内网自控」,代价是要养一套系统;GitHub Actions 强在「跟仓库零距离、托管免运维、yml 即用」。 我这个项目代码本来就在 GitHub、流程也不复杂,还能直接 SSH 到自己的服务器部署,所以 GitHub Actions 是更省心的选择。反过来,如果是大型企业、对代码外发敏感要求全程内网、或者流水线复杂到需要跨多仓库统一调度,Jenkins(或自建 GitLab CI)会更合适。

一、服务器准备(两台都要来一次)

自动部署的前提是服务器先就绪一次。这步是手动的、只做一次,但测试服和正式服都要各做一遍

# 1. 装好 docker / docker compose / git(略)
# 2. 配置好github的deploy_key,这样才能从GitHub拉取代码。当然个人的key也可以,但deploy_key应该是最好的方式
# 3. 把仓库 clone 到一个固定目录,后面 Secret 里的 *_DEPLOY_PATH 就指它
#    (测试服 -b test、正式服 -b main;其实无所谓,CI 每次部署都会硬对齐分支)
mkdir -p ~/app && cd ~/app
git clone -b main <your-repo-url> .

# 4. 准备好 .env(密钥、数据库连接等)等环境文件
# 5. 建好容器要挂的外部网络——名字按这台服务器要跑的 compose 文件里写的来。
docker network create app-net

关键一步:给 CI 生成一把「部署专用」的 SSH key,公钥放服务器、私钥交给 GitHub,这样 Actions 才能免密登录上来执行命令。两台服务器的 authorized_keys 都要放——可以共用一把,也可以每台一把(反正 GitHub 那边 Secret 是分开存的,每台一把泄露了也好单独吊销)。

# 本地/任意机器生成一对专用密钥(不要用你日常那把)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/gh-deploy

# 公钥追加到(每台)服务器的 authorized_keys
cat ~/.ssh/gh-deploy.pub | ssh user@test-server "cat >> ~/.ssh/authorized_keys"
cat ~/.ssh/gh-deploy.pub | ssh user@prod-server "cat >> ~/.ssh/authorized_keys"

# 私钥内容(~/.ssh/gh-deploy 的完整文本,含 BEGIN/END 行)待会儿贴进 GitHub Secret

二、GitHub 配置 Secrets(两套,按前缀区分)

服务器的 IP、账号、私钥这些东西绝对不能写进仓库,得放进 GitHub 的加密 Secret 里,workflow 运行时以 ${{ secrets.XXX }} 的形式注入。

两台服务器就是两套凭证,我用前缀区分:TEST_* 是测试服、PROD_* 是正式服。进入仓库的 Settings → Secrets and variables → Actions → New repository secret,逐个添加:

说明测试服正式服
服务器 IP 或域名TEST_SERVER_HOSTPROD_SERVER_HOST
SSH 登录用户名(建议非 root)TEST_SERVER_USERPROD_SERVER_USER
部署私钥完整内容(含 -----BEGIN/END----- 行)TEST_SERVER_SSH_KEYPROD_SERVER_SSH_KEY
SSH 端口(默认 22)TEST_SERVER_PORTPROD_SERVER_PORT
服务器上仓库所在目录,如 /home/user/appTEST_DEPLOY_PATHPROD_DEPLOY_PATH

image.png

另一条实现双环境的路是 Settings → Environments:建 test / production 两个环境,把同名 secrets 分别放进各自环境,deploy job 上标 environment: xxx——好处是还能给 production 加「需要人工审批才能部署」的保护规则。我这里选了更直接的「前缀区分 repo secrets」,不用动 job 结构;如果你想要合并 main 后先卡审批再部署,Environments 更合适。

三、编写 workflow

workflow 就是仓库里 .github/workflows/ 下的 yml 文件。下面是我的完整配置:

name: CI
on:
  pull_request:
    branches: [main, test]
    types: [closed]          # PR 关闭时触发,再用 merged==true 过滤出「真正合并」的
permissions:
  contents: read
  packages: write

jobs:
  test:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - uses: snok/install-poetry@v1
        with:
          version: latest
          virtualenvs-create: true
          virtualenvs-in-project: true
      - name: Install dependencies
        run: poetry install --no-interaction
      - name: Lint (soft)
        run: |
          poetry run flake8 src tests || true
          poetry run black --check src tests || true
          poetry run isort --check-only src tests || true
          poetry run mypy src || true
      - name: Tests
        run: |
          set +e
          PYTHONPATH=src poetry run pytest -q
          rc=$?
          # 退出码 5 = 未收集到任何用例(tests/ 未入库)→ 视为通过
          if [ "$rc" -eq 5 ]; then echo "::warning::无测试用例,视为通过"; exit 0; fi
          exit $rc

  security:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - uses: snok/install-poetry@v1
      - run: poetry install --no-interaction
      # 依赖漏洞扫描用 pip-audit(PyPA 官方,查 OSV/PyPI 库、免认证);--skip-editable 跳过本地可编辑安装的自身包。
      - run: poetry run pip install pip-audit && poetry run pip-audit --skip-editable
      - run: poetry run pip install bandit[toml] && poetry run bandit -r src/ -ll

  deploy:
    needs: [test, security]      # 测试、安全都过了才部署
    if: github.event.pull_request.merged == true && (github.event.pull_request.base.ref == 'test' || github.event.pull_request.base.ref == 'main')
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1.0.0
        with:
          # 按目标分支选服务器:合并到 main → 正式服(PROD_*);合并到 test → 测试服(TEST_*)
          host: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_HOST || secrets.TEST_SERVER_HOST }}
          username: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_USER || secrets.TEST_SERVER_USER }}
          key: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_SSH_KEY || secrets.TEST_SERVER_SSH_KEY }}
          port: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_PORT || secrets.TEST_SERVER_PORT }}
          script: |
            set -e
            BR="${{ github.event.pull_request.base.ref }}"
            DEPLOY_PATH="${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_DEPLOY_PATH || secrets.TEST_DEPLOY_PATH }}"
            cd "$DEPLOY_PATH"
            # git 一次性对齐到目标分支:fetch + 强制切分支(-f 扛脏工作区) + 硬重置
            git fetch origin "$BR"
            git checkout -f "$BR"
            git reset --hard "origin/$BR"
            # 合并到 test → test 编排;合并到 main → prod 编排
            if [ "$BR" = "test" ]; then ENVN=test; else ENVN=prod; fi
            echo "branch=$BR env=$ENVN"
            SKIP_PULL=1 ./deploy.sh "$ENVN" deploy   # 让脚本跳过它自己的 git pull(上面已对齐)
      - name: Verify
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_HOST || secrets.TEST_SERVER_HOST }}
          username: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_USER || secrets.TEST_SERVER_USER }}
          key: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_SSH_KEY || secrets.TEST_SERVER_SSH_KEY }}
          port: ${{ github.event.pull_request.base.ref == 'main' && secrets.PROD_SERVER_PORT || secrets.TEST_SERVER_PORT }}
          script: |
            set -e
            sleep 10
            curl -f http://localhost:8000/api/v1/health || exit 1

四、部署脚本 deploy.sh

CI 里最后调的 ./deploy.sh test deploy / ./deploy.sh prod deploy 是服务器上的部署脚本,它干的活就是一条龙:校验配置 → 建日志目录 → builddown 旧容器 → up 新容器 → 轮询健康检查。第一个参数决定用哪个 compose 文件——「不同环境用不同 docker-compose yml」就是在这里落地的

case "$1" in
  test) COMPOSE_FILE="docker-compose.test.yml" ;;   # 测试服:小内存/CPU 配额
  prod) COMPOSE_FILE="docker-compose.yml"      ;;   # 正式服:大配额
esac

dc() { docker compose -f "$COMPOSE_FILE" --env-file .env "$@"; }

full_deploy() {
    check_files; check_docker; check_networks
    prepare_volumes; pull_code
    dc build --parallel
    dc down
    dc up -d
    check_health          # 轮询 curl localhost:8000/api/v1/health
}

两个 compose 文件的服务定义基本一致,差异只在资源配额(deploy.resources.limits 的 memory/cpus)和外部网络名。因为每台服务器只跑自己那个环境,compose 文件虽然都在仓库里,但测试服永远只会被 CI 用 test 参数调、正式服永远只会被用 prod 调,不会串。

这里有两个当时踩过、值得单独讲的设计:

① 为什么 git 用 fetch + checkout -f + reset --hard,而不是 git pull git pull 本质是 fetch + merge,工作区一旦有本地改动或分支分叉,就会报冲突、部署直接挂掉。CI 要的是确定性:无条件把服务器代码对齐到刚合并的那个 commit。所以用 reset --hard origin/$BR 硬重置;checkout-f 是为了即便工作区被人手动改脏,也能强切干净、不 abort。

② 为什么给部署脚本传一个「跳过 pull」的标记? 不少部署脚本内部会自己再 git pull 一次。但 CI 已经用上面三行把代码对齐好了,两边都动 git 会互相打架。所以可以用一个环境变量(例子里叫 SKIP_PULL=1)让脚本在 CI 场景下跳过它自己的 git pull——git 只在一个地方做。人工手动执行时不带这个变量,脚本照常自己 pull。

五、触发与运行

配置好之后,日常就回归到最舒服的姿势——只管提 PR:

image.png

想上测试环境,就把功能分支的 PR 合并到 test,几十秒后测试服就是新版本;在测试服上验证没问题,再提一个 testmain 的 PR,合并的一瞬间正式服跟着更新。可以在仓库的 Actions 标签页看到 test / security / deploy 三个任务依次执行:

image.png

点进具体任务还能看到每一步的实时日志,SSH 上去执行的每条命令输出都在里面——deploy 日志开头那行 branch=xxx env=xxx 就能确认这次部署落在了哪个环境,出问题一眼定位。

六、结果

一切顺利的话,Actions 全绿:

image.png

此时对应服务器上的容器已经被就地换成了新版本,最后的 Verify 步骤 curl 健康检查也返回了 200:

$ curl http://localhost:8000/api/v1/health
{"status":"healthy","version":"1.0.0"}

从此以后,合并到 test 自动上测试服、合并到 main 自动上正式服,再也不用手动 SSH 上去敲那一串命令,也不会再有「到底部署了没、部署到哪台、部署的哪个版本」的疑问了。

踩过的坑 / 注意事项

  1. && || 分流有个暗坑:它不是真三元。 如果条件为真但 A 取出来是空串(比如 PROD_SERVER_HOST 这个 secret 名字打错了、根本没配),表达式会静默落到 B——后果是「合并到 main,却部署到了测试服」,全程不报错。所以两套 secrets 配完后,先各合并一个小 PR,去 Actions 日志里确认 branch= env= 和实际连的机器对得上,再正式使用。
  2. 容器撞名 container name ... already in use 根因是 compose 里写死了 container_name,而 docker compose down 只清「本工程」(工程名默认 = 执行 compose 时所在目录名)的容器。只要手动部署和 CI 都在同一个目录里跑 compose,工程名就一致,是同一批容器、能就地替换;一旦换了目录或工程名,down 清不掉旧工程占着的同名容器,up 就会撞名。服务器上如有旧残留容器,切换前先 docker rm -f <容器名> 清一次。
  3. git reset --hard 会覆盖服务器上对「已跟踪文件」的手改。 比如你在测试服上手动调了 compose 的内存上限,下次 CI 一部署就被打回。要改这类值,请提交进 git 或改成 .env 变量驱动,别在服务器上直接手改。
  4. 别让「双份 git 操作」打架。 CI 已 reset --hard,脚本内就别再 git pull——用一个环境变量(如 SKIP_PULL=1)让脚本跳过它自己的 pull。
  5. checkout 记得加 -f,否则工作区一脏就 abort,部署失败。
  6. pytest 退出码 5 可以特判为通过,别让「还没写测试」把流水线卡红。
  7. Secret 不要硬编码,SSH 用「部署专用」密钥、最小权限;两台服务器各配一把,泄露了也能单独吊销。

如果只有一台服务器?

退化非常简单:secrets 只留一套(SERVER_HOST / SERVER_USER / SERVER_SSH_KEY / SERVER_PORT / DEPLOY_PATH),deploy / Verify 里所有 && || 表达式换回 ${{ secrets.SERVER_HOST }} 这种直引,分支的作用只剩「切 compose 文件」——合并 test 用小配额编排、合并 main 用大配额编排,部署到同一台机器上。其余所有东西——触发条件、三行 git、deploy.sh、健康检查——一字不用改。我最早就是这么跑的,后来测试和正式在一台机器上互相抢资源、测试的折腾也可能波及线上,才拆成了两台。

小结

  • pull_requestclosed + merged == true 精确地在「合并后」触发,比 on: push 更可控;
  • 一个 deploy job 服务两个环境:host / 秘钥 / 路径全用 base.ref == 'main' && PROD_* || TEST_* 分流,不复制 job、部署逻辑永远只有一份;
  • 部署阶段用 SSH + fetch/checkout -f/reset --hard 做确定性对齐,比 git pull 稳;
  • 敏感信息全走 Secret(两套前缀区分环境),环境差异收敛到「五个表达式 + 一个 compose 文件名」,CI 只负责「选环境 + 对齐代码 + 调脚本 + 验健康」。

接好之后,部署这件事就从「每次都要小心翼翼手动操作」,变成了「提 PR、点合并、喝口水、Done」。