目录

Linux-14 启动流程与 systemd

前置阅读:Linux-06 进程管理与作业控制Linux-08 信号与进程间通信

服务怎么开机自启、怎么崩溃后自动拉起、日志去哪了、资源怎么限制——这些在容器时代之前是运维的事,但现在每个后端工程师都要会写 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 3init 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 之外的任何退出

长期运行的服务用 alwayson-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

maskdisable 的区别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

blamecritical-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;⑤ systemddefault.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=simpleType=notifyType=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 没准备好时就连接失败——两个都要写RequiresWants 的区别: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.confPAM 机制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:KillModemixedcontrol-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 定时任务、日志与包管理