如何高效编写和管理自定义 Systemd 单元文件

掌握管理 Linux 服务的艺术,通过这份关于自定义 systemd 单元文件的全面指南。学习创建、配置和排查 `.service` 文件,利用 `ExecStart`、`WantedBy` 和 `Type` 等关键指令。本文提供逐步说明和实用示例,帮助您标准化应用程序启动、确保可靠运行,并将自定义进程无缝集成到 Linux 系统环境中。对于追求稳健服务管理的开发者和系统管理员至关重要。

如何高效编写和管理自定义 Systemd 单元文件

自定义 systemd 单元文件将终端中运行的命令转变为操作系统可以启动、停止、重启、记录和监控的服务。这种区别至关重要。在 shell 中运行的命令继承您的环境,并在会话结束时终止。而服务则拥有明确的用户、工作目录、重启策略、依赖关系、资源限制和日志。

这是我用于小型内部 API、工作进程、sidecar 脚本和一次性守护进程的实用方法:编写最简单正确的单元,以专用用户身份运行,将日志发送到 journal,并且仅在出现实际需求时才添加高级指令。

理解 Systemd 单元文件

Systemd 管理各种系统资源,称为 单元,由配置文件定义。这些单元包括服务 (.service)、挂载点 (.mount)、设备 (.device)、套接字 (.socket) 等。对于管理应用程序和后台进程,.service 单元类型最为常见且相关。

Systemd 单元文件是纯文本文件,通常存储在特定目录中。按优先级顺序,主要位置包括:

  • /etc/systemd/system/:这是自定义单元文件和覆盖文件的推荐位置,因为它们优先于系统默认值,并在系统更新后仍然保留。
  • /run/systemd/system/:用于运行时生成的单元文件。
  • /usr/lib/systemd/system/:包含由已安装软件包提供的单元文件。不要直接修改此目录中的文件。

通过将自定义单元文件放置在 /etc/systemd/system/ 中,可以确保它们被 systemd 正确识别和管理。

.service 单元文件的结构

systemd .service 单元文件由多个部分组成,每个部分由 [SectionName] 表示,包含各种指令(键值对)。服务单元的三个主要部分是 [Unit][Service][Install]

让我们分解您将使用的最关键的指令:

[Unit] 部分

此部分包含关于单元、其描述和依赖关系的通用选项。

  • Description:描述服务的人类可读字符串。这将显示在 systemctl status 输出中。
    Description=我的自定义 Python Web 应用程序
    
  • Documentation:指向服务文档的 URL(可选)。
    Documentation=https://example.com/docs/my-app
    
  • After:指定此单元应在列出的单元 之后 启动。这有助于管理启动顺序。对于 Web 应用程序,您可能希望确保网络已启动。
    After=network.target
    
  • Requires:强依赖关系。如果所需的单元未能启动,此单元将不会启动。如果所需的单元被停止,此单元也可能被停止。
    Requires=docker.service
    
  • Wants:较弱的依赖关系。如果所需的单元失败或未找到,此单元仍会尝试启动。这通常是比 Requires 更好的默认选择。
    Wants=syslog.target
    

[Service] 部分

