还在手动 SSH 上服务器拉代码、重启服务?GitHub Actions 免费额度就够中小团队用了。本文从真实项目出发,手把手搭建一条从代码推送到自动部署的完整流水线。
痛点:手动部署的代价
运维老哥都经历过这种场景:开发喊一声"代码合了",你 SSH 上去 git pull、docker build、docker-compose up -d,顺便祈祷别忘了跑数据库迁移。
问题不在于操作本身,而是——
- 人会忘事:漏跑 migration、忘记清缓存、配置没同步
- 无法审计:谁在什么时间部署了什么版本?翻
history猜吧 - 回滚靠运气:上个版本的镜像还在不在?Tag 打了没?
CI/CD 不是锦上添花,是消灭人为失误的基础设施。
方案:GitHub Actions + Docker + 自托管 Runner
架构一句话说清:Push 代码 → Actions 自动构建镜像 → 推到 Registry → SSH 到目标机部署。
Developer → Push/Merge → GitHub Actions
↓
Build Docker Image
↓
Push to ghcr.io
↓
SSH Deploy to Production
为什么选 GitHub Actions?三个理由:免费额度够用(公开仓库无限,私有仓库每月 2000 分钟)、和代码仓库零距离、Marketplace 有现成的 Action 直接拿来用。
实操步骤
第 1 步:定义 Workflow 文件
在仓库根目录创建 .github/workflows/deploy.yml:
name: Build & Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
第 2 步:镜像打双标签
注意上面的 tags 写了两个——github.sha 是精确版本号,latest 是方便快速部署。回滚时靠 SHA 标签,日常部署靠 latest。
第 3 步:添加部署 Job
在同一个文件里追加部署步骤:
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cd /opt/myapp
export IMAGE_TAG=${{ github.sha }}
docker compose up -d --no-build
docker image prune -f
第 4 步:配置 Secrets
在仓库 Settings → Secrets and variables → Actions 中添加:
| Secret 名 | 内容 |
|---|---|
DEPLOY_HOST |
目标服务器 IP |
DEPLOY_USER |
SSH 用户名 |
DEPLOY_KEY |
SSH 私钥 |
第 5 步:增加健康检查
部署后别急着下班,加一步验证:
- name: Health Check
run: |
sleep 15
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://your-domain.com/healthz)
if [ "$STATUS" != "200" ]; then
echo "❌ Health check failed: HTTP $STATUS"
exit 1
fi
echo "✅ Health check passed"
第 6 步:通知到群聊
部署结果推到 Slack 或钉钉,全团队可见:
- name: Notify
if: always()
run: |
STATUS=${{ job.status }}
curl -X POST "${{ secrets.WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d "{\"text\":\"Deploy ${STATUS}: ${{ github.repository }}@${{ github.sha }}\"}"
避坑指南
坑 1:Secret 泄漏——别在日志里 echo 变量
GitHub Actions 会自动遮蔽 Secrets 的值,但如果你把 Secret 拼到 URL 或文件名里,遮蔽可能失效。安全铁律:Secret 只通过环境变量传递,永远不拼进字符串。
# ❌ 危险写法
run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# ✅ 安全写法
env:
HOST: ${{ secrets.DEPLOY_HOST }}
run: echo "Deploying to $HOST"
坑 2:并发部署互相踩——需要 concurrency 控制
两个人几乎同时 push 到 main,两条流水线同时跑部署——服务器上 docker compose up 被调了两次,端口冲突或数据库迁移冲突。
加一行就解决:
concurrency:
group: deploy-production
cancel-in-progress: false # 排队而非取消
坑 3:磁盘被撑爆——旧镜像没清理
每次部署都 docker pull 新镜像,旧的不删,一个月后磁盘就满了。上面脚本已经加了 docker image prune -f,但更稳的做法是加个 cron:
# /etc/cron.daily/docker-cleanup
#!/bin/bash
docker image prune -a --filter "until=168h" -f
docker volume prune -f
保留最近 7 天的镜像,其余全清。
总结
| 步骤 | 作用 |
|---|---|
| Workflow 文件 | 定义触发条件和任务 |
| 双标签 | SHA 精确回滚 + latest 快速部署 |
| SSH 部署 | 拉镜像、重启服务 |
| Secrets | 安全存储敏感信息 |
| 健康检查 | 部署后自动验证 |
| 群聊通知 | 全团队可见,出问题秒响应 |
一条流水线跑通之后,你会发现:回滚就是重跑某次 Action,审计就是看 Actions 历史,协作就是 PR 合并后自动生效。运维的安全感,从自动化开始。