Contents

Cron作业仅运行一次

1. 概述

cron守护进程允许任务的调度。它允许我们控制任务何时开始,但仅此而已。

特别是,cron无法**防止同一任务的两次执行重叠。**如果我们想避免并发执行我们的任务,我们需要在任务脚本中自己实现它。

在本教程中,我们将学习两种基于进程检测和使用*.pid*文件来防止任务重叠的方法。

2. 工作示例

出于本教程的目的,我们将使用bash脚本作为任务。我们将构建它,一次一种技术,因此它将阻止第二个实例运行。

2.1. 任务脚本

首先,让我们准备脚本:

#!/usr/bin/env bash
DURATION=$1
do_the_action () {
  date +"PID: $$ Action started at %H:%M:%S, ETA: $DURATION seconds"
  sleep $DURATION
  date +"PID: $$ Action finished at %H:%M:%S"
}
do_the_action

这里我们使用sleep 命令来模拟任务持续时间。第一个参数——$1——将允许我们定义任务执行的时间。

为了帮助理解执行期间发生了什么,date 命令输出带有时间戳的消息。为了帮助理解哪个进程是哪个,我们从 bash 变量 $$ 输出进程PID

2.2. 安排任务

现在,让我们设置一个cron任务来每分钟运行一次该脚本,持续时间为 55 秒。任务应该在下一个实例出现之前结束。

我们将脚本放在*/tmp/action1.sh*中,然后创建一个 crontab 记录来运行我们的任务并将输出记录到日志文件中:

$ echo '*/1 * * * * /tmp/action1.sh 55 >> /tmp/action1.log' | crontab

我们应该观察到crontab 命令的管道替换了当前用户的**整个 crontab 。**这对我们的教程来说不是问题,但可能不适合在生产中使用。

让这个任务运行几分钟后,我们可以检查*/tmp/action1.log*文件并注意没有重叠:

$ tail -f /temp/action1.log
PID: 21764 Action started at 13:41:01, ETA: 55 seconds
PID: 21764 Action finished at 13:41:56
PID: 21770 Action started at 13:42:01, ETA: 55 seconds
PID: 21770 Action finished at 13:42:56

2.3. 模拟重叠任务

现在让我们将持续时间增加到 70 秒来创建我们想要修复的场景:

echo '*/1 * * * * /tmp/action1.sh <strong>70</strong> >> /tmp/action1.log' | crontab

这次我们可以清楚地看到问题所在,下一个任务在上一个任务完成之前开始:

$ tail -f /temp/action1.log
PID: 21881 Action started at 13:43:01, ETA: 70 seconds
PID: 21886 Action started at 13:44:01, ETA: 70 seconds
PID: 21881 Action finished at 13:44:11

为了防止这种冲突,我们需要实现代码来检测先前的实例并中止脚本的重叠调用。

3. 按进程检测运行实例

3.1. 识别任务进程

在我们的示例中,脚本使用bash。当它运行时,我们可以使用pgrep 找到它:

$ pgrep --list-full bash
19125 bash
21172 bash /tmp/action1.sh 70
21187 bash /some/other/script

在这里,我们使用*–list-full*选项来获取所有 bash 进程的列表及其命令行。

从上面的输出中,我们可以看到进程21772似乎是我们任务的一个实例,因为它的命令行包含我们脚本的名称。

我们可以使用带有确切脚本名称的额外grep来缩小搜索范围:

$ pgrep --list-full bash | grep '/tmp/action1.sh'
21172 bash /tmp/action1.sh

在将这些方法添加到我们的脚本之前,在命令行中尝试这些方法会很有帮助。

但是,如果我们将此方法添加到我们的脚本中,它将检测脚本的每次调用,包括当前实例。我们需要从结果中排除这个实例。这就是使用grep实用程序 是正确的地方:  *grep -v “^$$”*将过滤掉以当前 PID 开头的所有行并过滤掉当前调用。

3.2. 实施准则

现在,让我们将检测器添加到脚本中的一个函数中:

previous_instance_active () {
  pgrep -a bash | grep -v "^$$ " | grep --quiet '/tmp/action1.sh' 
}

我们应该注意到在最后一个grep命令中添加了*–quiet*选项。这可以防止检测输出出现在日志文件中。

接下来,我们修改任务脚本以在检测到先前的调用时退出:

if previous_instance_active
then
  date +'PID: $$ Previous instance is still active at %H:%M:%S, aborting ... '
else
  do_the_action
fi

3.3. 测试结果

应用这些更改后,我们可以再次检查*/tmp/action1.log*文件以查看检测是否按预期进行:

$ tail -f /tmp/action1.log
...
PID: 11529 Action started at 14:18:01, ETA: 70 seconds
PID: 11531 Previous instance is still active at 14:19:01, aborting ... 
PID: 11529 Action finished at 14:19:11
PID: 11545 Action started at 14:20:01, ETA: 70 seconds

尽管此方法似乎运行良好,但对于每个用例而言可能都不够可靠。

实际上,脚本可能不知道它在文件系统上的唯一路径,因此可能必须搜索包含其名称的子字符串。因此,如果 bash 进程的另一个实例恰好包含相同的子字符串,那么就会出现误报。我们可能需要更强大的解决方案。

4. 通过*.pid*文件检测正在运行的实例

4.1. 使用文件来传达状态

通过这种方法,我们利用了.pid文件技术 。这样,下一个任务实例**可以检测到前一个化身留下的文件,以确定另一个实例是否正在运行。*我们将介绍一个PIDFILE变量和两个处理.pid*文件的过程:

PIDFILE="/tmp/action1.pid"
create_pidfile () {
  echo $$ > "$PIDFILE"
}
remove_pidfile () {
  [ -f "$PIDFILE" ] && rm "$PIDFILE"
}

我们应该从执行操作的代码周围调用它们(在实例已经运行时中止的条件逻辑中):

create_pidfile 
do_the_action
remove_pidfile 

4.2. 从上一个实例中读取消息

让我们重写我们的previous_instance_active函数来使用这个方法:

previous_instance_active () {
  local prevpid
  if [ -f "$PIDFILE" ]; then
    prevpid=$(cat "$PIDFILE")
    kill -0 $prevpid 
  else 
    false
  fi
}

通过在此处使用*kill -0*,我们通过额外检查强制执行*.pid*文件技术。如果失败,那么我们知道在文件中检测到的先前实例 PID不再是正在运行的进程

4.3. 避免旧的*.pid*文件

基于文件的检测比使用pgrep可靠得多,但它可能仍然不够健壮。

我们考虑一下任务执行中途出现故障的 情况。任务进程消失,但*.pid*文件将保留。尽管我们正在检查该 PID 是否处于活动状态,但系统可以将 PID 重新用于其他进程。这可能会导致错误检测。

因此,我们希望增加进程退出时删除*.pid*文件的机会。我们可以使用trap 指令来做到这一点。trap命令将代码的执行绑定到系统信号。**我们可以使用特殊的 EXIT 信号使清理代码在脚本退出时运行,**无论退出原因是什么。

要添加此退出时删除功能,我们只需修改最后几行:

trap remove_pidfile EXIT
create_pidfile
do_the_action

我们不再显式调用remove_pidfile过程,而是让bash在脚本终止时执行该调用。