理解Systemd单元:服务配置深度解析

学习systemd服务单元的工作原理,包括Unit、Service、Install、覆盖配置、重启策略和日志管理。

理解Systemd单元:服务配置深度解析

Systemd单元文件是决定服务如何启动、依赖什么、以哪个用户运行以及失败时如何处理的小型文本文件。如果你曾疑惑为什么systemctl restart myapp.service对一个应用有效而对另一个无效,答案通常就在单元文件中。

本指南聚焦于.service单元,因为这是管理员和开发者最常编辑的类型。同一系统也管理套接字、定时器、挂载点、设备、路径和目标,但服务文件是大多数操作错误出现的地方。

什么是Systemd单元文件?

Systemd单元文件是包含特定单元配置指令的简单文本文件。一个单元代表由systemd管理的资源。最常见的类型是服务单元,它定义了如何启动、停止、重启和管理后台进程或应用程序。

单元文件按节组织,每个节由方括号([])标识。服务单元最重要的节包括:

  • [Unit]:包含单元的元数据、依赖关系和启动顺序。
  • [Service]:定义服务本身的行为,包括如何执行。
  • [Install]:指定如何启用或禁用单元,通常将其链接到目标单元。

Systemd在几个标准目录中查找单元文件,最常见的有:

  • /etc/systemd/system/:用于本地配置的单元,可覆盖默认单元。
  • /usr/lib/systemd/system/:用于许多发行版中由软件包安装的单元。
  • /lib/systemd/system/:某些Debian系列系统用于软件包提供的单元。

当需要检查单元时,避免猜测路径。使用:

systemctl cat nginx.service
systemctl show -p FragmentPath nginx.service

systemctl cat特别有用,因为它显示基础单元以及任何drop-in覆盖配置。这是systemd实际使用的版本。

.service单元文件的结构

让我们分解一个典型的.service单元文件,以理解其组成部分。

[Unit]

此节提供描述性信息并定义单元之间的关系。

  • Description=:服务的人类可读描述。
  • Documentation=:服务文档的URL或路径。
  • After=:指定此单元应在列出的单元完成启动之后启动。
  • Requires=:类似于After=,但使列出的单元成为必需。如果必需的单元启动失败,此单元也将失败。
  • Wants=:较弱的依赖形式。此单元将尝试启动其想要的单元,但它们的失败不会阻止此单元启动。
  • Conflicts=:指定不能与此单元同时运行的单元。

[Unit]节示例:

[Unit]
Description=我的自定义Web服务器
Documentation=https://example.com/docs/my-web-server
After=network.target

这表明我们的自定义Web服务器应在网络可用后启动。

一个常见陷阱:After=控制顺序,而非依赖。如果你写After=postgresql.service,当两者都属于同一事务时,systemd会在PostgreSQL之后启动你的服务,但不会自动拉取PostgreSQL。如果你的应用确实需要PostgreSQL由同一事务启动,请同时使用Wants=postgresql.service,或对于硬依赖使用Requires=postgresql.service

即便如此,依赖关系也不是健康检查。After=network.target不保证DNS工作、远程API可达或数据库接受连接。你的应用程序仍然需要合理的重试行为。

[Service]

这是运行服务的核心逻辑所在。

  • Type=:定义进程启动类型。常见类型包括:
    • simple(默认):主进程是由ExecStart=启动的进程。Systemd在ExecStart=进程被fork后立即认为服务已启动。
    • forking:用于传统守护进程,它们fork出子进程后退出。Systemd等待父进程退出。
    • oneshot:用于执行单个命令然后退出的任务。
    • notify:服务在完成启动时向systemd发送通知。
    • dbus:用于获取D-Bus名称的服务。
  • ExecStart=:启动服务时要执行的命令。
  • ExecStop=:停止服务时要执行的命令。
  • ExecReload=:重新加载服务配置而不重启时要执行的命令。
  • Restart=:定义何时应重启服务。选项包括no(默认)、on-successon-failureon-abnormalon-watchdogon-abortalways
  • RestartSec=:重启服务前等待的时间。
  • User= / Group=:服务运行的用户和组。
  • WorkingDirectory=:执行进程的工作目录。
  • Environment= / EnvironmentFile=:为服务设置环境变量。

[Service]节示例:

[Service]
Type=simple
ExecStart=/usr/local/bin/my-web-server --config /etc/my-web-server.conf
User=www-data
Group=www-data
Restart=on-failure
RestartSec=5

