Systemd 技巧

目前 Linux 发行版都集成 SystemCtl 做系统管理, 现在如果要把二进制应用写入成系统应用基本上绕不开.

Systemctl 的服务基本可以分为以下类别:

  • service: 常规服务
  • timer: 定时器
  • mount: 挂载点
  • device: 设备
  • socket: 套接字

这里先从基础的自定义 service 编写服务开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# ============================================================================
# 基础单元描述信息
# 用于定义服务的基本信息、依赖关系、启动顺序等, 不涉及服务运行逻辑
# ============================================================================
[Unit]

# Description: 服务的简短描述
Description=Java Depoly Application

# Documentation: 服务的文档地址(如手册、URL)
Documentation=https://app.meteorcat.me

# After: 指定本单元应在哪些单元之后启动(仅控制顺序不强制依赖), 例如 After=network.target 表示网络就绪后再启动
# 注: 多个应用以空格划分, 允许依赖多个单元之后启动
After=network.target redis.service

# Before: 与 After 相反, 指定本单元应在哪些单元之前启动防止被抢断
Before=nginx.service

# Requires: 强制依赖, 若依赖的单元启动失败, 本单元也会启动失败; 若依赖单元停止,本单元也会被停止
# 这也是个很有用的配置, 防止数据库等崩溃还继续接收请求执行业务
Requires=mysql.service

# Wants: 弱依赖, 看起来和 After 差不多, 实际上检测到指定应用未启动的时候会去触发启动
# 就比如当启动应用检测到 redis.service 没有启动, 就会自动启动等待完成之后再执行本应用启动完成
# 不过这里不会管自动启动是否成功, 而是平行去启动两个服务, 所以两者启动成功都不会互相干扰
# 所以如果两者有启动前后顺序的话, 要同时配置 Wants 和 After 项, 保证通过 Wants 启动的应用能够被排序执行到
# 简单说 Wants 负责 '拉起来' 依赖, After 负责 '排好序', 两者结合才能实现 '依赖自动启动且按顺序启动' 的效果
Wants=redis.service

# Conflicts: 冲突单元, 若指定的单元启动, 本单元会被停止, 反之亦然
# 实际上就是防止某些应用抢占的时候会用到, 实际上很少会去用到
Conflicts=apache.service


# ============================================================================
# 单元运行启动信息
# 定义服务的启动命令、运行模式、用户权限、重启策略等关键配置
# ============================================================================
[Service]

# Type: 服务单元的启动类型, 对应以下启动几个类型( dbus,idle 这些基本上是桌面系统使用或者日常使用比较少就不列入)
# - simple(默认): 服务启动后立即运行, 不 fork 子进程(需确保主进程不退出)
# - forking: 服务启动时会 fork 子进程, 父进程退出后视为启动完成(需配合 PIDFile=/var/run/xxx.pid, 传统后台服务常用)
# - oneshot: 一次性任务, 运行完成后立即退出(需配合 RemainAfterExit=yes 保留 "运行中" 状态)
# 一般来说默认 simple 即可
# forking 应用于老应用默认自带启动自带 daemon(自动 fork 子进程并让父进程退出, 也就是启动自动挂在后台运行不影响当前命令行操作, 比如 apache 的 httpd 模式)
# oneshot 则是用于单次初始化这种情况, 比如一次性任务和初始化脚本这种, 也有的用于启动之后默认初始化服务器环境配置
Type=simple

# User|Group: 以哪些权限按照启动的用户和用户组, 建议定义非 root 运行时使用从而提高安全性
User=devops
Group=devops

# WorkingDirectory: 服务运行的工作目录, 相当于 cd 到指定目录, 不过注意后续启动命令也是要填写成绝对路径
WorkingDirectory=/var/lib/devops

# UMask: 服务创建文件的权限掩码(默认 0022), 用于一些比较特定用户暴露,
# 有的服务本身作为远程下载存在, 比如 bt 下载文件这些可以暴露给其他用户组存在读取可能就要修改成 0002[能被服务用户|同组用户(甚至其他用户)访问]
UMask=0022

# Environment: 设置环境变量
# 不过一般不会直接硬编码写入, 而是采用外部环境变量文件
Environment="PORT=8080"

# EnvironmentFile: 设置加载环境变量的外部文件, 每一行作为一个配置, 支持 # 号注释
# 加载外部环境变量文件, 可以以 'DEVOPS_ARGS=XXX' 这样定义, 内部文件读取到这些环境变量可以作为启动配置
EnvironmentFile=/etc/devops/app.env

