在华为云 ECS 上用 systemd 托管 Python Web 服务:自动重启、日志轮转和资源限制
一、场景:nohup 跑起来的服务,总在半夜挂掉
很多人在 ECS 上部署小型 Python Web 服务(Flask 或 FastAPI)时,图省事直接 nohup python app.py & 了事。这样跑一段时间后,常见问题有:
- 进程被 OOM 杀掉或者自己崩了,没人拉起来,第二天用户反馈才发现;
- 服务器重启后服务没有自动起来;
nohup.out越写越大,容易把系统盘写满;- 某个接口内存泄漏,把同机的其他进程也拖慢了。
这些事其实 systemd 都能管。Ubuntu 22.04、openEuler、EulerOS 默认都用 systemd 做 init,不用额外装 supervisor。下面是一套可以直接复用的配置,按步骤写下来。
示例约定:项目放在 /opt/myapp,虚拟环境在 /opt/myapp/venv,用 gunicorn 监听 127.0.0.1:8000,前面挂 Nginx。如果你直接对外暴露端口,记得在安全组里放行对应端口。
二、准备运行用户和目录
不要用 root 跑 Web 服务。先建一个不能登录的系统用户:
sudo useradd --system --no-create-home --shell /sbin/nologin myapp
sudo mkdir -p /opt/myapp
sudo chown -R myapp:myapp /opt/myapp
/sbin/nologin 在 Ubuntu 和 openEuler 上都存在(Ubuntu 上也可以写 /usr/sbin/nologin)。
装依赖时用虚拟环境,后面 unit 文件里写绝对路径,就不依赖 source activate:
cd /opt/myapp
sudo -u myapp python3 -m venv venv
sudo -u myapp ./venv/bin/pip install gunicorn flask
Ubuntu 22.04 上如果 venv 模块报错,先装 python3-venv。
环境变量(数据库地址、密钥等)单独放一个文件,权限收紧:
sudo tee /etc/myapp.env >/dev/null <<'CONF'
APP_ENV=production
DATABASE_URL=postgresql://user:pass@10.0.0.5:5432/app
CONF
sudo chown root:myapp /etc/myapp.env
sudo chmod 640 /etc/myapp.env
三、编写 unit 文件
新建 /etc/systemd/system/myapp.service:
[Unit]
Description=My Python Web App (gunicorn)
After=network-online.target
Wants=network-online.target
# 60 秒内最多重启 5 次,超过就放弃,避免无限重启刷日志
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/myapp/venv/bin/gunicorn \
--workers 3 \
--bind 127.0.0.1:8000 \
--access-logfile - \
--error-logfile - \
app:app
ExecReload=/bin/kill -s HUP $MAINPID
# 自动重启
Restart=on-failure
RestartSec=5s
# 优雅停止:先 SIGTERM,30 秒后还没退出再 SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=30
# 资源限制
MemoryMax=512M
CPUQuota=150%
TasksMax=200
LimitNOFILE=65535
# 基础加固
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
几个值得说明的点:
- Restart=on-failure:进程非零退出、被信号杀死(包括 OOM)、超时都会重启;正常
systemctl stop不会触发。想要“不管怎么退出都拉起”可以用always,但我更喜欢on-failure,手动停服务时不会被拉回来。 - StartLimitIntervalSec / StartLimitBurst 要写在 [Unit] 段。老教程里写在 [Service] 的
StartLimitInterval是旧名字,新版本虽然兼容,但放错段落很容易让人困惑。 - PYTHONUNBUFFERED=1:不加的话,Python 的 stdout 在非终端下是块缓冲的,
print的内容会很久才出现在日志里,排查时很迷惑。 --access-logfile -和--error-logfile -:让 gunicorn 把日志写到 stdout/stderr,交给 journald 统一收集,不再自己写文件。- ExecReload 发 HUP:gunicorn 收到 HUP 会平滑重启 worker,改了代码执行
systemctl reload myapp就行。
启用并启动:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
curl -I http://127.0.0.1:8000/
--workers 我一般按“CPU 核数 × 2 + 1”起步,再根据内存调整。2 核 4G 的小规格 ECS 上,worker 开太多反而更容易碰到后面的内存上限,宁可先少开几个,观察一段时间再加。
可以模拟一次崩溃,验证自动重启:
sudo systemctl kill -s SIGKILL myapp
sleep 6
systemctl status myapp # Active 时间应该刷新了
最后别忘了做一次真正的重启验证:sudo reboot,重新登录后看 systemctl is-enabled myapp 和 systemctl is-active myapp 是否分别是 enabled 和 active。常见的情况是只 start 没 enable,机器维护重启后服务没起来。
多台机器参数不同时,用 override 而不是复制一份
同一个服务部署在几台规格不同的 ECS 上,内存上限、worker 数可能不一样。我不会去改主 unit 文件,而是用:
sudo systemctl edit myapp
在打开的编辑器里只写需要覆盖的部分,例如:
[Service]
MemoryMax=1G
保存后会生成 /etc/systemd/system/myapp.service.d/override.conf,systemd 会自动 reload,再 restart 即可生效。这样主文件可以放进 Git 统一管理,每台机器的差异一目了然,用 systemctl cat myapp 能看到合并后的完整配置。注意:如果要覆盖 ExecStart,需要先写一行空的 ExecStart= 清掉原值,再写新的,否则会报错说不能有多个 ExecStart。
四、日志:journald 加上限,文件日志用 logrotate
日志交给 journald 后,看日志用:
journalctl -u myapp -f # 实时跟踪
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err -b # 本次开机以来的错误
journald 默认按磁盘比例限制大小,但我习惯写死一个上限。编辑 /etc/systemd/journald.conf:
[Journal]
Storage=persistent
SystemMaxUse=1G
MaxRetentionSec=14day
然后 sudo systemctl restart systemd-journald。Storage=persistent 保证重启后日志还在。改之前可以用 journalctl --disk-usage 看看当前占用,想立刻清理可以用 sudo journalctl --vacuum-size=500M。
如果业务代码自己往文件里写日志(比如 /var/log/myapp/app.log),就交给 logrotate。新建 /etc/logrotate.d/myapp:
/var/log/myapp/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
su myapp myapp
create 0640 myapp myapp
postrotate
systemctl kill -s USR1 --kill-whom=main myapp.service >/dev/null 2>&1 || true
endscript
}
gunicorn 收到 USR1 会重新打开日志文件;如果是你自己用 logging.FileHandler 写的文件,进程不会响应 USR1,这时把 postrotate 段去掉,改用 copytruncate(代价是切割瞬间可能丢几行)。写完先空跑一下:
sudo logrotate -d /etc/logrotate.d/myapp
需要集中检索或长期保存时,可以在 ECS 上装 LTS 云日志服务的采集端,把 journald 导出的文件或应用日志文件接进去,这里不展开。
五、资源限制到底有没有生效
MemoryMax、CPUQuota 依赖 cgroup。先确认系统用的是哪个版本:
stat -fc %T /sys/fs/cgroup/
# cgroup2fs 表示 cgroup v2,tmpfs 表示 v1
Ubuntu 22.04 默认是 cgroup v2,MemoryMax 直接生效。部分 openEuler / EulerOS 版本默认还是 cgroup v1,如果发现 MemoryMax 不起作用,可以改用旧参数 MemoryLimit=512M。检查实际值:
systemctl show myapp -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax
systemd-cgtop # 实时看各个服务的 CPU、内存
内存超过上限时,内核会在这个 cgroup 里触发 OOM,systemctl status 会显示 Failed with result 'oom-kill',然后按 Restart=on-failure 自动拉起。这比整台机器内存打满、sshd 都卡住要好得多。配合云监控 CES 的内存使用率告警,基本能在用户发现之前收到通知。
六、常见坑和排查方法
1. 改了 unit 文件不生效
每次修改都要 sudo systemctl daemon-reload,再 restart。systemctl status 里如果出现 “changed on disk” 的提示,就是忘了这一步。
2. 状态一直是 failed,也不再重启
多半是触发了 StartLimitBurst。先 journalctl -u myapp -n 50 找真正的报错,修好后执行 sudo systemctl reset-failed myapp 再启动。
3. 手动能跑,systemd 里报 203/EXEC 或找不到模块
203/EXEC 一般是 ExecStart 路径写错或者没有执行权限。systemd 不会读 ~/.bashrc,也不会自动激活虚拟环境,所以 ExecStart 必须写 venv 里的绝对路径。找不到模块时,检查 WorkingDirectory 和 app:app 的模块名。
4. 权限问题
用 myapp 用户手动执行一遍最直接:sudo -u myapp /opt/myapp/venv/bin/gunicorn app:app。如果系统开了 SELinux(getenforce 显示 Enforcing),放在非标准目录的程序可能被拦,可以用 sudo ausearch -m avc -ts recent 看看有没有拒绝记录。
5. 想监听 80 端口
普通用户不能绑定 1024 以下端口。推荐前面放 Nginx;实在要直接绑定,可以加 AmbientCapabilities=CAP_NET_BIND_SERVICE。
6. 停服务很慢
systemctl stop 卡 30 秒,通常是有请求或后台线程没结束,等到 TimeoutStopSec 才被 SIGKILL。可以检查代码是否正确处理了 SIGTERM,或者按业务情况调整超时。
7. EnvironmentFile 里的变量没读到
这个文件不是 shell 脚本,每行只写 KEY=VALUE。不要加 export,也不要写 $(...) 这种命令替换;值里有空格就用引号包起来。用 systemctl show myapp -p Environment 只能看到 unit 里直接写的 Environment,排查 EnvironmentFile 时,更可靠的办法是在代码启动时把关键变量是否存在打到日志里(别打印密钥本身)。
8. 进程没退出,但服务已经“假死”
Restart 只管进程退出,如果 worker 卡在某个慢查询上、端口还在但不响应,systemd 是感知不到的。gunicorn 自带的 --timeout 会杀掉长时间无响应的 worker,算是第一道保护;第二道我会加一个外部检查,比如用 CES 的告警盯住主机指标,再配一个定时 curl 健康检查接口的脚本,连续失败就 systemctl restart。不要指望一个 unit 文件解决所有可用性问题。
七、小结
从 nohup 换成 systemd 后,可以显著减少“半夜挂了没人管”和“日志写满磁盘”这两类问题。核心就这几行:Restart=on-failure 加启动频率限制负责自愈,stdout 加 journald 上限负责日志,MemoryMax/CPUQuota 负责把故障关在一个服务里。这份 unit 文件可以当模板复用到多台 ECS 上,换个路径和端口就能用。
- 点赞
- 收藏
- 关注作者
评论(0)