Language:Chinese VersionEnglish Version

需要保持运行的进程存在的问题

每个生产服务器最终都会面临相同的问题:如何保持长时间运行的进程存活,在崩溃后干净地重启它,捕获其输出,并将其集成到系统生命周期中?错误的答案是在 tmux 会话中使用 nohup ./app &。正确的答案取决于你的技术栈、团队以及你实际需要的控制程度。

Linux 进程管理不是一个单一工具的问题。systemd 是几乎所有现代 Linux 发行版中的初始化系统,在操作系统级别处理服务管理。Supervisor 是一个轻量级的 Python 守护进程,以最少的配置开销管理任意进程。PM2 是一个 Node.js 原生的进程管理器,在基础功能之上增加了集群模式、零停机重载和内置日志轮转。每个工具都有其独特的定位,如果你在超出其优势的范围内使用它们,每个都有可能导致你陷入困境的失败模式。

本指南涵盖了所有三种工具的实际配置模式、直接功能比较,以及基于你实际运行内容的决策框架。


systemd:操作系统级别的标准

systemd 是自 2015 年以来每个主要 Linux 发行版都附带的进程管理器。如果你运行的是 Ubuntu 16.04 或更高版本、Debian 8+、CentOS 7+ 或任何现代衍生版本,你已经拥有 systemd。将其用于你的应用进程是可用的最原生的 Linux 进程管理方法。

编写单元文件

systemd 服务在单元文件中定义,通常系统管理的服务放在 /etc/systemd/system/,用户范围的服务放在 ~/.config/systemd/user/。以下是一个针对 Python API 的最小但可用于生产的示例:

[Unit]
Description=MyApp Python API
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/.env
ExecStart=/opt/myapp/venv/bin/python app.py
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Type 指令是大多数初学者容易出错的地方。Type=simple 告诉 systemd ExecStart 启动的进程是主进程,systemd 会直接跟踪它。适用于不进行分叉的进程。Type=forking 用于传统的 Unix 守护进程,这些进程会调用 fork() 然后退出父进程。如果你在一个分叉进程中设置了 Type=simple,systemd 会在启动父进程时认为服务已启动,然后在父进程退出时将其标记为失败——即使子进程运行良好。如果你不确定,可以运行 strace -e fork yourprocess 来查看启动时实际发生了什么。

Type=notify 值得了解:使用 systemd 库中的 sd_notify() 的进程可以明确发出就绪信号。这可以避免竞争条件,即 systemd 在服务绑定到其端口之前就认为服务已就绪。

重启策略

Restart= 指令控制 systemd 何时尝试重启:

  • no — 从不重启(默认值,仅适用于一次性任务)
  • on-failure — 仅在非零退出码或信号时重启;在干净退出或 SIGTERM 时不重启
  • always — 无论退出状态如何都重启;对于可以干净退出的服务要谨慎使用
  • on-abnormal — 在信号、看门狗超时或非零退出时重启,但不包括干净停止

将这些与 StartLimitBurstStartLimitIntervalSec 配合使用,以防止在持续损坏的应用程序上出现无限重启循环。上面的示例允许在 60 秒内进行三次重启,之后 systemd 会放弃并将单元标记为失败。你需要运行 systemctl reset-failed myapp 来清除该状态。

使用 journalctl 读取日志

StandardOutput=journal 时,stdout 和 stderr 会被 journal 捕获,你可以获得结构化的日志访问,而无需配置单独的日志文件:

# 跟随实时输出
journalctl -u myapp -f

# 最后 100 行
journalctl -u myapp -n 100

# 自上次启动以来
journalctl -u myapp -b

# 在时间戳之间
journalctl -u myapp --since "2026-03-20 08:00:00" --until "2026-03-20 09:00:00"

# 以 JSON 格式输出用于日志传输
journalctl -u myapp -o json

一个常见陷阱:在某些发行版上,journal 存储默认使用易失性内存(/run/log/journal/)。如果日志在重启后消失,请检查 /etc/systemd/journald.conf 并设置 Storage=persistent 以强制在 /var/log/journal/ 中进行磁盘存储。

systemd 的局限性