# ExecStart: 核心服务启动命令
# 这里有个技巧就是采用 EnvironmentFile 当作配置文件, 在文件当中自定义多个环境变量:
# DEVOPS_JRE="/usr/bin/java"
# DEVOPS_JAR="/var/lib/devops/app.jar"
# DEVOPS_ARGS="--httpPort=8080 --httpListenAddress=0.0.0.0"
# 这样内部单元可以直接加载使用, 以 $XXX 方式引入, 这个技巧可以避免每次修改的时候都高权限读写 service, 而是将加载参数作为外部文件来引入
ExecStart=$DEVOPS_JRE -jar $DEVOPS_JAR $DEVOPS_ARGS

# ExecStartPre: 启动 ExecStart 前执行的命令(如预处理|检查等),失败则服务启动失败
# 一般来说用于初始化权限或者配置功能, 比如需要创建本地特殊日志目录
# 不过需要注意, 这些操作是跟随 User/Group 运行的, 最好确定操作能够被指定的权限组运行
ExecStartPre=/usr/bin/mkdir -p /var/lib/devops/files

# ExecStartPost: 启动 ExecStart 后执行的命令
# 这个用于启动彻底完成之后做些收尾工作, 这里作为演示比如我这里就是启动完成就把应用服务主进程ID写入到本地文件
# $MAINPID 就是 Systemctl 自带的变量, 指代启动服务器主进程ID, 关于自带变量后面会具体说明
ExecStartPost=/usr/bin/sh -c 'echo $MAINPID > /var/lib/devops/pid'

# ExecStop: 停止服务的命令, 可以不配置, 默认都是直接 ExecStop=/bin/kill $MAINPID 杀死进程
# 所有你的应用需要特殊的停止命令可以在这个配置, 否则一般是不需要动到这个配置
ExecStop=/usr/bin/kill -TERM $MAINPID

# ExecStopPost: 彻底停止服务之后执行的命令, 一般用于清理临时文件情况
# 之前配置 ExecStartPost 写入主进程ID文件, 现在就可以直接删除了
ExecStopPost=/usr/bin/rm -r /var/lib/devops/pid

# ExecReload: 重载命令, 可以不配置, 一般用于热更新配置(systemctl reload xxx.service)
# 这里采用 -HUP 推送型号给进程, 一般可以在应用当中拦截更新信号从而做到内部热更新
ExecReload=/usr/bin/kill -HUP $MAINPID

# RestartSec: 服务重启前的等待时间, 用于预留服务重启的周期, 单位为秒
RestartSec=3

# TimeoutStartSec: 启动超时时间, 超时则判定启动失败, 默认为 90 秒
TimeoutStartSec=90

# TimeoutStopSec: 停止超时时间, 超时则强制终止, 默认 90 秒
TimeoutStopSec=90

# Restart: 服务退出后的重启策略,常用值如下
# - no(默认):不重启
# - on-failure: 非正常退出(退出码非 0)时重启
# - always: 无论退出原因,总是重启
# - on-abnormal: 被信号终止或超时才重启
# 一般推荐采用 on-failure 即可, 其他配置按照各自需求修改
Restart=on-failure

# StartLimitInterval: 重启次数限制的时间窗口(默认 10 秒)
# 实际上不需要修改到该配置, 就是在10s之内启动周期配合 StartLimitBurst 来确定限制
StartLimitInterval=10

# StartLimitBurst: 时间窗口内的最大重启次数(默认 5 次), 超限则停止尝试
# 实际上不需要修改到该配置, 除非你知道自己在做什么
StartLimitBurst=5

# PIDFile: 配合 Type=forking 类型, 指定后台监听的服务主进程 PID 文件路径
# 比如 PIDFile=/var/run/mysqld/mysqld.pid

# RemainAfterExit: 配合 Type=oneshot 类型, 任务结束后仍显示 "active" 状态(默认 no)
# 这样才能看到一次性任务是否已经被调用, 否则一直不知道是否执行过
# RemainAfterExit=yes

# LimitNOFILE: 服务可打开的最大文件描述符数, 针对网络应用很有用的配置, 防止文件描述符不足引发的502错误
LimitNOFILE=65535

# PrivateTmp: 为服务分配独立的临时目录, 针对权限隔离的很有用的配置, 防止目录混合在一起被获知到各自临时内容
PrivateTmp=yes


# ============================================================================
# 单元服务启动配置
# 定义服务如何被 systemctl enable(启用)或 disable(禁用), 关联到指定目标单元
# ============================================================================
[Install]

