GitHub Actions 实现 CI/CD 自动部署
CICD已经是软件工程中非常成熟的步骤了。成熟的项目都会有自己的CICD流程。CICD的实现有很多,这篇笔记主要分享一下通过GitHub Actions实现基础的CICD。
如果没有CICD,每次改完代码要部署,都得 SSH 上服务器,git pull、docker compose build、down、up,再 curl 一下健康检查看看起没起来。步骤多、容易漏,多人协作时更乱——谁部署了、部署的是哪个版本,全靠口头对齐。
理想的状态是:合并到 test 分支,测试服自动更新;合并到 main 分支,正式服自动更新——测试、构建、部署、健康检查全程自动化。 这就是 CI/CD 想解决的事。这篇就记录一下我用 GitHub Actions 给项目接「双环境自动部署」的完整过程:从 GitHub 上配两套 Secret,到写 workflow 的 yml(重点是按目标分支选服务器的写法),到整条流程怎么跑,最后是结果。
整体流程
先看一眼整条链路,一句话概括:
开发提 PR → 合并到
test/main→ GitHub Actions 自动跑测试 + 安全扫描 → 通过后按目标分支选服务器:test→ SSH 到测试服、main→ SSH 到正式服 → 拉取最新代码、用对应环境的 compose 重建容器、健康检查 → 完成。
用文字拆开就是:
- 本地开发,推分支,开 Pull Request;
- PR 合并到
test或main; - GitHub 的托管机器(
ubuntu-latest)自动拉代码,跑 lint、单元测试、依赖与代码安全扫描; - 测试和安全都通过后,
deploy任务按目标分支选一套服务器凭证——合并到test用测试服那套,合并到main用正式服那套——SSH 连到对应的服务器; - 服务器上把代码对齐到刚合并的那个分支,用这台服务器对应的 docker-compose yml 重新
build/down/up容器; - 最后
curl健康检查接口,返回 200 才算部署成功。
我的项目是个用 Docker + docker-compose 部署的后端服务。两台服务器配置不一样:测试服资源小、正式服资源大,所以两边用不同的 compose 文件(docker-compose.test.yml / docker-compose.yml,差在内存/CPU 配额和网络名),这个选择由部署脚本按环境参数完成,后面会讲。
为什么用 GitHub Actions(对比 Jenkins)
说到 CI/CD,绕不开 Jenkins。它是这个领域的老牌工具,功能强、插件多、几乎什么流水线都能编排。但对我这种「代码已经在 GitHub 上、就想合并后自动部署」的场景,Jenkins 有点重——它是需要你自己搭一台服务器、自己维护的系统。两者放一起对比:
| 维度 | GitHub Actions | Jenkins |
|---|---|---|
| 部署形态 | 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_HOST | PROD_SERVER_HOST |
| SSH 登录用户名(建议非 root) | TEST_SERVER_USER | PROD_SERVER_USER |
部署私钥完整内容(含 -----BEGIN/END----- 行) | TEST_SERVER_SSH_KEY | PROD_SERVER_SSH_KEY |
| SSH 端口(默认 22) | TEST_SERVER_PORT | PROD_SERVER_PORT |
服务器上仓库所在目录,如 /home/user/app | TEST_DEPLOY_PATH | PROD_DEPLOY_PATH |

另一条实现双环境的路是 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 是服务器上的部署脚本,它干的活就是一条龙:校验配置 → 建日志目录 → build → down 旧容器 → 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:

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

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

此时对应服务器上的容器已经被就地换成了新版本,最后的 Verify 步骤 curl 健康检查也返回了 200:
$ curl http://localhost:8000/api/v1/health
{"status":"healthy","version":"1.0.0"}
从此以后,合并到 test 自动上测试服、合并到 main 自动上正式服,再也不用手动 SSH 上去敲那一串命令,也不会再有「到底部署了没、部署到哪台、部署的哪个版本」的疑问了。
踩过的坑 / 注意事项
&& ||分流有个暗坑:它不是真三元。 如果条件为真但 A 取出来是空串(比如PROD_SERVER_HOST这个 secret 名字打错了、根本没配),表达式会静默落到 B——后果是「合并到 main,却部署到了测试服」,全程不报错。所以两套 secrets 配完后,先各合并一个小 PR,去 Actions 日志里确认branch= env=和实际连的机器对得上,再正式使用。- 容器撞名
container name ... already in use。 根因是 compose 里写死了container_name,而docker compose down只清「本工程」(工程名默认 = 执行 compose 时所在目录名)的容器。只要手动部署和 CI 都在同一个目录里跑 compose,工程名就一致,是同一批容器、能就地替换;一旦换了目录或工程名,down清不掉旧工程占着的同名容器,up就会撞名。服务器上如有旧残留容器,切换前先docker rm -f <容器名>清一次。 git reset --hard会覆盖服务器上对「已跟踪文件」的手改。 比如你在测试服上手动调了 compose 的内存上限,下次 CI 一部署就被打回。要改这类值,请提交进 git 或改成.env变量驱动,别在服务器上直接手改。- 别让「双份 git 操作」打架。 CI 已
reset --hard,脚本内就别再git pull——用一个环境变量(如SKIP_PULL=1)让脚本跳过它自己的 pull。 checkout记得加-f,否则工作区一脏就 abort,部署失败。pytest退出码 5 可以特判为通过,别让「还没写测试」把流水线卡红。- 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_request的closed+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」。