常见的 Systemd 配置错误及修复方法

修复常见的 systemd 单元文件错误:路径错误、服务类型错误、环境变量缺失、权限问题和依赖顺序错误。

常见的 Systemd 配置错误及修复方法

Systemd 配置错误通常看起来比实际情况更严重。服务拒绝启动、部署回滚、或者启动过程卡在一个你几乎不记得创建过的单元名称上。然后真正的原因往往是 ExecStart= 中缺少一个斜杠、进程以错误的用户身份运行、或者单元文件更改后从未执行 daemon-reload 通知 systemd 管理器。

解决这些问题最快的方法是将单元文件视为一份契约。它告诉 systemd 要运行什么进程、以哪个用户身份运行、需要哪些前置条件、如何报告就绪状态、以及崩溃后应该做什么。当其中某个细节出错时,systemd 通常只是在忠实地执行指令。我们的工作是找出应用程序需求与单元文件实际内容之间的不匹配。

1. 单元文件中的语法和路径错误

服务失败最常见的原因之一是单元文件中的简单拼写错误或路径定义不正确。

Exec 命令中的路径不正确或非绝对路径

Systemd 不会在你用于测试的同一个 shell 会话中运行你的服务。它在一个受控的环境中启动进程,因此关于别名、shell 函数、虚拟环境激活和自定义 PATH 的假设通常会失败。在 ExecStart= 中使用可执行文件的绝对路径,并明确指定服务所需的每个目录或文件。

错误示例:

使用命令名称而不指定其位置。

[Service]
ExecStart=my-app-server --config /etc/config.yaml

如果 my-app-server 位于 /usr/local/bin,systemd 很可能找不到它。

修复方法:

始终使用可执行文件的完整绝对路径。

[Service]
ExecStart=/usr/local/bin/my-app-server --config /etc/config.yaml

在配置 ExecStart= 之前,使用 command -v my-app-serverwhich my-app-server 验证路径。如果应用程序位于特定语言的目录中,例如 /opt/myapp/venv/bin/gunicorn 下的 Python 虚拟环境,请直接指向该二进制文件,而不是依赖激活脚本。

拼写错误和大小写敏感

Systemd 配置指令是大小写敏感的,并且必须放在正确的部分([Unit][Service][Install])中。拼写错误或大小写不正确会导致服务无法加载或出现意外行为。

错误示例:

[Service]
ExecStart=/usr/bin/python3 app.py
RestartAlways=true  ; 应该是 Restart=always

修复方法:

在重新加载守护进程之前使用 systemd-analyze verify <unit_file>。它不会捕获所有运行时错误,但能捕获许多拼写错误的指令、无效的部分放置和解析错误,避免你在追踪应用程序日志上浪费时间。

$ systemd-analyze verify /etc/systemd/system/my-service.service

2. 服务依赖和顺序管理不当

依赖关系定义了服务需要哪些资源,而顺序定义了这些资源何时必须可用。

混淆 RequiresWants

这些指令用于定义依赖关系,但处理失败的方式不同:

  • Wants=:弱依赖。如果被依赖的单元失败或未启动,当前单元仍会尝试启动。用于非关键依赖。
  • Requires=:强依赖。如果必需的单元无法启动,当前单元也会失败。如果必需的单元被显式停止,依赖它的单元也会被停止。

依赖 Requires 而没有正确的顺序

定义一个依赖关系,例如 Requires=network.target,会将依赖项拉入事务中。但它本身并不创建启动顺序,而且 network.target 并不意味着“网络可用于出站连接”。如果你的服务需要配置好的网络,请使用 network-online.target,并确保在需要该行为时已启用发行版的 wait-online 服务。

错误示例:

Web 服务器启动了,但数据库连接失败,因为网络堆栈仍在初始化。

修复方法:使用 After=Before=

要强制执行顺序,你必须使用 After=(或 Before=)。一个常见的要求是确保网络完全启动并配置完成后再继续。

[Unit]
Description=我的 Web 应用程序服务
Wants=network-online.target
After=network-online.target

[Service]
...

对于大多数应用程序服务,请将依赖意图与顺序意图配对。Wants=postgresql.service 表示“请也启动 PostgreSQL”。After=postgresql.service 表示“在 PostgreSQL 的启动任务完成后启动我”。它们解决不同的问题。

