一台 4 核 4G 的轻量应用服务器,空白 Ubuntu 22.04 镜像,目标是把一个 Node 服务加上 HTTPS 跑起来, 给小程序做接口。看起来是半小时的活,实际卡了整整一晚。这篇按踩坑顺序记录。
几乎所有教程都是这一行:
curl -fsSL https://get.docker.com | sh
在国内服务器上,这条命令有相当概率卡在下载阶段然后超时。更麻烦的是它的失败方式是静默的: 如果脚本里写的是
if ! command -v docker >/dev/null 2>&1; then
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && sh /tmp/get-docker.sh >/dev/null
fi
curl 超时返回非 0,&& 后面的 sh 不执行,
整个 if 块的退出码却是 0——set -e 不会让它停下来。
脚本继续往下跑,装 Nginx、签证书、写配置,全部成功,最后在「启动服务」那一步失败。
回头看日志,前面一片绿,很有迷惑性。
稳妥的做法是走发行版自己的仓库:
sudo apt-get update
sudo apt-get install -y docker.io docker-compose-v2
sudo systemctl enable --now docker
docker --version && docker compose version
Ubuntu 22.04 仓库里的版本不算新,但对跑一个 Node 服务完全够用。
Compose 插件如果提示找不到 docker-compose-v2,退到 docker-compose 也能用。
写部署脚本时,凡是「下载外部资源后执行」的步骤,都要显式检查退出码并给出可执行的补救命令。
set -e挡不住管道和条件语句里的失败。
Docker 装好之后,docker compose up -d --build 卡在 pulling node:20-slim。
盯了两分钟,进度条几乎不动。实测下行速度大约 50 KB/s,一个 70MB 的镜像层要拉半小时。
配镜像加速:
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"registry-mirrors": ["https://docker.1ms.run", "https://docker.m.daocloud.io"]
}
EOF
sudo systemctl restart docker
sudo docker info | grep -A2 "Registry Mirrors"
要确认 docker info 里真的出现了这两行。写在文件里但服务没重启的话,配置是不生效的,
而 up 的表现和没配一样。
镜像拉下来之后,构建卡在 npm ci。官方源在国内同样慢,所以在 Dockerfile 里提前指定:
WORKDIR /app
COPY package.json package-lock.json ./
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
RUN npm ci --omit=dev || npm install --omit=dev
那个 || 是有意加的:某些镜像源下 npm ci 会因为 lockfile 与 registry
的元数据不一致而失败,退回 npm install 通常能过。构建镜像不需要 lockfile 那么严格的保证。
Let's Encrypt 免费,90 天有效期,可以自动续期。签发方式选 webroot,
因为 80 端口已经有 Nginx 在跑,用 --webroot 不需要停服务:
sudo certbot certonly --webroot -w /var/www/html \
-d api.example.com \
--agree-tos -m you@example.com --no-eff-email
原理是 HTTP-01 校验:CA 会请求 http://你的域名/.well-known/acme-challenge/某串,
certbot 把文件写进 webroot 目录,Nginx 从那儿读出来,CA 看到内容匹配就签发。
所以 80 端口必须能从公网访问,而且不能有跳转到 HTTPS 的强制规则挡在 ACME 路径前面。
签完在 /etc/letsencrypt/live/你的域名/ 下会有两个关键文件:
fullchain.pem(证书 + 中间证书)和 privkey.pem(私钥)。
配置里必须用 fullchain,只用 cert.pem 会缺中间证书,
浏览器能过(会自己补),但小程序和很多 API 客户端会直接判证书链不完整。
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000" always;
续期靠 certbot.timer,装包时自动启用。确认一下:
systemctl is-active certbot.timer
systemctl list-timers | grep certbot
这一节是整晚最有价值的收获。证书签好了、nginx -t 通过、systemctl reload 成功,
但 https://域名 打不开。从外网测:
node -e "require('net').connect({host:'1.2.3.4',port:443,timeout:5000})"
结果是超时,不是报错。这两个现象指向完全不同的原因:
| 现象 | 含义 | 查哪儿 |
|---|---|---|
| 连接超时 | 包在网络层被丢掉,根本没到服务器 | 云平台防火墙 / 安全组 |
| 连接被拒绝 | 包到了,但没有程序在这个端口监听 | ss -lntp | grep 443 |
超时的时候,服务器上可能一切正常——ss -lntp 显示 nginx 在 443 监听,本机
curl -k https://127.0.0.1 也通。问题只在云平台的防火墙规则里。
轻量应用服务器要在控制台的「防火墙」页签单独放行 443,和系统里的 ufw/iptables 是两套东西。
判断方法很简单:同时测一个已知可用的 baidu.com:443。如果那个通、你的不通,
就排除本地网络问题,锁定在目标端。
接口反代到本机 3000,其余路径一律 404:
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
return 404;
}
Node 只监听 127.0.0.1:3000,不绑 0.0.0.0。
这样即使防火墙配错了,3000 端口也不可能从公网直接访问。
管理后台这类东西,不要靠「路径猜不到」来保护。它的访问入口应该被彻底关掉:
在 nginx 里对 /admin 返回 404,需要时用 SSH 隧道临时打开——
ssh -N -L 3000:127.0.0.1:3000 user@your-server
# 然后本地浏览器访问 http://127.0.0.1:3000/admin
这样后台在公网上不存在,而不是「存在但需要密码」。
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
...
location /api/ {
limit_req zone=api burst=60 nodelay;
}
30 r/s 单 IP 足够任何正常客户端使用,主要防的是脚本刷接口。
burst=60 nodelay 允许短时突发,避免用户连续点几下就被拦。
部署完成后按这个顺序验,每一项都有明确的期望值:
# 1. 服务活着
curl https://api.example.com/healthz
# 期望 200,正文里有 ok
# 2. 证书可信(自签会失败)
curl -I https://api.example.com/healthz | head -1
# 3. 未带凭证的接口应被拒
curl -X POST https://api.example.com/api/xxx
# 期望 401
# 4. 不存在的路径应 404
curl -I https://api.example.com/nothing-here
# 5. 证书链完整
openssl s_client -connect api.example.com:443 -servername api.example.com \
</dev/null 2>/dev/null | grep -E "Verify return|Protocol"
# 期望 Verify return code: 0 (ok),Protocol 为 TLSv1.2 或 TLSv1.3
第 5 条最容易被跳过,但它是唯一能确认「中间证书带齐了」的方式。 前四条全过、小程序仍然报证书错误的情况,基本都是这里的问题。
技术细节之外,这次最大的收获是关于脚本的失败方式:
以及一个操作层面的习惯:先确认自己在哪台机器上。
我在这上面浪费了很久——改了半天的配置在另一台服务器上,两台机器主机名都是
VM-0-x-ubuntu,只有 IP 不同。