没事造轮子没事造轮子

没事造轮子

如何在 Docker 中运行 Cron?

  • W_Z_C
  • 阅读约 6 分钟
如何在 Docker 中运行 Cron?

前几天刚刚在新的平台部署了博客系统,目前使用还比较顺利,不过因为使用了 cloudflare 服务,国内访问速度有点感人。使用的过程中突然想到数据备份的问题,因为博客系统目前核心数据是 Markdown 文档,这些会实时上传到 github,并不太担心,除此之外博客的点赞数据以及其它的动态数据却是个问题,万一哪天平台的云盘坏掉了,我这个免费撸羊毛的用户可能都没地方说理去,再加上我准备接下来添加评论之类的功能,可能对数据库的依赖逐级递增,所以数据库备份这个问题还是要提上进程的。

我想到了一个最简单的方案,就是直接将数据库上传到 github,以后数据大了,可以上传到 Google 云盘之类的地方,问题不大。理论上上传到这些地方应该一个简单的脚本就搞定了,因此我脑海中的第一个想法就是创建一个定时任务,每隔 6 个小时就上传一次。 目前 fly.io 并没有提供类似的服务,所以需要专门创建一个镜象来执行此类任务。

Ofelia 是一个不错的选择,但是我怀疑部署的时候 fly.io 平台是否支持某些参数,我的要求很简单,理论上直接使用 cron 定时运行脚本即可,因此我的想法是直接封装一个专门运行 cron 的镜象,然后部署到云主机即可,但是没想到 cron 在 Docker 中运行会遇到很多问题,查了很多资料,下面是我的一些总结,供大家参考。

cron 是什么?

工具型软件 cron 是一款类 Unix 的操作系统下的基于时间的任务管理系统。用户们可以通过 cron 在固定时间、日期、间隔下,运行定期任务(可以是命令和脚本)。cron常用于运维和管理,但也可用于其他地方,如:定期下载文件和邮件。cron 该词来源于希腊语 chronos(χρόνος),原意是时间。

通常,任务时间表(crontab)文件储存的指令被 crond 守护进程激活,守护进程在后台运行,并每一分钟检查是否有定期的作业需要执行。这类作业一般称为 cron jobs。

上面是维基百科中关于 Cron 的描述,本质上就是支持定时运行某些命令的程序。cron 的 API 接口以及被标准化了,但是该标准有很多不同的实现(dcron、cronie、fcron 以及 vixie-cron 等)。除此之外,有些功能 cron 需要和 anachron 配合使用,例如非 7 x 24 开机的机器,如果关机状态下 cron 是无法运行的,即使等机器开机之后在此期间需要执行的任务请求也会被忽略。而 anachron 程序可以弥补这个潜在的问题。

总而言之,cron 的实现有很多种,它们的功能差异化较大,有些 cron 已经内置了 anachron 的功能,而有些并没有。因此对于不同的版本,cron 的使用方式可能略有不同,特别是在 Docker 环境下。

在不同版本的 Linux 中,Cron 的安装方式也越有差异:

  • Debian/Ununtu:apt-get update && apt-get install -y cron && cron
  • Centos:yum install -y cronie && crond -V
  • Alpine:已经预装,可以使用 which crond 查看。

配置文件

crontab 是 cron table 的简称,是 cron 程序的配置文件。cron 支持在多用户环境下运行,配置文件可能存在几个地方,不同的 Linux 版本可能存在一定的差异:

  • /var/spool/cron/crontabs 目录下对应不同用户的配置文件,该文件一般是由 crontab 命令创建,不建议手动修改。

  • /etc/cron.d 目录下是为了更精确的控制,在编辑的时候需要注意权限。

  • /etc/crontab 是系统级的配置文件。

对于 Docker 容器来说,多用户的作用不大,只需要专注全局的配置文件:/etc/crontab 即可。

下面的配置命令可以每分钟运行一次 date 命令:

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name command to be executed

* * * * * root date

前台执行

