流水线总览
磨坊项目的部署拆成两条独立流水线,分别覆盖后端 API 栈和前端 SCADA 单页应用:
deploy-mill-server.yml:MySQL、私有 MQTT、API 三个容器重建deploy-mill-scada.yml:前端静态资源 rsync 上传
两条线都跑在 ubuntu-latest 上,触发条件、构建步骤、上传策略各不相同,但共享同一套 SSH 密钥和 VPS 凭据。
触发条件:paths 过滤
两条流水线都监听 main 分支 push,但用 paths 过滤只让真正变更的子目录触发部署,避免改前端就重启后端数据库。
| Workflow | 监听路径 |
|---|---|
| server | Platform/server/**、Platform/package.json、Platform/pnpm-lock.yaml、Platform/pnpm-workspace.yaml、自身 yml |
| scada | Platform/web/mill-scada/**、共享 lock 文件、自身 yml |
锁文件 pnpm-lock.yaml 同时进入两条过滤列表——依赖变化必须能同时触发两边重建,否则会出现「本地跑得起来、VPS 跑不起来」的依赖漂移。两条都保留 workflow_dispatch,方便手动补发或回滚。
pnpm 语法检查
server 流水线在 SSH 之前先做静态检查,提前把语法错误挡在 VPS 之外:
- name: Check API syntax
working-directory: Platform
run: |
pnpm install --frozen-lockfile
pnpm --filter stm32-mill-api check
--frozen-lockfile 禁止 CI 内改写锁文件,pnpm --filter <pkg> check 只跑指定 workspace 包的 check 脚本,避免误触发其他子包的 lint。
SCADA 流水线没有单独的 check 步骤,但跑 pnpm --filter d-project-html-mill-scada build——vite build 自带类型检查,构建失败即流水线失败,相当于隐式 gate。
SSH 密钥:手动注入而非 appleboy
社区常用 appleboy/ssh-action,但这里选择手动配置,原因是要在远端跑大段 heredoc 脚本,手动 SSH 更可控:
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.VPS_SSH_KEY }}" | tr -d '\r' > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 > /dev/null
ssh-keyscan -p ${{ secrets.VPS_PORT }} -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
几个细节:
tr -d '\r':去掉 GitHub Actions 在 Windows 编辑器里可能混入的 CRLF,避免私钥格式损坏ssh-keygen -y -f:校验私钥可读,格式错误立即报错而非在后续 rsync 时才暴露ssh-keyscan -H:预写到known_hosts,跳过首次连接的交互式确认
VPS_SSH_KEY、VPS_HOST、VPS_PORT、VPS_USER 全部走 repository secrets,密钥永不出现在日志和产物里。
VPS 运行时备份
上传新代码前,先把 VPS 上的运行时数据拉回备份目录,覆盖失败时能快速回滚:
BACKUP_DIR="${REMOTE_DIR}/deploy/backups/mill-pre-upload-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
backup_file "${REMOTE_DIR}/.env" ".env"
backup_file "${REMOTE_DIR}/mqtt/passwordfile" "mqtt/passwordfile"
backup_file "${REMOTE_DIR}/mqtt/aclfile" "mqtt/aclfile"
backup_file 函数做了两层降级:先普通 cp,失败再 sudo -n cp(密码免输),最后都失败只打 warning 不中断流水线。原因是部分文件权限严格(如 .env 通常 600 root:root),只读不破坏即可。
MySQL 走逻辑备份:
if docker ps -a --format '{{.Names}}' | grep -Fxq stm32-mill-mysql; then
docker exec stm32-mill-mysql sh -c \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --databases stm32_mill' \
> "$BACKUP_DIR/stm32_mill.sql" || rm -f "$BACKUP_DIR/stm32_mill.sql"
fi
关键点:
--single-transaction:InnoDB 一致性快照,不锁表docker exec ... sh -c:把$MYSQL_ROOT_PASSWORD留在容器内展开,不进宿主进程列表|| rm -f:dump 失败时清掉残缺 SQL,避免误用
rsync 上传:排除密钥、保留旧 chunk
server 端 rsync 把 Platform/server/ 同步到 VPS /opt/varka/:
rsync -avz \
--exclude='.env' \
--exclude='mqtt/passwordfile' \
--exclude='mqtt/certs/' \
--exclude='mqtt/*.db' \
--exclude='node_modules' \
--exclude='services/stm32-mill-api/node_modules' \
-e "ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.VPS_PORT }}" \
Platform/server/ \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${REMOTE_DIR}/
排除清单分两类:
- 运行时密钥:
.env、passwordfile、certs/、MQTT*.db——这些只在 VPS 上存在,绝不允许被仓库覆盖 - 本地依赖:
node_modules——CI 上没装,VPS 上由 Docker 构建
SCADA 端策略相反,要刻意保留旧 chunk:
rsync -avz --no-owner --no-group --no-perms \
-e "ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.VPS_PORT }}" \
./Platform/web/mill-scada/dist/ \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/mill-api.varka.cn/scada/
Vite 构建产物文件名带内容 hash(assets/index-a3f9b2c1.js),同名文件 rsync 默认跳过,旧文件不会被覆盖。已打开 SPA 的用户在部署瞬间仍可懒加载到旧 chunk,等下次刷新才切到新版本,实现「零中断发布」。
--no-owner --no-group --no-perms 关掉权限同步,避免 CI 的 uid/gid 覆盖 VPS 上 Nginx 的 www-data 所有权。
Nginx 配置 diff 与热重载
docker-compose.mill.yml 重建容器后,还要更新宿主 Nginx 的反向代理配置:
install_nginx_config() {
source_conf="$REMOTE_DIR/nginx/mill-api.varka.cn.conf"
target_conf="/etc/nginx/conf.d/mill-api.varka.cn.conf"
if [ -f "$target_conf" ] && cmp -s "$source_conf" "$target_conf"; then
echo "Nginx config unchanged; skip reload."
return 0
fi
if ! sudo -n true 2>/dev/null; then
echo "Nginx config changed but passwordless sudo is not available for deploy user." >&2
return 1
fi
sudo -n cp "$source_conf" "$target_conf"
sudo -n nginx -t
sudo -n systemctl reload nginx
}
三段逻辑:
cmp -s比对新旧配置,无变化直接 return 0,避免无谓 reload 抖动sudo -n true探测免密 sudo 是否可用,不可用直接报错而不是sudo cp卡在密码提示nginx -t先做语法校验,再systemctl reload热重载(不重启进程、不断连接)
docker compose 重建
docker compose -f docker-compose.mill.yml \
--profile private-mqtt --profile storage \
up -d --build stm32-mill-mysql stm32-mill-mqtt stm32-mill-api
要点:
--profile:激活 compose 文件里的可选 profile,让私有 MQTT 和存储服务一起拉起up -d --build:先 rebuild 镜像再 up,保证代码变更生效(不复用旧镜像层)- 显式列出三个服务名:只重建这三个容器,其他无关服务不动
容器健康轮询
重建后不是直接 return,而是轮询三层健康检查,任何一层失败都让 job 失败:
wait_for_healthy() {
name="$1"
for attempt in $(seq 1 60); do
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null || true)"
if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then
return 0
fi
echo "Waiting for $name health ($attempt/60): ${status:-missing}"
sleep 2
done
docker logs --tail 120 "$name" || true
echo "Timed out waiting for $name health" >&2
return 1
}
模板里用 {{if .State.Health}} 区分有 healthcheck 的容器(MySQL)和无 healthcheck 的容器(只看 running),两种都支持。超时 120 秒(60×2s),失败时自动 dump 最后 120 行日志,方便排查启动卡点。
调用顺序:
wait_for_healthy stm32-mill-mysql
wait_for_url http://127.0.0.1:3001/health
wait_for_url https://mill-api.varka.cn/health
| 层级 | URL | 检查内容 |
|---|---|---|
| 容器层 | docker inspect | MySQL 健康状态 |
| 进程层 | http://127.0.0.1:3001/health | API 进程已起 |
| 端到端 | https://mill-api.varka.cn/health | Nginx + HTTPS + 上游全部通 |
第三层走公网域名,覆盖了 Nginx 配置、TLS 证书、反向代理、API 容器全链路。任何一环坏了都会在 curl -fsS 上失败,触发整条流水线红。
SCADA 的 SPA 部署
SCADA 流水线相比 server 简化很多,没有数据库和容器重建,只做静态资源 rsync:
ssh ... "mkdir -p /var/www/mill-api.varka.cn/scada"
rsync -avz --no-owner --no-group --no-perms \
-e "ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.VPS_PORT }}" \
./Platform/web/mill-scada/dist/ \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/mill-api.varka.cn/scada/
SPA 部署有两个细节:
- try_files 回退:Nginx 配置里
try_files $uri $uri/ /scada/index.html,所有未命中静态文件的路径回退到index.html,交给前端路由处理(这部分由 server 流水线管理的mill-api.varka.cn.conf提供) - 保留旧 chunk:rsync 不加
--delete,旧 hash 文件留在服务器上,部署瞬间老用户不会因为懒加载 404 而白屏
如果加 --delete,部署瞬间的请求会命中已删除的旧 chunk,SPA 直接崩。生产环境宁可让旧 chunk 累积,定期人工清理。
并发控制:cancel-in-progress=false
concurrency:
group: deploy-mill-server
cancel-in-progress: false
group 把同名的 workflow 串行化,同 group 内同时只允许一个运行。cancel-in-progress: false 是关键——新的 push 不打断正在进行的部署。
为什么不用默认的 true?后端部署涉及:
- rsync 写文件(中途打断会留下半截文件)
docker compose up --build(中途打断容器可能停在半启状态)- Nginx reload(打断可能配置没切完)
任何一处中断都会让 VPS 处于不可预测的中间态,比部署慢几分钟严重得多。让旧部署跑完,新 push 进队列等下一轮,是更稳的选择。
两条流水线步骤汇总
server
| 步骤 | 动作 | 关键点 |
|---|---|---|
| Checkout | actions/checkout@v4 | 拉源码 |
| Setup pnpm | pnpm/action-setup@v4 | 版本 10.33.2 |
| Setup Node.js | actions/setup-node@v6 | Node 22,按 lock 缓存 |
| Check API syntax | pnpm install + check | frozen-lockfile,挡语法错误 |
| Setup SSH | 手动写 id_ed25519 | tr 去 CRLF,keyscan 预填 known_hosts |
| Backup runtime | SSH heredoc | .env、aclfile、mysqldump 三件套 |
| Upload files | rsync -avz | 排除密钥与 node_modules |
| Restart stack | SSH heredoc | Nginx diff + docker compose up —build + 健康轮询 |
scada
| 步骤 | 动作 | 关键点 |
|---|---|---|
| Checkout | actions/checkout@v4 | 拉源码 |
| Setup pnpm | pnpm/action-setup@v4 | 版本 10.33.2 |
| Setup Node.js | actions/setup-node@v6 | Node 22,按 lock 缓存 |
| Install deps | pnpm install —frozen-lockfile | 不改锁文件 |
| Build | pnpm —filter …scada build | vite build 自带类型检查 |
| Setup SSH | 手动写 id_ed25519 | 同 server |
| Prepare dir | ssh mkdir -p | 确保目标目录存在 |
| Deploy | rsync -avz | 不 delete,保留旧 chunk |
结语
两条流水线的设计有几个共同原则:
- 不破坏运行时:rsync 排除密钥、备份先于上传、concurrency 不取消在跑的部署
- 失败要早:pnpm check 挡语法、ssh-keygen 校验私钥、nginx -t 校验配置
- 验证要全:从容器健康到端到端 HTTPS,任何一层红都让 job 红
- 可回滚:备份带时间戳、Nginx 配置变更前 diff、SCADA 旧 chunk 不删
这套流水线不是模板照搬,是按 VPS 实际权限、容器拓扑、SPA 部署特性逐步调出来的。每条规则背后都对应一次踩坑,留作后续类似项目部署的参考。