systemd 需要 root 权限(或 sudo)来管理系统级单元。在共享托管环境或容器中,如果你没有 root 权限,这将是一个硬性障碍。单元文件语法也很冗长——管理五十个每个都有略微不同环境文件的微服务很快就会变得繁琐。而且,如果你需要为不同进程配置不同的保留策略的日志轮转,你需要使用 logrotate 作为单独的工具。最后,systemd 没有共享配置的进程组概念;每个服务都是一个独立的单元文件。


Supervisor:无需 Root 的实用进程控制

Supervisor (supervisord) 是一个基于 Python 的进程管理器,十多年来一直是 Django 和 Flask 部署的标准选择。它不取代初始化系统——它本身作为一个守护进程运行——但它为在单一配置下管理一组相关进程提供了更简单的接口。

supervisord.conf 结构

使用 pip install supervisorapt install supervisor 安装。位于 /etc/supervisor/supervisord.conf 的主配置文件处理守护进程本身;单个程序放在 /etc/supervisor/conf.d/ 中:

; /etc/supervisor/conf.d/myapp.conf

[program:myapp-web]
command=/opt/myapp/venv/bin/gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
directory=/opt/myapp
user=appuser
autostart=true
autorestart=true
startsecs=5
startretries=3
stopwaitsecs=30
stdout_logfile=/var/log/myapp/web.out
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
stderr_logfile=/var/log/myapp/web.err
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=5
environment=FLASK_ENV="production",DATABASE_URL="postgresql://..."

[program:myapp-worker]
command=/opt/myapp/venv/bin/celery -A tasks worker --loglevel=info
directory=/opt/myapp
user=appuser
autostart=true
autorestart=true
startsecs=10
stdout_logfile=/var/log/myapp/worker.out
stderr_logfile=/var/log/myapp/worker.err

startsecs 参数定义了一个时间窗口,在此窗口内进程必须保持运行状态,Supervisor 才会认为它成功启动。如果进程在该窗口关闭前退出,则视为启动失败。将此值设置为比你的应用程序启动时间更长的值,以避免错误的失败计数。

进程组

Supervisor 支持将相关程序分组,这样你就可以一起管理它们:

[group:myapp]
programs=myapp-web,myapp-worker
priority=999

定义了组后,supervisorctl stop myapp:* 会原子性地停止组中的所有程序。这对于部署确实很有用:停止整个组,更新代码,重新启动整个组。无需记住每个单独的进程名称。

使用 supervisorctl 管理

# 重新加载配置而不重启运行中的进程
supervisorctl reread
supervisorctl update

# 所有进程的状态
supervisorctl status

# 重启单个程序
supervisorctl restart myapp-web

# 交互式查看日志
supervisorctl tail -f myapp-web stdout

一个实际的陷阱:supervisorctl reread只读取新的配置;它不会对现有程序应用更改。在重新读取后,你需要使用supervisorctl update来启动新添加的程序或停止已移除的程序。这几乎会让每个人第一次都踩坑。

Supervisor通过stdout_logfile_maxbytesstdout_logfile_backups实现的内置日志轮转很简单但有限制。它不会压缩轮转后的文件,也不支持基于时间的轮转。对于超出基本基于大小的轮转需求,你仍然需要logrotate


PM2:Node.js 工作负载的进程管理

PM2 专为 Node.js 构建,尽管它可以运行任何可执行文件。其杀手级功能是集群模式,它使用 Node.js cluster 模块将你的 Node.js 应用程序分叉到所有可用的 CPU 核心上——并内置负载均衡和零停机重载。

ecosystem.config.js 文件

PM2 中的声明式配置方法围绕ecosystem.config.js展开:

module.exports = {
  apps: [
    {
      name: 'api-server',
      script: './src/server.js',
      instances: 'max',          // 每个CPU核心一个
      exec_mode: 'cluster',
      watch: false,
      max_memory_restart: '500M',
      env: {
        NODE_ENV: 'production',
        PORT: 3000
      },
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      error_file: '/var/log/myapp/api-error.log',
      out_file: '/var/log/myapp/api-out.log',
      merge_logs: true,
      restart_delay: 4000,
      exp_backoff_restart_delay: 100
    },
    {
      name: 'background-worker',
      script: './src/worker.js',
      instances: 2,
      exec_mode: 'fork',
      cron_restart: '0 2 * * *',  // 每天凌晨2点重启
      max_memory_restart: '300M'
    }
  ]
}

