一个后台程序要一直跑着,挂了自动拉起来;另一个程序要每 10 分钟跑一次,跑完就退出。这两件事在 Linux 上都归 systemd 管,前者写成 .service,后者要 .service + .timer 一对。

本文把这两种单元的每个字段拆开讲清楚,说明为什么定时任务用 timer 比 crontab 更合适,以及命令实际跑在 Docker 容器里时最容易踩的那个坑。


一、先分清楚 service 和 timer

很多人第一次配 timer 时会困惑:「定时任务为什么要写两个文件?」

因为这是两件事:

  • .service 回答「这个程序该怎么跑」——跑什么命令、用哪个用户、挂了要不要重启、超时多久算失败
  • .timer 回答「什么时候去启动它」——每 10 分钟、每天凌晨 3 点、开机 5 分钟后

.service 是名词,.timer 是闹钟。闹钟响了就去按一下那个 service 的启动按钮,至于按下去之后发生什么,是 service 自己的事。

所以常驻型的程序只要一个 .service(它自己不停,不需要闹钟),周期型的要一对,而且两个文件必须同名myapp-sync.servicemyapp-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= 顺带把它也拉起来,但它失败不影响自己

AfterWants 的区别是顺序依赖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.targetnetwork.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-fileslist-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 就是照着它建一个符号链接。

所以 startenable 是两件独立的事:

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」的形式出现;配置文件读不到甚至完全不报错,只是缓存了一份默认值。