服务类型管理错误

Systemd 服务有几种执行类型,由 Type= 指令管理。配置错误是导致服务短暂启动然后立即失败的常见原因。

错误示例:误用 Type=forking

如果你的应用程序设计为在前台运行并维护单个主进程,设置 Type=forking 会告诉 systemd 期望旧式的守护进程行为。这可能导致令人困惑的结果:systemd 可能会等待父进程退出,无法识别真正的主进程,或者在应用程序实际上未就绪时将服务标记为活动。

修复方法:

  1. 对于现代应用程序: 使用 Type=simple。这是默认值,期望 ExecStart 进程是主进程。
  2. 对于需要守护化(fork)的旧版应用程序: 设置 Type=forking,并且关键的是,定义 PIDFile= 指令,以便 systemd 可以跟踪 fork 后存活的子进程。
[Service]
Type=forking
PIDFile=/var/run/legacy-app.pid
ExecStart=/usr/sbin/legacy-app

另一个常见的就绪陷阱:对需要很长时间才能变得可用的应用程序使用 Type=simple。使用 Type=simple,systemd 会在进程生成后立即认为服务已启动。如果另一个服务随后立即启动并连接到它,你可能会看到间歇性故障。对于可以通知 systemd 自己已就绪的应用程序,Type=notify 更清晰。对于无法做到这一点的应用程序,不要仅仅因为进程存在就假装单元已完全就绪;在依赖的应用程序中使用真正的健康检查、套接字激活,或者当先决条件足够简单可以测试时,使用 ExecStartPre= 检查。

也要小心 oneshotType=oneshot 服务用于执行任务然后退出的命令,例如创建目录、加载防火墙规则或运行迁移。如果你将其用于长时间运行的守护进程,systemd 不会像你期望的那样监督它。如果命令成功退出并且你希望该单元保持“活动”状态以供依赖项使用,请添加 RemainAfterExit=yes;否则,依赖的单元可能看不到你预期的状态。

3. 环境和用户上下文问题

服务失败通常源于服务在与应用程序预期不同的上下文中运行,通常与权限或环境变量有关。

权限被拒绝或文件缺失

手动测试应用程序时,它通常以你的用户帐户运行并具有适当的权限。当由 systemd 运行时,它通常默认为 root 用户或单元文件中指定的用户。

错误示例:

典型症状很直接:Permission deniedNo such file or directoryFailed to open log file,或者应用程序特定的错误提示无法创建套接字、写入 PID 文件或读取配置文件。当你以 root 身份手动运行命令时该单元可能工作正常,然后在 User=app 下失败。

修复方法:

  1. 定义非 root 用户: 始终为你的服务指定一个专用的、低权限的用户和组。

    [Service]
    User=www-data
    Group=www-data
    ...
    
  2. 检查所有权: 确保服务的工作目录、日志文件和配置文件由指定的 User=Group= 拥有。

    sudo chown -R www-data:www-data /var/www/my-app
    
  3. 检查服务接触的每个路径: 不要停留在应用程序目录。检查 WorkingDirectory=、日志目录、上传目录、缓存目录、TLS 密钥文件、Unix 套接字以及环境文件中引用的任何路径。

    sudo -u www-data test -r /etc/my-app/config.yml
    sudo -u www-data test -w /var/lib/my-app
    sudo -u www-data /usr/local/bin/my-app --check-config
    

如果服务需要绑定到端口 80 或 443,不要自动以 root 身份运行它。在许多系统上,你可以在其前面放置一个反向代理、使用套接字激活,或者授予二进制文件所需的特定能力。正确的选择取决于服务,但广泛的 root 进程不应该是默认答案。

还有一个权限细节会让人困惑:父目录需要执行权限。一个文件可能看起来可读,但服务仍然无法访问它,因为 /opt/opt/myapp 或其他父目录阻止了服务用户的遍历。namei -l /opt/myapp/config.yml 很有用,因为它显示每个路径组件的权限,而不仅仅是最终文件。

缺少环境变量

Systemd 服务在最小环境中运行。任何关键的环境变量(如 API 密钥、数据库连接字符串或自定义库路径)都必须显式传递。

修复方法:使用 Environment=EnvironmentFile=

对于简单的变量,使用 Environment=