# WantedBy: 服务被启用时, 链接到指定目标单元的 wants 目录(弱关联)
# 常规采用 WantedBy=multi-user.target(多用户命令行模式自动启动)即可
# 一般会在系统服务当中的 /etc/systemd/system/multi-user.target.wants/ 当中创建单元
# 当启用 systemctl enable|disable xxx 的时候, 会在这个目录创建单元从而实现开机启动的功能
WantedBy=multi-user.target

# RequiredBy: 服务被启用时, 链接到指定目标单元的 requires 目录(强关联)
# 除非你知道自己在干什么, 否则不要去编写这个配置, 乱写会导致服务异常崩溃
# 注意: 千万不要把业务层在 RequiredBy 实现, 会导致整体环境异常

# Alias: 服务别名, 用于让系统拥有多个启动名称, 实际上是为了兼容可能版本变动导致单元名称变动
# 以下名称通过 systemctl start devops-app/devopsd.service 还有原单元服务名称都可以启动
# 多个别名也是可以通过空格隔开做多别名配置
Alias=devops-app.service devopsd.service

# Also: 连锁影响的单元, 也就是使用 systemctl enable|disable 会连带一起影响对应单元
# 一般不需要去配置, 只要需要同时关联系统级别启动才会去配置

这就是编写系统单元所需要配置, 实际上排除注释也就几个配置, 以部署 Jenkins 为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[Unit]
Description=Jenkins Service
Documentation=https://www.jenkins.io/doc
After=network.target

[Service]
Type=simple
User=jenkins
Group=jenkins

# 加载根目录和读取配置
# EnvironmentFile 内部变量如下:
# - JENKINS_JRE: 启动 jre
# - JENKINS_JAR: 启动的 war|jar 包
# - JENKINS_ARGS: 启动参数
WorkingDirectory=/var/lib/jenkins
EnvironmentFile=/etc/jenkins/jenkins.conf
ExecStart=$JENKINS_JRE -jar $JENKINS_JAR $JENKINS_ARGS
ExecStop=/bin/kill -TERM $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
PrivateTmp=true

[Install]
WantedBy=multi-user.target

这几行就直接编写好系统服务单元了, 之后更新下系统服务并且启动即可:

1
2
3
sudo systemctl daemon-reload # 更新系统服务
sudo systemctl start jenkins.service # 启动服务
sudo systemctl enable jenkins.service # 开机启动

上面那个例子修改下基本上就可以在其他服务上面使用, 其他的就是 timer|mount|device 相关的服务.

另外需要说明下的是 Type=oneshot 这种类型的服务单元, 这类服务单元可以实现很有用的小技巧;
比如可以在内存足够大的情况, 可以生成本地缓存空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[Unit]
Description=Local Memory Cache Service
Documentation=man:mount(8) man:tmpfs(5)
# 利用 tmpfs 将创建临时内存系统
# local-fs.target 代表了该单元需要确保在文件系统初始化后执行
After=local-fs.target

[Service]
Type=oneshot

# 预先确认目录是否存在, 不存在就创建
ExecStartPre=/usr/bin/mkdir -p /var/cache/memory

# 挂载 tmpfs 到 /var/cache/memory(大小限制为 512M,根据内存调整)
# 选项说明:
# - size=512M:限制最大使用内存(默认不限制,可能耗尽内存)
# - mode=777:目录权限(根据需求调整, 如果设置为 775 只允许同组用户读写操作, 如果对于信息不敏感可以设定为 777 )
# - uid=daemon:指定所有者(避免 root 权限)
# 因为这里涉及到底层操作, 所以执行主题只能采用默认 root 账号启动, 但是构建之后的归属的还是 daemon 及其用户组
ExecStart=/usr/bin/mount -t tmpfs -o size=512M,mode=775,uid=daemon,gid=daemon tmpfs /var/cache/memory

# 执行 systemctl reload 就相当于重置这块内存区
ExecReload=/usr/bin/mount -o remount,size=512M /var/cache/memory

# 停止时卸载 tmpfs(可选,系统关机时会自动清理)
# -l: 延迟释放, 等待目标进程处理之后再卸载
# -f: 强制释放, 直接强制释放内容, 不过长期占用的话会导致任务一直处于停摆, 不如直接强制释放来得快
ExecStop=/usr/bin/umount -f /var/cache/memory

# 完全停止的时候删除掉目录, 避免被检测到继续创建于此
ExecStopPost=/usr/bin/rm -rf /var/cache/memory

# 任务完成后保持 active 状态(便于查看是否执行成功)
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
Alias=local-cache.service

