云服务器部署 web 项目,最容易返工的地方不在代码,在进程怎么活下来。文件传上去了,端口也通了,SSH 一断,服务跟着一起没。
这次的过程可以拆成两层:进程层决定服务活不活,入口层决定外面能不能进来。本文记录的是进程层这一遍,以及中间卡住的三个位置。
一、背景
一台新加坡区域的实例,系统是 Ubuntu 22.04,跑一个 Node 服务,监听 3000。
项目的启动方式是 node server.js,本地开发时一直这么起,没有任何进程管理。部署的目标只有两条:机器重启之后服务自己起来,异常退出之后服务自己回来。
二、现象:SSH 断开,服务就没了
第一次登上去,进目录,执行启动命令,前台开始打日志,浏览器也能打开。
关掉终端窗口,再刷新页面,连接被拒绝。
重新登上去看,进程列表里已经没有 node。端口上没有监听,报的是 connection refused,不是超时。
这两种表现要分开看。超时是包被丢在机器外面,拒绝是机器上没人听这个端口。
一个指向网络,一个指向本机。
三、排查
按进程、端口、日志三层的顺序走下来。前一层没有结论之前不动下一层。
第一步:确认进程还在不在
断开连接之后再执行一次:
ps -ef | grep server.js | grep -v grep
输出是空的。
空输出就是第一个结论:进程不在了。不是端口的问题,也不是网络的问题。
第二步:看端口当时被谁占着
进程还在的时候,端口是有人听的。
ss -lntp | grep :3000
-l 只看监听状态,-n 不做域名解析,-t 限 TCP,-p 带出进程。
四个参数合起来,一次看清端口归谁。这一步的作用是排除端口冲突。
端口被别的进程占着时,表现同样是打不开,但进程列表里会出现另一个名字。
第三步:看服务是怎么被拉起来的
ps -ef 的输出里有一列父进程号,这时候它是某个 bash,不是 1。
父进程是 bash,说明进程挂在当前的登录会话下面。会话结束,内核会给这个会话里的进程发挂断信号,进程跟着退出。
这一层看的是血缘,不是启动命令本身。命令写得再对,挂在错误的父进程下也活不过一次断线。
四、根因
两个根因都在默认状态上:进程挂在前台会话里,服务没有交给 init 系统托管。
前台启动的进程属于当前会话,会话是 SSH 连接的一部分。终端关掉,会话结束,进程收到挂断信号,默认行为是退出。
机器重启之后,也没有任何东西会把它再拉起来。
这一点在当时看不出来,要等到下一次断线或者下一次重启才暴露。

五、方案:交给 systemd
systemd 是 Ubuntu 22.04 默认的 init 系统,负责开机拉起服务,也负责进程退出之后的重启。写一个 unit 文件,把启动方式交给它托管。
第四步:写 unit 文件
[Unit]
Description=app server
After=network.target
[Service]
User=app
WorkingDirectory=/srv/app
EnvironmentFile=/etc/app.env
ExecStart=/usr/bin/node /srv/app/server.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
几个字段各管一件事。
User 决定进程以谁的身份运行。用 root 跑,等于把整个账号的权限面交给这个进程。
WorkingDirectory 决定相对路径的基准。缺了它,程序按当前目录找配置文件会找不到。
EnvironmentFile 把密钥和连接串从 unit 文件里拿出来,unit 文件本身就不用限制权限了。
Restart=always 让异常退出后自动重启,RestartSec 决定重启间隔。
After=network.target 只保证网络栈就绪之后启动,不保证公网可达。这一点在排查时容易搞混,把启动延迟当成网络问题。
第五步:环境变量文件的归属
环境变量文件要归服务用户所有,权限收到 600。
sudo chown app:app /etc/app.env
sudo chmod 600 /etc/app.env
两条的用途是让服务读得到文件,同时不让其他用户读到内容。
这一步做错的表现很典型。服务起不来,日志里只有一句权限被拒绝,看不出是哪个文件。
第六步:重载、自启、看日志
改过 unit 文件之后先重载,再开机自启,最后看状态与日志。
sudo systemctl daemon-reload
sudo systemctl enable --now app
systemctl status app --no-pager
journalctl -u app -n 100 --no-pager
daemon-reload 不能漏。systemd 读的是已经加载的配置副本,文件改了不会自动重读。
enable 管开机,start 管当前,--now 是把两个动作合成一条命令。
journalctl -u app 看的是这个 unit 的输出,比翻 /var/log 下的文件准。
日志里的时间戳和系统时区一致,不用换算。这一条在跨时区协作时省下的时间比想象中多。
六、复盘
进程活不活,看的不是启动命令对不对,是它归属于谁。
交互式启动的进程天然属于会话,会话一断就结束。交给 init 系统托管之后,进程归属于系统,断线、重启都不影响它。
入口层这次没碰。进程正常而外面打不开,问题在放行规则和反向代理上,不在 systemd。
七、常见问题
status 显示 active,页面还是打不开? active 只说明进程在跑。要看 ss -lntp 有没有监听,再看放行规则有没有开对应端口。
改完 unit 文件重启服务没生效? daemon-reload 漏了。systemd 读的是已经加载的配置副本。
日志里只有一行退出码怎么办? 137 是收到强制信号,1 是程序自己退出。前者查内存和 OOM,后者直接看程序输出。