此配置启动我们的Web服务器,以www-data用户和组运行,并在失败时自动重启,延迟5秒。

Type=需要特别注意。许多损坏的单元使用Type=forking,因为旧的init脚本使用了守护进程模式。对于保持在前台运行的现代应用程序,Type=simple通常是正确的。如果你的进程fork到后台,但systemd未被告知如何识别真正的主进程,状态报告和重启可能会产生误导。

对于一次性任务,使用Type=oneshot,并且如果已完成的操作应计为活动状态,通常加上RemainAfterExit=yes。例如,准备防火墙规则或挂载特殊资源的单元可能成功退出,但仍代表你关心的状态。

[Install]

此节用于启用或禁用单元。它定义单元如何集成到systemd的目标单元中。

  • WantedBy=:指定启用时应该“想要”此单元的目标。对于应在启动时启动的服务,通常使用multi-user.target

[Install]节示例:

[Install]
WantedBy=multi-user.target

当你运行systemctl enable my-custom-service.service时,systemd会从/etc/systemd/system/multi-user.target.wants/创建一个符号链接指向你的服务文件,确保系统达到多用户运行级别时启动它。

如果单元没有[Install]节,它仍然可能完全有效。只是除非存在其他安装机制,否则无法直接使用systemctl enable启用。有些单元旨在通过依赖关系、套接字、定时器或目标拉取,而不是手动启用。

创建和管理自定义服务单元

让我们逐步创建自定义服务单元。

步骤1:创建单元文件

/etc/systemd/system/中创建一个扩展名为.service的新文件。例如,创建/etc/systemd/system/my-app.service

[Unit]
Description=我的自定义应用程序服务
After=network.target

[Service]
Type=simple
ExecStart=/opt/my-app/bin/run-app --port 8080
User=appuser
Group=appgroup
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

重要考虑事项:

  • 确保ExecStart命令指向可访问且具有执行权限的可执行脚本或二进制文件。
  • 如果指定的UserGroup不存在,请创建它们(sudo useradd -r -s /bin/false appusersudo groupadd appgroupsudo usermod -a -G appgroup appuser)。
  • 确保应用程序可以使用指定命令正确启动和停止。

在将命令放入ExecStart=之前,如果可能,以相同用户手动运行它:

sudo -u appuser /opt/my-app/bin/run-app --port 8080

这会在systemd介入之前捕获缺失的执行权限、缺失的目录、错误的相对路径和权限问题。一旦手动运行正常,将其移入单元并让systemd处理监督。

步骤2:重新加载Systemd配置

创建或修改单元文件后,必须通知systemd重新加载其配置。

sudo systemctl daemon-reload

此命令扫描新的或更改的单元文件,并更新systemd的内部状态。

步骤3:启用并启动服务

要立即启动服务并配置为开机启动:

sudo systemctl enable my-app.service  # 创建开机启动的符号链接
sudo systemctl start my-app.service   # 立即启动服务

步骤4:管理服务

使用systemctl命令管理服务:

  • 检查状态:

    sudo systemctl status my-app.service
    

    这将显示服务是否活动、其进程ID、最近的日志条目等。

  • 停止服务:

    sudo systemctl stop my-app.service
    
  • 重启服务:

    sudo systemctl restart my-app.service
    
  • 重新加载服务(如果定义了ExecReload=):

    sudo systemctl reload my-app.service
    
  • 禁用服务(防止开机启动):

    sudo systemctl disable my-app.service
    

步骤5:使用journalctl查看日志

Systemd与journald紧密集成用于日志记录。你可以使用journalctl查看服务的日志:

  • 查看特定服务的日志:

    sudo journalctl -u my-app.service
    
  • 实时跟踪日志:

    sudo journalctl -f -u my-app.service
    
  • 查看自上次启动以来的日志:

    sudo journalctl -b -u my-app.service
    

最佳实践和技巧

  • 对现代应用程序使用Type=notify 如果你的应用程序支持,Type=notify提供与systemd更好的集成,使其能够准确跟踪服务的就绪状态。
  • 以非root用户运行服务: 始终在[Service]节中指定User=Group=以最小化安全风险。
  • 仔细定义依赖关系: 使用After=Requires=Wants=确保服务按正确顺序启动,并满足关键依赖关系。
  • 利用Restart= 配置适当的重启策略以确保服务可用性。
  • 保持单元文件简单: 对于复杂的启动序列,考虑使用由ExecStart=调用的包装脚本,而不是在单元文件中直接使用复杂命令。
  • 使用systemctl cat <unit> 查看systemd看到的单元文件完整内容,包括任何覆盖配置。
  • 使用systemctl edit <unit> 此命令打开编辑器为现有单元创建覆盖文件,这是修改默认单元文件更干净的方式,而不是直接编辑它们。