这样就生成内存本地内存目录, 适用给 make|npm 等工具配置缓存目录,
还有 Nginx/Apacheproxy_cache_path 设定缓存目录提升静态资源响应速度.

还有比较激进的方法就是利用 Unixdomain 的文件防止在其中, 然后通过 nginx 代理转发进来提高 http 的网络转发效率.

这种方法利用 Unix 域套接字(Unix domain socket) 减少网络协议栈开销能显著降低延迟并提高吞吐量,
但这种方法需要进行严格测试, 大部分功能场景的瓶颈其实不是内部的网络转发, 所以很多时候并不是那么迫切用到这么激进的策略.

本地编译直接创建个内存盘提高编译时候的读写效率, 这里也附带下参考(编译生成的产物可能很大, 需要确定内存盘容量能够承载):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 这里提供下 skynet 手动编译的流程
# 生成内存盘系统服务单元起名为 local-memory-cache.service
# 启动下应用生成内存盘, 内存盘目录为 /var/cache/memory
# 需要注意: 如果是谁都可以运行的编译任务, 那么内存盘的权限应该设定为 777 保证编译过程不会被提示权限不足
sudo systemctl start local-memory-cache.service

# 进入内存盘准备编译
cd /var/cache/memory

# 安装所需依赖
sudo apt install -y gcc make

# 拉取源代码
git clone https://github.com/cloudwu/skynet.git

# 执行编译
cd skynet && make linux
# 直到最后输出: 离开目录“/var/cache/memory/skynet”就代表编译成功

# 如果清理这部分编译直接退出当前目录(注意: 如果卸载的时候你还处于内存目录, 可能会因为占用无法卸载)
cd ~
sudo systemctl stop local-memory-cache.service
# 这样就完成利用内存盘编译的流程

之后就是要说 timer(定时器) 方面的问题, 日常如果用 crontab 其实没有问题, 当时两者都有其优劣的地方:

  • crontab: 几乎所有 Linux/Unix 都支持, 简单直接调用命令脚本或者命令即可, 但是仅支持分钟级精度(最小单位 1 分钟)
  • systemctl-timer: 强依赖 systemd, 学习成本较高且命令都要编写成 systemd 单元让其调用, 但是支持复杂精细时间调度

像是数据库备份到本地的任务, 其实不在于执行时间复杂度, 直接对于授权维护用户启动 crontab -e 编辑追加一行即可:

1
2
# 每天凌晨2点执行导出任务
0 2 * * * /usr/local/backup/mysql_backup.sh

这种情况下对时间的精度就没有这么多要求, 但是如果是高精度时间调度情况, 比如 支付回调返回 的情况, 每分钟/次的唤醒调度就有大问题.

海外有的支付通道默认只返回单次返回结果, 所以如果作为支付服务提供方接收到直接返回成功然后另外开后台定时任务

这种独立调度的任务大部分需要秒级的调度, crontab 能够 sleep 延迟调度实现秒级别调第, 但是会创建大量子进程严重拖慢系统效率.

所以基于这种情况下 systemctl-timer 就可以实现, 首先创建被定时器调度的单元(比如 payment-callback.service ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Unit]
Description=Payment Callback Retry Service
After=network.target

[Service]
Type=oneshot

# 这里采用 php 调度脚本
# 执行回调检查脚本(确保脚本幂等,避免重复处理)
ExecStart=/usr/bin/php -f /www/project/callback.php

# 限制脚本超时时间(如 5 秒, 避免任务卡死)
TimeoutSec=5

# 脚本失败后重试(最多 3 次,间隔 2 秒)
Restart=on-failure
RestartSec=2

# 运行用户(非 root 更安全)
User=www-data
Group=www-data

# 这个服务不允许被开启开机启动安装, 所以不用 [Install] 相关

这就是简单被定时器调度的单元, 之后就是调度定时器配置(比如 payment-callback.timer, 定时器常规命名需要为 .timer 后缀):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Unit]
Description=Timer For Payment Callback Retry
# 依赖服务单元, 也就是我们自己编写调度 service
Requires=payment-callback.service

[Timer]
# 定时器激活后立即执行第一次任务
OnActiveSec=0s

# timer 也支持 crontab的功能调用
# OnCalendar=Mon..Fri *-*-* 09:00:00

# 上次任务完成后, 间隔 3 秒再次执行(核心配置)
OnUnitActiveSec=3s

# 绑定到服务单元
# 也就是我们自己编写服务单元
Unit=payment-callback.service

