Node 应用上线与加固
前言
用 nginx 做唯一入口,pm2 守护进程,certbot 管理证书,重点放在目录隔离、权限、密钥、备份、发布和排错上
如果你是第一次部署网站,想先搞清楚“怎么搭起来”,建议先看 网站部署(一):前后端分离架构部署。本文面向 Node 技术栈,默认你已经知道域名、DNS 和 SSH 的基本用法
它可以用来部署一个新项目,也可以用来换服务器后重新搭一遍:第 1 节整段照抄;第 2 节每一项按“怎么生成”重新生成,不能沿用别的项目的值;再按第 3 节从零搭建
文中所有命令均是 shell 变量。先在服务器上把第 2 节的变量 export 一遍,后面的命令就可以原样粘贴。示例里的域名、目录、用户名都是占位,请换成自己的
0. 骨架:不变的部分
浏览器 ──HTTPS 443──► nginx(证书 certbot 自动续;唯一入口)
│ proxy_pass,覆写 X-Real-IP
▼
127.0.0.1:$PORT ← Node 应用,只绑本机回环地址
pm2 守护,跑在专用低权限用户 $APP 名下(不是 root)
│
┌─────────────┼──────────────────┐
▼ ▼ ▼
$CODE_DIR $DATA_DIR $BACKUP_DIR
git 克隆, 数据库 / 上传文件 / 每天 root 定时备份,
只 reset 不改 密钥 / pm2 配置 只有 root 能读写
(随时可删重来) (仓库外,永不随发布动) (应用被攻破也删不掉)七条原则,每个项目都一样:
- 代码、数据、备份三个目录分开。 发布只动代码目录。数据和密钥放在仓库外,重新部署(哪怕
rm -rf代码目录)也碰不到用户。 - Node 只绑
127.0.0.1。 绑0.0.0.0时,公网能直接访问http://<ip>:<port>,等于绕过 nginx:HTTPS 失效,登录密码明文过公网,X-Forwarded-For随便伪造,按 IP 限流形同虚设。 - 应用用专用低权限用户跑。 就算后端被打穿,攻击者也只拿到这个用户的权限:碰不到系统和 SSH 密钥,删不掉备份。
- 密钥不进仓库。 它们放在
$DATA_DIR下的文件里,权限600。.gitignore只是 Git 规则,不是密钥保护。 - 服务器是纯部署目标。 发布用
git reset --hard origin/<分支>,不在服务器上 commit,也不改已跟踪的文件。 - 备份由 root 跑、存到 root-only 目录,SQLite 用
.backup,不用cp。 - 登录用的第三方 OAuth 客户端,单独放在一个只做登录的云项目里。 这个项目不开任何 API、不建 API 密钥和服务账号。同项目里的 API 密钥一旦在别处泄露被盗用,云厂商会把整个项目停用,客户端随之报
401 disabled_client,全站第三方登录一起不可用(真实事故:Google 登录客户端和 Gemini 密钥建在同一个项目里)。
1. 照抄:每个项目、每台机器都一样
1.1 应用代码:监听地址与客户端 IP
监听地址默认必须是回环地址。框架默认值往往是 0.0.0.0,比如 @hono/node-server 的 serve({ fetch, port }):
// server/node.ts(Hono;Express 同理:app.listen(port, '127.0.0.1'))
import { serve } from '@hono/node-server'
const port = Number(process.env.PORT || 3000)
const hostname = process.env.HOST || '127.0.0.1'
serve({ fetch: app.fetch, port, hostname }, (info) => {
console.log(`[web] listening on http://${hostname}:${info.port}`)
})取客户端 IP 只认 X-Real-IP,不认 X-Forwarded-For。前者由 nginx 用 $remote_addr 整个覆写,客户端伪造不了;后者是追加的,最左边那段客户端想写什么就写什么:
export function clientIp(c: Context): string {
return c.req.header('x-real-ip') || 'unknown'
}流式接口(SSE / chunked)要在应用里带上 X-Accel-Buffering: no。nginx 的 proxy_buffering 默认开着,会把整条流攒满再一次性下发:本地 dev 直连 node 看不出来,线上就成了「等一会儿整段蹦出来」。
1.2 进程包装器 $PM2_WRAPPER(以 mtweb 为例)
pm2 守护进程按用户各一份。应用跑在 $APP 名下,root 直接敲 pm2 list 是空的。包装器把「切用户 + 切目录」焊死:
cat > /usr/local/bin/$PM2_WRAPPER <<EOF
#!/bin/bash
set -e
[ "\$(id -u)" = 0 ] || { echo "要用 root 跑"; exit 1; }
exec su -s /bin/bash $APP -c "cd $CODE_DIR/$APP_SUBDIR && pm2 \$*"
EOF
chmod 755 /usr/local/bin/$PM2_WRAPPER
cd不能省。 少了它,pm2 会继承 root 的/root(权限 700)当工作目录,报一个指向 node 的假错误spawn /usr/bin/node EACCES。看着像 node 权限问题,其实是工作目录不对。
以后所有 pm2 操作都用包装器,用法和 pm2 完全一样:$PM2_WRAPPER list、$PM2_WRAPPER logs $PM2_NAME --lines 50 --nostream。
logs默认是流式的,会一直挂着不返回。脚本或 AI 执行时必须加--nostream。
1.3 pm2 配置 $DATA_DIR/ecosystem.config.cjs
放在数据目录,不放代码目录,因为里面有密钥。
module.exports = {
apps: [{
name: '<PM2_NAME>',
script: 'npm',
args: 'start',
cwd: '<CODE_DIR>/<APP_SUBDIR>',
env: {
NODE_ENV: 'production', // 少了它 cookie 不带 Secure
PORT: '<PORT>',
DATA_DIR: '<DATA_DIR>',
AUTH_SECRET: '<见第 2 节生成>',
// 其他密钥用外部 env 文件,不要直接写在这里(见 4.1)
SENTRY_RELEASE: process.env.SENTRY_RELEASE || '', // 启动时带上就能对齐当前 commit
},
}],
}chown $APP:$APP $DATA_DIR/ecosystem.config.cjs && chmod 600 $DATA_DIR/ecosystem.config.cjs改了这个文件里的变量,必须
start,不能restart。$PM2_WRAPPER restart <name> --update-env只刷新进程环境,不重读配置文件(实测:加了新变量之后 restart,应用里仍然读不到)。要重读配置:$PM2_WRAPPER start $DATA_DIR/ecosystem.config.cjs --update-env
1.4 nginx /etc/nginx/sites-available/$APP-web
server {
listen 80;
server_name <DOMAIN>;
client_max_body_size 20m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:<PORT>;
proxy_http_version 1.1;
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;
}
}ln -s /etc/nginx/sites-available/$APP-web /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d $DOMAIN --redirect # DNS 先解析过来,只留一条 A 记录指向本机
systemctl list-timers | grep certbot # 确认自动续期的 timer 在
add_header放在 server 级。一旦某个location里也写了add_header,继承来的那组会被整组顶掉,要在那个 location 里全部重写一遍。 证书过期时浏览器会因为 HSTS(1 年)硬拒绝,用户点不进去。用certbot renew --dry-run看续期有没有问题。
1.5 网络与日志:开 BBR、带耗时的访问日志、日志轮转
开 BBR(TCP 拥塞控制)。 Linux 默认用 cubic,一丢包就把发送速度砍半。服务器在境外、用户在国内时,跨境线路晚上丢包重,速度会被一路压低。实测(香港机器、国内移动家宽、晚上):同一台手机连境外测速点 19.6Mbps,连这台服务器只有 0.53Mbps,视频(码率约 1.2Mbps)一直缓冲;开 BBR 后同一时段 4.69Mbps,基本跑满这台机器约 5.4Mbps 的出口。BBR 按实测带宽发送,偶尔丢包不降速。
modprobe tcp_bbr && echo tcp_bbr > /etc/modules-load.d/bbr.conf
printf "net.core.default_qdisc=fq\nnet.ipv4.tcp_congestion_control=bbr\n" > /etc/sysctl.d/90-bbr.conf
sysctl -p /etc/sysctl.d/90-bbr.conf && tc qdisc replace dev eth0 root fq # 网卡名用 ip link 看
sysctl net.ipv4.tcp_congestion_control # 应为 bbr- 内核 4.9 以上才有 BBR。
- 不用重启,立即生效,重启后保留;撤回就删掉这两个文件再重启。
- 先判断是不是这个问题:用户说卡,先让他换一种网络(家里 WiFi ↔ 手机流量)测同一个文件。流量快、WiFi 慢,而测速网显示他自己的宽带很快,就是丢包线路被 cubic 压速,开 BBR;两种网络都慢才是机器带宽不够。用自己电脑对比时关掉代理:挂代理走的是代理节点的线路,测出来不算数。
nginx 访问日志带上耗时。 默认格式只有字节数,不知道这些字节发了多久,事后算不出用户实际收到的速度。在 /etc/nginx/nginx.conf 的 http 块里替换 access_log 那一行:
log_format timed '$remote_addr [$time_local] "$request" $status $body_bytes_sent rt=$request_time urt=$upstream_response_time range="$http_range" "$http_user_agent"';
access_log /var/log/nginx/access.log timed;rt是整条请求的耗时,包括慢慢发给用户的时间,字节数 ÷ rt就是用户实际收到的速度。urt是 Node 交出响应的耗时:urt很小、rt很大,说明慢在 nginx 到用户这段,不在应用里。- 应用自己记的发送耗时不能当成用户网速:nginx 默认开着
proxy_buffering,会先把响应攒到本地,应用那边早就「发完」了,用户可能还在慢慢收。 - nginx 日志系统自带按天轮转、保留 14 天,不用另外管。
pm2 日志要自己加轮转。 pm2 默认不清理日志,会一直涨:
cat > /etc/logrotate.d/$APP-pm2 <<EOF
/home/$APP/.pm2/logs/*.log {
weekly
rotate 8
maxsize 50M
compress
delaycompress
missingok
notifempty
copytruncate
su $APP $APP
}
EOF
logrotate -d /etc/logrotate.d/$APP-pm2 # 试运行,不报错即可- 用
copytruncate:pm2 一直开着日志文件,普通轮转会把文件挪走,pm2 却还在往旧文件里写。 - pm2 日志行没有时间戳。要事后拿来对账的日志(比如前端打回服务端的上报记录),在应用里自己打上时间和
X-Real-IP,才能和 nginx 日志对上。
1.6 备份 /usr/local/bin/$APP-backup.sh(root 跑)
#!/bin/bash
set -e
D=$(date +%F)
sqlite3 <DATA_DIR>/<DB_FILE> ".backup <BACKUP_DIR>/db-$D.db"
gzip -f <BACKUP_DIR>/db-$D.db
find <BACKUP_DIR> -name "db-*.db.gz" -mtime +14 -delete
# 「库里只存路径 / 清单、字节在磁盘」的上传文件,必须跟库一起备(没有就删掉这几行)。
# 目录可能在有人用到该功能前并不存在,所以先判断;每多一类这样的目录就照抄一段
if [ -d <DATA_DIR>/<UPLOAD_SUBDIR> ]; then
tar -czf <BACKUP_DIR>/files-$D.tar.gz -C <DATA_DIR> <UPLOAD_SUBDIR>
find <BACKUP_DIR> -name "files-*.tar.gz" -mtime +14 -delete
fi
# 不可再生的快照(比如抓取的上游数据,过期后上游就没了)同样要备(没有就删掉这段)
if [ -d <DATA_DIR>/<SNAPSHOT_SUBDIR> ]; then
tar -czf "<BACKUP_DIR>/snapshots-$D.tar.gz" -C <DATA_DIR> <SNAPSHOT_SUBDIR>
find <BACKUP_DIR> -name "snapshots-*.tar.gz" -mtime +14 -delete
fi
chown <APP>:<APP> <DATA_DIR>/<DB_FILE>{,-wal,-shm} 2>/dev/null || true # ← 别删,见下chmod 700 /usr/local/bin/$APP-backup.sh
( crontab -l 2>/dev/null; echo "0 4 * * * /usr/local/bin/$APP-backup.sh" ) | crontab -- 用
sqlite3 .backup,不用cp。 库开着 WAL,cp会在写入中途拷到一份撕裂的文件。 - 最后的
chown不能删。 root 打开库时,如果 WAL/SHM 文件不存在,会新建成 root 所有,应用从此写不了自己的库,会在某个凌晨 4 点之后毫无征兆地 500。 - 数据库和上传文件是一份数据的两半,要备同一天,也要一起恢复。
- 可再生的数据不进备份,不可再生的必须进。 比如从上游全量同步下来的索引库,丢了重跑一遍就行;但抓下来的「当季快照」,上游过季就删了,丢了就真没了。
恢复:
$PM2_WRAPPER stop $PM2_NAME
gunzip -c $BACKUP_DIR/db-<日期>.db.gz > $DATA_DIR/$DB_FILE
rm -f $DATA_DIR/$DB_FILE-wal $DATA_DIR/$DB_FILE-shm # 旧 WAL 会盖掉恢复的内容
tar -xzf $BACKUP_DIR/files-<同一日期>.tar.gz -C $DATA_DIR
# 还有其他备份目录(快照等)的话,同日期的也一起解开:
# tar -xzf $BACKUP_DIR/snapshots-<同一日期>.tar.gz -C $DATA_DIR
chown -R $APP:$APP $DATA_DIR && chmod 600 $DATA_DIR/$DB_FILE
$PM2_WRAPPER start $PM2_NAME1.7 日常发布
ssh -p $SSH_PORT -i $SSH_KEY root@$SERVER_IP
cd $CODE_DIR
# 重置前先看服务器上有没有「没提交过」的东西,reset 会把它们静默删掉(见下方说明)
git status --short
git branch backup-$(date +%F) 2>/dev/null || true # 留个锚,反悔能回
git fetch origin && git reset --hard origin/$BRANCH # 不是 git pull
export RELEASE="$(git rev-parse --short HEAD)"
cd $APP_SUBDIR && npm ci && npm run build
SENTRY_RELEASE="$RELEASE" $PM2_WRAPPER start $DATA_DIR/ecosystem.config.cjs --update-env
$PM2_WRAPPER logs $PM2_NAME --lines 20 --nostream # 看一眼没报错
curl -s https://$DOMAIN/<HEALTH_PATH> | head -c 80 # 验真上去了,别只看 pm2 说 online- 用
reset --hard,不用pull。 远端历史一旦被重写过,旧克隆和远端就没有共同祖先了,git pull会当成「无关历史」直接失败或糊出一堆冲突。reset --hard两种情况都对。 - 代价:它会静默丢掉服务器上的本地提交、对已跟踪文件的改动,以及热更新传上去的文件。所以发布前那句
git status --short不能省:有M/??时,先弄清楚是不是还没提交的工作,必要时备份。 - 真要保留一个「只存在于服务器」的已跟踪文件,用
git update-index --skip-worktree <file>。不能靠.gitignore,它对已跟踪文件无效。 - 小内存机器(2G、没有 swap)
npm run build可能被Killed,这是 OOM。临时开 swap:fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
SPA 构建后,重启前检查入口引用的资源都在(dist/ 会被清空重建):
cd $CODE_DIR/$APP_SUBDIR && python3 - <<'PY'
import re
from pathlib import Path
dist = Path('dist')
html = (dist / 'index.html').read_text()
refs = re.findall(r'(?:src|href)="(/[^"?#]+)', html)
missing = [r for r in refs if not (dist / r.lstrip('/')).is_file()]
print(f'asset_refs={len(refs)} missing={len(missing)}')
if missing:
print('\n'.join(missing)); raise SystemExit(1)
PY不要写成
ref.removeprefix('/assets/'):那样连目录名也去掉了,拼出来是dist/index-xxx.js,永远报 missing。那是脚本自己的 bug,不是构建坏了。
1.8 临时热更新(真机调试用,不经 git)
只改了服务端 TS,并且生产直接跑 TS 源码(tsx server/node.ts,没有服务端构建产物)时,可以直接 scp 上去:
ssh -p $SSH_PORT -i $SSH_KEY root@$SERVER_IP "mkdir -p $DATA_DIR/hotfix-backup && cp -n $CODE_DIR/<文件> $DATA_DIR/hotfix-backup/"
scp -P $SSH_PORT -i $SSH_KEY <本地文件> root@$SERVER_IP:$CODE_DIR/<文件>
ssh -p $SSH_PORT -i $SSH_KEY root@$SERVER_IP \
"chown $APP:$APP $CODE_DIR/<文件> && \
SENTRY_RELEASE=\$(cd $CODE_DIR && git rev-parse --short HEAD)-hotfix-<名字>-<第几版> \
$PM2_WRAPPER start $DATA_DIR/ecosystem.config.cjs --update-env"- 先备份、再覆盖。
cp -n要在 scp 之前跑,否则备下来的是新文件。 - scp 上去的文件属主是 root,要
chown回$APP。 - 前端(
src/**、public/**)的改动必须重新构建,不能这样传。 - 热更新是临时的:下次日常发布的
reset --hard会把它抹掉。测好了要提交、推送,再正常发布一次。 - 热更新期间,服务器上的
git status显示M是预期内的,不是有人在服务器上改代码。但日常发布前要确认这些M已经全部提交进远端(见 1.7)。
1.9 维护页(可选,由你决定要不要开)
发布前后不想让用户在半途操作(改了表结构、动了登录、正在跑迁移),就临时开维护页。普通发布不用开,平时没有这个开关,站点行为不变。
开关是数据目录里的一个文件 $DATA_DIR/MAINTENANCE(可以是空文件),应用每个请求都看一眼它在不在,不用重启 pm2:
touch $DATA_DIR/MAINTENANCE # 开:页面显示维护页,接口返回 503 + code=MAINTENANCE
rm $DATA_DIR/MAINTENANCE # 关:立即恢复推荐顺序:开维护 → 按 1.7 发布 → 验证无误 → 关维护。
维护期间让管理员进站(发布后在生产环境验证用)
维护页不能提供任何登录入口,否则等于把后台入口摆给所有人。所以只放行两类人:
- 已登录的管理员:会话 cookie 对得上就放行,什么都不用做,刷新页面照常用。
- 持有「维护口令」的浏览器:管理员一旦退出登录,维护页会把登录接口也拦住,进不去。这时靠口令先进门,再正常登录。
口令就是 MAINTENANCE 文件的内容(空文件 = 没有口令,只放行已登录的管理员)。在你自己的电脑上一条命令开维护并拿到进站网址:
ssh -p $SSH_PORT -i $SSH_KEY root@$SERVER_IP \
'KEY=$(openssl rand -hex 16); echo "$KEY" > '$DATA_DIR'/MAINTENANCE; echo "https://'$DOMAIN'/?maint-key=$KEY"'终端会打印一行形如 https://<你的域名>/?maint-key=<32 位随机串> 的网址,整行复制,粘贴到浏览器地址栏回车,只需要打开这一次:
- 浏览器跳回不带口令的首页,这台浏览器 12 小时内整站可用;没登录的话在正常页面上登录管理员账号。
- 其他人仍然看到维护页,包括你自己没打开过这行网址的其他浏览器。
- 网址丢了:重新执行上面那条命令,会生成新口令,旧口令和旧通行证立即作废。
- 关维护:
ssh -p $SSH_PORT -i $SSH_KEY root@$SERVER_IP "rm $DATA_DIR/MAINTENANCE",所有通行证随之作废。 - 只想要普通维护页、不需要自己进站:
touch $DATA_DIR/MAINTENANCE。
隐私与安全注意:
口令等同于一把临时钥匙,不要发到群里、不要写进文档或工单、不要提交到仓库;文档里一律写
<口令>占位。口令会出现在 nginx 访问日志的请求行里(
?maint-key=…)。日志只有 root 能读,但别把日志原样发给别人;用完关维护即作废。每次开维护都重新生成,不复用上一次的;cookie 里存的是口令的哈希,不是口令本身,换口令后旧 cookie 立即对不上。
校验通过后服务端立刻 302 跳回不带口令的地址,口令不会留在地址栏和浏览器历史里。
对口令输错的尝试要限流(比如每个 IP 15 分钟 10 次),口令本身用
openssl rand -hex 16(128 位)。放
$DATA_DIR,不放代码目录,也不用环境变量。 发布会重建代码目录;改 pm2 环境变量要重读配置并重启,和「发布前先关门」的目的冲突。忘了
rm站点会一直维护中。 发布完最后一步就是删它。开着时,除
/api/health*外一律 503(带Retry-After),监控仍能看到进程活着。重启进程那几秒 nginx 回 502 是正常的,维护页盖不住。验证:不带通行证的
curl -si https://$DOMAIN/<任意接口> | head -1开着是503,关掉恢复200(curl 没有 cookie,所以管理员放行不影响这个验证)。
应用侧的写法(Hono,可照抄到别的项目)。把它挂在安全响应头之后、所有路由和静态文件之前:
const flagFile = join(dataDir, 'MAINTENANCE')
const BYPASS_COOKIE = 'maint_pass'
// null = 没开维护;'' = 开了但没口令;其余 = 口令
function readFlag(): string | null {
try { return readFileSync(flagFile, 'utf8').trim() } catch { return null }
}
// cookie 里放口令的 HMAC,不放口令本身;口令一换,旧 cookie 立刻对不上
const bypassToken = (key: string) => createHmac('sha256', AUTH_SECRET).update(`maintenance:${key}`).digest('hex')
export function maintenanceMode(): MiddlewareHandler {
return async (c, next) => {
const key = readFlag()
if (key === null || c.req.path.startsWith('/api/health')) return next()
if (key) {
const given = c.req.query('maint-key')
if (given !== undefined && c.req.method === 'GET' && !rateLimited(`maint-key:${clientIp(c)}`, 10, 15 * 60_000)) {
if (safeEqual(given, key)) { // timingSafeEqual 比较
setCookie(c, BYPASS_COOKIE, bypassToken(key), { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', maxAge: 12 * 3600 })
const clean = new URL(c.req.url); clean.searchParams.delete('maint-key')
return c.redirect(`${clean.pathname}${clean.search}`, 302) // 口令不留在地址栏
}
}
const cookie = getCookie(c, BYPASS_COOKIE)
if (cookie && safeEqual(cookie, bypassToken(key))) return next()
}
const session = await getSession(c) // 你项目里解析登录态的函数
if (session && isAdmin(session.uid)) return next() // 已登录的管理员直接放行
c.header('Retry-After', '120')
c.header('Cache-Control', 'no-store')
if (c.req.path.startsWith('/api/')) return c.json({ error: '…', code: 'MAINTENANCE' }, 503)
// 维护页自带内联样式,默认 CSP 的 style-src 'self' 会把它挡成裸文本,要单独覆盖
c.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'")
return c.html(PAGE, 503)
}
}- 接口返回 JSON,不要返回 HTML:前端按
code能认出这是维护而不是故障。 - 维护页里用到的图片要在中间件里单独放行,别的静态资源仍然 503。
1.10 系统更新与重启
- 安全补丁交给
unattended-upgrades。MOTD 上「XXX updates can be applied」大多是普通 bug 修复,不是安全更新。只看安全更新数:apt-get -s upgrade | grep "^Inst" | grep -c security - 内核要
apt full-upgrade才会升,并且必须重启才生效。 - VPS 上建议
apt-mark hold cloud-init:升级 cloud-init 再重启,是「网络配置被重新生成、机器起不来」的经典场景。 - 重启前
$PM2_WRAPPER save(快照是旧的,重启后恢复的就是旧配置)→reboot→ 等 1 分钟站点自己回来。中间那几秒的 502 是 nginx 比 node 先起来,正常。 - 验证开机自启:直接
systemctl restart pm2-$APP会假失败(守护进程已经在跑,systemd 报Result: protocol)。要先$PM2_WRAPPER kill把守护进程杀干净,再systemctl start pm2-$APP,这才是真开机的条件。
升级 Node(安全补丁)
nodesource 源不在 unattended-upgrades 范围内,Node 出安全公告要手动升;而且 apt 换掉二进制后,正在跑的进程还是旧 node(/proc/<pid>/exe 显示 (deleted)),不重启等于没升。
apt-get update && apt-cache policy nodejs # Candidate 是不是公告里的修复版
apt-get install -y --only-upgrade nodejs && node -v
$PM2_WRAPPER kill && systemctl start pm2-$APP # 理由见上「验证开机自启」:直接 restart 会假失败
# 验:监听 $PORT 的进程真的换成了新 node
P=$(ss -ltnp | grep ":$PORT " | grep -o 'pid=[0-9]*' | cut -d= -f2); /proc/$P/exe -v
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:$PORT/别用 root 直接敲 pm2 update:root 名下的 pm2 是空的,什么都不会重启;sudo -u $APP pm2 update 又会继承 root 的 cwd,报 spawn /usr/bin/node EACCES、把进程停在半路(实测站点断约 2 分钟)。中间那几秒 502 正常。
2. 每实例单独生成:不能照抄,按方法生成
先在服务器和本机上 export 这些变量,第 1 节和第 3 节的命令就能直接粘贴。
| 变量 | 是什么 | 怎么生成 / 怎么确定(换项目、换机器必须重做) |
|---|---|---|
SERVER_IP | 服务器公网 IP | 云厂商控制台 |
SSH_PORT | SSH 端口 | 云厂商分配,或改 /etc/ssh/sshd_config 的 Port |
SSH_KEY | 本机登录用的私钥 | 本机 ssh-keygen -t ed25519 -f ~/.ssh/<名字>,把 .pub 追加到服务器 /root/.ssh/authorized_keys。每台机器一把,不复用 |
APP | 低权限系统用户名,同时作为各种名字的前缀 | 自己起,同一台机器上多个项目不能重名 |
CODE_DIR | 代码克隆目录 | /opt/$APP |
APP_SUBDIR | 仓库里网页应用所在的子目录(没有就填 .) | 看仓库结构 |
DATA_DIR | 数据 + 密钥目录(仓库外) | /opt/$APP-data |
BACKUP_DIR | 备份目录(root-only) | /opt/$APP-backup |
DB_FILE | SQLite 文件名 | 看代码里拼的路径 |
UPLOAD_SUBDIR | DATA_DIR 里存上传文件的子目录 | 看代码;没有上传功能就去掉备份脚本那两行 |
PORT | Node 监听端口 | 同一台机器上不能撞:先 ss -tlnp 看哪些已经被占,挑一个空的 |
PM2_NAME | pm2 进程名 | $APP-web |
PM2_WRAPPER | 包装器命令名 | 自己起一个短名字,不要和系统命令重名 |
DOMAIN | 站点域名 | DNS 里加一条 A 记录指向 SERVER_IP,只留这一条 |
BRANCH | 部署分支 | 看仓库 |
HEALTH_PATH | 验证「真上去了」的接口 | 挑一个不需要登录、会返回 JSON 的接口。SPA 兜底让任何路径都回 200,所以看 body,不看状态码 |
AUTH_SECRET | 会话 / JWT 签名密钥 | openssl rand -hex 32,每个实例重新生成;换掉它等于所有用户被登出 |
| 其他密钥(SMTP、AI key、Sentry token…) | 第三方凭据 | 在各自平台生成,写进 $DATA_DIR/.env.<用途>(权限 600),见 4.1 |
给自己的项目准备一份变量清单,示例(值全部是占位):
export SERVER_IP=<公网 IP> SSH_PORT=22 SSH_KEY=~/.ssh/myapp_deploy
export APP=myapp CODE_DIR=/opt/myapp APP_SUBDIR=web
export DATA_DIR=/opt/myapp-data BACKUP_DIR=/opt/myapp-backup DB_FILE=web.db UPLOAD_SUBDIR=uploads
export PORT=3000 PM2_NAME=myapp-web PM2_WRAPPER=mtweb DOMAIN=app.example.com BRANCH=main HEALTH_PATH=api/healthSSH 登录被拒
Permission denied (publickey,password):多半是-i $SSH_KEY漏了,用了默认的~/.ssh/id_rsa。看着像密码错,其实是密钥不对。
3. 新机器从零搭(Ubuntu 22.04 为例)
# 0. 先 export 第 2 节的变量
# 1. 运行环境
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs nginx sqlite3 unzip certbot python3-certbot-nginx
npm i -g pm2
# 2. 代码
git clone <仓库地址> $CODE_DIR
cd $CODE_DIR/$APP_SUBDIR && npm ci && npm run build
# 3. 专用低权限用户(--system:系统账号,密码位锁死,没人能拿它登录)
useradd --system --create-home --home-dir /home/$APP --shell /bin/bash $APP
# 4. 数据目录交给应用用户;备份目录故意留给 root
mkdir -p $DATA_DIR && chown $APP:$APP $DATA_DIR && chmod 700 $DATA_DIR
mkdir -p $BACKUP_DIR && chmod 700 $BACKUP_DIR
# 5. 写 $DATA_DIR/ecosystem.config.cjs(第 1 节.3),AUTH_SECRET 用 openssl rand -hex 32 现生成
# 6. 包装器(第 1 节.2)
# 7. 起进程 + 开机自启
$PM2_WRAPPER start $DATA_DIR/ecosystem.config.cjs
pm2 startup systemd -u $APP --hp /home/$APP # 这条用 root 跑,生成 pm2-$APP 单元
$PM2_WRAPPER save # 少了这步,重启后站点不会自己回来
# 8. 确认只绑了回环地址:应该看到 127.0.0.1:$PORT,不能是 0.0.0.0 或 *
ss -tlnp | grep ":$PORT"
# 9. nginx + HTTPS(第 1 节.4)
# 10. BBR、nginx 耗时日志、pm2 日志轮转(第 1 节.5)
# 11. 备份脚本 + cron(第 1 节.6)
# 12. 按需附加的一次性步骤(第 4 节)验收(每台新机器都做):
curl -s https://$DOMAIN/$HEALTH_PATH | head -c 80 # 走 nginx 正常返回
curl -s -m 5 http://$SERVER_IP:$PORT/ && echo "危险:公网能直连 node" # 必须超时或被拒
curl -sI http://$DOMAIN | grep -i location # http 跳 https4. 按需附加
4.1 第三方密钥:外部 env 文件
cat > $DATA_DIR/.env.ai <<'EOF'
AI_API_KEY=<真实 key>
AI_BASE_URL=<服务商接口地址>
AI_MODEL=<模型名>
EOF
chown $APP:$APP $DATA_DIR/.env.ai && chmod 600 $DATA_DIR/.env.ai在 ecosystem 的 env 里加 APP_ENV_FILE: '<DATA_DIR>/.env.ai',然后 start 重读(第 1 节.3)。应用生产模式只读这个外部文件和进程环境,不读仓库里的 .env。下面这段读取逻辑可以照抄到别的项目(取自实际项目):
// server/secrets.ts —— 必须是服务端最先 import 的模块,后面的模块才读得到这些变量
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const productionRuntime = process.env.NODE_ENV === 'production'
const externalEnvFile = process.env.APP_ENV_FILE?.trim() // 变量名按项目改
// 本地开发可以读工作区里的 .env.local / .env;生产只接受部署目录外、显式指定的那个文件。
// 仓库目录里未跟踪的 .env 在 reset 和打包之后还会留着,可能被误传、误备份,所以生产不读。
// 只补「尚未设置」的键,绝不覆盖 pm2 已经给出的变量。
const envFiles = [
...(externalEnvFile ? [externalEnvFile] : []),
...(productionRuntime ? [] : [join(process.cwd(), '.env.local'), join(process.cwd(), '.env')]),
]
for (const name of envFiles) {
try {
for (const line of readFileSync(name, 'utf8').split('\n')) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line)
if (!m || process.env[m[1]] !== undefined) continue
process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
}
} catch {
/* 文件不存在:正常 */
}
}
// 生产缺密钥就拒绝启动,不要悄悄退回一个开发用的默认值
const configuredSecret = process.env.AUTH_SECRET?.trim() ?? ''
if (productionRuntime && configuredSecret.length < 32) {
throw new Error('[auth] 生产必须设置至少 32 个字符的随机 AUTH_SECRET')
}
export const AUTH_SECRET = configuredSecret || 'dev-insecure-secret-change-me'
export const AI_API_KEY = process.env.AI_API_KEY?.trim() ?? ''验收只看存在性,不打印 key: $PM2_WRAPPER env $PM2_NAME | awk -F= '$1 ~ /^AI_/ { print $1 "=<redacted>" }'
4.2 Sentry(可选)
- 浏览器和服务端用两个 Sentry 项目,否则同名 issue 会混在一起。
- 构建时需要的变量放在
$DATA_DIR/.env.sentry(600),发布时先source再构建。VITE_SENTRY_RELEASE是构建时快照,构建之后再改进程环境没有用;SENTRY_RELEASE是运行时变量,start时带上即可。 - 完整做法见 Sentry 监控接入。
4.3 一次性预热外部数据
应用依赖第三方接口的历史数据时,可以部署后用脚本一次性抓完,省得用户第一次访问时现等。这类脚本对上游来说就是批量请求,要克制:
su -s /bin/bash $APP -c "cd $CODE_DIR/$APP_SUBDIR && DATA_DIR=$DATA_DIR npm run -s warm -- --limit 3" # 先小批量试
nohup su -s /bin/bash $APP -c "cd $CODE_DIR/$APP_SUBDIR && DATA_DIR=$DATA_DIR npm run -s warm" \
> /var/log/$APP-warm.log 2>&1 &
tail -f /var/log/$APP-warm.log- 串行,请求之间留间隔并加随机抖动。 不要并发,不要卡着整点。
- 任何一次失败(429 / 403 / 5xx / 超时)立刻整体停下,不重试。 重试只会加重限流,甚至被封。脚本应该打印原因并以退出码 1 退出。
- 能断点续跑。 已经抓到的不重抓,重跑从没抓过的接着来。被限流后别立刻重跑,隔几十分钟到几小时。
- 落盘要原子(写临时文件再
rename),这样跑的时候用户照常访问,也不用重启 pm2。 - 用
$APP跑,不要用 root,DATA_DIR要显式传:cron 和手敲的 shell 不会继承 pm2 的变量,root 跑还会留下 root 属主的文件。 - 抓到的如果是不可再生的快照,记得加进备份(见 1.6)。
- 这类脚本不用手动迁移表结构:新增列由应用启动时自动补齐并回填,部署后抽查一下新列有没有留下空值即可。
4.4 上游依赖的连通状态接口
应用依赖某个容易限流的第三方服务时,可以暴露一个健康接口给外部监控(如 Heartbeat)。关键是 只记录用户真实触发的请求结果,不主动去探测上游:定时探测本身就是额外流量,限流时只会雪上加霜。
- 服务端在内存里记最近一段时间(比如 15 分钟)真实请求的成功 / 失败。
ok和idle(这段时间没人请求,不是故障)返回 200;最近一次是 429 / 403(限流或被拦),或者失败占一半以上,返回 503,body 里带各类失败次数和最近失败时间。- 状态只存内存,重启清零,不用落库。
- 监控侧新增一个 http 检查,地址指向这个接口。它每次只碰自己的接口,看到的是真实流量的结果;没人访问的时段显示
idle,不会误报。
4.5 服务器要能访问的外部依赖
应用运行时如果要拉取外部资源(节假日表、字典、CDN 上的 JSON 等),新机器上要先验证出站可达,并让应用把结果缓存到 $DATA_DIR:
curl -sI https://<外部资源地址> | head -1 # 应为 200- 已经不会再变的数据(如往年的节假日)缓存后不再请求;还没公布的,限制重查频率(比如每天最多一次)。
- 拉不到时要明确报错,不要静默用错误的默认值(比如用错日期算交期)。
4.6 新增路由后构建被拦:路由快照与功能登记
如果构建流程里有一步「核对服务端实际路由与登记文件(路由快照、功能说明文档)是否一致」,新增接口而没重新生成快照时,构建会中止。站点不受影响,仍是旧版本,但发不出去。报错形如:
Error: FEATURE_ROUTE_REVIEW_REQUIRED: site-routes.json 与路由不一致;新增 GET server/xxx.ts /history/:id/files/:file处理(在本机,不是服务器):先在功能登记文件里补上新前缀的归属,再重新生成快照并本地先过一遍构建,然后提交并推送后再发布。只在服务器上改或只 scp 一份,下次 reset --hard 会抹掉,构建又会失败。验证网页版构建一律走完整的 npm run build,别直接跑 vite build——那会跳过这一步,问题到上线才暴露。
4.7 热更新后「功能说明待同步」
如果应用启动时会拿「源码哈希」和构建时生成的清单比对(比如给站内助手用的知识库),热更新(1.8)改了任意源文件,哈希就对不上,知识库会被标成「待同步」,对话全部报错。清单只在构建时重建,所以热更新之后要手动重建一次并重启:
ssh -p $SSH_PORT -i $SSH_KEY root@$SERVER_IP \
"cd $CODE_DIR/$APP_SUBDIR && npm run <重建清单的脚本> && chown $APP:$APP <清单文件> && \
$PM2_WRAPPER restart $PM2_NAME --update-env"或者干脆走一遍 1.7 的日常发布(构建里已经包含这一步)。
4.8 CSP 有两份,要一起改
如果内容安全策略(CSP)同时存在两份,浏览器取交集:
- 服务端响应头:中间件对每个响应写
Content-Security-Policy。 - 页面里的
<meta http-equiv="Content-Security-Policy">:由构建插件在vite build时往index.html的</head>前注入。
// vite.config.ts(只展示结构;策略内容要和服务端响应头的那份逐项一致)
const PRODUCTION_SECURITY_META = `
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; ...; img-src 'self' https: data: blob:; ...; upgrade-insecure-requests" />`
{
name: 'production-security-meta',
transformIndexHtml(html: string): string {
// dev 不注入:React Refresh 的内联脚本会被严格 CSP 挡掉
return command === 'build' ? html.replace('</head>', `${PRODUCTION_SECURITY_META}\n </head>`) : html
},
}- 只改响应头那一份,线上毫无变化,白排查一轮。两处必须一起改,并且要重新
npm run build:<meta>是构建期写进dist/index.html的,只热传服务端 TS 不会更新它(1.8)。 - dev 不发 CSP。浏览器端新用到的 URL scheme(
blob:、data:)或新域名,本地看不出问题,线上才被挡:真实事故是 Canvas 生成的分享图预览在线上是裂图,而下载正常(<a download>不受img-src管),症状很像「图没生成出来」。改完 CSP 要在线上实际点一遍用到它的功能。
4.9 可选环境变量(第三方登录、管理员、开关)
这些都写在 ecosystem 的 env 里;哪组没配,对应功能就不可用或走默认。改完用 start --update-env(1.3)。
// 第三方登录:每个提供商 ID + SECRET 成对;Google 的客户端按原则 7 建在独立项目里
GOOGLE_CLIENT_ID: '', GOOGLE_CLIENT_SECRET: '',
GITHUB_CLIENT_ID: '', GITHUB_CLIENT_SECRET: '',
// 管理员:用户 id 逗号分隔,或一个已验证邮箱
ADMIN_USER_IDS: '1,2',
ADMIN_EMAIL: 'admin@example.com',- 功能开关类的变量(总开关、测试用户、后台任务开关)同理:不设就走默认值,测试用的变量线上别设。
5. 排错
| 现象 | 多半是 |
|---|---|
pm2 list 是空的 | 你在用 root 敲。应用在 $APP 名下 → 用 $PM2_WRAPPER list |
spawn /usr/bin/node EACCES | 不是 node 的权限问题。su 不切工作目录,继承了 /root → 用包装器 |
| 502 Bad Gateway | node 挂了 → 看 logs,并用 $PM2_WRAPPER list 看重启次数有没有在涨;或者端口不对 → ss -tlnp | grep $PORT |
nginx error.log 里有 connect() failed (111) | node 进程正在崩溃重启。去应用的 error log 里找没人接住的 error 事件或异常栈 |
| 站点 500,日志说数据库 readonly | WAL/SHM 被 root 拿走了属主 → chown $APP:$APP $DATA_DIR/$DB_FILE{,-wal,-shm} |
| 一调数据库相关接口就 500 | better-sqlite3 这类原生模块,换过 node 大版本要 npm rebuild better-sqlite3 |
| 登录成功,刷新就掉线 | cookie 没带 Secure → 查 NODE_ENV=production 有没有生效 |
| 改完 ecosystem 里的变量不生效 | 用了 restart → 改用 start <配置文件> --update-env |
| 改完代码没生效 | 没跑 npm run build,或者跑在别的目录 → $PM2_WRAPPER describe $PM2_NAME | grep cwd |
构建被 Killed | 内存不够,OOM → 开 swap(第 1 节.7) |
| 站点全是 503、页面写着维护中 | $DATA_DIR/MAINTENANCE 还在 → rm 掉(1.9) |
构建报 FEATURE_ROUTE_REVIEW_REQUIRED / FEATURE_ROUTE_COVERAGE | 新增了路由但没登记 / 没重新生成快照 → 4.6 |
| 所有对话报「功能说明待同步」 | 热更新后没重建清单 → 4.7 |
| 估价 / 日期计算报外部数据拉取失败 | 服务器访问不到外部资源 → 4.5 的 curl 验证 |
| 证书过期,浏览器点不进去 | HSTS 硬拒绝 → certbot renew --dry-run 看报错 |
| 改了 CSP,线上没变化 / 某功能只在线上被挡(裂图、脚本不执行) | 只改了一份 → 4.8,两处一起改并重新构建 |
Google 登录全站突然不可用,报 401 disabled_client | 客户端所在的云项目被停用,多半是同项目的 API 密钥泄露 → 按原则 7 把登录客户端迁到独立项目 |
| 用 curl 探测得到 200,以为文件泄露了 | SPA 兜底让任何路径都回 200 + index.html → 看 body 是不是那份 HTML,不要只看状态码 |
| 用户说下载 / 视频卡,但他测速网很快 | 先用 nginx 日志 字节数 ÷ rt 算他实际收到的速度,再让他换网络(WiFi ↔ 流量)测同一个文件:只有一种网络慢 → 跨境丢包被 cubic 压速,开 BBR(1.5) |
| 线上下载 / 媒体速度上不去 | 先在服务器上测一个与项目无关的地址(例如 curl -o /dev/null -w "%{speed_download}" "https://speed.cloudflare.com/__down?bytes=50000000"),分清是机器自己的带宽还是上游的问题。注意单位:KB/s × 8 ≈ kbps,6M 宽带跑满约 700KB/s |