现在我们已经有了一个 cron 配置文件,正常情况下,cron 被作为守护进程来启动,一般由 service 服务来管理这些后台进程。但是在 Docker 内部,默认只能启动一个程序(其实可以运行 systemd,是另一种解决方案),所以 cron 应该在前台运行。大多数的 cron 程序都支持这一点。

  • Debian/Ubuntu:cron -f
  • Alpine:cron -f
  • Centos:crond -n

环境变量

前面已经提到过,cron 被设计支持多用户,所以 cron 守护进程无法对运行环境做出统一的行为。cron 为此强制每个任务从自定义环境中运行(通常是 /etc/environment 文件)。所以如果你在 cron 运行脚本,可能会导致无法读取到某些环境变量的问题。除此之外,cron 默认的 shell 是 sh,而非 bash。

当在 Docker 中运行 cron 时,环境变量就会非常重要,因为 Docker 最常用的配置方式就是使用环境变量,因此必须确保容器内的环境变量可以传入到 cron 的子进程中。较为合适的方式是使用 Docker 中的 entrypoint 参数,在前台 cron 程序运行之前导入需要的环境变量。

下面是 /entrypoint.sh 的大致代码:

entrypoint.sh
#!/bin/sh

env >> /etc/environment

# start cron in the foreground (replacing the current process)
exec "cron -f"

Centos 系统中 cronie 并不是从 /etc/environment 中读取环境变量的。你需要在脚本运行之前手动获取,例如:

* * * * * root . /etc/environment; date

如果 crontab 配置中存在多个任务,你可以改变默认的 shell,并加载指定的环境变量:

SHELL=/bin/bash
BASH_ENV=/etc/environment

* * * * * root echo "${CUSTOM_ENV_VAR}"

STDOUT/STDERR

如果你运行第二小节中的例子,可能会发现终端上没有显示 date 命令的输出信息,这是因为即使 cron 运行在前台,其子进程(任务进程)的输出也被重定向到日志文件(一般在 /var/log/cron 中)。这个在主机运行的时候没有什么问题,但是在 Docker 中运行显然不是最佳方案,因为你无法在 Docker 日志中直接查看 cron 的输出。

解决这个问题的方法有很多,stackoverflow 上有一个 方案,直接在 Dockerfile 中输出 cron 日志:

# Run the command on container startup
CMD cron && tail -f /var/log/cron.log

我更偏向于文章中另一种,直接将日志重定向:

# >/proc/1/fd/1 redirects STDOUT from the `date` command to PID1's STDOUT
# 2>/proc/1/fd/2 redirects STDERR from the `date` command to PID1's STDERR

* * * * * root date >/proc/1/fd/1 2>/proc/1/fd/2

因为 Docker 内部 cron 进程号为 1,因此你可以在 Docker 日志中看到所有 cron 进程的输出信息。

Alpine 中无需这样做,只需要直接运行:crond -f -l 2

Kill

在测试的时候,你可能会发现运行 cron 的容器很难被关掉,普通的容器直接使用 Ctrl + Cdocker stop 就可以关停,但是 cron 容器你可能必须使用 docker kill 才可以。

这是因为 cron 在前台运行的时候好像并不能及时正确的处理 SIGINT 信号。目前还没有特别好的简易方案,先忍忍吧!

Centos 中可以正常功能,cronie 程序可以很好的处理前台的 SIGTERM 信号。

代码

下面是最后整合的代码:

FROM ubuntu

RUN apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install -y cron \
    # Remove package lists for smaller image sizes
    && rm -rf /var/lib/apt/lists/* \
    && which cron \
    && rm -rf /etc/cron.*/*

COPY entrypoint.sh /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

# https://manpages.ubuntu.com/manpages/trusty/man8/cron.8.html
# -f | Stay in foreground mode, don't daemonize.
# -L loglevel | Tell  cron  what to log about jobs (errors are logged regardless of this value) as the sum of the following values:
CMD ["cron","-f", "-L", "2"]

下面是镜像编译和运行命令:

docker build -t cron .
docker run --rm --name cron -e CUSTOM_ENV_VAR=foobar -v `pwd`/crontab:/etc/crontab cron

你可以看到下面的输出:

foobar
Wed Jun 15 07:46:01 UTC 2022

总结

条件允许的情况下,还是用 Ofelia 吧!🤣