一个后台程序要一直跑着,挂了自动拉起来;另一个程序要每 10 分钟跑一次,跑完就退出。这两件事在 Linux 上都归 systemd 管,前者写成 .service,后者要 .service + .timer 一对。
本文把这两种单元的每个字段拆开讲清楚,说明为什么定时任务用 timer 比 crontab 更合适,以及命令实际跑在 Docker 容器里时最容易踩的那个坑。
一、先分清楚 service 和 timer
很多人第一次配 timer 时会困惑:「定时任务为什么要写两个文件?」
因为这是两件事:
.service回答「这个程序该怎么跑」——跑什么命令、用哪个用户、挂了要不要重启、超时多久算失败.timer回答「什么时候去启动它」——每 10 分钟、每天凌晨 3 点、开机 5 分钟后
.service 是名词,.timer 是闹钟。闹钟响了就去按一下那个 service 的启动按钮,至于按下去之后发生什么,是 service 自己的事。
所以常驻型的程序只要一个 .service(它自己不停,不需要闹钟),周期型的要一对,而且两个文件必须同名:myapp-sync.service 配 myapp-sync.timer——timer 靠文件名找到对应的 service,改错一个字母就是「闹钟响了但没有任何反应」。
两种文件都放在 /etc/systemd/system/。
二、常驻型:Type=simple + Restart=always
先看一个真实的例子。一个 Python 写的后台 worker,从队列里领任务、执行、再领下一个,永远不退出:
[Unit]
Description=myapp 后台 worker
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
Environment=TZ=Asia/Shanghai
WorkingDirectory=/var/www/myapp
ExecStart=/var/www/myapp/.venv/bin/python -m worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
逐段拆开。
[Unit]:启动顺序
| 字段 | 含义 | 不写会怎样 |
|---|---|---|
Description |
systemctl status 里显示的一句话 |
只显示文件名,排查时看不出是干什么的 |
After= |
排在这些单元之后启动 | 可能在网络就绪前就启动,连不上数据库直接失败 |
Wants= |
顺带把它也拉起来,但它失败不影响自己 | — |
After 和 Wants 的区别是顺序和依赖:After 只管先后,不保证对方会被启动;Wants 管启动,但不管顺序。所以要「等网络好了再启动」,两个都要写。
拿包管理器的依赖来类比
这套东西本质上就是服务之间的依赖声明,和 composer.json / package.json 里写依赖是同一件事:声明「我要用到谁」,由一个统一的东西去解析、排序、拉起来。
对应关系大致是这样:
| 包管理器 | systemd | 含义 |
|---|---|---|
require(必需依赖) |
Requires= |
它起不来,我就不起 |
suggest(可选依赖) |
Wants= |
顺带拉它一把,它失败不影响我 |
conflict |
Conflicts= |
两个不能同时存在 |
| (没有对应的) | After= / Before= |
纯粹的先后顺序 |
最后一行是关键:包管理器没有「顺序」这个维度。npm 装完就完事了,包躺在 node_modules 里,什么时候用是你的事。而服务是活的——「装了」和「此刻能用」是两回事,所以 systemd 把依赖拆成了两个正交的轴:
Requires / Wants → 要不要把它拉起来
After / Before → 谁先谁后
这两个轴互不影响,这是最反直觉的地方。只写 Requires=docker.service 而不写 After=,systemd 会把 docker 拉起来,但可能和你的服务同时启动——docker 还在初始化,你的 docker exec 已经跑了,然后失败。所以实践中这两个几乎总是成对出现。
另一个差别:包依赖是装的时候解析一次,之后就固定了;服务依赖是每次开机重新解析,而且服务跑着跑着还会挂掉——所以除了声明依赖,还得有 Restart= 这类运行期的兜底,这在包管理器里是没有对应概念的。
注意 network-online.target 和 network.target 不是一回事:后者只表示「网络子系统起来了」,网卡可能还没拿到 IP。要真的能发请求,用前者。
After= 里该写什么?先把机器上有哪些单元列出来
不用猜,也不用背。名字必须和机器上实际存在的单元完全一致,写错了不会报错,只是那一行静默失效。
# 装了哪些单元(含从没启动过的)——找名字用这条
systemctl list-unit-files --type=service
# 当前加载的单元和它们的状态,--all 连没在跑的也列出来
systemctl list-units --type=service --all
# 名字记不全就用通配符
systemctl list-unit-files "docker*"
systemctl list-unit-files "*sql*"
# 所有 target(那些「集合点」)
systemctl list-units --type=target
# 看某个单元到底是怎么定义的(含它自己的 After/Wants)
systemctl cat docker.service
# 某个 target 由谁满足、拉起了哪些东西
systemctl list-dependencies network-online.target
list-unit-files 和 list-units 的区别值得记一下:前者列「磁盘上装了哪些单元文件」,后者列「systemd 此刻加载了哪些」。找依赖名字用前者,因为你要依赖的服务这会儿可能没在跑。
输出长这样,最后一列 STATE 是有没有设开机自启:
$ systemctl list-unit-files "docker*"
UNIT FILE STATE PRESET
docker.service enabled enabled
docker.socket enabled enabled
几个最常用的依赖对象:
写在 After= 里 |
什么时候需要 |
|---|---|
network-online.target |
要发网络请求 |
docker.service |
命令是 docker exec、或程序连容器里的服务 |
mysql.service / mariadb.service / postgresql.service |
直连本机数据库(名字按发行版不同,先列一下) |
redis-server.service |
直连本机 Redis |
nginx.service |
需要 Web 服务先在 |
remote-fs.target |
数据在 NFS 之类的网络存储上 |
time-sync.target |
逻辑依赖准确时间(对时前系统时间可能差很远) |
network-online.target 是 systemd 自带的,每台跑 systemd 的机器都有——
$ systemctl cat network-online.target
# /usr/lib/systemd/system/network-online.target
# This file is part of systemd.
所以这个名字永远写得出来。有差别的只是它会不会真的等(取决于前面说的 wait-online 服务有没有 enable)。而数据库那类就不一定了:同一个 MySQL,Debian 上叫 mysql.service,CentOS 上可能是 mysqld.service,装了 MariaDB 又变成 mariadb.service——写之前一定先列一下。
[Service]:怎么跑
Type= 决定 systemd 怎么判断「启动完成了」,这是最容易配错的一个字段:
| 取值 | 含义 | 用在什么程序上 |
|---|---|---|
simple |
命令一执行就算启动成功 | 常驻程序,本身不退出 |
oneshot |
命令跑完退出才算成功 | 一次性任务,配 timer 用 |
forking |
命令会 fork 出子进程后自己退出 | 老式的守护进程(nginx -d 那种) |
notify |
程序自己通过 sd_notify 告诉 systemd 「我好了」 | 需要程序配合支持 |
写错的后果很直接:常驻程序写成 oneshot,systemd 会一直等它退出,systemctl start 挂住不返回;一次性任务写成 simple,命令还没跑完就被当成「已启动」,timer 会在下一轮准时再开一个。
Restart= 决定进程没了要不要拉起来:
| 取值 | 行为 |
|---|---|
no(默认) |
退出就退出了,不管 |
on-failure |
只有非 0 退出码或被信号杀死才重启;正常退出(exit 0)不管 |
always |
不管怎么退出的都重启,包括正常退出 |
常驻 worker 用 always:它正常退出本身就是不该发生的事。而如果这个程序会正常结束,千万别用 always——它一退出就被拉起来,等于变成了死循环。
RestartSec=10 是重启前等 10 秒。这个值不要设成 0:如果失败的原因是数据库连不上,0 秒重启就是每秒撞一次墙,日志几分钟刷几万行,真正的错误被淹掉。
Environment=TZ=Asia/Shanghai 这一行的讲究
服务器时区通常是 UTC,不该为了一个程序去改整机时区(同一台机器上别的服务、数据库都在按 UTC 记时间)。Environment= 只给这一个单元设环境变量,影响面刚好。
但设完之后会出现一个奇怪的现象:
Sep 02 06:18:52 my-server myapp[123]: [14:18:52] 开始处理任务
└──────┬───────┘ └────┬────┘
journald 打的前缀 程序自己打的时间
还是 UTC 已经是北京时间
同一行里两个时间差了 8 小时。因为前缀是 journald 加的,它按宿主机时区走,不受单元里的 TZ 影响。看日志时给 journalctl 也套上时区,两截才对齐:
TZ=Asia/Shanghai journalctl -u myapp-worker -f
[Install]:enable 时把链接建到哪
WantedBy=multi-user.target 是「开机进入多用户模式时启动我」。这一段只在 systemctl enable 时起作用——enable 就是照着它建一个符号链接。
所以 start 和 enable 是两件独立的事:
systemctl start myapp-worker # 现在跑起来,但重启机器就没了
systemctl enable myapp-worker # 开机自启,但现在不动
systemctl enable --now myapp-worker # 两件事一起做 ← 通常要的是这个
没写 [Install] 段的单元不能被 enable(会报 The unit files have no installation config)。timer 触发的那种 service 就属于这一类,见下一节。
三、周期型:Type=oneshot + .timer
同一个项目里另一个需求:每 10 分钟去拉一次订单数据。
service 部分(myapp-sync.service):
[Unit]
Description=myapp 拉订单数据
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/docker exec -u 1000 app-workspace-1 sh -c "cd /var/www/myapp && php artisan orders:sync --concurrency=5"
TimeoutStartSec=900
StandardOutput=journal
StandardError=journal
注意这里没有 [Install] 段,也没有 Restart=。它不需要开机自启——负责自启的是 timer;它也不该被重启——一次性任务失败了就等下一轮,重试是 timer 的节奏说了算。
TimeoutStartSec=900 是「跑超过 15 分钟就判失败、杀掉」。这一行对定时任务是必需的:某一轮卡在一个不返回的网络请求上,没有超时它会永远占着,后面每一轮都被跳过,而表面上看服务还是「正在运行」。
timer 部分(myapp-sync.timer):
[Unit]
Description=每 10 分钟拉一次订单数据
[Timer]
OnCalendar=*:0/10
RandomizedDelaySec=30
Persistent=true
AccuracySec=10s
[Install]
WantedBy=timers.target
这次 [Install] 写在 timer 上——要 enable 的是闹钟,不是被叫醒的那个。
OnCalendar 怎么写
语法是 星期 年-月-日 时:分:秒,前面的部分可以省略,* 是任意,/ 是步进:
| 写法 | 含义 |
|---|---|
*:0/10 |
每 10 分钟(0、10、20、30、40、50 分) |
hourly |
每小时整点 |
*-*-* 03:00:00 |
每天凌晨 3 点 |
Mon..Fri 09:00 |
周一到周五早 9 点 |
*-*-01 00:00:00 |
每月 1 号零点 |
写完一定要验一下,别等到第二天发现没跑:
$ systemd-analyze calendar "*:0/10"
Original form: *:0/10
Normalized form: *-*-* *:00/10:00
Next elapse: Wed 2026-09-02 06:30:00 UTC
From now: 6min left
它会把你写的式子标准化,并算出下一次什么时候触发。写错了这里会直接报 Failed to parse calendar specification。
另一种写法是 OnUnitActiveSec=10min,意思是「上一轮跑完之后再过 10 分钟」。区别是:OnCalendar 对齐到整点(10:00、10:10、10:20),OnUnitActiveSec 是相对的,任务跑了 3 分钟的话下一轮在 10:13。要固定节奏用前者,要保证间隔用后者。
另外三个字段
RandomizedDelaySec=30:在触发时刻上加 0~30 秒的随机延迟。一台机器上如果有好几个整点任务,不错开的话它们会在同一秒一起启动,瞬间把 CPU 和网络打满。
Persistent=true:机器关机期间错过的触发,开机后补跑一次。注意是一次,不是把错过的每一轮都补上——关机两天不会开机就打 288 次请求。
AccuracySec=10s:允许的误差。systemd 默认是 1 分钟,它会在这个窗口里挑一个时刻把多个任务合并唤醒以省电。对服务器来说省电没意义,收紧到 10 秒让触发更准时。
timer 自带的一个好处:不会重叠
上一轮还在跑,这一轮不会被触发。 因为 timer 的动作是「启动那个 service」,而一个 service 已经处于 active 状态时,再启动它是空操作。
这是 crontab 没有的。cron 到点就起一个新进程,不管上一个死没死——任务偶尔跑得比周期长,进程就会越堆越多,最后把机器拖垮。要在 cron 里避免这件事,得自己写文件锁或者上 flock。
装上去
scp myapp-sync.service myapp-sync.timer root@server:/etc/systemd/system/
ssh root@server 'systemctl daemon-reload && systemctl enable --now myapp-sync.timer'
改完任何单元文件都必须 daemon-reload,否则 systemd 用的还是内存里的旧版本,你改的东西一点效果都没有——而且它不会提示你。
四、为什么用 timer 不用 crontab
crontab 一行就能写完,timer 要两个文件,看起来是倒退。但机器一多、任务一复杂,差距就出来了:
| crontab | systemd timer | |
|---|---|---|
| 上一轮没跑完 | 照样起新的,进程堆积 | 自动跳过 |
| 日志 | 自己 >> /var/log/xxx.log,还要自己轮转 |
进 journal,自动轮转、可按单元过滤 |
| 失败了怎么知道 | 默认发邮件(通常没配,等于没有) | systemctl status 有退出码,可配 OnFailure= 触发告警 |
| 下次什么时候跑 | 自己按表达式推算 | systemctl list-timers 直接告诉你 |
| 环境变量 | 极简的 PATH,经常「手动能跑定时跑不了」 | 显式写 Environment=,不依赖登录环境 |
| 超时控制 | 没有 | TimeoutStartSec= |
| 错过的补跑 | 没有 | Persistent=true |
| 依赖关系 | 没有 | After= / Requires= |
那条「手动能跑定时跑不了」是 cron 最经典的坑:cron 的 PATH 通常只有 /usr/bin:/bin,你在自己 shell 里配的一切它都没有。systemd 也是干净环境,但它逼你把命令写成绝对路径,问题在写配置时就暴露了,而不是在半夜。
反过来说,crontab 也不是不能用——一台机器只有一两个简单任务,且没有 systemd(比如容器内部)时,cron 更省事。判断标准是这个任务失败了会不会有人管:会,就用 timer。
五、命令实际跑在容器里:那个 -u 1000
现在很多部署是「代码在宿主机上,运行环境在容器里」——宿主机上根本没装 PHP/Python,命令要 docker exec 进容器跑。这时 ExecStart 长这样:
/usr/bin/docker exec -u 1000 app-workspace-1 sh -c "cd /var/www/myapp && php artisan orders:sync"
└──────┬───────┘ └──┬──┘ └──────┬─────┘ └────────────┬────────────┘
绝对路径 ★ 以哪个用户身份 ★ 容器名 真正要跑的命令
两个标 ★ 的地方都会让人白折腾半天。
一、docker 必须写绝对路径
systemd 启动服务时的 PATH 不包含你登录 shell 里的那些目录。ExecStart=docker exec ... 会直接报:
Failed to locate executable docker: No such file or directory
which docker 查出来填进去。这条对所有命令都成立,不只是 docker。
二、-u 1000:用错身份会留下一地属主不对的文件
docker exec 默认以容器里的 root 执行。而 Web 应用的进程(php-fpm、uwsgi)通常是以某个普通用户跑的(uid 1000)。两者写同一批目录,就会出事:
# 不加 -u,root 跑出来的缓存文件:
-rw-r--r-- 1 root root cache/config.php ← php-fpm 是 uid 1000,写不进去
而且它不会立刻炸。已经生成好的缓存文件人人可读,服务照常。等到某个请求需要新写一个缓存文件时才失败——看起来就像「部署完过一会儿突然 500」,排查时根本想不到是几小时前那条定时任务干的。
所以规则很简单:定时任务用哪个身份跑,就要和 Web 进程用同一个身份。
万一忘了,补救是把属主改回去:
docker exec app-workspace-1 chown -R 1000:1000 /var/www/myapp/storage /var/www/myapp/cache
三、更隐蔽的一种:以 1000 的身份,却读不到配置文件
上面那个坑还有个镜像版本。假设配置文件是这样:
-rw------- 1 root root .env ← 权限 600,属主 root
以 uid 1000 的身份跑「生成配置缓存」这类命令时,它读不到这个文件——但很多框架不会因此报错,而是当成「没有配置文件」,用一套默认值生成了缓存。命令行里全是绿色的成功提示,实际上缓存里的数据库配置已经变成了默认的本地 SQLite,之后每个请求都去找一个不存在的文件。
这个比上一个更难查,因为没有任何错误信息。防的办法是让配置文件的属主和跑命令的身份一致:
chown 1000:1000 .env # 权限保持 600 不变
这不降低安全性——权限还是 600,只有属主能读,而 root 本来就能读任何文件。
跑完之后自查一句,比什么都管用:
grep -c "sqlite" cache/config.php # 期望是 0,不是 0 就说明缓存被污染了
六、排查这几条就够
# 这个单元现在什么状态、最近几行日志
systemctl status myapp-sync.service
# 所有定时器:上次什么时候跑的、下次什么时候
systemctl list-timers --all
# 只看某个单元的日志,-f 跟踪,-n 50 看最后 50 行
TZ=Asia/Shanghai journalctl -u myapp-sync -n 50
# 只看这次开机以来的错误
journalctl -u myapp-sync -p err -b
# 不等定时,立刻手动跑一轮(跑的是 service,不是 timer)
systemctl start myapp-sync.service
# 上一轮的退出码
systemctl show myapp-sync.service -p Result -p ExecMainStatus
最后一条尤其有用:Result=success / ExecMainStatus=0 才是真跑成功了。有些命令即使内部有失败,只要进程退出码是 0,systemd 就认为成功——所以脚本内部有失败时要返回非 0 退出码,否则 systemd 这一层永远看不到问题。
小结
.service说「怎么跑」,.timer说「什么时候跑」,周期任务两个文件同名成对。Type=simple给常驻程序,Type=oneshot给一次性任务,配反了要么 start 挂住,要么任务重叠。Restart=always只给「本不该退出」的程序,会正常结束的程序用它等于死循环;RestartSec不要给 0。- timer 比 crontab 多给的东西:不重叠、进 journal、有退出码、能查下次触发时间、超时控制——任务失败了有人管,就用 timer。
OnCalendar写完用systemd-analyze calendar验一遍;改完任何单元文件先daemon-reload。- 依赖和顺序是两个正交的轴:
Requires/Wants管「要不要拉起来」,After/Before管「谁先谁后」,通常要成对写。 After=里的名字先用systemctl list-unit-files列一下,写错不报错、只是静默失效;数据库那类的单元名各发行版不一样。- 命令写绝对路径,systemd 的 PATH 很干净。
- 跑在容器里的话,身份要和 Web 进程一致(
docker exec -u)——属主错了不会立刻炸,会在几小时后以「突然 500」的形式出现;配置文件读不到甚至完全不报错,只是缓存了一份默认值。