[Install]
# 随系统启动自动激活定时器, 这里定时器是支持开机启动, 所以需要编写 [Install]
# 并且定时器依赖的是不同的安装目录, 默认采用 timers.target
WantedBy=timers.target

更新下系统服务就可以启动, 并且可以看到所有定时调度任务:

1
2
3
4
5
6
7
8
sudo systemctl start payment-callback.timer # 启动服务
sudo systemctl enable payment-callback.timer # 开机启动

# 查看定时器调度周期
sudo systemctl list-timers payment-callback.timer
# 输出示例:
# NEXT LEFT LAST PASSED UNIT ACTIVATES
# Wed 2025-11-14 17:30:10 CST 10s left Wed 2025-11-14 17:30:00 CST 0s ago payment-callback.timer payment-callback.service

这样就实现系统级别优雅的秒级调度任务, 但是也将任务的稳定性全盘移交给 systemd 托管, 如果想临时调整属性可以按照以下处理:

1
2
3
4
5
6
7
8
9
10
11
# 临时调整为10秒间隔
sudo systemctl set-property payment-callback.timer OnUnitActiveSec=10s

# 恢复3秒间隔
sudo systemctl set-property payment-callback.timer OnUnitActiveSec=3s

# 如果要持久修改需要追加 --runtime=false 声明不是运行时配置
sudo systemctl set-property --runtime=false payment-callback.timer OnUnitActiveSec=3s

# 如果想看可以修改属性, 可以通过 show
sudo systemctl show payment-callback.timer

systemctl set-property 可以修改大部分的执行当中服务单元, 而不需要重新加载并重启系统服务.

相关技巧

这里提供些比较少用到的好用技巧, 以之前部署的 jenkins 服务为例子, 可以通过以下命令查看到系统服务涵盖的属性:

1
2
3
4
5
6
# 这里面提供大量的属性配置, 这里取几个比较好用的配置:
# - MemoryLimit: 内存使用上限(如 1G、512M)
# - Nice: 进程优先级(-20~19,值越小优先级越高, 默认为0)
# - NoNewPrivileges: 是否禁止进程提升权限(yes 防止特权升级漏洞, 防止授权账号被提升为root或者调用sudo)
# - ProtectSystem: 系统目录保护(填写 strict 表示只读系统目录, 从而增强安全性)
sudo systemctl show jenkins.service

以上配置都是正式环境推荐设置的, 能够有效安全管理 systemd 服务.

另外有个特殊配置 [Socket], 如果你按照 ssh 服务可以在 /etc/systemd/system/ssh.service.requires/ssh.socket 看到:

1
2
3
4
5
6
[Socket]
ListenStream=0.0.0.0:22
ListenStream=[::]:22
BindIPv6Only=ipv6-only
Accept=no
FreeBind=yes

这部分的服务都是以 .socket 后缀做结尾, 用于 systemd 直接创建并监听套接字(而非服务自身启动后监听), 确保套接字始终可用.

实际上就是起到套接字抢占避免服务端口等资源被其他服务抢占, 也就是访问 ListenStream 地址会自动创建进程激活 Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 以 /etc/systemd/system/php-fpm.socket 为例子
[Unit]
Description=PHP-FPM Unix Socket

[Socket]
# 监听 Unix 域套接字文件(而非端口)
ListenStream=/run/php-fpm.sock
# 套接字权限:允许 Nginx(www-data 组)访问
SocketMode=0660
SocketUser=www-data
SocketGroup=www-data
# 关联 PHP-FPM 服务
# 注意: 默认将 ssh.socket 与同名的服务单元(ssh.service) 做关联, 也就是不写该项目会默认关联到 ssh.service(别名)|sshd.service
Service=php-fpm.service

[Install]
WantedBy=sockets.target

Socket 系统服务单元激活后的服务按照 Type 来做区分:

  • Type=oneshot: 远程客户端关闭时, 绑定的服务单元状态变为 inactive(正常结束) 并等待下次唤起重新启动 service
  • Type=simple(默认): 远程客户端关闭时, 按照 fork 创建子进程或者初始化单独线程, 这类会将连接转移之后主进程不会做退出
  • 其他基本上很少用到, 需要的话可以参考其他资料

如果要做进程池或者线程池模型单独处理, 可以考虑 Type=simple, 不过一般也很少回去用到 [Socket] 类的功能.

对于非高频服务通过 .socket 实现 ‘有连接才启动进程’, 平时不占用内存(对比传统 systemctl start 启动后常驻内存的方式)

最后就是关于 systemctl 命令行的参数说明, 除了传统的 start|stop|reload|enable|disable 之外还提供以下功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 重启并立即生效(自动重载配置), 而不需要 systemctl daemon-reload 
sudo systemctl restart --now myservice.service