[Service]
Environment="APP_PORT=8080"
Environment="API_KEY=ABCDEFG"

对于复杂或大量的变量,使用指向标准 .env 文件的 EnvironmentFile=

[Service]
EnvironmentFile=/etc/default/my-app.conf

将机密信息远离世界可读的单元文件。/etc/systemd/system 下的单元文件通常对本地用户可读。如果你将 API 密钥直接放在 Environment= 中,请假设它们会暴露给任何可以读取单元文件或检查进程元数据的人。优先使用具有严格权限的 root 拥有的环境文件、密钥管理器,或者支持 systemd 凭证的发行版上的 systemd 凭据。

还要记住 EnvironmentFile= 不是 shell 脚本。像 export APP_PORT=8080 这样的行或像 TOKEN=$(cat /run/token) 这样的命令替换不会像在 Bash 中那样被解释。使用简单的赋值:

APP_PORT=8080
APP_ENV=production

如果应用程序需要登录 shell 设置,这通常是一个不好的迹象。将真实环境放入单元文件、环境文件或应用程序自己的配置中,而不是依赖 .bashrc

对于语言运行时,将 systemd 指向你实际测试过的运行时。Python 服务通常应该直接调用虚拟环境二进制文件,例如 /opt/myapp/venv/bin/python/opt/myapp/venv/bin/gunicorn。通过版本管理器安装的 Node 服务可能在你的终端中工作,但在 systemd 下失败,因为 nvmasdf 只修改了你的交互式 shell。在生产单元中,显式路径胜过 shell 启动魔法。

4. 关键的调试工作流程

最常见的配置错误是忘记了编辑单元文件和尝试重启服务之间的关键步骤。

忘记重新加载守护进程

Systemd 不会自动监视单元文件的更改。在修改 /etc/systemd/system/ 中的文件后,必须指示 systemd 管理器重新加载其配置缓存。

错误示例:

你编辑了文件,运行 systemctl restart my-service,但旧配置仍然被使用。

修复方法:运行 daemon-reload

在保存单元文件更改后立即执行此命令:

sudo systemctl daemon-reload
sudo systemctl restart my-service

如果你使用 systemctl edit my-service 编辑了覆盖配置,同样的规则适用。生成的覆盖配置存储在 /etc/systemd/system/my-service.service.d/ 下,systemd 仍然需要重新加载其单元缓存才能使新设置生效。

当重启行为异常时,检查 systemd 看到的精确合并单元:

systemctl cat my-service.service
systemctl show my-service.service -p FragmentPath -p DropInPaths -p User -p ExecStart

这可以捕获一个常见错误:编辑 /usr/lib/systemd/system 中的供应商单元,而 /etc/systemd/system 中的覆盖配置仍然更改了设置,或者编辑了一个不是 systemd 加载的单元的副本。

有效使用日志工具

当服务失败时,依赖官方工具进行准确诊断。

  1. 检查服务状态: 这会给你即时状态、退出代码和最后几行日志。

    systemctl status my-service.service
    
  2. 检查 Journal: Journal 保存了服务的完整输出(stdout/stderr)。查找诸如 "Permission denied""No such file or directory" 之类的线索。

    # 查看特定单元的最新日志
    journalctl -u my-service.service --since '1 hour ago' 
    
    # 查看日志并实时跟踪输出
    journalctl -f -u my-service.service
    

实用的故障排查流程

当我审查一个损坏的单元时,我通常按以下顺序进行一次检查:

systemctl status my-service.service
journalctl -u my-service.service --since "15 minutes ago"
systemctl cat my-service.service
systemd-analyze verify /etc/systemd/system/my-service.service

然后我问一些简单的问题。ExecStart= 指向一个真实的可执行文件吗?配置的 User= 能运行它吗?WorkingDirectory= 存在吗?环境变量是否存在而不依赖于 shell?Type= 是否诚实地反映了进程的行为?当我需要另一个单元启动并在此单元之前排序时,Wants=After= 是否都存在?

每次编辑后,重新加载并测试一件事:

sudo systemctl daemon-reload
sudo systemctl restart my-service.service
systemctl status my-service.service --no-pager

如果服务仍然失败,请克制住盲目更改单元文件的冲动。Journal 通常会告诉你下一个问题是 systemd 配置、应用程序配置、权限,还是一个自身失败的依赖项。