instances: 'max'exec_mode: 'cluster'的组合是 PM2 在 Node.js 生产环境中立足的关键。使用pm2 reload api-server进行重载会逐个循环工作进程,在整个过程中保持应用程序可用。与 systemd 的systemctl restart相比,后者会同时向所有进程发送 SIGTERM,导致服务出现中断。

集群模式的权衡

集群模式要求您的应用程序是无状态的。任何内存中的会话存储、进程内缓存或共享的可变状态都会在多个工作进程之间立即失效。如果您使用粘性会话,这些必须在负载均衡器级别处理,而不是在应用程序中。这不是 PM2 的限制——这是水平扩展的标准约束——但 PM2 可能会让您在不经意间就遇到这个问题。

exp_backoff_restart_delay 设置允许在重启时使用指数退避。从 100ms 开始,每次后续重启都会将延迟时间加倍,最多达到 15 秒。这可以防止因部署故障导致的崩溃循环洪流冲击您的数据库或下游服务。

日志轮转

PM2 的日志轮转通过一个单独的模块处理:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'

与 Supervisor 内置的轮转不同,pm2-logrotate 支持压缩,并且支持基于大小和基于时间的轮转。配置存储在 PM2 自己的数据存储中,这意味着它在重启后仍然存在,但除非您明确导出,否则不会被跟踪在您的版本控制系统中。

启动集成

PM2 通过桥接到 systemd 实现持久启动:

pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd

pm2 startup systemd 命令会生成一个 systemd 单元,将 PM2 本身作为系统服务运行。然后 PM2 将您的 Node 进程作为子进程进行管理。这是一种有用的混合方法,但它增加了一层间接性,当启动时出现问题时会使调试变得复杂。


直接比较:systemd vs Supervisor vs PM2

功能 systemd Supervisor PM2
需要root权限 是(系统单元) 否(以任何用户身份运行)
配置格式 INI风格单元文件 INI风格.conf文件 JavaScript / JSON / YAML
集群/多实例 手动(模板) 手动(多个程序) 原生(集群模式)
零停机重载 需要自定义逻辑 不支持 是(重载命令)
日志管理 journald(结构化) 基于文件(大小轮转) 基于文件 + pm2-logrotate
进程组 目标(间接) 原生组 配置中的应用程序数组
内存重启 无原生支持 无原生支持 max_memory_restart
语言亲和性 语言无关 语言无关 Node.js优化
容器适用性 差(初始化冲突) 良好 良好
监控集成 systemd-exporter, journald HTTP状态端点 pm2 monit, Keymetrics

何时使用哪种工具

选择systemd的情况

  • 您部署的服务需要在任何用户登录前启动(数据库、网络守护进程、基础设施服务)
  • 您希望与操作系统服务生命周期深度集成,包括通过After=Requires=指令进行依赖排序
  • 您需要结构化、可搜索的日志,与现有的journald或日志传输管道集成
  • 您的团队熟悉Linux管理,并且已经以这种方式管理其他系统服务
  • 应用程序是用任何语言编写的,并且您希望在整个服务器上使用单一、一致的管理界面

选择Supervisor的情况

  • 您在共享服务器、权限受限的 VPS 或无法安装 systemd 单元的环境中
  • 您有一个 Python Web 应用程序(Django、Flask、FastAPI),并且有关联的后台工作进程需要作为逻辑单元进行管理
  • 您想要比 systemd 更简单的配置,同时不牺牲以非 root 用户运行的能力
  • 您需要在一个配置文件下管理一组异构进程——Web 服务器、队列工作进程和调度器,并在部署时一起重启它们

选择 PM2 的情况

  • 您的工作负载是 Node.js,并且希望使用所有可用的 CPU 内核,而不需要手动管理反向代理和多个端口分配
  • 零停机重载是硬性要求,您无法在部署期间承受哪怕是短暂的停机
  • 您希望基于内存的自动重启作为防止长时间运行的 Node 进程内存泄漏的安全措施
  • 您的团队更喜欢 JavaScript 原生配置而不是 INI 文件

实际应用中的监控和重启策略

仅重启策略并不构成完整的监控策略。每五秒重启一次的进程并不健康——它已经出故障,只是在掩盖问题。为您的进程管理器配备实际的健康检查。

