写一篇我这个月刚踩完的坑:用 GitHub Actions 把自己的 Node + Docker 项目自动部署到阿里云 ECS。逻辑本身没有任何难度------构建、rsync、SSH 上去
docker compose up -d,二十行 YAML 的事。真正磨人的是国内服务器拉镜像、跨境 SSH 长连接、端口抢占这一地碎片。我把完整流程和每一个坑的排障过程记下来------真实情况是这套二十行 YAML 的东西前后折腾了我好几天,希望刷到这篇的同学能一天走完。阅读时间约 12 分钟,全是实战复现过的内容,配置可直接抄(把尖括号占位符换成你自己的即可)。
一、先晒成果:最终跑起来的形态
git push (main)
│
▼
GitHub Actions Runner(海外)
├─ ① 构建前端 dist(node 22)
├─ ② rsync 同步 backend/ deploy/ dist/ → 服务器 /opt/app/
└─ ③ SSH 执行 deploy/deploy.sh
│
▼ 服务器(阿里云 ECS,Alibaba Cloud Linux)
1/4 flock 互斥锁
2/4 docker compose pull(失败重试 3 次)
3/4 docker compose up -d --build
4/4 60 秒内 /api/health 检查,不健康就报红退出
日常一次部署 2~3 分钟见绿;失败时旧容器继续服务,线上无感。
看起来人畜无害的流水线,我落地时真实经历了这些事故:
| 现象 | 定位过程 | 根因 |
|---|---|---|
Deploy 步骤连着几天反复 Downloading 失败重试 |
看日志是服务器在连 registry-1.docker.io |
镜像拉取链路问题,与 CI 平台无关 |
exit code 12 |
rsync 协议错误 | 服务器没装 rsync |
exit code 18 |
传输阶段出错 | 同上,镜像超时导致连锁失败 |
| 配了加速器 ghcr 还是拉不动 | docker info 里 mirror 生效了呀? |
registry-mirrors 只对 Docker Hub 生效 |
Setup SSH 步骤离奇红 |
日志里 echo "" > deploy_key |
Secrets 被误删,模板渲染成空串 |
构建正常推进时突然 exit code 255 |
client_loop: send disconnect: Broken pipe |
跨境 NAT 掐断 ssh 长连接 |
address already in use :80 |
ss -tlnp 看到裸 nginx 进程 |
dnf 安全更新顺手装了宿主机 nginx |
| 容器起了但健康检查全红 | curl 127.0.0.1 返回 301 |
nginx if ($host != 域名) 误伤 IP 直访,301 死循环 |
下面按上线顺序把整套方案和对应的坑一次讲清。
二、服务器侧一次性初始化
2.1 部署专用账户(别让 CI 拿着 root 跑)
bash
# 创建部署用户并加入 docker 组(免 sudo)
sudo useradd -m -s /bin/bash deployer
sudo usermod -aG docker deployer
sudo mkdir -p /opt/app && sudo chown deployer:deployer /opt/app
# 授权 CI 公钥(私钥留在自己电脑上,公钥贴进 GitHub Secrets 的是配对的私钥)
sudo -u deployer mkdir -p /home/deployer/.ssh && sudo -u deployer chmod 700 /home/deployer/.ssh
echo "ssh-ed25519 AAAA... ci-deploy-key" | sudo -u deployer tee -a /home/deployer/.ssh/authorized_keys
sudo -u deployer chmod 600 /home/deployer/.ssh/authorized_keys
💡 坑 0(很多人第一步就中招) :
sudo -u deployer mkdir ~/.ssh里的~是由当前 root shell 先展开 的,实际会去创建/root/.ssh,直接Permission denied。给别的用户建目录请用绝对路径/home/deployer/.ssh。
本地生成密钥对:
powershell
ssh-keygen -t ed25519 -f $HOME\.ssh\app-deploy -N '""'
# 公钥贴服务器 authorized_keys,私钥整段贴进 GitHub Secret DEPLOY_KEY
2.2 装好 rsync(Actions 的同步依赖)
bash
sudo dnf install -y rsync
不装的话你会得到一条非常吝啬的报错:
rsync: [sender] ... protocol error: exit code 12
rsync exit 12 = 远端没有 rsync 命令。协议错误不告诉你"对方不存在",只丢一个数字,第一次见真的会去怀疑人生。
2.3 Docker 加速器:先搞清楚它管多大范围
/etc/docker/daemon.json:
json
{
"registry-mirrors": [
"https://<你的专属地址>.mirror.aliyuncs.com",
"https://docker.m.daocloud.io"
]
}
bash
sudo systemctl daemon-reload && sudo systemctl restart docker
docker info | grep -A 3 "Registry Mirrors" # 必须看到这两行才算生效
三个细节:
- 阿里云容器镜像服务控制台里的"镜像加速器"要给专属地址 (
xxxxxx.mirror.aliyuncs.com),网上抄的公共地址大多已失效; - 我在排查期间见过有人把
https://huecker.io这类镜像收录网站填进 registry-mirrors------它是给你搜镜像用的目录站,不是 registry 端点,填了纯属无效条目; - 改完必须
restart docker,docker info验证,不要凭"文件写了 = 生效了"。
配置正确的加速器能让 Docker Hub 镜像(nginx、redis、postgres、node)秒拉。但注意,它对你项目里最容易卡住的 ghcr.io 镜像完全无效------这是本篇最大的坑,第五节细说。
三、GitHub Actions 工作流
仓库 Settings → Secrets and variables → Actions 建 4 个 Secret:DEPLOY_HOST(服务器 IP)、DEPLOY_USER(deployer)、DEPLOY_KEY(私钥整段文本)、DEPLOY_PORT(22)。
.github/workflows/deploy.yml(生产验证版):
yaml
name: deploy-production
on:
push:
branches: [main]
# 纯文档改动不触发部署,省 runner 也省一次重启
paths-ignore:
- '**.md'
- 'doc/**'
# 同一时间只允许一条部署流水线,排队不取消
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
# ubuntu-latest 自带 rsync/openssh,不需要 apt 装(见下方坑 5)
- name: Setup SSH
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
if [ -z "$DEPLOY_KEY" ] || [ -z "$DEPLOY_HOST" ]; then
echo "::error::Secrets DEPLOY_KEY/DEPLOY_HOST 未配置"
exit 1
fi
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Sync to server
run: |
rsync -az --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude '.env' \
--exclude 'backend/uploads' \
--exclude 'backend/data' \
-e "ssh -i ~/.ssh/deploy_key -p ${DEPLOY_PORT:-22} -o ServerAliveInterval=30 -o ServerAliveCountMax=20" \
./ "${DEPLOY_USER}@${DEPLOY_HOST}:/opt/app/"
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
- name: Deploy on server
run: |
ssh -i ~/.ssh/deploy_key -p "${DEPLOY_PORT:-22}" \
-o ServerAliveInterval=30 -o ServerAliveCountMax=20 \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
'cd /opt/app && bash deploy/deploy.sh'
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
设计要点逐条说明(都是拿失败 run 换的):
paths-ignore:改 README 也触发部署纯属烧 runner 分钟数,排除**.md和doc/**;workflow 文件自身是.yml不受影响,改流水线仍能验证。- Secrets 用
env:传值而不是${{ }}直接拼进命令行 。直接拼接有两个问题:密钥值可能出现在日志命令行里;Secret 未配置时渲染成空串,命令拼出ssh deployer@""这种诡异错误。我亲眼见过日志里echo "" > ~/.ssh/deploy_key------排查了十分钟才反应过来是 Secret 名字打错了。开头加空值守卫,缺了直接一句话报给你。 ServerAliveInterval=30+ServerAliveCountMax=20:每 30 秒发一个保活包,容忍连续 20 次无响应。不加这行的后果见第七节。- rsync 的
--exclude是生产数据的生命线 :.env(服务器上的真实密钥)、用户上传目录、数据库文件必须排除,否则--delete一推下去就是生产事故。
四、deploy.sh:服务器上真正的部署逻辑
放在仓库 deploy/deploy.sh,被 CI SSH 调用,手动上传也能跑:
bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
# ---- 互斥锁:两条链路(CI / 手动)同时进来时只许一个跑 ----
exec 9>/tmp/app-deploy.lock
if ! flock -n 9; then
echo "另一个部署正在进行,本次退出"
exit 1
fi
echo "==> 1/4 预拉取基础镜像(重试 3 次)"
for attempt in 1 2 3; do
if docker compose pull redis web umami umami-db; then
break
fi
if [ "$attempt" = 3 ]; then
echo "镜像拉取失败。注意 registry-mirrors 只对 Docker Hub 生效,ghcr.io 需直连或代理站"
exit 1
fi
sleep 15
done
echo "==> 2/4 构建并滚动更新"
docker compose up -d --build
echo "==> 3/4 健康检查(最多 60 秒)"
for i in $(seq 1 12); do
if curl -fs http://127.0.0.1/api/health >/dev/null 2>&1; then
echo "健康检查通过"
break
fi
sleep 5
if [ "$i" = 12 ]; then
echo "❌ 服务未恢复健康,容器现场如下:"
docker compose ps
exit 1
fi
done
echo "==> 4/4 ✅ 部署完成"
docker compose ps
两个容易忽略的细节:
docker compose pull后面要显式列服务名 。我 compose 里的api/worker是本地build:出来的,没有远端镜像,裸跑docker compose pull在 compose v2 会直接报错。只列真正有远端镜像的四个服务。flock防的是同机并发 。GitHub Actions 的concurrency只锁 CI 内部,锁不住你手动 SSH 跑的那一次,所以脚本层面的互斥锁仍然必要。
五、重头戏:镜像拉取地狱与 ghcr 的最后一公里
链路配置好后第一次 push,Deploy on server 步骤开始疯狂刷下载日志------这一刷就是好几天:加速器、换镜像源、重跑重试轮番试过,每次看似在推进,第二天看又回到原样。最后点 Cancel 时我意识到必须先搞清楚"下载到底发生在哪台机器上"。这一节是全文最想传递的经验。
5.1 先归因,再动手
看到"一直在下载",第一反应很容易是"GitHub 不行,换个 CI 平台吧"。冷静,先看下载到底发生在哪台机器上。 Actions 的 Deploy on server 步骤只是 SSH 触发了远端脚本,真正在拉镜像的是你的服务器,流量根本不经过 GitHub。换云效、Gitee Go、任何 CI------服务器该拉不到的一样拉不到,白白折腾一次平台迁移。
诊断三板斧,全部在服务器上执行:
bash
cat /etc/docker/daemon.json # ① 加速器配了没?条目是不是有效 registry 端点?
docker info | grep -A3 'Registry Mirrors' # ② 配了 ≠ 生效了,重启过 docker 吗?
docker pull redis:7-alpine # ③ Docker Hub 链路单独测
docker pull ghcr.io/umami-software/umami:3.4 # ④ ghcr 链路单独测(重点!)
我当时的结果:③ 经加速器 2 秒过,④ 直连 ghcr 龟速但层在动。这说明 Docker Hub 链路已经治好了,问题集中在 ghcr.io------这正是大部分项目卡死的地方:你的分析/监控组件(Umami 等)几乎都托管在 ghcr。
5.2 为什么 registry-mirrors 救不了 ghcr
daemon.json 的 registry-mirrors 的工作原理是:Docker 拉 Docker Hub 镜像时改走 mirror。对带完整域名前缀的镜像(ghcr.io/xxx、quay.io/xxx、mcr.microsoft.com/xxx)一概不生效,仍然是直连。这不是配置问题,是机制问题------所以"加速器配了 ghcr 还是慢"不是你操作错了。
5.3 层缓存与断点续传:先把"卡死"和"很慢"分开
ghcr 直连在国内的表现经常是:某一层反复 Retrying / Downloading 看似死循环。但它每次重跑都在前进:
bash
docker pull ghcr.io/umami-software/umami:3.4 # 中断后原样重跑
# 已完成层显示 Pull complete / Already exists,只补缺的层
判据很简单:盯某个层的字节数有没有在几个 retry 周期内变化。在动就让它跑完;彻底静止超过五分钟再考虑换路。
5.4 终局方案:南京大学 ghcr 镜像站
真正一锤定音的是这个------ghcr.nju.edu.cn(南京大学镜像站对 ghcr.io 的代理):
bash
docker pull ghcr.nju.edu.cn/umami-software/umami:3.4
# 拉下来后改回 compose 里认识的官方名:
docker tag ghcr.nju.edu.cn/umami-software/umami:3.4 ghcr.io/umami-software/umami:3.4
实测同一个 Digest: sha256:9f376b984d0b...,内容完全一致,速度从"挂着好几天拉不完"变成分钟级。compose 里镜像名不用改,本地已有同名标签 + compose 默认 pull 策略 missing,起服务直接命中。
同类可选:
ghcr.m.daocloud.io等高校/厂商代理站。代理站可用性会随时间波动,思路是 换一个 registry 前缀拉同一镜像,再 tag 回原名,这招对任何被墙 registry 通用。
5.5 顺手修掉的一颗雷:浮动标签
排查时发现 compose 里写的是:
yaml
image: ghcr.io/umami-software/umami:postgresql-latest # ❌
postgresql-latest 这种浮动标签的问题是双向的:上游发新版它会静默升级(数据库迁移可能直接起不来),你明明跑着 3.4 却没法钉死版本。确认服务器实际运行版本后钉死:
bash
docker exec <umami容器> node -p "require('./package.json').version" # 3.4.x
yaml
image: ghcr.io/umami-software/umami:3.4 # ✅
注意:同内容不同标签不共享本地缓存 ------从 postgresql-latest 钉到 3.4 仍会触发一次真实拉取,请配合 5.4 的代理站来做。
5.6 预热方法论:清单从哪来,怎么验收
把上面所有经验收敛成一套"首次部署前镜像预热"的标准动作:
bash
# ① 清单不要凭记忆列,直接从 compose 提取去重全集:
grep -E '^\s+image:' deploy/docker-compose.yml | awk '{print $2}' | sort -u
# ② 逐条单独 pull,不要串 &&:
docker pull node:22-bookworm
docker pull nginx:1.27-alpine
# ...
# ③ 验收按 "镜像名:tag" 精确比对,不是 grep 个名字前缀:
docker images | grep -E 'node:22-bookworm|umami:3.4|...'
为什么这么较真?我踩过的"假完成":
- 用
&&串了六条 pull,第一条失败后面五条根本没跑,终端看着像"整批执行完了",缺项无声消失; - 用
docker images | grep 镜像名验收,同仓库不同 tag(比如postgres:15-alpine和postgres:16-alpine)只要任一存在就整行命中,另一个从没拉下来过你完全看不见。
预热清单里最容易漏的是构建基础镜像 (node:22-bookworm 这种 1.1GB 的)------它不在 image: 行里出现在服务定义顶部,而在 build: 段的 Dockerfile FROM 里。漏了它,部署会在 build 阶段重演一遍下载地狱。
六、Secrets 离奇消失事件
workflow 一切正常跑了几轮后,某天 Setup SSH 突然红掉,日志:
echo "" > ~/.ssh/deploy_key
ssh-keyscan -H "" >> ~/.ssh/known_hosts
Error: Process completed with exit code 1.
${{ secrets.X }} 在 X 不存在时静默渲染为空串,不会报"Secret 缺失"。我的场景是在 GitHub 网页上做了一次 workflow 清理,把 Repository Secrets 一起带没了(删除文件本身不删 Secrets,但清理 Actions 配置页时很容易连锅端)。
两层防御(已在第三节 YAML 里):Secrets 经 env 传递 + 第一步空值守卫直接报中文人话。否则你要从 mkdir 开始的日志里倒推半天。
七、Broken pipe:长构建不要塞进 CI 的 ssh 窗口
镜像与缓存全部就位后,还剩最后一类事故:Deploy on server 正常刷了十几分钟构建日志(apt 装 LibreOffice、pip 拉依赖,都是国内源、都在前进),突然:
client_loop: send disconnect: Broken pipe
Error: Process completed with exit code 255.
根因:Actions 的部署是一条前台 ssh 长连接挂着远端十几分钟的命令。跨境链路上的 NAT/防火墙设备对"长时间低流量的 TCP 会话"经常有清理策略,掐断 ssh 的同时远端构建进程一起殉职。
两招组合拳:
bash
# ① ssh 保活(workflow 已加):每 30 秒空包,撑住中间设备
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=20 ...
bash
# ② 首次全量构建这种重活,绕开 CI,直接在服务器本地后台跑:
cd /opt/app/deploy
nohup docker compose build api worker > /tmp/build.log 2>&1 &
tail -f /tmp/build.log # Ctrl+C 只退出查看,构建不停
构建完的层全部进 Docker 本地缓存,之后 CI 里的 up -d --build 只重建 COPY src 薄层------ssh 窗口从 15 分钟缩到 90 秒,掐断的机会本身就没了。保活参数负责兜底未来 Dockerfile 大改的偶发长构建。
八、address already in use :80
倒数第二个坑。一切就绪后 up -d 报:
Error starting userland proxy: listen tcp4 0.0.0.0:80: bind: address already in use
容器端口映射起不来,八成是宿主机有东西抢了 80。30 秒定位:
bash
ss -tlnp | grep ':80 '
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
我的情况有点意外:打安全系统更新(dnf upgrade-minimal --security)时包管理器顺手把 nginx 作为依赖装上并开机自启了,宿主机裸跑一个 nginx 占着 80,和容器 nginx 完全无关。请它下场:
bash
systemctl disable --now nginx
如果是别的滞留容器占坑,docker rm -f 或找到它原 compose 目录 docker compose down。另一条排查经验:用 -v 参数的 docker compose down 会连卷一起删 ------数据库卷没了就是生产事故,日常停栈请不要加 -v。
九、健康检查 301 死循环(nginx 小雷)
栈起来了但 deploy.sh 的健康检查怎么都过不去:curl http://127.0.0.1/api/health 返回的不是 200 而是一串 301。配置里写的是:
nginx
if ($host != pdf.example.com) { return 301 http://pdf.example.com$request_uri; }
初衷是把所有非正式域名的访问 301 到主域做 SEO 权重集中。但它把 Host: 127.0.0.1 的本机健康检查、LB 探活、IP 直访全!部! 301 到了一个本机根本解析不到的域名上。正确写法是只显式匹配需要跳转的域名,其余放行:
nginx
if ($host = example.com) { return 301 http://pdf.example.com$request_uri; }
if ($host = www.example.com) { return 301 http://pdf.example.com$request_uri; }
if ($host != X) 和 if ($host = X) 一对不等价的条件,前者是"误伤一切",后者是"精确打击"。另外这类"看起来线上正常"的配置,务必确认它已经 commit 进了仓库------CI 部署永远以仓库内容为准,你本地改了没推,线上就还是旧的。
十、收尾:验证清单与经验总结
部署见绿后过一遍:
bash
curl -s http://127.0.0.1/api/health # 服务器本机:200 + 依赖状态
docker compose ps # 全容器 Up / healthy
浏览器实测核心业务链路(上传、转换、后台登录各走一遍),再确认失败时旧容器仍在服务(deploy.sh 3/4 步红掉即代表旧版本还活着)。
最后把全文的坑压缩成一张贴工位上的速查表:
| 症状 | 根因 | 处方 |
|---|---|---|
| Deploy 一直 Downloading | 服务器拉境外 registry,与 CI 平台无关 | 三板斧分层定位,别换平台 |
| 加速器配了 ghcr 还是慢 | registry-mirrors 只管 Docker Hub | ghcr.nju.edu.cn 代理 + tag 回原名 |
exit 12 |
服务器缺 rsync | dnf/apt install rsync |
| pull 六条串 && 只成三条 | 第一条失败静默跳过后续 | 逐条独立 pull,精确 tag 验收 |
echo "" > deploy_key |
Secrets 缺失渲染空串 | env 传值 + 空值守卫 |
exit 255 / Broken pipe |
长构建挂 ssh 被 NAT 掐 | ServerAliveInterval + 重活服务器本地 nohup |
:80 already in use |
dnf 更新顺手起了裸 nginx | ss -tlnp 定位 + systemctl disable --now |
| 健康检查返回 301 | $host != 误伤 IP 直访 |
改 $host = 显式匹配 |
postgresql-latest |
浮动标签静默升降级 | 容器内查实际版本 → 钉死标签 |
docker compose pull 报错 |
build-only 服务无远端镜像 | 显式列有镜像的服务名 |
回头看这条流水线的 YAML,前后差异其实就十几行;真正值钱的是排障时那套"先归因,再动手"的肌肉记忆:下载慢先在目标服务器上分层测链路,而不是先换 CI 平台;报错 12 先查远端命令存不存在,而不是怀疑网络。希望这份踩坑记录能让准备上 Docker + Actions + 国内服务器这套组合的同学,一次把坑绕完。
有问题评论区见,看到必回。