知识广场
按学科筛选:计算机科学 / 操作系统 / systemd
«计算机科学 / 操作系统 / systemd» 分类下共 4 篇帖子
`tmpfiles.d(5)` 能解决大多数自动清理需求,但偶尔我们需要更"剧本化"的清理逻辑: 按业务规则保留某些目录、清完后通知 Prometheus pushgateway,或者顺便压缩归档。 这时候手写一个 `oneshot` 服务比改 `tmpfiles.d` 干净得多。 ## 1. 单元文件 ```ini # /etc/systemd/system/tmp-purge.service [Unit] Description=Purge stale /tmp entries older than 24h After=network.target [Service] Type=oneshot ExecStart=/usr/local/sbin/tmp-purge.sh Nice=10 IOSchedulingClass=idle ProtectSystem=strict ProtectHome=true ReadWritePaths=/tmp NoNewPrivileges=true ``` `ReadWritePaths=/tmp` 是关键 —— `ProtectSystem=strict` 之后整个 `/` 默认只读, 不显式开白名单会让脚本写不了任何东西。 ## 2. 脚本 ```bash #!/usr/bin/env bash # /usr/local/sbin/tmp-purge.sh set -euo pipefail PROTECTED='^/tmp/(systemd-private-|\.X11-unix|\.ICE-unix)' find /tmp -mindepth 1 -maxdepth 1 \ -mmin +1440 \ ! -regex "$PROTECTED" \ -exec rm -rf -- {} + logger -t tmp-purge "completed at $(date -Iseconds)" ``` `-mmin +1440` 是 24 小时;用 `+10080` 改为一周。`-regex` 那条把 `systemd-private-*` 和 X session 套接字目录排除掉,否则容易踩坑。 ## 3. timer ```ini # /etc/systemd/system/tmp-purge.timer [Unit] Description=Daily /tmp purge [Timer] OnCalendar=*-*-* 03:30:00 Persistent=true RandomizedDelaySec=15m [Install] WantedBy=timers.target ``` `Persistent=true` 在机器关机过那一时刻后会补跑一次。`RandomizedDelaySec` 让多 台机器错峰,避免 NFS / 备份目标在同一时刻被打爆。 ## 4. 启用 + 校验 ```bash chmod +x /usr/local/sbin/tmp-purge.sh systemctl daemon-reload systemctl enable --now tmp-purge.timer # 手动跑一次确认 systemctl start tmp-purge.service journalctl -u tmp-purge.service -n 20 --no-pager # 看下一次触发时间 systemctl list-timers tmp-purge.timer ``` ## 踩过的坑 - 早期版本忘记 `Type=oneshot`,systemd 默认 simple 模式会把"脚本退出"判为 "服务挂了",反复重启把磁盘 I/O 拉爆。一定要写 oneshot。 - 在很老的 systemd(< 235)上 `ReadWritePaths=/tmp` 会被 `PrivateTmp=true` 覆盖;如果你的 base unit 继承了 `PrivateTmp`,记得显式写 `PrivateTmp=false`。 - `find -delete` 不会递归删除非空目录,所以这里用 `-exec rm -rf -- {} +`。
cron 用了二十多年但有几个老问题:失败没有 retry、错过的任务(机器关机) 不补跑、日志要自己重定向、表达式只到分钟、找不到任务跑慢了的根因。 systemd timer 把这些一站式解决。 ## 一个完整例子 需求:每小时把 PostgreSQL 备份压缩传到远端,失败重试 3 次。 ### service 单元 ```ini # /etc/systemd/system/pgdump.service [Unit] Description=Hourly pg_dump + ship to backup host Wants=network-online.target After=network-online.target [Service] Type=oneshot User=postgres Group=postgres # 失败重试:5s, 15s, 45s 三次 Restart=on-failure RestartSec=5s StartLimitIntervalSec=120 StartLimitBurst=4 # 运行约束 Nice=10 IOSchedulingClass=idle TimeoutStartSec=30m # 命令本身 ExecStart=/usr/local/sbin/pgdump-ship.sh # 资源限制(避免 OOM 把别的进程踩死) MemoryMax=2G TasksMax=64 ``` `Restart=on-failure` 是关键 —— 脚本非零退出就触发重启。`StartLimitBurst=4` 意味着 120s 内最多触发 4 次(即 3 次重试),超过就 systemd 放弃并把单元 标记为 `failed`,配合 `OnFailure=` 还能进一步触发告警。 ### timer 单元 ```ini # /etc/systemd/system/pgdump.timer [Unit] Description=Hourly pg_dump [Timer] # 每小时第 5 分钟(避开 nginx logrotate 等整点任务) OnCalendar=*:05:00 # 错过的执行(如机器关机时)启动时补一次 Persistent=true # 多机器错峰 RandomizedDelaySec=2m # 防止上一次还没跑完又起新的 Unit=pgdump.service [Install] WantedBy=timers.target ``` ### 启用 ```bash sudo systemctl daemon-reload sudo systemctl enable --now pgdump.timer systemctl list-timers pgdump.timer # NEXT LEFT LAST PASSED UNIT ACTIVATES # Sat 2026-05-23 14:05:00 CST 22min left Sat 2026-05-23 13:05:00 CST 38min pgdump.timer pgdump.service ``` ## OnCalendar 表达式速查 | 表达式 | 含义 | |---|---| | `*-*-* 03:00:00` | 每天 03:00 | | `Mon..Fri 09:00` | 工作日 09:00 | | `*:0/15` | 每 15 分钟 | | `*:0/30:00` | 每 30 分钟(秒级精度) | | `2026-05-23 12:00` | 一次性 | | `weekly` | 每周一 00:00(别名) | 校验表达式: ```bash systemd-analyze calendar 'Mon..Fri 09:30' # Next elapse: Mon 2026-05-26 09:30:00 CST ``` ## OnUnitActiveSec / OnBootSec:相对触发 不基于墙钟,基于上次激活时间: ```ini [Timer] OnBootSec=15min # 开机后 15 分钟触发首次 OnUnitActiveSec=1h # 之后每隔 1 小时 ``` 适合"开机后 N 分钟做一次健康检查 + 之后周期跑"的场景。 ## 失败时告警 ```ini # 在 pgdump.service 里加 [Unit] [email protected] ``` ```ini # /etc/systemd/system/[email protected] [Service] Type=oneshot ExecStart=/usr/local/sbin/notify.sh "%i failed: $(journalctl -u %i -n 30 --no-pager)" ``` `%i` 是模板实例名,这里就是 `pgdump`。`notify.sh` 调用 Slack webhook / 邮件 / 任意。 ## 日志:journal 自动归档 不需要 `>> /var/log/...`。所有 stdout / stderr 自动进 journal: ```bash journalctl -u pgdump.service --since '1 day ago' journalctl -u pgdump.service -p err # 只看 error 级 ``` ## 一次性手动触发 ```bash sudo systemctl start pgdump.service # 立刻跑一次,不影响 timer ``` ## 踩过的坑 - `Type=oneshot` 漏写:systemd 默认 simple,脚本退出后认为"挂了"会触发 restart 死循环。所有 cron-style 任务必须 `Type=oneshot`。 - `Wants=network-online.target` 但脚本仍然连不上:很多 ISP 给 IPv6 默认路由 比 IPv4 慢;加 `ExecStartPre=/bin/sleep 5` 简单粗暴。 - `Persistent=true` 在频繁开关机的笔记本上会变成"开机就跑一堆任务"风暴, 可以加 `RandomizedDelaySec=10m` 散开。 - 两个 timer 同时触发同一个 service 时只跑一次 —— 这是 feature 不是 bug (服务的"活跃中"状态不会被重复启动)。
## 起因 服务挂了 systemd `Restart=on-failure` 能自动重启。但 "进程没崩,却卡死 不响应" 这种情况 systemd 看不出来——TCP 连接还在,进程还活,只是不 处理请求了。 `WatchdogSec` 让服务定期向 systemd 发"我还活"信号;超时不发就被 systemd 当死了重启。 ## 工作原理 ``` service 进程 ↓ 每 N 秒 sd_notify(WATCHDOG=1) systemd watchdog timer ↓ 收到 → reset timer ↓ 没收到 → SIGTERM + restart ``` 时间预算(WatchdogSec=30s)= 服务必须在 30s 内"喂狗"一次。 ## 配置 systemd 单元 `/etc/systemd/system/myapp.service`: ```ini [Service] Type=notify WatchdogSec=30 NotifyAccess=main ExecStart=/usr/local/bin/myapp Restart=on-watchdog RestartSec=5 ``` - `Type=notify`:systemd 期待服务在启动好后发 `READY=1` - `WatchdogSec=30`:30 秒内没"喂狗"就重启 - `Restart=on-watchdog`:watchdog 超时也重启(默认 on-failure 不含) - `NotifyAccess=main`:只允许主进程发 notify ## 应用代码:发 WATCHDOG ### Python ```python import os import socket import threading import time def notify(message): """向 systemd notify socket 发消息""" sock_path = os.environ.get('NOTIFY_SOCKET') if not sock_path: return s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) if sock_path.startswith('@'): sock_path = '\0' + sock_path[1:] try: s.sendto(message.encode(), sock_path) finally: s.close() def watchdog_loop(): interval = int(os.environ.get('WATCHDOG_USEC', '30000000')) // 1_000_000 // 2 while True: # 在这里 check 应用真"活"——不只是循环跑 if app_is_healthy(): notify('WATCHDOG=1') time.sleep(interval) # 启动时 notify('READY=1') # 后台喂狗 threading.Thread(target=watchdog_loop, daemon=True).start() # 业务主循环 serve_forever() ``` `WATCHDOG_USEC` 是 systemd 自动设的环境变量(微秒)。喂狗间隔通常 设为 WatchdogSec 的 1/2 给容错。 `app_is_healthy()` 是你的健康检查: ```python def app_is_healthy(): # check DB / Redis / 关键资源 try: db.ping(timeout=2) # 检查事件循环没死锁 / queue 不爆 return queue.qsize() < 10000 except Exception: return False ``` 如果 health check 失败,不要喂狗 → systemd 杀进程重启。 ### Go ```go import ( "os" "time" "github.com/coreos/go-systemd/v22/daemon" ) func main() { // ... 初始化 daemon.SdNotify(false, daemon.SdNotifyReady) // 启动 watchdog goroutine interval, err := daemon.SdWatchdogEnabled(false) if err == nil && interval > 0 { go func() { tick := time.NewTicker(interval / 2) for range tick.C { if appHealthy() { daemon.SdNotify(false, daemon.SdNotifyWatchdog) } } }() } serve() } ``` ### Rust ```rust use systemd::daemon; use std::thread; use std::time::Duration; fn main() { daemon::notify(false, [(daemon::STATE_READY, "1")].iter()).unwrap(); thread::spawn(|| { let interval = Duration::from_secs(15); loop { if app_healthy() { daemon::notify(false, [(daemon::STATE_WATCHDOG, "1")].iter()).unwrap(); } thread::sleep(interval); } }); serve(); } ``` ### 不会修改源码的服务 跑现成 binary 没 sd_notify 支持?用 `systemd-notify` wrapper: ```ini [Service] ExecStart=/path/to/wrapper.sh ``` ```bash #!/bin/bash # wrapper.sh your-app & APP_PID=$! while kill -0 $APP_PID 2>/dev/null; do if curl -sf http://localhost:8080/health > /dev/null; then systemd-notify WATCHDOG=1 fi sleep 15 done ``` 但这种方式增加复杂度;建议优先改应用代码。 ## 实测 ```bash sudo systemctl start myapp sudo systemctl status myapp # 启动后看 status: active (running) # 状态行有 "Watchdog: 30s" # 模拟"应用卡死" —— 让 app_is_healthy 返 False # 30 秒后 systemd 会: # 1. 发 SIGTERM # 2. 等待终止 # 3. 按 Restart=on-watchdog 重启 journalctl -u myapp -f # 看到 "Watchdog timeout (limit 30s)!" + restart ``` ## 与"被动监控 + 外部重启" 对比 外部脚本检测 + 重启: ```bash # cron */1 * * * * if ! curl -sf http://localhost:8080/health; then systemctl restart myapp fi ``` 优点:简单,不改应用代码。 缺点: - 间隔最小 1 分钟(cron) - 健康检查走 HTTP(增加耦合) - restart 流程慢(systemctl 命令 + 进程退出 + 重启) WatchdogSec 优点: - 毫秒级触发 - 应用内部直接判断"我健康吗" - systemd 一站式管 生产推荐 WatchdogSec。 ## 几个调参 ### WatchdogSec 太短 `WatchdogSec=5`:网络抖动 / GC 暂停 / 大事务 commit → 误杀健康进程。 通常 30-120 秒是合理范围。 ### 启动期不喂狗 启动加载大模型几分钟: ```ini TimeoutStartSec=10min WatchdogSec=30 ``` 启动期内由 `TimeoutStartSec` 控制;READY 发出后才进入 WatchdogSec 监管。 应用代码:模型 load 完才 `notify('READY=1')` + 启动 watchdog 线程。 ### RuntimeMaxSec:周期性重启 ```ini WatchdogSec=30 RuntimeMaxSec=24h # 跑超过 24h 强制重启 ``` 防内存泄漏类问题:每天自动重启一次,泄漏不可能跑到 OOM。 ## 实战:FastAPI + uvicorn + WatchdogSec uvicorn 不原生支持 sd_notify。 解决方案 1:用 hypercorn 或 gunicorn (`UvicornWorker`),自己写 hook。 或者用 ASGI lifespan event: ```python # app/main.py import asyncio, socket, os from fastapi import FastAPI from contextlib import asynccontextmanager def notify(msg): sock_path = os.environ.get('NOTIFY_SOCKET') if not sock_path: return s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) try: s.sendto(msg.encode(), sock_path) finally: s.close() async def watchdog(): interval = int(os.environ.get('WATCHDOG_USEC', '30000000')) / 2_000_000 while True: if await check_health_async(): notify('WATCHDOG=1') await asyncio.sleep(interval) @asynccontextmanager async def lifespan(app: FastAPI): notify('READY=1') task = asyncio.create_task(watchdog()) yield task.cancel() app = FastAPI(lifespan=lifespan) # health check 是真检查事件循环 + DB async def check_health_async(): try: await asyncio.wait_for(db.execute('SELECT 1'), timeout=2) return True except Exception: return False ``` systemd unit: ```ini [Service] Type=notify WatchdogSec=60 ExecStart=/srv/app/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 Restart=on-watchdog ``` 效果:DB 卡死 → health check 不通过 → 不喂狗 → 60s 后 systemd 杀重启。 ## 监控 systemd journal 里 watchdog 触发显示: ``` systemd[1]: myapp.service: Watchdog timeout (limit 30s)! systemd[1]: myapp.service: Killing process 12345 with signal SIGABRT. ``` Prometheus node_exporter 自动收 systemd `*_restart_count` 指标。 告警:`rate(systemd_unit_restart_total[1h]) > 5` → 服务持续不健康。 ## 踩过的坑 1. **`Type=simple` 不 work**:必须 `Type=notify` watchdog 才生效。 2. **fork 后子进程喂狗** `NotifyAccess` 默认 main:子进程发的被忽略。 要么改成 `NotifyAccess=all`,要么主进程负责。 3. **测试期间设置太短**:`WatchdogSec=5` 测试是因为快,生产忘改回 常态 → 高负载时 GC 一下被误杀。 4. **app 用 thread + GIL**:watchdog 线程被 GIL 卡住 → 整个 Python 进程都不喂狗 → 误杀。用 async 或 multiprocessing 把 watchdog 独立。 5. **WATCHDOG_USEC 没读到**:环境变量是 systemd 启动子进程时注入。 如果你的 service 用 shell wrapper 启动,要 `exec` 替换或显式 `env` 传过去。
## 起因 我有一台小 VPS 跑十几个偶尔被用的服务(小工具 API、自建图床、内部 Wiki 之类)。每个 24x7 常驻进程都吃 50-200 MB 内存。 "按需启动 + 闲置自动停"能省一大半内存。 `xinetd` 是老古董做法;systemd 的 socket activation 是现代等价物 + 完全集成系统单元 + 更灵活。 ## 工作原理 1. systemd 启动时只创建 listening socket(端口),不启服务进程 2. 第一个请求到达端口时,systemd 启动服务进程并把 socket fd 传给它 3. 服务正常工作,处理后续请求 4. 闲置一段时间后服务可以 exit,systemd 重新只监听端口 5. 下次请求重复 1-4 ## 解决方案 ### 例子:按需启动一个 Python 小服务 `my-tool.service`: ```ini # /etc/systemd/system/my-tool.service [Unit] Description=My Tool API After=network.target my-tool.socket Requires=my-tool.socket [Service] Type=notify # 配合 sd_notify,启动好通知 systemd User=trio Group=trio ExecStart=/srv/my-tool/.venv/bin/python /srv/my-tool/server.py StandardInput=socket # systemd 把 socket fd 传给进程 Restart=on-failure # 闲置 5 分钟自动退出 RuntimeMaxSec=infinity [Install] WantedBy=multi-user.target ``` `my-tool.socket`: ```ini # /etc/systemd/system/my-tool.socket [Unit] Description=My Tool socket [Socket] ListenStream=8088 Accept=no # systemd 不 accept,把 listening socket 传给服务 # 闲置多久关: NoDelay=true [Install] WantedBy=sockets.target ``` ```bash sudo systemctl daemon-reload # 只 enable + start socket,不启 service sudo systemctl enable --now my-tool.socket sudo systemctl status my-tool.socket # active (listening) sudo systemctl status my-tool.service # inactive (dead) ss -tlnp | grep 8088 # 看到是 systemd 在监听 # 第一次访问 curl http://localhost:8088/ # systemd 启动 my-tool.service,处理请求 sudo systemctl status my-tool.service # active (running) ``` ### 服务端代码(Python)拿 socket fd ```python # /srv/my-tool/server.py import os, socket from wsgiref.simple_server import make_server def app(environ, start_response): start_response('200 OK', [('Content-Type', 'text/plain')]) return [b'hello from on-demand service'] # 检测是否被 systemd 启动 + socket activated LISTEN_FDS_START = 3 if os.environ.get('LISTEN_FDS') == '1': fd = LISTEN_FDS_START sock = socket.socket(fileno=fd) # 用现成的 listening socket 而非新建 sock.listen(128) # 配合 wsgiref: from wsgiref.simple_server import WSGIServer httpd = WSGIServer(('', 0), None, bind_and_activate=False) httpd.socket = sock httpd.set_app(app) print(f'serving on socket fd {fd}', flush=True) # 通知 systemd 启动完成(Type=notify 要求) notify_socket = os.environ.get('NOTIFY_SOCKET') if notify_socket: ns = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) ns.sendto(b'READY=1', notify_socket) httpd.serve_forever() else: httpd = make_server('', 8088, app) httpd.serve_forever() ``` 很多框架原生支持 socket activation: - **systemd-python** 库的 `daemon.listen_fds()` 简化拿 fd - **Gunicorn** 支持 `--bind fd://3`(systemd LISTEN_FDS 模式) - **Caddy / nginx** 都能 socket activate ### 让服务闲置后自动退出 服务端代码:accept 后开 N 秒计时,无请求就 sys.exit(0)。 或者用 `RuntimeMaxSec=10min` 强制最多跑 10 分钟(systemd 会 SIGTERM)。 ### accept-per-connection 模式(替代 inetd) 如果服务每个连接超轻量(如 TCP echo),可以 `Accept=yes`: ```ini # my-tool.socket [Socket] ListenStream=8088 Accept=yes ``` ```ini # [email protected] (注意 @) [Service] ExecStart=/srv/my-tool/handle.sh StandardInput=socket StandardOutput=socket ``` 每个连接 systemd accept 后 fork 一个 service 实例处理。 适合纯短任务,不适合 HTTP(每请求新进程开销大)。 ### 监控 ```bash # 看 socket 等待状态 sudo systemctl list-units --type=socket # 看 service 历史 sudo systemctl list-timers journalctl -u my-tool.service --since '1 day ago' ``` ## 效果 我的 VPS 上: | | 常驻 | socket activation | |---|---|---| | 12 个小服务总内存 | 1.6 GB | ~200 MB(只有正在用的几个) | | 冷启动延迟 | 0 | 100-500ms(首次请求) | | 闲置时 CPU | < 1% | 0% | 代价:用户每天第一次访问慢半秒。绝大多数内部工具能接受。 ## 与替代方案对比 | | systemd socket | inetd / xinetd | nginx fastcgi | k8s scale-to-zero | |---|---|---|---|---| | 复杂度 | 低 | 中 | 高 | 高 | | 现代 | ✅ | ❌(淘汰) | ✅ | ✅ | | 每连接 fork | 可选 | 是 | 否 | 否 | | 内存节省 | 大 | 大 | 中 | 大 | | 适合场景 | 偶用小服务 | 纯短任务 | Web 服务 | 微服务集群 | ## 踩过的坑 1. **`Type=notify` 但代码不发 READY**:systemd 默认 90 秒后认为启动 失败,把服务 kill 掉。改 `Type=simple` 或确认代码发 sd_notify。 2. **第一次冷启动慢得离谱**:venv 加载 / 模型加载 / DB 连接初始化 都在第一次请求时发生。考虑 `WatchdogSec=` + 预热请求。 3. **socket 端口冲突**:先确保对应端口没被别的进程占用。 `Type=simple` 的进程自己 bind 会冲突,必须用 socket activation 提供 的 fd。 4. **service 退出后端口短暂关闭**:闲置退出 → systemd 检测到 → 重新 开 socket。这之间几十 ms 可能拒绝连接。`KeepAlive=true` + 服务退出 前发 stop 信号给 systemd 让它先准备好。 5. **不适合高并发**:socket activation 设计给"偶尔用"。如果服务持续 有流量,常驻反而更高效(避免冷启动)。