Linux-14 启动流程与 systemd
服务怎么开机自启、怎么崩溃后自动拉起、日志去哪了、资源怎么限制——这些在容器时代之前是运维的事,但现在每个后端工程师都要会写 systemd unit:非容器化的服务、宿主机上的 agent、以及容器运行时本身都靠它管。
本篇重点是写出一个生产级的 unit 文件,以及理解那些「配了不生效」的坑。
1. 从按下电源到 shell 提示符
① 固件阶段:BIOS 或 UEFI
- 加电自检(POST),初始化硬件
- BIOS:读磁盘第一个扇区的 MBR(512 字节)
- UEFI:读 ESP 分区(FAT32)里的 .efi 文件 <- 现代机器都是这个
v
② 引导加载器:GRUB2
- 显示启动菜单,加载内核和 initramfs 到内存
- 传递内核启动参数(root=、ro、quiet 等)
v
③ 内核阶段
- 解压自身,初始化内存管理、调度器、中断
- 挂载 initramfs 作为【临时】根文件系统
v
④ initramfs 阶段
- 加载访问真正根分区所需的驱动(磁盘控制器、LVM、加密卷、网络存储)
- 挂载真正的根分区
- switch_root:切换到真正的根,执行 /sbin/init
v
⑤ systemd(PID 1)
- 读取 default.target(通常是 multi-user.target 或 graphical.target)
- 按依赖关系【并行】启动所有 unit
v
⑥ 登录:getty / sshd / display manager
1.1 为什么需要 initramfs
这是个常被跳过但很关键的设计:内核要挂载根分区,就需要磁盘驱动;但驱动通常编译成模块存在 /lib/modules 里,而那个目录在根分区上——鸡生蛋问题。
initramfs 就是解法:一个包含必要驱动的小型内存文件系统,先挂它,加载驱动,再挂真正的根分区。
# 看 initramfs 里有什么
lsinitramfs /boot/initrd.img-$(uname -r) | grep -E "\.ko" | head -5 # Debian
lsinitrd /boot/initramfs-$(uname -r).img | head -20 # RHEL
# 内核启动参数(排查启动问题时必看)
cat /proc/cmdline
# BOOT_IMAGE=/vmlinuz root=UUID=xxx ro quiet splash
# 重建 initramfs(改了驱动、加了 LVM 之后需要)
update-initramfs -u -k all # Debian/Ubuntu
dracut -f --regenerate-all # RHEL 系
坑:改了
/etc/fstab加了新的 LVM 或加密卷后没重建 initramfs,会导致系统起不来(进入 emergency shell)。这类问题在物理机上要接显示器才能救,云上要用 VNC 控制台。第 11 篇提到的nofail挂载选项能避免大部分这类事故。
1.2 排查启动失败
# 看上一次启动的完整日志(-b -1 = 上一次启动)
journalctl -b -1 -p err
# 看本次启动的所有日志
journalctl -b
# 内核环形缓冲(硬件、驱动问题都在这)
dmesg -T | grep -iE "error|fail|warn" | head -20
# 哪些服务启动失败了
systemctl --failed
# UNIT LOAD ACTIVE SUB DESCRIPTION
# myapp.service loaded failed failed My Application
紧急恢复手段(在 GRUB 菜单里按 e 编辑内核参数):
systemd.unit=rescue.target 单用户模式,最小服务集
systemd.unit=emergency.target 只挂载根分区(只读),连 systemd 服务都不启
init=/bin/bash 完全绕过 systemd,直接进 shell(终极手段)
rd.break 在 initramfs 阶段就停下(RHEL,重置 root 密码用)
2. systemd 的核心概念
2.1 为什么替代了 SysV init
| SysV init | systemd | |
|---|---|---|
| 启动方式 | 串行执行 /etc/init.d/ 脚本 |
并行,按依赖自动排序 |
| 服务描述 | Shell 脚本(几百行,各写各的) | 声明式配置(十几行) |
| 服务状态 | 靠 pid 文件猜(不可靠) | 内核 cgroup 精确跟踪 |
| 崩溃重启 | 需要额外工具(supervisor、monit) | 内建 Restart= |
| 日志 | 各自写文件,格式不一 | journald 统一收集,带结构化字段 |
| 资源限制 | 无 | 原生集成 cgroup |
| 按需启动 | 无 | socket/path/timer 激活 |
「用 cgroup 跟踪服务」是最本质的改进。SysV 时代判断服务是否在运行靠读 pid 文件——进程被 kill -9 后 pid 文件还在,或者 pid 被别的进程复用,都会误判。systemd 把服务的所有进程(含它 fork 出来的子孙)放进一个 cgroup,是否运行、用了多少资源、停止时该杀谁,全部准确无误。
# 看某个服务的 cgroup 树
systemd-cgls /system.slice/nginx.service
# nginx.service
# +-1234 nginx: master process /usr/sbin/nginx
# +-1235 nginx: worker process
# +-1236 nginx: worker process
# 看资源占用(类似 top 但按服务聚合)
systemd-cgtop
第 6 篇讲的「守护进程 fork 后脱离父进程」在 systemd 下不再需要——反而是有害的,因为它会让 systemd 追踪不到主进程。这就是 Type=simple 应该是默认选择的原因(见 §3.2)。
2.2 unit 类型
| 后缀 | 用途 |
|---|---|
.service |
服务(最常用) |
.socket |
socket 激活:有连接才启动服务 |
.timer |
定时任务(cron 的替代,第 15 篇讲) |
.mount |
挂载点(/etc/fstab 会自动生成对应的 unit) |
.target |
一组 unit 的集合,取代 runlevel |
.path |
监视文件/目录变化来触发 |
.slice |
cgroup 层级,用于资源分组管理 |
配置文件的三个位置,优先级从高到低:
/etc/systemd/system/ <- 【管理员写的,优先级最高】你应该改这里
/run/systemd/system/ <- 运行时生成的,临时
/usr/lib/systemd/system/ <- 软件包自带的,【不要直接改】
永远不要直接改 /usr/lib/systemd/system/ 下的文件——包升级会覆盖你的修改。正确做法是用 drop-in 片段:
# 只覆盖某几个配置项,不动原文件
systemctl edit nginx.service
# 会创建 /etc/systemd/system/nginx.service.d/override.conf
# 完整替换(复制一份到 /etc/systemd/system/)
systemctl edit --full nginx.service
# 看最终生效的完整配置(合并了所有 drop-in)
systemctl cat nginx.service
drop-in 是最推荐的修改方式,尤其是只想加个 LimitNOFILE 或改个 Restart 策略时:
mkdir -p /etc/systemd/system/nginx.service.d/
cat > /etc/systemd/system/nginx.service.d/limits.conf <<'EOF'
[Service]
LimitNOFILE=65535
EOF
systemctl daemon-reload
2.3 target 取代 runlevel
systemctl get-default
# multi-user.target
# 切换(临时)
systemctl isolate multi-user.target
# 设置默认
systemctl set-default multi-user.target
| 旧 runlevel | target | 说明 |
|---|---|---|
| 0 | poweroff.target |
关机 |
| 1 | rescue.target |
单用户 |
| 3 | multi-user.target |
无图形界面,服务器的标准 |
| 5 | graphical.target |
图形界面 |
| 6 | reboot.target |
重启 |
init 3、init 5 这些命令仍然能用(systemd 提供了兼容层),但已经不是推荐写法。
3. 写一个生产级的 service
3.1 完整示例
# /etc/systemd/system/myapp.service
[Unit]
Description=My Go Application
Documentation=https://wiki.internal/myapp
# 依赖:网络就绪之后再启动
After=network-online.target
Wants=network-online.target
# 如果依赖数据库在同机
After=postgresql.service
# 重启频率限制 —— 注意这两项属于 [Unit] 段,不是 [Service]
# 10 秒内启动失败超过 5 次就放弃(防止无限重启风暴)
StartLimitIntervalSec=10
StartLimitBurst=5
[Service]
Type=notify
NotifyAccess=main
# ---------- 身份与目录 ----------
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp/current
# ---------- 启动命令 ----------
# 必须用绝对路径,不走 PATH 查找
ExecStart=/opt/myapp/current/bin/myapp --config=/etc/myapp/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
# ---------- 环境变量 ----------
Environment="GOMAXPROCS=4"
Environment="GOMEMLIMIT=3500MiB"
EnvironmentFile=-/etc/myapp/env
# ^ 前缀 - 表示文件不存在也不报错
# ---------- 重启策略 ----------
Restart=always
RestartSec=3
# 频率限制在上面的 [Unit] 段
# ---------- 优雅关闭(第 8 篇)----------
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=45
# ---------- 资源限制 ----------
LimitNOFILE=65535
MemoryMax=4G
CPUQuota=400%
OOMScoreAdjust=-500
UMask=0027
# ---------- 安全加固 ----------
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/myapp /var/lib/myapp
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target
systemctl daemon-reload # 改了 unit 文件必须先 reload
systemctl enable --now myapp
systemctl status myapp
3.2 Type 的选择:默认用 simple
这是 unit 文件里最容易配错的一项,配错会导致启动卡住或状态误判。
| Type | 语义 | 何时用 |
|---|---|---|
simple |
ExecStart 拉起的进程就是主进程,立即认为启动完成 |
默认,绝大多数场景 |
exec |
类似 simple,但等 execve() 成功才算启动完成 |
想捕获「二进制不存在」这类错误 |
forking |
期待进程 fork 后父进程退出,子进程是主进程 | 传统 daemon(老式 nginx、sshd) |
notify |
服务通过 sd_notify() 主动报告「我就绪了」 |
有初始化过程的服务,最精确 |
oneshot |
跑完就退出,配 RemainAfterExit=yes |
初始化脚本、迁移任务 |
dbus |
在 D-Bus 上注册名字后算启动完成 | 桌面服务 |
Type=simple 的局限:systemd 一 fork 出进程就认为「启动成功」,此时服务可能还在加载配置、连数据库、预热缓存。所以依赖它的其他服务可能过早启动。
Type=notify 解决这个问题——服务自己说「我真的准备好了」:
// Go 里用 sd_notify(不需要 CGO)
import "github.com/coreos/go-systemd/v22/daemon"
func main() {
srv := setupServer()
go srv.ListenAndServe()
// 所有初始化完成后再通知 systemd
warmupCache()
checkDBConnection()
daemon.SdNotify(false, daemon.SdNotifyReady) // <- 现在才算 ready
// 配合 WatchdogSec 做健康上报
go func() {
interval, _ := daemon.SdWatchdogEnabled(false)
if interval == 0 { return }
for {
if healthy() {
daemon.SdNotify(false, daemon.SdNotifyWatchdog)
}
time.Sleep(interval / 2)
}
}()
waitForShutdown(srv)
}
[Service]
Type=notify
WatchdogSec=30 # 30 秒没收到心跳就认为服务挂了,触发重启
Restart=always
WatchdogSec 是个很实用的功能——它能发现「进程还活着但已经死锁/无响应」这种情况,而单纯的 Restart=always 只能处理进程退出。
坑:
Type=forking配错是「启动卡住 90 秒然后超时失败」的最常见原因。如果你的程序不 fork(现代程序基本都不 fork),却配了Type=forking,systemd 会一直等父进程退出,等到TimeoutStartSec(默认 90s)超时。反过来,老式 daemon 配了Type=simple,systemd 会把「fork 后立即退出的父进程」当成服务崩溃。不确定就用simple,程序自己不要 fork。
3.3 Restart 策略
| 值 | 何时重启 |
|---|---|
no |
从不(默认) |
on-success |
只在正常退出(码 0)时 |
on-failure |
非 0 退出码、被信号杀死、超时 |
on-abnormal |
被信号杀死、超时(不含非 0 退出码) |
always |
总是重启,包括 systemctl stop 之外的任何退出 |
长期运行的服务用 always 或 on-failure:
always -> 即使程序正常退出(码 0)也重启。适合「本来就该一直跑」的服务
on-failure -> 只在异常时重启。适合「正常退出就是完成了工作」的场景
StartLimitBurst 必须配,否则会形成重启风暴:
[Unit]
# ⚠️ 这两项在 [Unit] 段,不是 [Service] —— 写错位置会被静默忽略
StartLimitIntervalSec=10
StartLimitBurst=5
# 含义:10 秒内如果重启超过 5 次,就【彻底放弃】,进入 failed 状态
[Service]
Restart=always
RestartSec=3
坑:
StartLimitIntervalSec/StartLimitBurst在 systemd 229 版本从[Service]移到了[Unit]。写在[Service]里不会报错,只是完全不生效——用systemctl show myapp -p StartLimitBurst能验证实际值是不是你配的。老文章里的写法大多还停留在 229 之前。
没有这个限制的后果:配置文件写错导致服务一启动就崩,systemd 会每秒重启一次,日志疯狂增长、CPU 被无意义地消耗。有了限制后服务会停在 failed 状态,让你能看到问题。
# 触发限制后需要手动重置
systemctl reset-failed myapp
systemctl start myapp
3.4 KillMode:停止时杀谁
这关系到「服务停了但子进程还在」这类问题。
| KillMode | 行为 |
|---|---|
control-group |
默认:给 cgroup 内所有进程发 SIGTERM |
mixed |
只给主进程发 SIGTERM,超时后给整个 cgroup 发 SIGKILL |
process |
只管主进程,子进程完全不管 |
none |
什么都不发(几乎不用) |
mixed 通常是最优选择:主进程收到 SIGTERM 后可以自己有序地关闭子进程(比如 nginx master 通知 worker 优雅退出),而 control-group 会同时给所有进程发信号,可能打乱应用自己的关闭顺序。
[Service]
KillSignal=SIGTERM # 先发这个(第 8 篇讲的优雅关闭)
KillMode=mixed
TimeoutStopSec=45 # 等 45 秒,超时后 SIGKILL
TimeoutStopSec 必须大于应用的优雅关闭耗时,否则优雅关闭会被 SIGKILL 打断——这就是第 8 篇讲的时间预算问题。
3.5 依赖:After 和 Requires 是两个维度
这是 systemd 最容易误解的地方:
After / Before -> 【顺序】:先启动谁,后启动谁
Requires / Wants -> 【依赖】:需不需要另一个 unit 也被启动
两者【完全独立】!
# ❌ 只写 Requires 不写 After
[Unit]
Requires=postgresql.service
# 结果:postgresql 会被【同时】启动,但不保证它先就绪
# 你的服务可能在 pg 还没准备好时就尝试连接
# ✅ 两个都要写
[Unit]
Requires=postgresql.service
After=postgresql.service
| Requires | Wants | |
|---|---|---|
| 依赖启动失败时 | 本服务也失败 | 本服务继续启动 |
| 依赖被停止时 | 本服务也被停止 | 不影响 |
| 推荐度 | 强依赖时用 | 一般情况用这个,更宽松 |
Wants 是更常用的选择——用 Requires 时,依赖服务的一次抖动会连带把你的服务停掉,级联失败的风险高。
网络依赖有个专门的坑:
# ❌ network.target 只表示「网络栈已初始化」,不代表【IP 已配好】
After=network.target
# ✅ network-online.target 才表示网络真正可用
After=network-online.target
Wants=network-online.target # <- 必须加 Wants,否则这个 target 不会被激活
network-online.target 必须同时写 Wants,因为它默认不在任何 target 的依赖里,不主动拉起就永远不会激活,After 也就形同虚设。这是「服务启动时报 DNS 解析失败或绑定 IP 失败」的常见原因。
3.6 安全加固选项
systemd 提供了一批几乎零成本的加固手段,实质是内核的 namespace 和 capability 机制:
[Service]
# 基础三项,建议所有服务都加
NoNewPrivileges=true # 禁止通过 SUID 提权(第 3 篇)
PrivateTmp=true # 独立的 /tmp,看不到别人的临时文件
ProtectSystem=strict # 整个文件系统只读,除了下面显式放开的路径
ReadWritePaths=/var/log/myapp /var/lib/myapp
# 进阶
ProtectHome=true # 看不到 /home、/root
ProtectKernelTunables=true # /proc/sys 只读
ProtectKernelModules=true # 禁止加载内核模块
ProtectControlGroups=true # /sys/fs/cgroup 只读
RestrictSUIDSGID=true # 禁止创建 SUID 文件
RestrictNamespaces=true # 禁止创建 namespace
LockPersonality=true
MemoryDenyWriteExecute=true # 禁止可写可执行的内存页(防 shellcode)
# 限制能力
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE # 让非 root 也能绑 80/443 端口
AmbientCapabilities=CAP_NET_BIND_SERVICE 很实用——它让服务能以非 root 用户绑定 1024 以下的特权端口,不需要 root 权限也不需要 setcap。
# 检查一个服务的加固程度,systemd 会给出评分和建议
systemd-analyze security myapp.service
# NAME DESCRIPTION EXPOSURE
# PrivateNetwork= Service has access to network 0.5
# User=/DynamicUser= Service runs as root user 0.4 <- 建议改
# ...
# -> Overall exposure level for myapp.service: 6.2 EXPOSED 🙁
4. systemctl 常用操作
# 生命周期
systemctl start|stop|restart|reload myapp
systemctl reload-or-restart myapp # 支持 reload 就 reload,否则 restart
# 开机自启
systemctl enable myapp # 只设置,不启动
systemctl enable --now myapp # 设置并立即启动
systemctl disable myapp
systemctl is-enabled myapp
# 状态
systemctl status myapp
systemctl is-active myapp # 脚本里用,只输出 active/inactive
systemctl --failed # 所有失败的服务
# 查看配置
systemctl cat myapp # 最终生效的配置(含 drop-in)
systemctl show myapp # 所有属性的当前值
systemctl show -p MemoryMax,LimitNOFILE myapp # 只看几项
# 依赖关系
systemctl list-dependencies myapp
systemctl list-dependencies --reverse myapp # 谁依赖它
# 屏蔽(比 disable 更彻底,连手动 start 也不行)
systemctl mask myapp
systemctl unmask myapp
mask 与 disable 的区别:disable 只是取消开机自启,其他服务的依赖关系仍可能把它拉起来;mask 把它软链到 /dev/null,任何方式都无法启动。用于彻底禁用某个碍事的系统服务。
改了 unit 文件后必须 daemon-reload:
systemctl daemon-reload # 重新读取所有 unit 文件
# 忘了这一步的表现:修改完全不生效,status 显示的还是旧配置
# systemd 会在 status 里提示:Warning: The unit file has changed on disk
5. journalctl:查日志
systemd 用 journald 统一收集所有服务的 stdout/stderr 和 syslog,带结构化字段。
# 看某个服务的日志
journalctl -u myapp
journalctl -u myapp -f # 跟随(相当于 tail -f)
journalctl -u myapp -n 100 # 最后 100 行
journalctl -u myapp --no-pager # 不分页,脚本里用
# 按时间
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp --since "2026-08-06 14:00" --until "2026-08-06 15:00"
journalctl -u myapp --since today
# 按优先级(0=emerg 到 7=debug)
journalctl -p err # 只看 error 及以上
journalctl -p warning..err
# 按启动次数
journalctl -b # 本次启动
journalctl -b -1 # 上一次启动 <- 排查重启原因
journalctl --list-boots
# 内核日志
journalctl -k
journalctl -k -b -1 | grep -i oom # 上次启动的 OOM 记录(第 10 篇)
# 结构化查询(journald 的独门功夫)
journalctl _PID=1234
journalctl _UID=1000
journalctl _COMM=sshd
journalctl _SYSTEMD_UNIT=myapp.service
# 输出格式
journalctl -u myapp -o json-pretty # JSON,便于用 jq 处理
journalctl -u myapp -o cat # 只有消息正文,不带时间戳前缀
journalctl -b -1 是排查「服务莫名重启」的第一条命令——它能看到上一次启动的完整日志,包括崩溃前的最后几行。
5.1 日志持久化
默认配置下 journald 的日志可能只存在内存里,重启就丢:
# 检查
journalctl --disk-usage
# Archived and active journals take up 1.2G in the file system.
# 如果显示 "in the runtime",说明只在内存里(/run/log/journal)
# 开启持久化
mkdir -p /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
systemctl restart systemd-journald
# /etc/systemd/journald.conf
[Journal]
Storage=persistent # 持久化到 /var/log/journal
SystemMaxUse=2G # 总大小上限
SystemMaxFileSize=200M # 单文件上限
MaxRetentionSec=2week # 保留时长
ForwardToSyslog=no # 关掉转发到 syslog,避免日志写两份
RateLimitIntervalSec=30s
RateLimitBurst=10000 # 限流,防止日志风暴打满磁盘
ForwardToSyslog=no 值得关注——默认开启时,同一条日志会同时写进 journal 和 /var/log/syslog,磁盘 IO 和空间都翻倍。如果不用 rsyslog 就关掉它。
# 手动清理
journalctl --vacuum-size=500M
journalctl --vacuum-time=7d
6. systemd-analyze:定位慢启动
# 总耗时
systemd-analyze
# Startup finished in 3.821s (kernel) + 12.453s (userspace) = 16.274s
# ^^^^^^^ 用户态花了 12 秒,偏慢
# 按耗时排序看哪些服务慢
systemd-analyze blame | head -10
# 8.234s NetworkManager-wait-online.service <- 罪魁祸首
# 2.103s docker.service
# 1.234s containerd.service
# 关键路径(真正决定总时长的依赖链)
systemd-analyze critical-chain
# multi-user.target @12.453s
# +-docker.service @10.350s +2.103s
# +-network-online.target @10.348s
# +-NetworkManager-wait-online.service @2.114s +8.234s
# ^^^^^^ 等网络等了 8 秒
# 生成可视化图
systemd-analyze plot > boot.svg
blame 和 critical-chain 要一起看:blame 列出所有慢服务,但其中很多是并行启动的,不影响总时长;critical-chain 才是真正的瓶颈路径。
NetworkManager-wait-online 是最常见的启动慢因——它会等所有网卡都拿到 IP。如果机器上有未连接的网卡(比如第二块网卡没插线),它会一直等到超时。
# 如果不需要等网络完全就绪,可以禁用
systemctl disable NetworkManager-wait-online.service
# 但要确认没有服务依赖 network-online.target
7. 实战:把一个 Go 服务纳入 systemd 管理
场景:一个用 nohup ./myapp & 跑着的 Go 服务,需要改成 systemd 管理,要求:崩溃自动重启、优雅关闭、日志可查、资源受限、非 root 运行。
① 创建专用用户和目录(第 3 篇的最小权限原则)
useradd --system --no-create-home --shell /usr/sbin/nologin myapp
install -d -o myapp -g myapp -m 0750 /var/log/myapp
install -d -o myapp -g myapp -m 0750 /var/lib/myapp
install -d -o root -g myapp -m 0750 /etc/myapp
# 配置文件含密钥,只给读权限
install -o root -g myapp -m 0640 config.yaml /etc/myapp/config.yaml
② 应用侧实现 sd_notify 和优雅关闭
func main() {
cfg := loadConfig()
db := mustConnectDB(cfg)
srv := &http.Server{Addr: cfg.Addr, Handler: buildRouter(db)}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
// 初始化全部完成后才通知 systemd 就绪
warmupCache()
daemon.SdNotify(false, daemon.SdNotifyReady)
log.Println("service ready")
// watchdog 心跳
go func() {
iv, err := daemon.SdWatchdogEnabled(false)
if err != nil || iv == 0 { return }
t := time.NewTicker(iv / 2)
defer t.Stop()
for range t.C {
if db.PingContext(context.Background()) == nil {
daemon.SdNotify(false, daemon.SdNotifyWatchdog)
}
}
}()
// 优雅关闭(第 8 篇)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
daemon.SdNotify(false, daemon.SdNotifyStopping)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = db.Close()
}
③ 写 unit 文件
# /etc/systemd/system/myapp.service
[Unit]
Description=My Go API Service
After=network-online.target postgresql.service
Wants=network-online.target
StartLimitIntervalSec=30
StartLimitBurst=5
[Service]
Type=notify
NotifyAccess=main
WatchdogSec=30
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp/current
ExecStart=/opt/myapp/current/bin/myapp --config=/etc/myapp/config.yaml
Environment="GOMEMLIMIT=3500MiB"
EnvironmentFile=-/etc/myapp/env
Restart=always
RestartSec=3
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=45
LimitNOFILE=65535
MemoryMax=4G
CPUQuota=400%
UMask=0027
OOMScoreAdjust=-500
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/myapp /var/lib/myapp
ProtectKernelTunables=true
RestrictSUIDSGID=true
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
④ 启用并逐项验证
systemctl daemon-reload
systemctl enable --now myapp
# 验证 ① 服务真的 ready 了(不是 simple 的假就绪)
systemctl show -p ActiveState,SubState myapp
# ActiveState=active
# SubState=running <- notify 型服务收到 READY=1 才会是 running
# 验证 ② 资源限制生效(第 11 篇讲的:只信 /proc/<pid>/limits)
MAINPID=$(systemctl show -p MainPID --value myapp)
grep -i "open files" /proc/$MAINPID/limits
# Max open files 65535 65535 files ✅
# 验证 ③ 内存限制(cgroup)
cat /sys/fs/cgroup/system.slice/myapp.service/memory.max
# 4294967296 ✅ 4GB
# 验证 ④ 以非 root 运行
ps -o user= -p $MAINPID
# myapp ✅
# 验证 ⑤ 优雅关闭:观察停止耗时和退出码
time systemctl stop myapp
# real 0m2.134s <- 远小于 TimeoutStopSec,说明是主动退出的
systemctl show -p ExecMainStatus,ExecMainCode myapp
# ExecMainStatus=0 <- 退出码 0 = 优雅退出 ✅
# ExecMainCode=1 (1=exited 正常退出;2=killed 被信号杀)
# 如果 ExecMainStatus=143(128+15)说明没捕获 SIGTERM
# 如果耗时正好等于 TimeoutStopSec 说明优雅关闭超时了,被 SIGKILL 打断
# 验证 ⑥ 崩溃自动重启
kill -9 $MAINPID
sleep 5
systemctl is-active myapp
# active ✅ 已自动拉起
journalctl -u myapp -n 5 | grep -i "scheduled restart"
# 验证 ⑦ 安全加固评分
systemd-analyze security myapp.service | tail -3
# -> Overall exposure level for myapp.service: 2.1 OK 🙂
⑤ 清理旧的 nohup 方式
# 确认没有遗留的进程在跑(否则会有两个实例抢端口)
pgrep -af myapp
# 只应该看到 systemd 管的那一个
# 清掉 crontab 里的 @reboot 启动项
crontab -l | grep -v myapp | crontab -
结果对比:
| 维度 | nohup 方式 |
systemd |
|---|---|---|
| 崩溃恢复 | 手动 | 自动重启 + 频率限制 |
| 无响应检测 | 无 | WatchdogSec |
| 日志 | 一个不断增长的文件 | journald,可按时间/级别查询,自动轮转 |
| 资源限制 | 靠 ulimit(易失效) | cgroup,精确且可验证 |
| 优雅关闭 | 手动 kill,超时靠自己 | KillSignal + TimeoutStopSec |
| 权限 | 常常是 root | 专用用户 + 加固选项 |
| 启动顺序 | 无保障 | 依赖声明 |
规律:systemd unit 写完必须逐项验证「配置真的生效了」——尤其是 LimitNOFILE(看 /proc/<pid>/limits)和优雅关闭(看退出码是 0 还是 143/137)。写了不等于生效。
8. 面试题
Q:Linux 的启动流程是什么?为什么需要 initramfs?
五个阶段:① 固件(BIOS 读 MBR / UEFI 读 ESP 分区的 .efi);② GRUB2 加载内核和 initramfs 到内存,传递启动参数;③ 内核 解压自身、初始化内存管理和调度器,挂载 initramfs 作为临时根;④ initramfs 加载访问真正根分区所需的驱动(磁盘控制器、LVM、加密卷),挂载真正的根分区,switch_root 后执行 /sbin/init;⑤ systemd 读 default.target,按依赖并行启动所有 unit。需要 initramfs 是因为鸡生蛋问题:内核要挂载根分区就需要磁盘驱动,但驱动模块存在 /lib/modules 里,而那个目录在根分区上。所以先挂一个包含必要驱动的内存文件系统。这也是「改了 LVM 或加密卷配置后不重建 initramfs 会导致系统起不来」的原因。
Q:systemd 相比 SysV init 最本质的改进是什么?
用 cgroup 精确跟踪服务。SysV 时代判断服务是否运行靠读 pid 文件——进程被 kill -9 后 pid 文件残留、或 pid 被别的进程复用,都会误判。systemd 把服务的所有进程(包括它 fork 出的子孙)放进一个 cgroup,是否运行、占用多少资源、停止时该杀哪些进程,全部准确。其他改进:并行启动(按依赖自动排序,不是串行跑脚本)、声明式配置(十几行替代几百行 shell)、内建 Restart=(不需要 supervisor)、journald 统一收集结构化日志、原生集成 cgroup 做资源限制。副产品:传统 daemon 的「fork 后父进程退出」在 systemd 下不再需要,反而有害——会让 systemd 追踪不到主进程。
Q:Type=simple 和 Type=notify、Type=forking 有什么区别?配错会怎样?
simple(默认):ExecStart 拉起的进程就是主进程,systemd 一 fork 出来就认为启动完成——此时服务可能还在加载配置、连数据库,依赖它的服务会过早启动。notify:服务通过 sd_notify() 主动上报 READY=1,systemd 才认为就绪,最精确,还能配 WatchdogSec 做心跳检测(发现「进程活着但死锁」这种 Restart=always 抓不到的情况)。forking:期待进程 fork 后父进程退出,子进程才是主进程——配错是「启动卡住 90 秒后超时失败」的最常见原因:现代程序基本不 fork,配了 forking 后 systemd 会一直等父进程退出直到 TimeoutStartSec 超时。不确定就用 simple,程序自己不要 fork。
Q:After= 和 Requires= 有什么区别?
两个完全独立的维度。After/Before 管顺序(先启动谁),Requires/Wants 管依赖(要不要把另一个 unit 也启动)。只写 Requires=postgresql.service 不写 After,postgresql 会被同时启动但不保证先就绪,你的服务可能在 pg 没准备好时就连接失败——两个都要写。Requires 和 Wants 的区别:Requires 是强依赖(依赖启动失败则本服务也失败,依赖被停止则本服务也被停止),Wants 更宽松。一般推荐 Wants,因为 Requires 下依赖服务的一次抖动会连带把你的服务停掉,级联失败风险高。
Q:为什么服务启动时报「网络不可用」,明明写了 After=network.target?
因为 network.target 只表示「网络栈已初始化」,不代表 IP 已配好、路由已就绪。要等网络真正可用应该用 network-online.target。但关键是必须同时写 Wants=network-online.target——这个 target 默认不在任何 target 的依赖链里,不主动拉起就永远不会激活,只写 After 形同虚设:
After=network-online.target
Wants=network-online.target
副作用:network-online.target 依赖 NetworkManager-wait-online.service,后者会等所有网卡拿到 IP。如果机器上有未插线的网卡,它会一直等到超时——这是 systemd-analyze blame 里最常见的启动慢因。
Q:改了 /etc/security/limits.conf 但服务的 fd 限制没生效,为什么?systemd 下该怎么配?
limits.conf 是 PAM 机制(pam_limits.so),只在用户登录时应用。systemd 启动服务不经过 PAM,完全不读这个文件。要在 unit 里写 LimitNOFILE=65535,或在 /etc/systemd/system.conf 里设 DefaultLimitNOFILE。唯一可信的验证方式是 cat /proc/<pid>/limits——ulimit -n 只反映你当前登录 shell 的限制,与服务进程无关。这也是第 11 篇强调的:写了配置不等于生效,必须验证。
Q:Restart=always 为什么还要配 StartLimitBurst?
防止重启风暴。如果配置文件写错导致服务一启动就崩,Restart=always + RestartSec=1 会让 systemd 每秒重启一次,永无止境——日志疯狂增长打满磁盘、CPU 被无意义消耗,而且问题被掩盖了(服务看起来「一直在尝试」)。配上 StartLimitIntervalSec=10 + StartLimitBurst=5 后,10 秒内重启超过 5 次就彻底放弃、进入 failed 状态,这样你能立刻通过 systemctl --failed 发现问题。注意:触发限制后需要 systemctl reset-failed 才能再次手动启动。
Q:KillMode 的 mixed 和 control-group 有什么区别?
control-group(默认)给 cgroup 内所有进程同时发 SIGTERM;mixed 只给主进程发 SIGTERM,超时后才给整个 cgroup 发 SIGKILL。mixed 通常更好,因为它让主进程能按自己的逻辑有序关闭子进程——比如 nginx master 收到 TERM 后会通知 worker 优雅退出(先停止 accept、处理完存量请求)。而 control-group 同时给所有进程发信号,可能打乱应用自己设计的关闭顺序。另外 TimeoutStopSec 必须大于应用优雅关闭所需的时间,否则优雅关闭会被 SIGKILL 打断——验证方法是看 ExecMainStatus:0 是优雅退出,143(128+15)说明没捕获 SIGTERM,137(128+9)说明被 SIGKILL 强杀。
Q:systemd-analyze blame 显示某服务耗时 8 秒,是不是就是它拖慢了启动?
不一定。blame 列出的是所有服务各自的耗时,但很多服务是并行启动的——一个耗时 8 秒但与其他服务并行的 unit,可能完全不影响总时长。真正决定总时长的是关键路径,要用 systemd-analyze critical-chain 看:它展示的是「A 必须等 B,B 必须等 C」这样的串行依赖链,链上的耗时才是真瓶颈。两个命令要一起看:blame 找候选,critical-chain 确认是否在关键路径上。实践中最常见的真瓶颈是 NetworkManager-wait-online.service——它等所有网卡拿到 IP,有未插线的网卡时会一直等到超时。
上一篇:Linux-13 网络配置与排查 | 下一篇:Linux-15 定时任务、日志与包管理
xingliuhua