Coolify 的 Laravel 任务调度最佳实践:withoutOverlapping、onOneServer 与 Schedule Groups 深度解析
Coolify 是一个可自托管的 PaaS(私有云应用平台),其服务端管理逻辑完全构建在 Laravel 之上,大量后台工作——SSL 证书续期、服务器健康检查、孤儿容器清理、版本更新检查、用户级备份与定时任务——都依赖 Laravel 的任务调度器(Scheduler)驱动。本文以仓库内置的 Laravel 调度最佳实践规则 scheduling.md 为主体,逐条讲解六项核心调度规则的设计动机,并结合 Console Kernel、生产环境 s6 调度服务 与 调度缓存去重助手 的真实源码,展示这些规则在 Coolify 中的落地方式,读完即可掌握编写生产级 Laravel 定时任务的完整方法论。
一、背景:Coolify 的调度器是如何跑起来的
在展开六条规则之前,先明确适用前提:Laravel 调度器必须有一个进程每分钟触发一次 schedule:run。Coolify 采用 schedule:work 常驻模式,由 s6-overlay 进程守护:
- 生产环境:docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run 中执行
exec php artisan schedule:work,并支持通过.env中的SCHEDULER_ENABLED=false完全关闭调度; - 开发环境:docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run 同样以
schedule:work启动。
所有调度条目集中声明在 app/Console/Kernel.php 的 schedule() 方法中。值得注意的是,Coolify 用 isDev() 将调度表分成两套:开发环境跑更高频的任务(如每分钟执行 CleanupInstanceStuffsJob、每十分钟执行 CheckHelperImageJob),生产环境则降频(每两分钟、每五分钟),避免自托管用户的生产服务器承担不必要的开销。这种"按环境裁剪任务频率"的思路,本身就是下一节 environments() 规则的延伸用法。
二、规则一:变长任务必须加 withoutOverlapping()
原文档第一条规则指出:没有 withoutOverlapping(),一个长时间运行的任务会在下一个触发点再被拉起第二个实例,导致重复处理或资源耗尽。
Coolify 的调度表中有直接实证:
$this->scheduleInstance->command('cleanup:stucked-resources')
->daily()
->onOneServer()
->withoutOverlapping(60);
(见 app/Console/Kernel.php#L49-L52)
cleanup:stucked-resources 负责扫描并修复卡死的资源部署状态,其执行时长取决于数据库里有多少"卡住"的记录,属于典型的变长任务。->withoutOverlapping(60) 的参数表示锁的过期时间为 60 秒:一旦上一轮执行在 60 秒内未结束,新一轮触发时会自动跳过。
更深一层的教训来自 app/Jobs/ScheduledJobManager.php#L52-L92。Coolify 的用户级定时任务管理器使用了队列层的 WithoutOverlapping 中间件:
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(90) // 高负载环境下锁 90 秒过期
->dontRelease(), // 锁冲突时不重新入队
];
源码注释明确记录了生产中遇到的真实坑:升级、Redis 重启等边界情况可能留下 TTL 为 -1 的"僵尸锁",永久阻塞所有调度执行。为此专门实现了 clearStaleLockIfPresent() 自愈逻辑——每次任务派发前检查锁的 TTL,若为 -1 则主动删除并写告警日志。结论:withoutOverlapping 类机制必须配套考虑锁的过期与清理策略,否则它自己会成为新的单点故障。
三、规则二:多服务器部署必须加 onOneServer(),且依赖共享缓存驱动
原文档第二条规则:多服务器部署下不加 onOneServer(),每台服务器会同时执行同一任务;该机制要求配置共享缓存驱动(Redis、database、Memcached)。
这条规则在 Coolify 中是默认姿势。浏览 app/Console/Kernel.php 可以看到,几乎所有会产生副作用的任务都挂上了 ->onOneServer():
| 任务 | 频率 | 出处 |
|---|---|---|
sanctum:prune-expired --hours=1 |
每小时 | Kernel.php#L53 |
ApiTokenExpirationWarningJob |
每小时 | Kernel.php#L54 |
ServerManagerJob(服务器连接/状态管理) |
每分钟 | Kernel.php#L82 |
ScheduledJobManager(备份与定时任务派发) |
每分钟 | Kernel.php#L87 |
RegenerateSslCertJob(SSL 证书再生成) |
每天两次 | Kernel.php#L89 |
CheckTraefikVersionJob |
每周日 00:00 | Kernel.php#L91 |
CleanupOrphanedPreviewContainersJob |
每天 | Kernel.php#L97 |
这些任务如果每台服务器各跑一遍,后果从重复发通知到重复续证书不等。而 onOneServer() 的锁正是通过共享缓存实现的——Coolify 的默认缓存驱动就是 Redis,见 config/cache.php#L18:'default' => env('CACHE_DRIVER', 'redis')。这恰好满足文档所述的前提约束;如果换成 file 或 array 驱动,onOneServer() 会退化为每台机器各自独立锁,形同虚设。
四、规则三:并发长任务使用 runInBackground()
原文档第三条规则:默认情况下,同一触发点(tick)的任务串行执行,一个慢任务会阻塞其后所有任务;runInBackground() 让每个任务作为独立进程并发运行。
这条规则在 Coolify 的 Kernel 中没有直接调用(当前调度表里的任务大多是"快速派发"型——真正耗时的工作被投递为队列任务,例如 ServerManagerJob 每分钟只负责派发和状态检查)。但从源码结构看,这正是该规则成立的原因:Coolify 把重活从调度器里剥离到 Horizon 队列(config/horizon.php、app/Console/Kernel.php 中 dev 环境每分钟执行 horizon:snapshot),从而避免了 tick 串行阻塞问题。可以推断,当你在自己的调度表里同时挂了多个分钟级长命令时,->runInBackground() 就是恢复并发性的标准手段。
五、规则四:用 environments() 限制任务运行环境
原文档第四条规则:防止生产专用任务(计费、报表)在 staging 上被误触发,并给出示例:
Schedule::command('billing:charge')->monthly()->environments(['production']);
Coolify 处理环境差异的方式略有不同:它用 isDev() 条件分支直接拆分两套任务清单(app/Console/Kernel.php#L56-L98),例如 horizon:snapshot 开发环境每分钟、生产环境每五分钟。两种写法各有取舍——isDev() 分支能裁剪频率和任务集合,而 environments() 声明式地限制"任务只属于哪些环境",对 billing 这类绝对不能跨环境的任务更稳妥。此外,Coolify 还展示了第三种条件控制手法:->when() 闭包按运行时配置动态启停任务:
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
->name('cleanup:ssh-mux')
->hourly()
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
(见 app/Console/Kernel.php#L44-L47)——只有启用了 SSH 连接复用且不在 Windows Docker Desktop 上时才清理 SSH 复用连接,体现了"条件化调度"的第三种形态。
六、规则五:用 takeUntilTimeout() 约束无界处理时间
原文档第五条规则:一个每 15 分钟跑一次、处理无界游标(unbounded cursor)的任务,可能和下一次运行重叠;应当用 takeUntilTimeout() 给执行时间设上限。
这条规则对应的是"批处理 + 时间窗口"场景。Coolify 里与之精神一致的实现出现在 bootstrap/helpers/shared.php#L986-L1007 的 shouldRunCronNow() 助手:用户自定义的备份/任务频率由数据库中的 cron 表达式驱动,派发判定采用"上次派发时间 vs 上一个到期时间点"的去重策略(getPreviousRunDate() + last-dispatch tracking),源码注释说明其设计目标是"对队列延迟保持弹性——即使任务晚了几分钟执行,仍能补上错过的 cron 窗口",去重键写入缓存后设置 30 天静态 TTL(2592000 秒)以便孤儿键自动清理。这展示了生产级调度中"时间约束"的另一个维度:不仅要限制单次执行时长,还要保证频率判定在时钟漂移和队列积压下依然正确。
七、规则六:用 Schedule Groups 共享重复配置
原文档第六条规则:避免在多个任务上重复 ->onOneServer()->timezone('America/New_York'),给出示例:
Schedule::daily()
->onOneServer()
->timezone('America/New_York')
->group(function () {
Schedule::command('emails:send --force');
Schedule::command('emails:prune');
});
Coolify 的 Kernel 没有直接用 group(),但用"私有方法抽取共享调度参数"实现了同样的目的,且参数全部来自运行时配置:
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
if (validate_timezone($this->instanceTimezone) === false) {
$this->instanceTimezone = config('app.timezone');
}
private function scheduleUpdates(): void
{
$this->scheduleInstance->job(new CheckForUpdatesJob)
->cron($this->updateCheckFrequency)
->timezone($this->instanceTimezone)
->onOneServer();
if ($this->settings->is_auto_update_enabled) {
$autoUpdateFrequency = $this->settings->auto_update_frequency;
$this->scheduleInstance->job(new UpdateCoolifyJob)
->cron($autoUpdateFrequency)
->timezone($this->instanceTimezone)
->onOneServer();
}
}
(见 app/Console/Kernel.php#L38-L42、app/Console/Kernel.php#L109-L123)
这里 PullTemplatesFromCDN、PullChangelog、CheckHelperImageJob、CheckForUpdatesJob、UpdateCoolifyJob 五个任务共享同一个 cron 频率与时区,频率值来自实例设置(update_check_frequency 缺省为 '0 * * * *',见 app/Console/Kernel.php#L36),并在 app/Livewire/Settings/Updates.php 提供 UI 配置与 validate_cron_expression() 校验——空值会回退到 '0 0 * * *'(自动更新)或 '0 * * * *'(更新检查)。这是比 group() 更进一步的做法:共享的不只是静态链式配置,还包括用户可改的运行时频率。
八、调度表全景:一条生产级 schedule() 应该长什么样
综合原文档六条规则与 Coolify 的实际代码,可以提炼出可直接套用的调度清单(均出自 app/Console/Kernel.php):
// 通用:按条件启停(对应 rules: environments/when 条件化)
->call(fn () => ...)->name('cleanup:ssh-mux')->hourly()->when(fn () => config('constants.ssh.mux_enabled'));
// 变长任务:锁 + 多节点去重组合(规则一 + 规则二)
->command('cleanup:stucked-resources')->daily()->onOneServer()->withoutOverlapping(60);
// 频率与时区全部来自实例设置(规则六的运行时版本)
->job(new PullChangelog)->cron($frequency)->timezone($tz)->onOneServer();
// 固定时间点任务(规则六:共享配置)
->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($tz)->onOneServer();
// 环境频率裁剪:dev 每 1 分钟,prod 每 2 分钟
if (isDev()) { ->job(new CleanupInstanceStuffsJob)->everyMinute()->onOneServer(); }
else { ->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); }
几点适用前提与限制需要强调:onOneServer() 依赖共享缓存,Coolify 默认走 Redis(config/cache.php#L18);withoutOverlapping 的锁可能因中间件重启残留而失效,需配套 TTL 过期与自愈逻辑(app/Jobs/ScheduledJobManager.php#L54-L92);schedule:work 进程本身可以通过 SCHEDULER_ENABLED=false 关闭,调试或灾备时可以整体停用(docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run)。
九、小结
scheduling.md 的六条规则对应调度器的四类典型事故:重叠执行(withoutOverlapping())、多节点重复执行(onOneServer() + 共享缓存)、串行阻塞(runInBackground())、跨环境误触发(environments()/when())、无界执行时间(takeUntilTimeout() 与派发去重)、配置重复(Schedule Groups 或参数抽取)。Coolify 的 Console Kernel 是一份高完成度的参考答案:它把 onOneServer() 当作副作用任务的默认修饰符,用 when() 与 isDev() 分支做条件化调度,并用私有方法 + 实例设置把 cron 频率与时区从硬编码中解放出来。对照 SKILL.md 第 14 节 的调度快速清单,这些模式可以直接迁移到任何生产级 Laravel 应用的定时任务设计中。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00