在共享 Docker 服务器上部署
何时使用
- 服务器非专用:已运行其他人或其他项目的部署。
- 应用包含原生依赖(
better-sqlite3、sharp、canvas)或浏览器(Chromium)。 - 已有反向代理(Caddy 或 nginx)发布多个域名。
- 任何
EADDRINUSE、OOM 或磁盘满问题都会影响其他栈。
黄金法则:新应用不能成为其他应用宕机的原因。 因此所有操作都经过预先限制和测量。
0. 构建前:测量并释放磁盘空间
使用 Chromium 构建会下载约 2 GB 的镜像。如果磁盘使用率达到 78%,构建会失败,或使服务器处于极限状态,下一次 prune 会删除生产镜像。
ssh SERVIDOR 'docker system df; df -h /'
通常可安全释放的闲置资源:
docker builder prune -af # 构建缓存(通常占用最大)
docker image prune -af # 未使用的镜像
docker container prune -f # 已停止的容器
前后统计容器数量以确认没有服务宕机:
docker ps -q | wc -l # 保存数字,之后对比
实际案例测量:78% → 32% 恢复,41 个生产容器完好无损。
1. 原生模块与 Node 版本
为另一个 Node 主版本编译的 .node 文件不会产生清晰的错误信息:它会在内部断言失败并导致进程崩溃,留下无用的原生堆栈跟踪。
典型症状:
# node.exe[61084]: void __cdecl node::RemoveEnvironmentCleanupHook(Isolate *, CleanupHook, void *)
# Assertion failed: (env) != nullptr
----- Native stack trace -----
原因:better-sqlite3@11 与 Node 24 不兼容。支持需要 better-sqlite3@12。
规则:
- 在集成之前,验证原生模块版本是否与 Node 主版本兼容。
- 在构建用的
Dockerfile中,安装工具链(python3 make g++),以便在预编译二进制不存在时可以即时编译。 - 运行时,不要假设需要工具链:最好使用更小的最终镜像。
- 如果模块从工作区的多个包中导入,在一个只做此事的进程中测试:延迟的 GC 崩溃可能在数小时后才出现。
2. 分离重负载进程与响应进程
将 Chromium 和原生模块放在与 HTTP 服务器相同的进程中会导致崩溃,并且长时间任务会阻塞事件循环。
api : HTTP + SSE, 低内存, HTTP 健康检查
worker : 队列 + 浏览器, 高内存, 无端口, 无 HTTP 健康检查
它们通过共享存储(SQLite、Redis、Postgres)通信。API 无需知道 Worker 是否存活。
健康检查仅在提供 HTTP 服务的容器上定义。 从 Dockerfile 继承健康检查到 Worker 会导致它永远处于 unhealthy 状态,因为它从不打开端口:
worker:
healthcheck:
disable: true
3. 为每个服务设置内存限制
api:
mem_limit: 400m
worker:
mem_limit: 1600m
mem_reservation: 400m
volumes:
- /dev/shm:/dev/shm:rw,size=256m # Chromium 使用 /dev/shm; 64 MB 会导致它崩溃
tmpfs:
- /tmp:size=256m
security_opt:
- no-new-privileges:true
此外,在代码中减少工作量:爬虫在服务器上的 DETAIL_MAX / MAX_PAGES 应比本地低。如果某天服务器内存不足,罪魁祸首几乎总是这个数字,而不是 Docker。
在执行实际搜索前后进行测量:
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
free -m | head -2
4. 构建:有效的阶段和缓存
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS build # 版本锁定到 npm 包版本
...
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS runtime
- 锁定镜像版本到已安装的 npm 包版本。如果不匹配,Playwright 找不到浏览器。
- 先复制
package.json,再复制代码,这样COPY . .不会使install层失效。 - 在运行时,如果启动脚本使用
pnpm/npm,则需要启用corepack enable。 - 如果
node_modules从构建阶段复制过来且权限不匹配,pnpm 会在每次启动时尝试“修复”并失败。可以通过在镜像的.npmrc中设置verify-deps-before-run=false来禁用此行为。 - 运行时使用非 root 用户。Playwright 镜像已包含
pwuser。
pnpm-lock.yaml 应提交到版本控制,并且构建时的 install 应使用 --frozen-lockfile。
5. 通过已有代理发布
如果已经有 Caddy 发布多个域名,不要启动另一个代理或打开另一个公共端口:只需在现有的 Caddyfile 中添加一个块,并将新服务加入 Docker 网络。
services:
api:
networks: [edge_proxy] # 外部网络,已创建
networks:
edge_proxy:
external: true
name: edge_proxy
找到网络和 Caddyfile 的位置:
docker inspect <proxy> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
在宿主机上编辑文件:如果 bind mount 为 rw=false,在容器内写入会失败并显示 Resource busy,或者在重启后丢失。
安全流程(绝不盲目编辑):
cp "$CADDYFILE" "$CADDYFILE.bak-$(date +%Y%m%d-%H%M%S)" # 1. 备份
# 2. 添加配置块
docker exec <proxy> caddy validate --config /etc/caddy/Caddyfile # 3. 验证
docker exec <proxy> caddy reload --config /etc/caddy/Caddyfile # 4. 重载(无 downtime)
如果 validate 失败,则恢复备份。重载不会断开现有连接。
Basic auth
prospector.ejemplo.dev {
basic_auth {
leo $2a$14$... # bcrypt,绝不用明文
}
reverse_proxy api:3000 {
flush_interval -1 # SSE 必需
}
encode zstd gzip
header {
X-Frame-Options DENY
-Server
}
}
- 在服务器上生成哈希:
docker exec <proxy> caddy hash-password --plaintext '...'。 - 仅将明文密码保存在一个 root 文件(
/root/<app>-password.txt,权限 600)中,以便恢复或轮换,绝不要保存在仓库中。 - 如果应用使用 SSE,不要在 Caddy 前面使用 Cloudflare 代理:100 秒超时和缓冲会中断进度。使用 DNS-only(灰色云)让源站颁发真实证书。
basic_auth仅保护通过代理的流量。如果应用还通过其他端口暴露,则不受保护:需记录此情况。
6. 从 Windows 部署
Windows 没有 rsync。跨平台可用的方案是 tar 通过 SSH 管道:
const tar = spawn('tar', ['-cf', '-', '--exclude', './node_modules', ...excludes, '.'], { cwd: ROOT });
const ssh = spawn('ssh', [HOST, `mkdir -p ${DIR} && tar -xf - -C ${DIR}`], { stdio: ['pipe', 'inherit', 'inherit'] });
tar.stdout.pipe(ssh.stdin);
在 Windows 上,spawn 必须使用 shell: true 才能解析 pnpm(它是一个 .cmd 文件),但这样不能传递文件句柄来重定向输出:应使用环境变量 LOG_FILE,让应用自己写入日志。
7. 接触共享服务器之前的检查清单
- 保存
docker ps -q | wc -l,最后对比 - 释放后有足够的可用磁盘空间
- 所有新服务设置
mem_limit - 重负载进程放在独立容器中,无端口
- 仅提供 HTTP 服务的容器配置健康检查
- 声明使用代理的外部网络,而非创建新网络
- 备份 Caddyfile,重载前执行
caddy validate - 日志写入文件(进程将无控制台)
- 部署脚本幂等:
check / push / build / up / logs / stats / backup - 备份卷并已测试恢复,而不仅仅是创建
快速诊断
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
docker logs --tail 80 <worker>
docker exec <api> node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(console.log)"
free -m && df -h /