此部分定义服务的执行参数,包括如何启动、停止和运行。

  • Type:定义进程启动类型。对于 systemd 如何监控您的服务至关重要。

    • simple(默认)ExecStart 命令是服务的主进程。Systemd 在调用 ExecStart 后立即认为服务已启动。它期望进程在前台无限期运行。
    • forkingExecStart 命令 fork 一个子进程,父进程退出。Systemd 在父进程退出后认为服务已启动。如果您的应用程序自行守护进程,请使用此类型。
    • oneshotExecStart 命令是一次性进程,完成后退出。适用于执行任务并终止的脚本(例如备份脚本)。
    • notify:类似于 simple,但服务在准备就绪时通知 systemd。这需要应用程序支持 systemd 通知。
    • idleExecStart 命令仅在所有作业完成后执行,将执行延迟到系统基本空闲时。
    Type=simple
    
  • ExecStart:服务启动时执行的命令。这是此部分中最重要的指令。始终使用可执行文件或脚本的绝对路径。

    ExecStart=/usr/bin/python3 /opt/my_app/app.py
    
  • ExecStop:服务停止时执行的命令(可选)。如果未指定,systemd 会向进程发送 SIGTERM

    ExecStop=/usr/bin/pkill -f 'my_app/app.py'
    
  • ExecReload:重新加载服务配置时执行的命令(可选)。

    ExecReload=/bin/kill -HUP $MAINPID
    
  • User:服务进程将运行的用户帐户。对于安全性至关重要;除非绝对必要,否则避免使用 root

    User=myappuser
    
  • Group:服务进程将运行的组帐户。

    Group=myappgroup
    
  • WorkingDirectory:执行命令的工作目录。

    WorkingDirectory=/opt/my_app
    
  • Restart:定义何时应自动重启服务。

    • no(默认):从不重启。
    • on-success:仅在服务正常退出时重启。
    • on-failure:仅在服务以非零状态码退出或被信号杀死时重启。
    • always:始终重启服务,无论退出状态如何。
    Restart=on-failure
    
  • RestartSec:重启服务前等待的时间(例如 5s 表示 5 秒)。

    RestartSec=5s
    
  • Environment:为执行的命令设置环境变量。

    Environment="APP_ENV=production" "DEBUG=false"
    
  • EnvironmentFile:从文件读取环境变量。每行应为 KEY=VALUE

    EnvironmentFile=/etc/default/my_app
    
  • LimitNOFILE:设置服务允许的最大打开文件描述符数量(例如 100000)。对于高并发应用程序很重要。

    LimitNOFILE=65536
    

[Install] 部分

此部分定义如何启用服务以在启动时自动启动。

  • WantedBy:指定“想要”此服务的目标单元。当目标单元被启用时,此服务将被符号链接到其 .wants 目录中,从而使其随目标一起启动。
    • multi-user.target:大多数服务器服务的标准目标,表示具有非图形多用户登录的系统。
    • graphical.target:对于需要图形环境的服务。
    WantedBy=multi-user.target
    
  • RequiredBy:类似于 WantedBy,但依赖关系更强。如果目标被启用,此单元也会被启用,并且如果此单元失败,目标也会失败。

对于大多数旨在服务器后台运行的自定义服务,Type=simpleWantedBy=multi-user.target 是正确的起点。如果应用程序已经自行守护进程,要么禁用该行为,要么小心使用 Type=forking。前台进程更容易被 systemd 监控。

分步指南:创建和管理自定义 Systemd 服务

让我们创建一个实际示例:一个简单的 Python HTTP 服务器,用于提供指定目录中的文件。我们将把它设置为 systemd 服务。

步骤 1:准备您的应用程序/脚本

首先,创建应用程序脚本。在此示例中,我们将使用一个简单的 Python HTTP 服务器。为您的应用程序创建一个目录,例如 /opt/my_app,并将 app.py 放入其中。

# /opt/my_app/app.py

import http.server
import socketserver
import os

PORT = int(os.environ.get("PORT", 8000))
DIRECTORY = os.environ.get("DIRECTORY", os.getcwd())

class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=DIRECTORY, **kwargs)

print(f"在端口 {PORT} 上提供目录 {DIRECTORY}")

with socketserver.TCPServer(("", PORT), Handler) as httpd:
    print("服务器已启动。")
    httpd.serve_forever()

创建目录和文件:

sudo mkdir -p /opt/my_app
sudo nano /opt/my_app/app.py

(粘贴 Python 代码)

确保脚本可执行(对于 python3 命令是可选的,但这是良好实践):

sudo chmod +x /opt/my_app/app.py

考虑为您的服务创建一个专用用户,以提高安全性:

sudo useradd --system --no-create-home myappuser

为您的应用程序目录设置适当的权限:

