Laravel Schedule 优雅退出实现
2024-12-20
问题背景
laravel定时任务重新部署,如果涉及正在运行的任务,会被强行终止,只能等待下一次执行。
并且如果使用了 withoutOverlapping()
/ onOneServer()
这类的锁,无法正常释放,下次即便到点也无法派发任务,需要等待锁自动过期后,下一次才执行。
Cron 退出并等待剩余php进程
laravel定时任务的本质,是依赖crontab去每分钟执行一个schedule:run
* * * * * /usr/local/bin/php /var/www/backend/artisan schedule:run
在项目里,docker 启动命令是将定时任务作为前台运行
CMD ["crond","-f"]
k8s发送停止命令,容器会被退出,正在执行的任务也被掐掉了,只能等待下次执行,如果是每日执行一次的任务,影响就很大了。
k8s优雅退出的机制是,先发送 STOPSIGNAL
, 等待30s(terminationGracePeriodSeconds), 如果还没停,则发送 SIGKILL
并且宽限时间如果设定为超过1分钟,则cron会再次调用 schedule:run , 这个也是不合理的。
也就是程序需要自行处理SIGTERM,终止Cron继续调用 schedule:run
派发新的任务
然后等待所有PHP进程全部结束,主动退出
则:
- PID1 不能为cron, 需要变为后台运行 (cron日志需要打到stdout上)
- 检测 SIGTERM 信号,kill cron
- 检测是否有正在执行的php, 无则退出
完整bash如下
#!/bin/sh# 0-正常运行, 1-Cron退出, 2-PHP任务全部执行完成
status=0
function stop() {
echo "stopping crond...."
kill $(ps aux | grep '[c]rond' | awk '{print $1}')
status=1
}
trap stop SIGQUIT SIGINT SIGTERM
crond
while true; do
if [ $status -eq 1 ]; then
## 判断是否有剩余的任务## 进程特征如下## sh -c ('/usr/local/bin/php' 'artisan' test:test > '/dev/null' 2>&1 ;
running=$(ps aux | grep '[s]h -c' -c)
echo "running process: $running"
if [ $running -lt 1 ]; then
echo "program exit"
exit 0
fi
fi
sleep 1
done
验证
子任务的优雅退出
仅仅是等待php任务执行完成还是不够,如果任务本身就是耗时,还是会因为超过宽限时间而被强制终止。
这就需要各个任务自行消化 SIGTERM 信号,主动退出。
如果是laravel9以上,可以用现成的 trap 函数
https://laravel.com/docs/11.x/artisan#signal-handling
这边给出laravel8及以下版本的解法
class Test extends Command
{
protected bool $shouldKeepRunning = true;
public function handle(): void
{
// 收到SIGTERM,标记为终止
pcntl_signal(SIGTERM, function () {
$this->shouldKeepRunning = false;
});
$start = microtime(true);
while (true) { // 长耗时任务,一般伴随循环处理
sleep(1);
$this->info('Keep running: '.microtime(true) - $start);
if(!$this->shouldKeepRunning) {
$this->info('保存进度');
exit(); // 主动退出
}
}
}
}
由于 k8s 发送的 STOPSIGNAL
只会给到 pid为1的进程,并且 laravel schedule 常伴随 runInBackground()
使用,这会导致派生的子进程收不到 STOPSIGNAL
前面的 sh脚本 改进一下, 当 kill crond 后,主动对 php 任务发送kill信号,注意不能直接kill到 schedule:run
这里简单解释一下 laravel schedule:run 执行后会发生什么
$schedule->command('test')->everyMinute()->runInBackground();
上面这条任务,执行后,会生成两个进程
一个是 schedule 进程,用于跟踪真正执行的任务,任务完成后,执行对应任务的schedule:finish
sh -c ('/usr/local/bin/php' 'artisan' test > '/dev/null' 2>&1 ; '/usr/local/bin/php' 'artisan' schedule:finish "framework/schedule-***" "$?") > '/dev/null' 2>&1 &
另一个是真正的任务进程
/usr/local/bin/php artisan test
要杀的是真正的任务进程,而不能直接杀死shedule进程。如果杀死shedule 则任务就成孤儿了,laravel再也管不了他的死活,如果使用了 withoutOverlapping()
或者 onOneServer()
redis锁也就无法释放了
#!/bin/sh## 判断是否有剩余的任务## 进程特征如下# 1. schedule 进程,用于跟踪真正执行的任务,任务完成后,执行对应任务的schedule:finish# sh -c ('/usr/local/bin/php' 'artisan' test > '/dev/null' 2>&1 ; '/usr/local/bin/php' 'artisan' schedule:finish "framework/schedule-***" "$?") > '/dev/null' 2>&1 &# 2. 真正的任务进程# /usr/local/bin/php artisan test
/usr/local/bin/php /var/www/backend/artisan schedule:refresh
# 0-正常运行, 1-Cron退出, 2-PHP任务全部执行完成
status=0
function stop() {
echo "stopping crond...."
# 终止cron, 不再继续派发任务
kill $(ps aux | grep '[/]usr/sbin/crond' | awk '{print $1}')
# 对当前正在执行的php任务, 发送SIGTERM, 注意,这里不能对schedule发送,而要对真正任务进程发送,见第8行
running=$(ps aux | grep '[s]h -c' -c)
if [ $running -gt 0 ]; then
echo "Send SIGTERM to running process: $running"
kill $(ps aux | grep '[/]usr/local/bin/php artisan' | awk '{print $1}')
fi
status=1
}
trap stop SIGQUIT SIGINT SIGTERM
#trap 'exit 1' SIGTERM
/usr/sbin/crond
running=0
while true; do
if [ $status -eq 1 ]; then
# 通过观察schedule进程,判断是否有未完成的schedule,见第6行# 有变化则输出
currentRunning=$(ps aux | grep '[s]h -c' -c)
if [ $running -ne $currentRunning ]; then
running=$currentRunning
echo "running process: $running"
fi
if [ $running -lt 1 ]; then
echo "program exit"
exit 0
fi
fi
sleep 1
done
坑
- 一开始只trap SIGTERM, docker stop正常,但在k8s下始终收不到SIGTERM,排查后发现,信号在php官方镜像里被修改为了 SIGQUIT
https://github.com/docker-library/php/blob/master/8.1/alpine3.21/fpm/Dockerfile#L275
# Override stop signal to stop process gracefully
# <https://github.com/php/php-src/blob/17baa87faddc2550def3ae7314236826bc1b1398/sapi/fpm/php-fpm.8.in#L163>
STOPSIGNAL SIGQUIT
至于为什么要选择 SIGQUIT 而 不是 SIGTERM 可以看下 nginx 这个 issue
https://github.com/nginxinc/docker-nginx/issues/377
https://trac.nginx.org/nginx/ticket/753
- 确保使用exec模式而不是shell模式