安全编辑现有单元

不要编辑/usr/lib/systemd/system//lib/systemd/system/中由软件包拥有的单元,除非你在调试可丢弃的机器。软件包升级可能会替换这些文件。请使用覆盖配置:

sudo systemctl edit nginx.service

这会在/etc/systemd/system/nginx.service.d/下创建一个drop-in。例如,要添加重启策略:

[Service]
Restart=on-failure
RestartSec=5s

某些指令可以多次指定。其他指令需要在替换前清除。ExecStart=是经典例子:

[Service]
ExecStart=
ExecStart=/usr/local/bin/my-nginx-wrapper

空白的ExecStart=行重置了之前的值。没有它,systemd可能会拒绝该单元或保留比你预期更多的命令。

在任何单元或drop-in更改后,使用相同的审查循环:

sudo systemctl daemon-reload
systemctl cat my-app.service
sudo systemctl restart my-app.service
journalctl -u my-app.service -n 50 --no-pager

一旦你区分了三个任务:[Unit]描述关系,[Service]描述进程行为,[Install]描述启用方式,单元文件就不难了。大多数实际调试只是找出哪个任务配置了错误的假设。

一个实际的服务文件演练

以下是一个用于Python Web应用程序的小型但实际的服务文件:

[Unit]
Description=库存API
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=inventory
Group=inventory
WorkingDirectory=/srv/inventory-api
EnvironmentFile=/etc/inventory-api/env
ExecStart=/srv/inventory-api/.venv/bin/gunicorn app:app --bind 127.0.0.1:9000
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

该文件中有几个隐含的决策。服务以inventory用户运行,而非root。命令使用虚拟环境中gunicorn的绝对路径,因此不依赖于交互式shell的PATH。应用程序绑定到localhost,因为反向代理将公开暴露它。环境文件位于单元外部,以便部署可以更新配置而无需重写软件包拥有的服务元数据。

依赖行有意保持适度。After=postgresql.service控制顺序,如果PostgreSQL是同一启动事务的一部分。它不证明数据库已准备好接受连接,也不替代应用程序的重试逻辑。network-online.target在正确实现网络就绪的系统上可能有帮助,但它不是每个远程依赖都可达的通用保证。

如果此服务失败,可预测的检查步骤是:

systemctl status inventory-api.service
journalctl -u inventory-api.service -b --no-pager
systemctl cat inventory-api.service
sudo -u inventory /srv/inventory-api/.venv/bin/gunicorn app:app --bind 127.0.0.1:9000

最后一个命令不是让你在生产环境中运行的。它是一个诊断检查,询问“配置的用户能否运行此命令?”如果它无法导入应用程序、读取环境文件或写入日志目录,systemd不会为你修复这些问题。

你经常会看到的资源和安全指令

许多生产单元包含加固或资源控制。一些常见示例:

[Service]
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
MemoryMax=512M
CPUQuota=80%

这些指令可能非常有用,但也可能破坏假设。PrivateTmp=true为服务提供私有的/tmp,因此其他进程可能看不到它写入的文件。ProtectHome=true可能阻止访问/home/root/run/userProtectSystem=full使系统的许多部分从服务的视角变为只读。如果应用程序突然无法写入它之前能写入的位置,在责怪应用程序之前先检查加固设置。

资源限制也有同样的权衡。MemoryMax=可以阻止一个服务消耗整个机器,但如果值太低,服务可能在正常负载下被杀死。检查日志中的内存不足消息,并在提高或移除限制之前将限制与实际使用情况进行比较。

最有用的调试命令

在处理服务单元时,请牢记这些命令:

systemctl status my-app.service
systemctl cat my-app.service
systemctl show my-app.service
systemd-analyze verify /etc/systemd/system/my-app.service
journalctl -u my-app.service -b --no-pager

systemctl show输出冗长,但它暴露了systemd在解析单元后计算的属性。这可以揭示从默认值、drop-in或重置指令继承的意外值。systemd-analyze verify在重启服务之前捕获一些语法和依赖错误。它不能替代测试应用程序,但足以捕获足够多的错误,值得运行。