sudo chown -R myappuser:myappuser /opt/my_app

步骤 2:创建单元文件

现在,为我们的 Python 应用程序创建 systemd 单元文件。我们将其命名为 my_app.service

sudo nano /etc/systemd/system/my_app.service

粘贴以下内容:

# /etc/systemd/system/my_app.service

[Unit]
Description=我的自定义 Python HTTP 服务器
Documentation=https://github.com/example/my_app
After=network.target

[Service]
Type=simple
User=myappuser
Group=myappuser
WorkingDirectory=/opt/my_app
Environment="PORT=8080" "DIRECTORY=/var/www/html"
ExecStart=/usr/bin/python3 /opt/my_app/app.py
Restart=on-failure
RestartSec=10s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

注意:我们设置了 StandardOutput=journalStandardError=journal,将服务的输出定向到 systemd journal,从而可以轻松使用 journalctl 查看日志。

如果您的应用程序需要机密信息,请避免直接将其放在单元文件中。使用具有严格权限的环境文件、机密管理器或特定于发行版的凭据支持。单元文件通常比您预期的更多人可读。

步骤 3:放置单元文件

按照指示,我们将单元文件放置在 /etc/systemd/system/ 中。这是自定义单元文件应存放的位置。

步骤 4:重新加载 Systemd 守护进程

创建或修改单元文件后,需要通知 systemd 更改。这通过重新加载 systemd 守护进程来完成:

sudo systemctl daemon-reload

步骤 5:启动服务

现在您可以启动您的服务:

sudo systemctl start my_app.service

步骤 6:检查服务状态和日志

验证您的服务是否正常运行:

systemctl status my_app.service

示例输出(截断):

● my_app.service - 我的自定义 Python HTTP 服务器
     Loaded: loaded (/etc/systemd/system/my_app.service; disabled; vendor preset: enabled)
     Active: active (running) since Tue 2023-10-26 10:30:00 UTC; 5s ago
       Docs: https://github.com/example/my_app
   Main PID: 12345 (python3)
      Tasks: 1 (limit: 1100)
     Memory: 6.5M
        CPU: 45ms
     CGroup: /system.slice/my_app.service
             └─12345 /usr/bin/python3 /opt/my_app/app.py

Oct 26 10:30:00 yourhostname python3[12345]: 在端口 8080 上提供目录 /var/www/html
Oct 26 10:30:00 yourhostname python3[12345]: 服务器已启动。

要查看服务的日志,请使用 journalctl

journalctl -u my_app.service -f

此命令显示 my_app.service 的日志,-f(跟随)将实时显示新日志。

您还可以从浏览器或 curl 测试服务器,访问 http://localhost:8080(假设 /var/www/html 存在并包含一些文件)。

步骤 7:启用服务以自动启动

要使您的服务在每次系统启动时自动启动,您需要启用它:

sudo systemctl enable my_app.service

此命令创建从 /etc/systemd/system/multi-user.target.wants/my_app.service/etc/systemd/system/my_app.service 的符号链接。

步骤 8:停止和禁用服务

要停止正在运行的服务:

sudo systemctl stop my_app.service

要防止服务在启动时自动启动(同时保持启用状态以便手动启动):

sudo systemctl disable my_app.service

如果您想完全删除服务,请先 disable,然后 stop,最后从 /etc/systemd/system/ 删除 .service 文件并运行 sudo systemctl daemon-reload

步骤 9:更新服务

如果您修改了 app.py 脚本或 my_app.service 单元文件,您需要更新 systemd 并重启服务:

  1. 编辑 /opt/my_app/app.py/etc/systemd/system/my_app.service
  2. 如果您修改了单元文件,请运行 sudo systemctl daemon-reload
  3. 重启服务:sudo systemctl restart my_app.service

实际服务的更安全模式

一个能工作的单元并不总是您想要维护多年的单元。这些模式可以防止常见错误:

  • 在前台运行。 让 systemd 监控主进程。避免在 ExecStart 中使用 nohupscreentmux、后台 & 或应用程序守护进程模式。
  • 保持 ExecStart 直接。 如果您需要 shell 功能,如管道或变量扩展,请有意调用 /bin/sh -c '...'。否则,直接运行可执行文件。
  • 使用专用用户。 只需要读取 /opt/my_app 并绑定到非特权端口的服务不应以 root 身份运行。
  • 编辑单元后重新加载。 当单元文件更改时,需要 sudo systemctl daemon-reload
  • 将代码部署与单元更改分开。 如果只更改了 Python 代码,请重启服务。如果单元更改,请先重新加载 systemd。

排查新单元的问题

如果服务失败,请从以下命令开始:

systemctl status my_app.service
journalctl -u my_app.service -n 100 --no-pager
systemctl cat my_app.service

常见的失败通常很简单:

  • status=203/EXEC 通常意味着可执行文件路径错误、文件丢失或文件不可执行。
  • Permission denied 通常意味着服务用户无法读取文件、进入目录、写入日志或绑定请求的端口。
  • address already in use 意味着另一个进程占用了该端口。使用 sudo ss -tulpen | grep ':8080' 检查。
  • 手动启动但在 systemd 下失败的服务通常依赖于环境变量、不同的工作目录或您主目录中的文件。

您可以以服务用户身份测试命令:

sudo -u myappuser /usr/bin/python3 /opt/my_app/app.py

这并非 systemd 环境的完美再现,但它能在您追究单元文件细节之前捕获明显的应用程序错误。

更面向生产的变体

对于长期运行的内部服务,我通常会添加一些防护措施:

[Unit]
Description=我的自定义 Python HTTP 服务器
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=myappuser
Group=myappuser
WorkingDirectory=/opt/my_app
EnvironmentFile=-/etc/my_app/my_app.env
ExecStart=/usr/bin/python3 /opt/my_app/app.py
Restart=on-failure
RestartSec=10s
TimeoutStopSec=30s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/my_app /var/log/my_app
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

ProtectSystem=full 使系统的大部分对服务只读,因此仅添加应用程序真正需要写入的目录到 ReadWritePaths=。一次测试一个加固指令。安全选项很有用,但无法读取其配置或写入其数据的服务将在启动时失败。

最佳实践和故障排除

  • 绝对路径:始终在单元文件中为 ExecStartWorkingDirectory 和任何其他文件路径使用绝对路径。相对路径可能导致意外行为。
  • 专用用户:在非特权、专用的用户帐户(例如 myappuser)下运行服务,以增强安全性并限制在受损情况下的潜在损害。
  • 清晰的日志记录:利用 StandardOutput=journalStandardError=journal 将服务输出定向到 systemd journal。使用 journalctl -u <service_name> 查看日志。
  • 依赖关系:仔细考虑 AfterWantsRequires,以确保您的服务相对于其依赖项(例如网络、数据库)以正确的顺序启动。
  • 测试更改:在启用服务以在启动时启动之前,通过手动启动和停止服务来彻底测试它。检查其状态和日志。
  • 资源限制:当服务具有已知限制或故障模式时,使用 LimitNOFILELimitNPROCMemoryMax 等指令。
  • 环境变量:对于可能更改或在不同环境之间变化的配置值,使用 Environment=EnvironmentFile=,而不是将其硬编码在单元文件或脚本中。
  • 脚本中的错误处理:确保您的应用程序脚本优雅地处理错误。非零退出代码将触发 Restart=on-failure

警告:避免直接修改 /usr/lib/systemd/system/ 中的单元文件。任何更改都可能被软件包更新覆盖。对于自定义单元或覆盖文件,请使用 /etc/systemd/system/

一个好的自定义单元在最理想的情况下是平淡无奇的:命令明确,用户非特权,重启行为有意图,日志易于查找。一旦这些稳固,systemd 定时器、套接字激活和更深的 cgroup 控制就是自然而然的下一步。