对于 systemd,watchdog 机制提供了应用程序级别的健康检查。在您的单元文件中设置 WatchdogSec=30,并让您的应用程序定期调用 sd_notify(0, "WATCHDOG=1")。如果应用程序未能及时检查,systemd 会终止并重启它。这可以捕获不会导致进程退出的死锁和卡住的事件循环。

对于在 Supervisor 或 PM2 后运行的应用程序,常见的模式是使用轻量级健康检查 sidecar——一个小型脚本或服务,它访问应用程序的 /health 端点,如果失败则调用 supervisorctl restartpm2 restart。这种方法虽然简单,但对于运行但停止响应的应用程序非常有效。

PM2 的 max_memory_restart 并不能替代修复内存泄漏,但它是一个实用的安全网。即使是轻微泄漏的 Node.js 应用程序,在运行数天或数周后也会积累内存。设置 500MB-1GB 的重启阈值可以在触发 OOM(内存不足)杀手之前捕获这个问题,OOM 杀手会造成更大的干扰。

三者共同的常见陷阱

  • 环境变量继承: 这三种工具都将环境与交互式 shell 隔离开来。在 ~/.bashrc~/.profile 中设置的变量不可用。始终在 EnvironmentFile= (systemd)、environment= (Supervisor) 或 env: (PM2) 中显式声明环境变量。
  • 工作目录: 如果没有显式设置 WorkingDirectory / directory,在交互式运行时工作正常的应用程序中的相对路径在进程管理器下会失效。始终使用绝对路径或设置工作目录。
  • 信号处理: 确保您的应用程序能够优雅地处理 SIGTERM。systemd 默认发送 SIGTERM 并等待 TimeoutStopSec(默认 90 秒)后发送 SIGKILL。Supervisor 在 stopwaitsecs 后使用 SIGTERM 然后 SIGKILL。PM2 在启动替换进程之前向工作进程发送 SIGINT。忽略 SIGTERM 的应用程序总是会被强制终止,中断正在进行的请求。
  • 日志缓冲: 许多运行时在未连接到终端时会缓冲 stdout。Python 在生产模式下会缓冲 stdout,除非您使用 PYTHONUNBUFFERED=1python -u。Node.js 默认无缓冲地写入 stdout,但库可能不会。在进程管理器下缺少日志通常是缓冲问题,而不是配置错误。

技术栈的实用决策

这些工具之间的选择不纯粹是技术性的 — 它也反映了操作复杂性。systemd 需要 Linux 管理知识和 root 权限。Supervisor 需要主机上有 Python,并且对其特殊的重新加载工作流程有足够的了解。即使对于非 Node 应用,如果您想要使用 PM2 的完整功能集,PM2 也会将 Node.js 作为服务器端依赖项添加。

对于运行单个应用程序栈的典型生产服务器,最可维护的方法是始终使用一种工具,而不是混合使用所有三种。一种行之有效的常见架构:systemd 管理 Supervisor 守护进程本身(以及任何系统级依赖,如 PostgreSQL),而 Supervisor 管理应用程序进程。这使系统级关注点保持在 systemd 中,应用程序级进程管理保持在 Supervisor 中,而不需要日常重启的 root 权限。

对于部署到专用服务器的 Node.js 密集型团队,使用基础层 systemd 单元的 PM2 是标准方法。PM2 ecosystem.config.js 文件位于版本控制中,变更像代码一样被审查,底层的 systemd 单元提供了启动持久性而不增加复杂性。

最糟糕的结果是不选择任何这些工具——而是在 screen 会话中运行生产流程,使用 & 后台运行,或者依赖人工干预在凌晨 3 点重启崩溃的服务。这三个工具中的任何一个都能解决这个问题。正确的选择取决于你已经在运行什么、谁来维护它,以及你想要承担多大的运维范围。

如果你有 root 访问权限并且正在运行标准的 Linux 服务器,可以从 systemd 开始。当你需要更简单的组管理或没有 root 权限时,转向 Supervisor。当你的技术栈是 Node.js 并且你需要集群模式或零停机重载时,添加 PM2。这里的每个选择都是可逆的,上面的配置示例为所有三个工具提供了可用的起点。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed