接手一台Debian服务器,最常打交道的不是内核参数,也不是网络配置,而是systemd。不管你愿不愿意,只要系统跑着服务,你就得写unit文件。很多人对unit文件的印象还停留在“从别处复制一份改改”,结果遇到依赖死锁、启动顺序错乱、重启策略失效这些问题时完全无从下手。真正吃透unit文件,核心就两块:一是怎么写对配置,二是怎么管好依赖关系。

unit文件的三个必填字段与执行环境

一个最精简的service unit文件,只有三个字段是必须的:Description、ExecStart和Type。Description不用多说,就是给服务起个看得懂的名字。ExecStart是服务启动时执行的命令,必须写绝对路径,不要在这里用shell的管道、重定向或者变量展开——这些语法systemd不认,除非你显式指定了Type=exec或者用ExecStartPre去处理。Type决定了systemd怎么判断服务是否启动成功。默认的simple类型,systemd只负责fork出进程,不管进程是否真正就绪。如果你的服务是网络监听型,建议用notify,让服务自己通过sd_notify通知systemd“我准备好了”。对于一次性任务,用oneshot并配合RemainAfterExit=yes,这样任务执行完后服务状态仍显示为active,适合那些执行完就退出的初始化脚本。

[Unit]
Description=My Custom API Service
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
ExecStart=/usr/local/bin/my-api --config /etc/my-api/config.yaml
Restart=on-failure
RestartSec=5s
User=myuser
Group=mygroup
EnvironmentFile=/etc/my-api/env.conf
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

上面这个例子还涉及几个容易被忽略的细节。Restart=on-failure表示只有进程以非零退出码退出或被信号终止时才重启,如果是systemctl stop手动停止的就不会重启。RestartSec=5s是重启间隔,设太短可能导致失败循环,设太长又影响恢复速度。User和Group指定运行身份,不要用root跑服务这是基本常识。EnvironmentFile可以加载环境变量,但注意这个文件里不要放敏感信息,因为它的权限通常是644。LimitNOFILE用来提高文件描述符上限,高并发服务必须调这个值。

依赖关系:Wants、Requires与After的三角关系

依赖管理是unit文件里最容易出错的地方。很多人把Wants和Requires混用,把After和它们捆绑理解,结果要么服务起不来,要么启动顺序完全不对。先说Wants和Requires的区别:Wants是弱依赖,被依赖的单元启动失败不会阻止当前单元启动;Requires是强依赖,被依赖的单元启动失败会导致当前单元也失败。但这里有个关键点——即使写了Requires,如果被依赖的单元没有和当前单元一起被拉入启动队列,Requires也不会生效。所以通常需要配合WantedBy或者在[Install]段里做好关联。

After和Before只控制启动顺序,不控制依赖关系。你可以只写After=network.target而不写Wants=network.target,这意味着如果network.target碰巧启动了,你的服务会在它之后启动,但如果network.target没启动,你的服务照样启动,不会去主动拉它。这就是为什么很多人发现明明写了After=mysql.service,结果MySQL没起,自己的服务也照样跑起来了。正确的做法是同时写After和Wants或Requires。

[Unit]
Description=Application Server
After=mysql.service redis.service
Requires=mysql.service
Wants=redis.service

这个配置的意思是:MySQL必须启动成功,否则本服务失败;Redis尽量启动,失败了也不影响本服务;同时确保本服务在MySQL和Redis之后启动。如果你想让自己的服务先于另一个服务启动,用Before。比如一个清理脚本需要在网络服务停止前执行,可以写Before=network.target,并在[Service]里加ExecStop执行清理逻辑。

PartOf和BindsTo:更精细的依赖控制

除了Wants和Requires,还有两个不太常用但很实用的依赖类型。PartOf表示当前单元是另一个单元的一部分,当那个单元重启或停止时,当前单元也会跟着重启或停止。典型场景是某个大型服务由多个子服务组成,用PartOf把它们绑定在一起,操作主服务时子服务自动联动。BindsTo比Requires更严格,它不仅要求被依赖的单元启动成功,而且一旦被依赖的单元意外退出,当前单元也会被停止。这适合那些与被依赖单元共享资源的服务,比如共享挂载点的多个服务。

[Unit]
Description=Worker Process
BindsTo=main-service.service
After=main-service.service

[Service]
ExecStart=/usr/local/bin/worker
Restart=always

这里用BindsTo意味着如果main-service.service挂了,worker也会被systemd停掉。配合Restart=always,当main-service恢复后worker也会重新拉起。这种联动机制在微服务架构的运维里非常有用,避免了僵尸进程和资源泄漏。

启动顺序陷阱:Type=oneshot与启动阻塞

一个常见的坑是Type=oneshot的服务如果没有RemainAfterExit=yes,它执行完后状态会变成inactive,导致那些Requires它的服务启动失败。因为systemd认为这个oneshot服务已经退出了,不再处于active状态,强依赖它的服务自然就失败了。所以初始化类、配置类脚本一定要加RemainAfterExit=yes。另一个陷阱是,如果服务A是Type=simple且After=服务B,而服务B是Type=oneshot,那么服务A可能在服务B的ExecStart执行完毕之前就启动了。因为simple类型只要fork就返回,oneshot要等ExecStart执行完才进入active。如果你需要严格保证B完全执行完后A才启动,要么把A的Type改成notify或forking,要么在A里用ExecStartPre做主动检测。

模板单元:用%i和%j实现参数化

当你需要运行多个相似的服务实例时,不要复制粘贴unit文件。用模板单元,文件名以@结尾,比如worker@.service。在unit文件里用%i引用实例名,%j引用转义后的实例名(路径分隔符会被转义)。

[Unit]
Description=Worker Instance %i
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/worker --instance %i --config /etc/worker/%i.yaml
Restart=on-failure
User=%i

[Install]
WantedBy=multi-user.target

启用时用systemctl enable worker@instance1.service,启动时systemctl start worker@instance1。%i会被替换成instance1。你可以在/etc/worker/下为每个实例准备不同的配置文件,实现一套代码多实例运行。这对于多租户环境、多环境部署非常实用。注意%i不能包含斜杠,如果需要路径分隔,用%j。

条件判断与断言:让unit文件更智能

Condition和Assert系列指令可以让unit文件根据运行环境自动决定是否启动。Condition检查失败只会让服务跳过启动,不会报错;Assert检查失败则会导致服务失败。常用的有ConditionPathExists检查文件或目录是否存在,ConditionHost检查主机名,ConditionKernelVersion检查内核版本。比如一个服务只应该在特定硬件上运行,可以写:

[Unit]
Description=GPU Dependent Service
ConditionPathExists=/dev/nvidia0

如果机器没有NVIDIA GPU,这个服务就会被静默跳过。这在管理异构集群时非常有用,同一份unit文件可以部署到所有节点,systemd自动判断该不该启动。ConditionACPower可以判断是否使用交流电源,适合笔记本环境的节能策略。

drop-in文件:不改原unit的定制化

直接修改系统包管理器安装的unit文件是坏习惯,下次包更新就会覆盖你的修改。正确做法是用drop-in文件。在/etc/systemd/system/下创建与原unit同名的目录,加上.d后缀,里面放.conf文件。比如要修改nginx.service的环境变量,创建/etc/systemd/system/nginx.service.d/override.conf:

[Service]
Environment=WORKER_PROCESSES=8
LimitNOFILE=65535

然后systemctl daemon-reload,systemd会自动合并这些配置。用systemctl cat nginx.service可以看到合并后的完整配置。这种方式既保留了原始unit文件,又实现了定制化,升级系统时不会被覆盖。

调试与排错:几个必会的命令组合

unit文件写好后,不要直接systemctl start。先用systemd-analyze verify /path/to/unit检查语法和依赖完整性。这个命令会指出缺失的依赖、错误的指令等。然后systemctl daemon-reload加载新配置。启动后用systemctl status看服务状态,重点看Active行和CGroup里的进程树。如果服务启动失败,journalctl -u service-name -xe能看到详细日志。要分析启动耗时,用systemd-analyze blame找出拖慢启动的服务,用systemd-analyze critical-chain看关键启动链上的耗时分布。这些工具组合起来,能快速定位是依赖配置问题还是服务本身的问题。

还有一个容易忽略的点:systemd对ExecStart的命令行长度有限制,大约2MB。如果你的命令参数特别多,考虑用EnvironmentFile或者把参数写到配置文件里让程序自己读取。另外ExecStartPre和ExecStartPost可以执行前置和后置脚本,但要注意它们的超时时间默认是90秒,复杂操作需要调TimeoutStartSec。

安全加固:用systemd做进程沙箱

systemd不只是启动器,它内置了强大的安全隔离能力。在[Service]段里可以加ProtectSystem=strict让整个文件系统只读,用ReadWritePaths指定可写目录。PrivateTmp=yes给服务分配独立的/tmp和/var/tmp,防止临时文件泄露。NoNewPrivileges=yes禁止进程获取新权限。这些配置不需要额外安装AppArmor或SELinux,systemd直接通过namespace和cgroup实现。对于面向公网的服务,这些加固措施是必选项,不是可选项。

[Service]
ExecStart=/usr/local/bin/webapp
ProtectSystem=strict
ReadWritePaths=/var/lib/webapp /var/log/webapp
PrivateTmp=yes
NoNewPrivileges=yes
ProtectHome=yes
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service

这个配置把服务锁得很死:只能读写指定目录,不能访问/home,只能使用IPv4和IPv6的socket,系统调用也被限制在system-service这个预定义集合内。SystemCallFilter可以大幅减少内核攻击面,但需要测试确认你的服务确实只需要这些系统调用。

掌握这些unit文件的编写技巧和依赖管理方法,Debian运维的效率会有质的提升。不再需要supervisor之类的第三方进程管理工具,也不用写一堆启动脚本。systemd本身就是一个完整的服务管理框架,关键是理解它的设计逻辑,而不是死记硬背配置项。