Cron 可以从不止一种任务表读取计划。用户任务表属于某个账号,而系统任务表能够为每一条记录指定运行账号。把一行原样复制到另一种表中,如果没有调整字段,cron 对命令起点的理解就会变化。排查看似正确的五字段日历表达式之前,应先确认正在编辑的到底是哪种任务表,而不是只检查分钟和小时。
账号列改变了解析边界
传统用户记录依次填写分钟、小时、月内日期、月份、星期,再接命令正文,由 crontab 命令安装给选定用户。Debian 的 /etc/crontab 和 /etc/cron.d 下文件,则在五个日历字段之后额外增加用户名。这个用户名属于系统表格式,并不是可以随意放进用户任务表的说明性字段;多写或少写都会改变后续解释。
月份限制仍然必须满足。当月内日期与星期都受到限制时,Debian/Vixie 的日期规则允许满足其中之一,但月份与星期并不是二选一。把表达式迁移到带秒字段或采用其他日期组合方式的调度产品前,应核对具体方言。表面上相似的空格布局,不能证明两个调度器提供相同语义。
系统任务文件还受所有权、权限、名称及末尾换行要求约束。Debian 中应采用 root 所有、组和其他用户不可写且名称被接受的文件,不要把任意备份后缀当成有效任务名。/etc/cron.daily 等周期目录通常通过发行版安排和 run-parts 被调用,不是每份脚本都独立当作 crontab 解析。实际运行时间需要查看本机已安装的调度安排。
坏掉的样子
在 root 用户自己的任务表中,五字段后再写 root,意味着让 shell 执行一个名为 root 的命令。任务表安装成功,不证明这个命令存在。反过来,系统表漏掉账号列,就会把命令文字当成用户名。审阅时需要同时检查解析边界与最终身份,尤其是任务会写文件或读取受保护数据时,不能仅凭“都是 root 配置的”推断执行账号。
任务环境同样是一份契约。不要默认交互别名、shell 启动文件或启动 cron 的父进程环境都会传给任务。需要的值应明确配置,同时选择合适的 PATH 和解释器。cron.d 文件中的环境也不应被认为自动继承 /etc/crontab 中的设置。验证时要采用真正的运行账号检查权限与输出,避免管理员手工执行成功掩盖低权限任务失败。
百分号在完整命令交给 shell 之前就有特殊含义。未转义的百分号会划分标准输入,后续百分号再变成换行。仅用 shell 引号包围内容,并不能取消 cron 的这一步解释。复杂逻辑适合放进脚本;确实需要把字面百分号写在表中时,应按 cron 规则转义。调度语法、命令能否执行和应用结果是否成功,必须分别检查。
快速验证
使用全新的临时 Debian 12 Docker 容器,以 root 运行,安装 python3、cron、procps 和 passwd。确认没有活动 cron 进程、已有 root 任务表,以及名为 xbtime 的账号。将代码保存为 cron-tables.py,执行 python3 cron-tables.py。它只创建实验账号、任务文件和临时目录,最多等待七十五秒经过一个真实计划分钟,结束后会清理这些资源。
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import pwd
import shutil
import subprocess
import time
assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container'
assert subprocess.run(['pgrep', '-x', 'cron'], capture_output=True).returncode == 1
assert subprocess.run(['crontab', '-l'], capture_output=True).returncode != 0
assert shutil.which('root') is None
username = 'xbtime'
try:
pwd.getpwnam(username)
except KeyError:
pass
else:
raise AssertionError('test account already exists')
system_table = Path('/etc/cron.d/xbcatm_time_test')
assert not system_table.exists()
subprocess.run(['useradd', '--create-home', '--shell', '/bin/sh', username], check=True)
account = pwd.getpwnam(username)
daemon = None
try:
with TemporaryDirectory(prefix='cron-tables-') as directory:
root = Path(directory)
os.chown(root, account.pw_uid, account.pw_gid)
worker = root / 'worker.py'
worker.write_text('''from pathlib import Path
import json,os,sys
root=Path(__file__).parent
mode=sys.argv[1]
data={'uid':os.getuid(),'table':os.environ.get('LAB_TABLE'),
'path':os.environ.get('PATH'),'shell':os.environ.get('SHELL'),
'parent_only':os.environ.get('PARENT_ONLY')}
if mode=='percent': data['stdin']=sys.stdin.read()
(root/(mode+'.json')).write_text(json.dumps(data))
''')
user_table = ('SHELL=/bin/sh\nPATH=/usr/bin:/bin\nLAB_TABLE=user\n'
f'* * * * * /usr/bin/python3 {worker} user\n'
f'* * * * * /usr/bin/python3 {worker} percent%first%second\n'
f'* * * * * root /usr/bin/id >{root}/wrong-column.err 2>&1\n')
subprocess.run(['crontab', '-'], input=user_table, text=True, check=True)
invalid = subprocess.run(['crontab', '-'], input='* * * * * /bin/true',
text=True, capture_output=True)
assert invalid.returncode != 0
assert subprocess.check_output(['crontab', '-l'], text=True) == user_table
system_table.write_text('SHELL=/bin/sh\nPATH=/usr/bin:/bin\nLAB_TABLE=system\n'
f'* * * * * {username} /usr/bin/python3 {worker} system\n')
system_table.chmod(0o644)
daemon = subprocess.Popen(['cron', '-f'], env={**os.environ, 'PARENT_ONLY': 'parent-demo'},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.monotonic() + 75
names = ['user.json', 'system.json', 'percent.json', 'wrong-column.err']
while time.monotonic() < deadline:
assert daemon.poll() is None
if all((root/name).exists() and (root/name).stat().st_size for name in names):
break
time.sleep(0.2)
else:
raise AssertionError('scheduled table outputs were not produced')
user = json.loads((root/'user.json').read_text())
system = json.loads((root/'system.json').read_text())
percent = json.loads((root/'percent.json').read_text())
wrong = (root/'wrong-column.err').read_text()
assert user['uid'] == 0 and user['table'] == 'user'
assert system['uid'] == account.pw_uid and system['table'] == 'system'
assert all(value['parent_only'] is None for value in (user, system))
assert all(value['path'] == '/usr/bin:/bin' for value in (user, system))
assert percent['stdin'] == 'first\nsecond\n'
assert 'root: not found' in wrong
print(json.dumps({'user_table': user, 'system_table': system,
'missing_final_newline_rejected': invalid.returncode,
'previous_table_preserved': True, 'percent_stdin': percent['stdin'],
'username_in_user_table_fails': wrong.strip()}), flush=True)
finally:
daemon.terminate()
daemon.communicate(timeout=5)
daemon = None
finally:
if daemon is not None:
daemon.terminate()
daemon.communicate(timeout=5)
system_table.unlink(missing_ok=True)
subprocess.run(['crontab', '-r'], capture_output=True)
subprocess.run(['userdel', '--remove', username], capture_output=True, check=True)
用户表任务实际以 UID 零执行;系统表任务使用新建账号,本次分配的 UID 为一千。两者分别得到自己的 LAB_TABLE 值和明确配置的 PATH,都没有继承启动守护进程的父环境变量 PARENT_ONLY。包含百分号的记录把 first\nsecond\n 传入标准输入。错误加入用户名的用户记录则输出 root: not found,即使它所在的任务表此前已经成功安装。
程序还尝试用没有末尾换行的新内容替换任务表,该操作以退出码一被拒绝,原表保持不变。全部断言已经在 cron 3.0pl1-162 上通过。额外探测发现,这个软件包即使使用检查选项,也接受了错误的分钟文字,因此这里不会从退出码零推导出所有字段都已经有效;记录的是版本限定的实际观察,而不是猜测其他版本必然相同。
实验验证账号、环境、百分号解析及安装失败后的保留行为,没有测试邮件投递、夏令时处理或实际业务成功。部署真正任务时,应把这些额外要求继续列为可验证条件,并检查最终运行结果,不能以文件存在或安装命令成功替代完成证据。
来源
- Debian 12: crontab(5),用户与系统任务表格式、环境及百分号处理,已核验 2026-09-13。
- Debian 12: cron(8),系统任务文件、所有权和执行环境,已核验 2026-09-13。
- Debian 12: crontab(1),任务表安装、检查选项和末尾换行要求,已核验 2026-09-13。