GitHub Actions 流水线配置实战:从语法到生产环境部署
一年前我接手一个前端项目,CI/CD 流程是用最原始的 shell 脚本拼凑出来的:登录服务器、pull 代码、重启服务,全靠人肉操作。那时候每次发版都心惊胆战,生怕哪个步骤手滑了。直到我花了两周把整个流程迁移到 GitHub Actions,才真正体会到什么叫"一键发布,睡安稳觉"。
这篇文章是我踩坑记录的整理,从最基础的语法讲起,最终覆盖生产环境部署的完整方案。不管你用的是 GitHub Enterprise 还是免费版,只要有代码在 GitHub 上,这套方案都能跑。
为什么是 GitHub Actions
在说具体配置之前,先回答一个我曾经纠结过的问题:为什么不用 Jenkins、GitLab CI 或者 CircleCI?
Jenkins 够强大,但维护成本高——你需要自己搭服务器、处理插件兼容、配置 master-slave 架构。GitLab CI 和 GitHub Actions 功能上差不多,但 GitHub Actions 和 GitHub 仓库的无缝集成让我最终选择了它:不需要额外登录什么平台,PR 页面直接能看到 CI 状态,review 代码的时候顺便检查流水线结果,一个界面全搞定。
经验之谈:如果你团队已经在用 GitHub 做代码托管,GitHub Actions 是成本最低的选择。没有之一。
核心概念:Job、Step、Action
GitHub Actions 的配置文件叫 workflow,存放在仓库根目录的 .github/workflows/ 文件夹下。一个仓库可以有多个 workflow,分别处理不同的事情——比如一个管 CI 测试,一个管 CD 发布。
最基本的结构是这样的:
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
逐行解释:on 定义触发条件,这里是 push 或 PR 到 main 分支时触发。jobs 下面定义一个叫 build 的 Job,它运行在 ubuntu-latest 虚拟机上。每个 step 要么是 uses 引用一个现成的 Action,要么是 run 执行一条命令。
第一个实战:Node.js 项目的 CI 流程
光说不练假把式,我们来跑一个完整的 CI 流水线。假设你有一个 Express.js 的 API 项目,需要跑单元测试、构建 Docker 镜像、推送镜像到仓库。
先看完整的配置文件:
name: Node.js CI & Build
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Unit Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
NODE_ENV: test
build:
name: Build Docker Image
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=,suffix=,format=short
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
这个配置里有个关键点:needs: test 表示 build Job 依赖 test Job,只有测试通过才执行构建。还有 if 条件确保只有 main 分支的 push 才会触发镜像构建,PR 阶段只跑测试,不推镜像。
多环境矩阵:一次构建,多个版本
Node.js 项目经常需要兼容多个 Node 版本。用矩阵策略可以一次配置、全版本覆盖:
jobs:
test:
name: Test on Node ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
strategy.matrix 定义了变量组合,Actions 会为每个组合生成一个独立的 Job 并行执行。如果某个版本失败,其他版本不受影响。
踩坑记录:早期我用的
npm install而不是npm ci。前者会修改 lockfile,在 GitHub Actions 的缓存环境下很容易出问题。CI 环境中永远用npm ci。
环境变量与 Secrets:敏感信息怎么处理
生产环境部署必然涉及敏感信息:数据库密码、API 密钥、Docker Registry 凭证。GitHub Actions 提供了两种机制处理这些。
环境变量适合非敏感配置:
env:
API_BASE_URL: https://api.example.com
NODE_ENV: production
Secrets 适合敏感数据,在 GitHub 仓库的 Settings → Secrets and variables → Actions 中添加。引用方式很简单:
- name: Deploy to Server
run: ./deploy.sh
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
引用格式是 ${{ secrets.SECRET_NAME }},GitHub 会自动遮蔽输出中的 secret 值。但这只是遮蔽,不是加密。如果你的脚本把密码打印到日志,仍然可能泄露。我的一般原则是:任何可能输出变量的地方,先确认是否必要。
# 错误示范:日志会打印密码
echo "Deploying with password: $DB_PASSWORD"
# 正确做法:只在必要时使用变量,且不打印
eval $(ssh-agent)
echo "$SSH_KEY" | ssh-add -
部署到生产服务器:SSH 密钥的正确姿势
这是整个流水线最复杂的部分。我的方案是使用部署密钥加环境变量,核心逻辑如下:
jobs:
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
deploy.sh
sparse-checkout-cone-mode: false
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} \
"cd /opt/myapp && ./deploy.sh ${{ github.sha }}"
env:
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
几个关键点:
- 稀疏检出:生产服务器不需要完整代码,只 checkout 部署脚本
- SSH 密钥通过环境变量注入:不在 workflow 文件中硬编码任何密钥
- known_hosts 处理:避免首次部署时 SSH 询问确认导致流程卡住
部署脚本 deploy.sh 大致长这样:
#!/bin/bash
set -e
NEW_COMMIT=$1
APP_DIR=/opt/myapp
cd $APP_DIR
# 拉取最新代码
git fetch origin main
git checkout $NEW_COMMIT
# 重启服务
systemctl restart myapp
# 健康检查
sleep 5
curl -f http://localhost:3000/health || exit 1
echo "Deployment successful"
血的教训:最开始我没加
set -e,结果部署脚本中途失败但 CI 显示成功。后来加了健康检查和set -e,才把"假成功"的情况堵住。
缓存优化:加速依赖安装
每次 CI 都重新下载 npm 包,浪费时间。用缓存可以省下 50% 以上的 CI 时间:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache: 'npm' 会自动缓存 node_modules,key 基于 lockfile 的 hash 值判定是否命中。这个是 setup-node 内置功能,不需要额外的缓存配置。
如果是其他语言或更复杂的缓存需求,可以用 actions/cache:
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
定时任务与手动触发
CI/CD 不只是代码推送才触发。比如定时备份数据库、定期安全扫描,都可以用 cron 表达式触发:
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
workflow_dispatch 开启后,GitHub 仓库的 Actions 页面会出现"Run workflow"按钮,可以手动指定输入参数触发执行。
小技巧:cron 表达式用 5 位而不是 6 位——GitHub Actions 不支持秒级精度。
完整的生产环境流水线
把所有知识点串起来,是一个完整的、多阶段的生产级流水线:
name: Production Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
NODE_ENV: test
build:
name: Build & Push Image
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=,suffix=,format=short
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy-staging:
name: Deploy to Staging
needs: build
runs-on: ubuntu-latest
if: github.event_name != 'workflow_dispatch'
environment: staging
steps:
- name: Deploy to staging
run: |
echo "Deploying ${{ needs.build.outputs.image-tag }} to staging"
deploy-production:
name: Deploy to Production
needs: [build, deploy-staging]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
deploy.sh
sparse-checkout-cone-mode: false
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to production
run: |
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} \
"cd /opt/myapp && ./deploy.sh ${{ github.sha }}"
env:
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
这个流水线从上到下:跑测试、构建镜像、自动部署到 staging 验证、确认后部署到生产。关键点是通过 environment 配置实现了生产部署的门禁,只有手动批准才能触发生产 Job。
常见坑点汇总
踩了一年的坑,这里是我的血泪总结:
1. 超时问题
默认超时是 6 小时,但对于大型项目可能不够。在 Job 级别指定超时:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
2. 并发控制
多人同时 push 可能导致流水线互相干扰。用 concurrency 限制同分支的并发:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
3. Action 版本锁定
用 @v4 而不是 @main。后者可能在你不知情时更新,导致流水线突然失败:
# 正确:锁定版本
- uses: actions/checkout@v4
# 危险:latest 可能 breaking change
- uses: actions/checkout@main
4. 权限最小化
不要给 workflow 满权限,按需分配:
permissions:
contents: read
packages: write
延伸思考
配置 CI/CD 流水线这件事,技术难度不高,但涉及的工程判断不少:什么时候触发构建、如何划分阶段、怎样设计回滚机制。这些问题没有标准答案,取决于团队规模和业务场景。
如果你在 5 人以下的小团队,我的建议是先跑通 test + build + deploy 三阶段,暂不拆分 staging。等团队扩大到 10 人以上、开始有多个功能并行开发时,再引入 staging 环境隔离和更细粒度的权限控制。
另外,GitHub Actions 的生态非常丰富。除了我上面提到的这些,还有 github-script 可以用 JS 操作 GitHub API、trivy 做容器安全扫描、tfaction 做 Terraform 部署。把这些工具组合起来,GitHub Actions 几乎可以替代大多数中小团队的 DevOps 需求。
最后一句话:CI/CD 的终点不是"自动化",而是"自动化 + 可观测"。流水线跑起来之后,别忘了配置 Slack/钉钉通知、失败告警、以及定期复盘流水线运行数据。跑通只是开始,用好才是本事。

评论已关闭!