流水线总览

磨坊项目的部署拆成两条独立流水线,分别覆盖后端 API 栈和前端 SCADA 单页应用:

  • deploy-mill-server.yml:MySQL、私有 MQTT、API 三个容器重建
  • deploy-mill-scada.yml:前端静态资源 rsync 上传

两条线都跑在 ubuntu-latest 上,触发条件、构建步骤、上传策略各不相同,但共享同一套 SSH 密钥和 VPS 凭据。

触发条件:paths 过滤

两条流水线都监听 main 分支 push,但用 paths 过滤只让真正变更的子目录触发部署,避免改前端就重启后端数据库。

Workflow监听路径
serverPlatform/server/**Platform/package.jsonPlatform/pnpm-lock.yamlPlatform/pnpm-workspace.yaml、自身 yml
scadaPlatform/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_KEYVPS_HOSTVPS_PORTVPS_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}/

排除清单分两类:

  • 运行时密钥.envpasswordfilecerts/、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
}

三段逻辑:

  1. cmp -s 比对新旧配置,无变化直接 return 0,避免无谓 reload 抖动
  2. sudo -n true 探测免密 sudo 是否可用,不可用直接报错而不是 sudo cp 卡在密码提示
  3. 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 inspectMySQL 健康状态
进程层http://127.0.0.1:3001/healthAPI 进程已起
端到端https://mill-api.varka.cn/healthNginx + 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 部署有两个细节:

  1. try_files 回退:Nginx 配置里 try_files $uri $uri/ /scada/index.html,所有未命中静态文件的路径回退到 index.html,交给前端路由处理(这部分由 server 流水线管理的 mill-api.varka.cn.conf 提供)
  2. 保留旧 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

步骤动作关键点
Checkoutactions/checkout@v4拉源码
Setup pnpmpnpm/action-setup@v4版本 10.33.2
Setup Node.jsactions/setup-node@v6Node 22,按 lock 缓存
Check API syntaxpnpm install + checkfrozen-lockfile,挡语法错误
Setup SSH手动写 id_ed25519tr 去 CRLF,keyscan 预填 known_hosts
Backup runtimeSSH heredoc.env、aclfile、mysqldump 三件套
Upload filesrsync -avz排除密钥与 node_modules
Restart stackSSH heredocNginx diff + docker compose up —build + 健康轮询

scada

步骤动作关键点
Checkoutactions/checkout@v4拉源码
Setup pnpmpnpm/action-setup@v4版本 10.33.2
Setup Node.jsactions/setup-node@v6Node 22,按 lock 缓存
Install depspnpm install —frozen-lockfile不改锁文件
Buildpnpm —filter …scada buildvite build 自带类型检查
Setup SSH手动写 id_ed25519同 server
Prepare dirssh mkdir -p确保目标目录存在
Deployrsync -avz不 delete,保留旧 chunk

结语

两条流水线的设计有几个共同原则:

  • 不破坏运行时:rsync 排除密钥、备份先于上传、concurrency 不取消在跑的部署
  • 失败要早:pnpm check 挡语法、ssh-keygen 校验私钥、nginx -t 校验配置
  • 验证要全:从容器健康到端到端 HTTPS,任何一层红都让 job 红
  • 可回滚:备份带时间戳、Nginx 配置变更前 diff、SCADA 旧 chunk 不删

这套流水线不是模板照搬,是按 VPS 实际权限、容器拓扑、SPA 部署特性逐步调出来的。每条规则背后都对应一次踩坑,留作后续类似项目部署的参考。