用 systemd 从零做一个系统健康巡检服务:脚本、沙箱与磁盘满排查实战
这是我运维学习第 2 周的产出:不写玩具 demo,做一个每天自动跑、真能发现问题的巡检服务。文章会讲清楚三件事------为什么这么设计、脚本怎么写、怎么验证它真的有用。
一、为什么做这个项目
学 systemd 的时候,很多人停留在"会 systemctl start/stop 就行"。但运维面试和实际工作里,真正被问的是:
- 你会不会写 unit 文件?
- 你知道
Type=oneshot和Type=simple的区别吗? - 定时任务为什么有时用 timer 而不是 cron?
- 服务以 root 跑,你怎么降低风险?
所以我给自己定了一个有实际作用的产出:一个每日系统健康巡检服务 。它每天定时采集负载、内存、磁盘、inode、大文件、已删除未释放文件、关键服务状态,把结果写进 journald,异常项带 WARN / CRIT 标记。
这件事的价值在于:它不是"为了练 systemd 而 systemd",而是模拟了运维日常里非常真实的一个需求------定时巡检 + 统一日志 + 异常可追溯。
二、整体设计
先看架构,三个文件各司其职:
text
bash
/usr/local/bin/health-check.sh 巡检脚本,负责"查什么"
/etc/systemd/system/health-check.service 定义"怎么跑",带安全加固
/etc/systemd/system/health-check.timer 定义"什么时候跑"
执行链路:
text
markdown
timer(每天 08:00)
└── 触发 service(oneshot)
└── 执行脚本
└── 输出到 journald
设计上有几个关键决策,后面会逐一解释:
| 决策 | 选择 | 原因 |
|---|---|---|
| 调度方式 | systemd timer | 日志统一进 journald,支持关机补跑 |
| 服务类型 | Type=oneshot |
脚本跑完就退出,不需要常驻 |
| 运行身份 | root + 收紧 capability | lsof 跨进程需要特权,但只保留最小能力 |
| 日志去向 | stdout → journald | 不自己写日志文件,避免日志散落和轮转问题 |
| 退出码 | 0/1/2 对应 OK/WARN/CRIT | 方便后续接监控告警 |
三、巡检脚本:查什么,怎么查
完整脚本放在文末附录,这里讲设计思路。
3.1 检查项分两层
基础层(每次必查,开销小):
- 负载:读
/proc/loadavg - 内存:读
/proc/meminfo的MemTotal和MemAvailable - 分区使用率:
df -PT - inode 使用率:
df -iPT - 关键服务状态:
systemctl is-active - 失败单元:
systemctl list-units --state=failed
深挖层(只在分区超阈值时触发,开销大):
du下钻 Top 目录- 大文件 Top N
/var/log大日志lsof +L1已删除未释放
这个分层很重要。定时任务如果每次都全盘 du 和 find /,在几千台机器上就是灾难。正常时不深挖,异常时才深挖,这是运维脚本的基本素养。
3.2 退出码有语义
bash
bash
if (( criticals > 0 )); then
exit 2
elif (( warnings > 0 )); then
exit 1
else
exit 0
fi
这样 systemctl status 会显示 failed,ExecMainStatus 能拿到 1 或 2,后续接 Prometheus、Zabbix 或自建告警时,直接看退出码就行,不用解析日志文本。
3.3 为什么输出到 stdout 而不是写文件
很多教程会让你把巡检结果写到 /var/log/health-check.log。我不推荐,原因:
- 日志文件需要自己处理轮转,否则巡检脚本自己成了磁盘满的元凶。
- journald 自带轮转、压缩、按时间查询。
journalctl -u health-check.service --since today一条命令就能看,不用记日志路径。
所以脚本只 printf 到 stdout,由 systemd 接管。
四、磁盘满排查链路:这是重点
磁盘满排查是运维高频场景,也是最容易踩坑的地方。完整套路是五步:
text
markdown
1. df -h 哪个分区满
2. df -i 是不是 inode 满
3. du 下钻 满的分区里哪个目录大
4. find 大文件 哪个单文件大
5. lsof +L1 有没有已删除但没释放的文件
第 5 步是最经典的坑。你 rm 了一个 10G 日志,df 还是满的,因为写日志的进程还持有文件句柄。只有重启进程,或者 : > /proc/PID/fd/N 清空,空间才会真正释放。
脚本把这五步都实现了,下面挑几个关键点解释。
4.1 不只检查 /,检查所有本地分区
bash
bash
df -PT | awk -v re="$EXCLUDE_FSTYPES" 'NR>1 && $2 !~ re { ... }'
df -P:POSIX 格式,保证一行一个文件系统,长设备名不换行。df -T:显示文件系统类型,用来排除tmpfs、overlay、proc等伪文件系统。- 生产机器常有
/data、/var单独分区,只盯/会漏。
4.2 inode 必须单独检查
bash
bash
df -iPT
inode 满的典型现象:df -h 看还有空间,但新建文件报 No space left on device。常见原因是邮件队列、缓存目录、session 文件堆了海量小文件。所以 inode 和磁盘使用率是两条独立的告警线。
4.3 du 下钻只在超阈值时跑
bash
bash
du -x --max-depth=1 "$mnt" | sort -rn | head -n 8
-x:不跨文件系统。从/下钻时不会统计到挂载在/data的另一块盘。--max-depth=1:只看第一层,脚本只给 Top 目录,逐层下钻是人工后续动作。- 性能关键 :只有
df超 80% 的分区才触发du,正常机器上这个分支根本不执行。
4.4 大文件扫描限定目录 + -xdev
bash
bash
find "$dir" -xdev -type f -size +500M -printf '%s\t%p\n'
-xdev:不跨文件系统,避免从/var扫到别处。-printf '%s\t%p\n':输出字节数 + 路径,方便按大小排序。- 只扫
/var /tmp /home /data /opt,不扫/,避免全盘 IO。
4.5 已删除未释放:lsof +L1
bash
swift
lsof +L1 | awk '$7 ~ /^[0-9]+$/ {print $7"\t"$1"\t"$2"\t"$9}'
+L1:显示 link count 小于 1 的文件,也就是已删除但仍有进程持有 fd 的文件。- 输出大小、命令、PID、原路径。
- 发现后处理:重启对应进程,或
: > /proc/PID/fd/N清空(谨慎,可能影响进程行为)。
五、service 单元:安全加固的权衡
先看完整配置:
ini
ini
[Unit]
Description=System Health Check & Report
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/health-check.sh
User=root
Group=root
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH
5.1 为什么用 root,而不是 nobody
第一版我用了 User=nobody,看起来更安全。但测试时发现 lsof +L1 看不到其他进程的文件句柄------因为读取 /proc/PID/fd 需要 CAP_SYS_PTRACE,读取任意文件需要 CAP_DAC_READ_SEARCH,nobody 都没有。
巡检的本质是只读诊断,它需要看到全系统的信息。所以正确做法不是降权到 nobody,而是:
用 root 身份,但丢掉除诊断之外的所有能力,同时把文件系统设为只读。
这就是 CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 的含义:只保留两个能力,其余全部丢弃。即使脚本被篡改,攻击者也只能读,不能写文件系统,不能加载内核模块,不能改内核参数。
5.2 关键加固项解释
| 配置 | 作用 |
|---|---|
ProtectSystem=strict |
整个文件系统只读,root 也无法写。这是最重要的边界 |
ProtectHome=read-only |
/home 只读可见。不能用 yes,因为脚本要扫 /home 大文件 |
NoNewPrivileges=yes |
禁止通过 SUID 等方式提权 |
ProtectKernelTunables=yes |
/proc/sys、/sys 只读,防止改内核参数 |
ProtectKernelModules=yes |
禁止加载内核模块 |
RestrictAddressFamilies |
只允许 Unix socket 和 IPv4/IPv6,巡检不需要其他协议 |
这个组合的效果:有 root 身份读系统信息,但没有写文件系统的能力,也没有多余特权。 对只读巡检脚本来说,这是安全性和功能性的合理平衡。
5.3 一个容易忽略的坑:不要开 PrivateTmp
第一版我加了 PrivateTmp=yes,结果扫描 /tmp 大文件时永远是 (none)。原因是 PrivateTmp 给服务分配了独立的 /tmp 命名空间,服务看到的不是宿主机 /tmp。
所以这里有意不启用 PrivateTmp,并在文档里注明。这是个很典型的"安全配置和功能需求冲突"的例子,需要根据实际场景权衡。
六、timer 单元:为什么不用 cron
ini
ini
[Unit]
Description=Daily Health Check Timer
[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true
RandomizedDelaySec=120
AccuracySec=1min
[Install]
WantedBy=timers.target
6.1 timer 相对 cron 的优势
| 对比项 | cron | systemd timer |
|---|---|---|
| 日志 | 需要自己重定向到文件 | 自动进 journald,journalctl -u 查看 |
| 关机补跑 | 不支持 | Persistent=true 支持 |
| 随机延迟 | 需要自己写脚本实现 | RandomizedDelaySec 一行配置 |
| 依赖管理 | 弱 | 可以 After=、Requires= |
| 查看下次执行 | crontab -l 看不出 |
systemctl list-timers 一目了然 |
| 安全加固 | 无 | 支持完整的沙箱配置 |
所以新项目我优先用 timer。cron 仍然适合简单场景,但 timer 在可观测性和安全性上明显更好。
6.2 几个参数的含义
OnCalendar=*-*-* 08:00:00:每天 08:00 触发。格式可以用systemd-analyze calendar验证。Persistent=true:如果 08:00 时机器关机,开机后补跑一次。这是 timer 相对 cron 最实用的优势之一。RandomizedDelaySec=120:在 08:00 后随机延迟最多 120 秒。多台机器同时触发时,避免对共享存储或监控系统造成瞬时压力。AccuracySec=1min:允许 1 分钟精度,systemd 可以合并唤醒以省电。
验证时间表达式:
bash
arduino
systemd-analyze calendar "*-*-* 08:00:00"
七、部署与验证
7.1 部署
bash
bash
# 1. 创建脚本并赋权
sudo chmod 755 /usr/local/bin/health-check.sh
# 2. 重载 systemd
sudo systemctl daemon-reload
# 3. 启用并启动 timer
sudo systemctl enable --now health-check.timer
# 4. 确认 timer 已生效
systemctl list-timers --all | grep health-check
7.2 手动触发并查看报告
bash
css
sudo systemctl start health-check.service
journalctl -u health-check.service --no-pager --since today
正常机器上的输出大概是这样:
text
erlang
--- Disk Usage (per filesystem) ---
[OK] Disk / (xfs) used: 42%
[OK] Disk /boot (xfs) used: 18%
--- Inode Usage (per filesystem) ---
[OK] Inode / (xfs) used: 6%
--- Top 5 Files > 500MB (scanned dirs) ---
(none)
--- Deleted Files Still Held Open (Top 5) ---
(none)
7.3 三个模拟测试
光看到"全部 OK"不够,要验证告警真的能触发。
测试 1:大文件告警
bash
bash
sudo dd if=/dev/zero of=/var/tmp/test-big.img bs=1M count=600 status=none
sudo systemctl start health-check.service
journalctl -u health-check.service --no-pager --since "1 min ago" | grep -A6 "Top 5 Files"
sudo rm -f /var/tmp/test-big.img
应看到 /var/tmp/test-big.img 600M 出现在 Top Files 段。
测试 2:已删除未释放
bash
javascript
sudo dd if=/dev/zero of=/var/tmp/deleted-test.img bs=1M count=200 status=none
sudo bash -c 'exec 3>/var/tmp/deleted-test.img; rm -f /var/tmp/deleted-test.img; sleep 300' &
sudo systemctl start health-check.service
journalctl -u health-check.service --no-pager --since "1 min ago" | grep -A6 "Deleted Files"
sudo pkill -f "sleep 300"
应看到 deleted-test.img 出现在 Deleted Files 段,大小约 200M。这个测试很有价值,因为它验证了脚本能发现"rm 了但空间没释放"的经典问题。
测试 3:阈值告警和退出码
bash
lua
sudo sed -i 's/^DISK_WARN=80/DISK_WARN=1/' /usr/local/bin/health-check.sh
sudo systemctl start health-check.service
systemctl status health-check.service
sudo sed -i 's/^DISK_WARN=1/DISK_WARN=80/' /usr/local/bin/health-check.sh
应看到 [WARN] Disk / used: ...,且 service 状态为 failed (Result: exit-code),退出码为 1。
7.4 验证沙箱配置生效
bash
css
systemctl show health-check.service \
-p ProtectSystem \
-p ProtectHome \
-p CapabilityBoundingSet \
-p NoNewPrivileges
应看到 ProtectSystem=strict、CapabilityBoundingSet=cap_sys_ptrace cap_dac_read_search 等。
八、踩坑记录
把过程中遇到的几个问题记下来,都是真实会遇到的:
坑 1:User=nobody 导致 lsof +L1 数据缺失
现象:巡检报告里 Deleted Files 永远是 (none),但手动 lsof +L1 能看到文件。
原因:nobody 没有 CAP_SYS_PTRACE,读不了其他进程的 /proc/PID/fd。
解决:改用 root + CapabilityBoundingSet 收紧能力。
坑 2:PrivateTmp=yes 导致 /tmp 扫描不到
现象:在 /tmp 造了大文件,巡检报告里看不到。
原因:私有 /tmp 命名空间,服务看到的不是宿主机 /tmp。
解决:去掉 PrivateTmp=yes,并在文档里注明原因。
坑 3:df 输出换行导致解析错位
现象:某些长设备名在 df -h 输出里换行,导致 awk 字段错位。
原因:默认 df 输出格式对长设备名不友好。
解决:用 df -P(POSIX 格式),保证一行一个文件系统。
坑 4:du 跨文件系统统计
现象:对 / 做 du 时,把 /data 的数据也算进去了。
原因:du 默认跨文件系统。
解决:加 -x 参数。
九、总结与收获
这个项目做完,我对 systemd 的理解从"会用命令"变成了"知道怎么设计一个生产可用的服务"。几个核心收获:
Type=oneshot适合一次性任务,巡检、备份、清理这类脚本不需要常驻。- timer 比 cron 更适合新项目,日志统一、支持补跑、可观测性好。
- 安全加固不是一味降权。巡检需要读全系统信息,正确做法是 root + 最小 capability + 只读文件系统,而不是降权到 nobody 导致功能缺失。
- 磁盘满排查是有套路的,df → df -i → du → find → lsof +L1,顺序不能乱。
- 脚本要分层设计,正常时轻量检查,异常时才深挖,避免定时任务拖慢系统。
- 验证比实现更重要。造大文件、造删除未释放、改阈值,三个测试跑通,才算真的能用。
如果后面要接监控,最简单的做法是让 Prometheus 的 textfile collector 读退出码,或者在脚本里加一段把 WARN/CRIT 计数推给告警系统。这个项目本身已经为后续扩展留好了接口。
附录:完整脚本
bash
bash
#!/usr/bin/env bash
set -uo pipefail
# ===== 阈值 =====
DISK_WARN=80
DISK_CRIT=90
INODE_WARN=80
INODE_CRIT=90
MEM_WARN=80
MEM_CRIT=90
LOAD_WARN=4.0
LOAD_CRIT=8.0
# ===== 磁盘满排查参数 =====
BIGFILE_SIZE_MB=500
BIGFILE_TOPN=5
DU_TOPN=8
LOG_BIGFILE_SIZE_MB=50
DELETED_TOPN=5
BIGFILE_SCAN_DIRS="/var /tmp /home /data /opt"
EXCLUDE_FSTYPES="tmpfs|devtmpfs|proc|sysfs|cgroup|cgroup2|overlay|squashfs|nsfs|ramfs"
warnings=0
criticals=0
report=""
add_line() { report+="$1"$'\n'; }
mark() {
local level="$1" msg="$2"
if [[ "$level" == "CRIT" ]]; then
criticals=$((criticals + 1))
elif [[ "$level" == "WARN" ]]; then
warnings=$((warnings + 1))
fi
add_line "[$level] $msg"
}
human() {
numfmt --to=iec "$1" 2>/dev/null || echo "$1"
}
add_line "========== Health Check $(date '+%Y-%m-%d %H:%M:%S') =========="
add_line "Hostname: $(hostname)"
add_line "Uptime: $(uptime -p 2>/dev/null || uptime)"
add_line ""
# ---------- 1. 负载 ----------
load1=$(awk '{print $1}' /proc/loadavg)
if (( $(echo "$load1 >= $LOAD_CRIT" | bc -l) )); then
mark CRIT "Load(1m): ${load1} (threshold ${LOAD_CRIT})"
elif (( $(echo "$load1 >= $LOAD_WARN" | bc -l) )); then
mark WARN "Load(1m): ${load1} (threshold ${LOAD_WARN})"
else
add_line "[OK] Load(1m): ${load1}"
fi
# ---------- 2. 内存 ----------
mem_total=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
mem_avail=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
mem_used_pct=$(awk -v t="$mem_total" -v a="$mem_avail" 'BEGIN {printf "%.1f", (t-a)/t*100}')
if (( $(echo "$mem_used_pct >= $MEM_CRIT" | bc -l) )); then
mark CRIT "Memory used: ${mem_used_pct}% (threshold ${MEM_CRIT}%)"
elif (( $(echo "$mem_used_pct >= $MEM_WARN" | bc -l) )); then
mark WARN "Memory used: ${mem_used_pct}% (threshold ${MEM_WARN}%)"
else
add_line "[OK] Memory used: ${mem_used_pct}%"
fi
# ---------- 3. 所有本地分区使用率 ----------
add_line ""
add_line "--- Disk Usage (per filesystem) ---"
full_mounts=()
while read -r fs type size used avail pcent mount; do
[[ -z "${pcent:-}" ]] && continue
if (( pcent >= DISK_CRIT )); then
mark CRIT "Disk ${mount} (${type}) used: ${pcent}% (threshold ${DISK_CRIT}%)"
full_mounts+=("$mount")
elif (( pcent >= DISK_WARN )); then
mark WARN "Disk ${mount} (${type}) used: ${pcent}% (threshold ${DISK_WARN}%)"
full_mounts+=("$mount")
else
add_line "[OK] Disk ${mount} (${type}) used: ${pcent}%"
fi
done < <(df -PT 2>/dev/null | awk -v re="$EXCLUDE_FSTYPES" '
NR>1 && $2 !~ re {
gsub(/%/, "", $6);
print $1, $2, $3, $4, $5, $6, $7
}')
# ---------- 4. inode 使用率 ----------
add_line ""
add_line "--- Inode Usage (per filesystem) ---"
while read -r fs type inodes iused ifree ipcent mount; do
[[ -z "${ipcent:-}" ]] && continue
if (( ipcent >= INODE_CRIT )); then
mark CRIT "Inode ${mount} (${type}) used: ${ipcent}% (threshold ${INODE_CRIT}%)"
elif (( ipcent >= INODE_WARN )); then
mark WARN "Inode ${mount} (${type}) used: ${ipcent}% (threshold ${INODE_WARN}%)"
else
add_line "[OK] Inode ${mount} (${type}) used: ${ipcent}%"
fi
done < <(df -iPT 2>/dev/null | awk -v re="$EXCLUDE_FSTYPES" '
NR>1 && $2 !~ re {
gsub(/%/, "", $6);
print $1, $2, $3, $4, $5, $6, $7
}')
# ---------- 5. 超阈值分区 du 下钻 ----------
if (( ${#full_mounts[@]} > 0 )); then
add_line ""
add_line "--- Top Directories on Full Filesystems ---"
for mnt in "${full_mounts[@]}"; do
add_line " [$mnt]"
while read -r kbytes path; do
add_line " $(human $((kbytes * 1024))) $path"
done < <(du -x --max-depth=1 "$mnt" 2>/dev/null | sort -rn | head -n "$DU_TOPN")
done
fi
# ---------- 6. 大文件 Top N ----------
add_line ""
add_line "--- Top ${BIGFILE_TOPN} Files > ${BIGFILE_SIZE_MB}MB (scanned dirs) ---"
bigfile_found=0
while IFS=$'\t' read -r size path; do
[[ -z "${path:-}" ]] && continue
bigfile_found=1
add_line " $(human "$size") $path"
done < <(
for dir in $BIGFILE_SCAN_DIRS; do
[[ -d "$dir" ]] || continue
find "$dir" -xdev -type f -size +${BIGFILE_SIZE_MB}M \
-printf '%s\t%p\n' 2>/dev/null
done | sort -rn | head -n "$BIGFILE_TOPN"
)
(( bigfile_found == 0 )) && add_line " (none)"
# ---------- 7. /var/log 大日志 Top ----------
add_line ""
add_line "--- Top 5 Log Files > ${LOG_BIGFILE_SIZE_MB}MB in /var/log ---"
log_found=0
while IFS=$'\t' read -r size path; do
[[ -z "${path:-}" ]] && continue
log_found=1
add_line " $(human "$size") $path"
done < <(
find /var/log -xdev -type f -size +${LOG_BIGFILE_SIZE_MB}M \
-printf '%s\t%p\n' 2>/dev/null | sort -rn | head -n 5
)
(( log_found == 0 )) && add_line " (none)"
# ---------- 8. 已删除但未释放的文件 ----------
add_line ""
add_line "--- Deleted Files Still Held Open (Top ${DELETED_TOPN}) ---"
deleted_found=0
while IFS=$'\t' read -r size comm pid name; do
[[ -z "${name:-}" ]] && continue
deleted_found=1
add_line " $(human "$size") PID=$pid $comm $name"
done < <(
lsof +L1 2>/dev/null \
| awk 'NR>1 && $7 ~ /^[0-9]+$/ {
name=$9;
for (i=10; i<=NF; i++) name=name" "$i;
print $7"\t"$1"\t"$2"\t"name
}' \
| sort -rn | head -n "$DELETED_TOPN"
)
(( deleted_found == 0 )) && add_line " (none)"
# ---------- 9. 关键服务 ----------
add_line ""
add_line "--- Services ---"
SERVICES_TO_CHECK="sshd chronyd"
for svc in $SERVICES_TO_CHECK; do
if ! systemctl list-unit-files --type=service 2>/dev/null \
| awk '{print $1}' | grep -qx "${svc}.service"; then
add_line "[SKIP] Service ${svc} not installed"
continue
fi
state=$(systemctl is-active "$svc" 2>/dev/null || true)
if [[ "$state" == "active" ]]; then
add_line "[OK] Service ${svc}: active"
else
mark CRIT "Service ${svc}: ${state:-unknown}"
fi
done
# ---------- 10. 失败单元 ----------
add_line ""
add_line "--- Failed systemd Units ---"
failed_units=$(systemctl list-units --state=failed --no-legend --no-pager 2>/dev/null | awk '{print $1}')
if [[ -n "$failed_units" ]]; then
mark CRIT "Failed units detected:"
while IFS= read -r unit; do
[[ -n "$unit" ]] && add_line " - $unit"
done <<< "$failed_units"
else
add_line "[OK] No failed systemd units"
fi
# ---------- 汇总 ----------
add_line ""
add_line "========== Summary: WARN=${warnings}, CRIT=${criticals} =========="
printf '%s\n' "$report"
if (( criticals > 0 )); then
exit 2
elif (( warnings > 0 )); then
exit 1
else
exit 0
fi