# 查看依赖目前服务单元的其他相关服务单元
sudo systemctl list-dependencies myservice.servic

# 反向查询目前服务单元依赖其他相关服务单元
sudo systemctl list-dependencies --reverse myservice.service

# 常用状态的单元服务
sudo systemctl list-units --type=service --state=active # 运行中的服务
sudo systemctl list-units --type=service --state=failed # 失败的服务

# systemd 自带日志监控
sudo journalctl -u myservice.service # 查看指定服务日志
sudo journalctl -u myservice.service -f # 实时跟踪日志(类似 tail -f)
sudo journalctl -u myservice.service --since "1 hour ago" # 查看1小时内的日志
sudo journalctl -u myservice.service -p err # 只显示 error 及以上级别的日志, err=3,级别:emerg(0) > alert(1) > crit(2) > err(3)

# 显示所有定时器的下次执行时间、上次结果
sudo systemctl list-timers

# 系统状态信息
systemd-analyze # 总启动时间
systemd-analyze blame # 各服务启动耗时(从长到短)
systemd-analyze critical-chain # 关键启动链(影响总耗时的服务)

# 编辑单元服务
sudo systemctl edit --full myservice.service # 编辑完整单元文件, 必须要输入冗长的目录, 注意这里采用系统编辑器(默认nano)
sudo systemctl cat myservice.service # 快速查看服务单元的完整配置(包括默认和自定义部分)

安全实践

这里需要说明创建 systemd 系统服务主要一定要明确 不能给运行的单元赋予太高权限, 所以考虑单元的权限等级:

  • 是否需要HOME目录: 有的第三方程序会用到本地 ssh 机制(jenkins|gitea), 所以需要针对 HOME 目录操作
  • 是否需要LOGIN账号: 大部分作为运行宿主的系统用户不需要提供 ssh|desktop 登陆功能, 但是可能有少部分需要登陆维护操作
  • 是否需要SUDO操作: 有些服务可能需要设计配置变动之后重启系统服务(比如重启 nginx), 需要特殊分配提供免密码 sudo 操作

按照这种方式分配创建的安全专属系统账户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 创建专属的用户和用户组 jenkins:jenkins
# -r: 代表创建系统用户, 系统用户UID的范围遵循系统约定(通常 < 1000,不同发行版可能微调), 默认不创建家目录且不自动添加到默认用户组
# -m: 声明虽然是系统用户, 但是需要强制创建 HOME 目录
# -d: 指定HOME目录地址
# -s: 指定默认操作 shell, /sbin/nologin 代表允许登陆
sudo useradd -r -m -d /var/lib/jenkins -s /sbin/nologin jenkins


# 如果是自己编写的简单 JaveWeb 服务且不需要HOME目录和登陆账号, 需要归属到 www-data 作为网络组
# 需要加入多个组可以用英文逗号分割(比如-G www-data,docker),所有的 systemd 都挂载该用户运行, 最后就是以下格式
sudo useradd -r -s /sbin/nologin -G www-data jre


# 还有声明创建的用户为 '无特权组', 也就是创建用户不需要额外创建分组
# 这种自身不需要特权组的情况, 可以指定 -g 65534, 65534 默认归属为 nobody(系统的默认无特权组)
sudo useradd -r -s /sbin/nologin -g 65534 -G www-data,docker,systemd-journal jre

对于大部分自己编写的功能业务情况, 推荐采用 useradd -r -s -g 65534 -G www-data,{其他组} {创建特权用户} 即可,
挂载在 nobody 运行且又属于 www-data 组能够将权限收缩到比较小范围.

如果需要分配额外 sudo 特权, 比如要赋予允许登陆的账号能够执行重启 nginx 的功能, 需要自己定制 sudoers.d 特权:

1
2
3
4
# 比如赋予能够登陆的 devops 特权账户重启 nginx 功能, 如果是要按照组规则首用户写 '%devops', 否则默认按照专门用户
echo "devops ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx" |sudo tee /etc/sudoers.d/devops
sudo chown root:root /etc/sudoers.d/devops # 绑定归属
sudo chmod 0440 /etc/sudoers.d/devops # 赋予权限

系统预定义变量

之前能看到 $MAINPID 这种主进程ID声明的变量, 这部分就是 systemd 内部启动的时候捕获设定的, 这里提供系统定义变量表:

变量名称 核心作用 依赖条件/生效前提 适用单元类型
$MAINPID 服务主进程的 PID 服务启动后自动生成 .service
$PIDFile 服务 PID 文件的完整路径 单元文件中显式配置 PIDFile= .service
$SERVICE 当前服务单元的名称(不含 .service 后缀,如 sshd 无(服务单元默认生效) .service
$UNIT 当前单元的完整名称(如 sshd.servicenginx.path 无(所有单元类型默认生效) 所有单元类型
$USER 服务运行的用户账号 单元文件中配置 User= .service
$GROUP 服务运行的用户组 单元文件中配置 Group= .service
$HOME 运行用户的主目录路径 已配置 User=(自动关联用户主目录) .service
$RUNTIME_DIRECTORY 服务专属运行时目录(通常位于 /run/ 下) 单元文件中配置 RuntimeDirectory= .service
$STATE_DIRECTORY 服务状态数据存储目录(通常位于 /var/lib/ 下) 单元文件中配置 StateDirectory= .service
$CACHE_DIRECTORY 服务缓存文件存储目录(通常位于 /var/cache/ 下) 单元文件中配置 CacheDirectory= .service
$LOGS_DIRECTORY 服务日志文件存储目录(通常位于 /var/log/ 下) 单元文件中配置 LogsDirectory= .service
$CONFIG_DIRECTORY 服务配置文件存储目录(通常位于 /etc/ 下) 单元文件中配置 ConfigDirectory= .service
$PATH 进程执行命令的环境变量 PATH 可通过 Environment=/EnvironmentFile= 自定义 所有单元类型
$SYSTEMD_EXEC_PID 执行当前单元的 systemd 子进程 PID 单元启动时自动生成(较少直接使用) 所有单元类型
$NOTIFY_SOCKET 服务状态通知的 Unix 套接字路径 单元文件中配置 Type=notify .service
$WATCHDOG_PID 看门狗进程的 PID 单元文件中配置 WatchdogSec= .service
$WATCHDOG_USEC 看门狗超时时间(单位:微秒) 单元文件中配置 WatchdogSec= .service
$TRIGGER 触发路径单元的具体文件/目录路径 路径单元监测到文件/目录事件时生成 .path
$DEVICE 设备单元对应的设备路径(如 /dev/sda1 无(设备单元默认生效) .device
$INTERFACE 网络接口单元对应的接口名称(如 eth0wlan0 无(网络接口相关单元默认生效) .netdev.network
$LISTEN_FDS 通过文件描述符传递的监听套接字数量 单元文件中配置 ListenStream= 等 socket 选项 .service.socket
$LISTEN_PID 传递监听套接字的进程 PID $LISTEN_FDS 配合使用(socket 激活场景) .service.socket

注意: *_DIRECTORY 相关变量不允许采用绝对路径而是默认采用附加形式; 如 $CONFIG_DIRECTORY=aaa 是设定成 /etc/aaa

我这边提供一个脚本命令用于打印这部分变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Unit]
Description=Print Systemd Variables

[Service]
Type=oneshot
# 打印常用变量到日志, 会自动输出在日志
ExecStart=/bin/sh -c 'echo "MAINPID: $MAINPID"; \
echo "UNIT: $UNIT"; \
echo "SERVICE: $SERVICE"; \
echo "USER: $USER"; \
echo "HOME: $HOME"; \
echo "RUNTIME_DIRECTORY: $RUNTIME_DIRECTORY"; \
echo "LOGS_DIRECTORY: $LOGS_DIRECTORY" ; \
echo "LISTEN_FDS: $LISTEN_FDS"; \
echo "NOTIFY_SOCKET: $NOTIFY_SOCKET"'

# 默认为 /run, 不允许绝对路径, 如果填写该值默认会追加目录, 比如下面会自动替换成 /run/PrintSystemdVariables 目录
RuntimeDirectory=PrintSystemdVariables

# 其他目录相关的服务也同理, 如下是设定成 /var/log/PrintSystemdVariables
LogsDirectory=PrintSystemdVariables

# 不需要开启启动安装

创建和启动服务:

1
2
3
4
5
6
7
8
9
# 写入单元内容
sudo vim /etc/systemd/system/print-systemd-variables.service

# 更新并启动服务
sudo systemctl daemon-reload
sudo systemctl start print-systemd-variables.service

# 查看输出日志, 如果有的变量为空需要注意是否满足变量配置的条件, 比如 $HOME 全局变量就要求必须设置 User 确定启动用户
sudo systemctl status print-systemd-variables.service

对于全局变量必须明确生效的场景, 如果没有生效默认都会是空值, 涉及移动|删除敏感操作不要用 root 权限, 非常危险的操作!!!

模板单元

比如部署个游戏服务 game.service 目标需要启动 /usr/bin/app 启动二进制ExecStart=/usr/bin/app -f server1.conf;
这里默认启动的是 QQ-1 服, 那么如果要多进程部署的话就要重新设定 game-2.service, 而内部仅仅是 -f server2.conf 变化而已.

systemd 内部提供这种单元复用方案, {单元服务}@.service 这种声明(服务名 + @.service), 这就是 模板单元(Template Units)

这里采用这种以下方式声明, 文件名如下:

1
2
# 声明模板单元文件
sudo vim /etc/systemd/system/[email protected]

模板单元的内容如下, 注意内部特殊变量 %I :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=Game Server Instance %I # %I 会替换为实例名称(如 server1、server2)
After=network.target

[Service]
# 启动命令:%I 作为配置文件的后缀(如 server1 → /etc/game/server1.conf)
ExecStart=/usr/bin/app -f /etc/game/%I.conf
# 可选:指定运行用户、工作目录、日志等
User=gameuser
WorkingDirectory=/var/lib/game
Restart=always # 进程意外退出时自动重启
RestartSec=5

[Install]
WantedBy=multi-user.target
  • 核心变量 %I: 表示模板实例的参数(即 @ 后面的部分, 如 game@server1%Iserver1)
  • 其他可用变量:%i(小写 i,会将 @ 后的参数中的 / 替换为 -,适合路径场景)
  • 其他比如 %H(主机名) 等, 可以参考 man systemd.unit

那么命令行启动的时候就需要另外变动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 默认会去加载 /etc/game/server1.conf 配置并启动
sudo systemctl start game@server1

# 如果要指定开机启动就需要声明指定单元
sudo systemctl enable game@server1

# 查看全部相关的模板服务
sudo systemctl status 'game@*'

# 查看单独的模板服务
sudo systemctl status game@server1

# 如果模板声明错误, 删除步骤就比较多
sudo systemctl stop game@error # 停止错误实例
sudo systemctl disable game@error # 禁用开机自启
sudo rm -f /etc/systemd/system/multi-user.target.wants/[email protected] # 删除实例的状态文件

# 查看所有模板的日志
sudo journalctl -u 'game@*' -f

这里面涉及到 -i/-I 之类特殊模板变量, 还有涉及其他模板会用到的变量, 可以按照需要来使用:

变量 含义与用途 示例(假设实例为 game@server-1/data
%I 保留原始实例名称(包括 / 等特殊字符),不做任何替换 实例 game@server-1/data 中,%Iserver-1/data
%i %I 类似,但会将名称中的 / 替换为 -(避免路径冲突,适合作为文件名) 实例 game@server-1/data 中,%iserver-1-data
%H 当前主机的 hostname(主机名) 若主机名为 game-server,则 %Hgame-server
%h 运行服务的用户的主目录(依赖 User= 配置,类似环境变量 $HOME User=gameuser,其主目录为 /home/gameuser,则 %h/home/gameuser
%t 系统运行时目录(固定为 /run,等价于 /var/run 的软链接) %t 始终为 /run
%T 服务的临时目录(通常为 /tmp/var/tmp,由 systemd 管理) %T 通常为 /tmp
%v 系统的版本号(来自 /proc/sys/kernel/osrelease 例如 6.2.0-31-generic(Ubuntu 系统)
%b 系统启动目录(通常为 /boot %b/boot
%m 机器 ID(来自 /etc/machine-id,唯一标识当前系统) 例如 a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
%M 机器 ID 的短格式(前 12 个字符) 基于上面的示例,%Ma1b2c3d4e5f6
%p 单元文件的前缀名称(即 @ 前面的部分) 对于 [email protected]%pgame
%N 单元文件的完整名称(包含 @ 和实例部分) 对于 [email protected]%N[email protected]
%n 单元文件的名称(不含 .service 等后缀) 对于 [email protected]%ngame@server1
%C 服务的配置目录(通常为 /etc/ 下的服务专属目录,需配合 ConfigDirectory= ConfigDirectory=game,则 %C/etc/game
%R 服务的运行时目录(配合 RuntimeDirectory=,等价于 $RUNTIME_DIRECTORY RuntimeDirectory=game,则 %R/run/game

大部分常用的其实是 -i, 大写的 -I 不对 / 等特殊字符不做替换, 这种情况容易出现问题

支持了解以上知识基本上能够手动编写好自己的 systemd 服务, 其他额外扩展就需要查阅官方手册去自行处理.