在华为云 ECS 上用 systemd 托管 Python Web 服务:自动重启、日志轮转和资源限制

举报
yd_294423728 发表于 2026/10/22 00:57:09 2026/10/22
【摘要】 用 nohup 跑的 Python 服务,常遇到崩溃没人拉起、日志写满磁盘、内存泄漏拖垮整机等问题。本文以 gunicorn 为例,给出一份可直接复用的 systemd unit 配置,涵盖自动重启与启动频率限制、journald 与 logrotate 日志管理、MemoryMax/CPUQuota 资源限制,并整理 203/EXEC、cgroup 版本差异等常见坑的排查方法。

一、场景: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

几个值得说明的点:

  1. Restart=on-failure:进程非零退出、被信号杀死(包括 OOM)、超时都会重启;正常 systemctl stop 不会触发。想要“不管怎么退出都拉起”可以用 always,但我更喜欢 on-failure,手动停服务时不会被拉回来。
  2. StartLimitIntervalSec / StartLimitBurst 要写在 [Unit] 段。老教程里写在 [Service] 的 StartLimitInterval 是旧名字,新版本虽然兼容,但放错段落很容易让人困惑。
  3. PYTHONUNBUFFERED=1:不加的话,Python 的 stdout 在非终端下是块缓冲的,print 的内容会很久才出现在日志里,排查时很迷惑。
  4. --access-logfile - 和 --error-logfile -:让 gunicorn 把日志写到 stdout/stderr,交给 journald 统一收集,不再自己写文件。
  5. 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 上,换个路径和端口就能用。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。