关于如何优雅地在容器中定时运行脚本


最早我是在 Windows 上面开始学习和开发 Python 程序的,最开始是写些脚本来定时帮我更新一些数据,后来觉得需要有个地方来让多人共享查看这些数据,就进一步进行了 Web 开发。

关于如何定时运行脚本,在 Windows 上面是有个叫“计划任务”的程序,当时就在它里面设置了一系列的运行任务,它会定时帮你启动程序脚本,如果使用的是 python.exe 启动,会有黑色命令行窗口,如果是使用 pythonw.exe 启动,则不会出现窗口,但是它确实在运行,你可以在任务管理器当中看到进程的存在。而我觉得还是需要观察下它到底运行的如何的,所以一般还是会使用带窗口的,虽然有时候还正在做着事情,忽然就弹出几个窗口来,后来习惯了也就觉得没啥影响了。

后来,开始在 Linux 上面来运行程序。在这个系统平台上面,可就没有什么可操作的界面给你用了,都是命令、命令、还是命令。

如果要在 Linux 上面定时运行任务,基本上会选择 cron 这个工具,不过它的命令其实又是 crontab。使用 crontab -l 列出当前用户已设置的任务列表,使用 crontab -e 调用默认编辑器对任务列表进行编辑,编辑器要是没有特别设置的话一般都是 vi 这个,初次接触的话可能要先去研究下怎么进入和退出编辑状态以及如何保存文件并退出。

虽然可能已经有很多针对 cron 的改进款替代品,不过一般也没谁特地再去折腾自己,简单纯粹,凑合用了。

由于 Linux 目前这个时间点能开箱直接让你用上 Python 3 的可能性几乎为零,所以一般情况下还需要额外再自己编译安装下 Python 3,特别是当你是非 root 账户的情况下,这又多了一次折腾了,多数情况下,你可能会遇到提示某个库缺失的情况,那就缺啥装啥,再来一次,反复折腾几次,就应该差不多了。

正常情况下,机器要是没遇到啥特别的情况,基本上运行环境就这样了,没谁会再冒着把环境搞砸的风险而手痒去维护更新升级,更不用说大概率是会让已经在运行的任务要么停止运行一段时间或者由于测试而产生重复运行导致数据不符合预设要求的情况。

以上基本上是绝大多数情况下的常规选择路径了,没啥特别情况的话,到此基本差不多了。

不过,其实这样的选择在很长一段时间内也不会有什么问题,只是总还是觉得有点麻烦,开启一批脚本并使得它们确认正常运行,还是需要花上蛮长一段时间的,毕竟当中有些步骤是只有当你去正式动手操作了才会知道具体会遇到什么问题,等搜索完相关信息并执行完解决方案,实际已经敲打了不少命令,然而这些命令,并不会自然地留下任何文件记录,除非你操作一步又额外再去复制内容记录一下,不过大多数情况下,都是不会有心情这么干的,何况记录的还不一定正确和完整。假设再来一次新机设定,也依然还是重复这个路径,当然,有运维的就另当别论,不过,即使是运维,也不会喜欢行一步看一步,并重复很多次。

所以,其实问题还是有很多。

Docker 从两三年前一飞冲天,坊间的互联网上到处都在谈论关于容器的事情,给人感觉但凡是个程序员都会设法尝试下容器并写出几篇“容器入门“的文章,不过容器的发展速度用日新月异来形容简直不为过分,留下在互联网上的一句句 docker pull 和 docker build 还有 docker run 并携带一串串长长的参数,可能并未给人留下多好的印象。毕竟,我们真的会那么喜欢命令么?并不,超过 3 个字符的命令我们都会设法 alias 为 2 个字符,更不用说那么一串几百个字符还带换行符的命令了。

还好,有 docker-compose 可以解决下,把命令都变成 yml 文件留下来吧,这样还能进入 git 进行版本控制,对我来说,只要有 docker-compose up -d 和 docker-compose down 就可以了。当然现在,更多的是 docker-compose -f docker-compose.dev.yml up -d 和 docker-compose -f docker-compose.dev.yml down,以及 docker-compose -f docker-compose.prod.yml up -d 和 docker-compose -f docker-compose.prod.yml down 这样的两套几乎仅有一处文件差异的启动和停止命令。

Web 服务程序反而是最开始启用 Docker 来运行的,而直到现在才认为找到了正确并合适的方法来在 Docker 中运行定时任务程序。

曾尝试过照旧使用 cron 在容器当中启动运行定时任务程序,虽然在本机 macOS 上的 Docker 中是基本没啥问题,可是到了 Docker for Linux 那边,就绝对不会一开始就让你正常运行的,毕竟,除了你要真的搞明白 cron 任务列表的更新原理之外,还有一道文件权限问题等着解决,这样就直接失去了简单跨平台的能力了。

基于寻求更好的简单跨平台能力,又方便维护更新的原则,基于 Python,寻找可替代 cron 启动脚本的方案。

可选的方案有几个:schedscheduleAPScheduler

一个是内置 python 库,要用应该是可以用的。

一个是说 Python job scheduling for humans,也挺不错,不过并行多任务需要额外看下文档。

一个是说 Advanced Python Scheduler,功能最多的了,还能基于它开发成 Web 方式去管理任务。

我由于 Tornado 用的勤快,所以我也就选择了它的 Tornado 模式,你选其它模式也是一样可以的。

APScheduler 的演示示例倒是写了好几种,不过反而关于如何设置定时的部分在文档里面不那么明显,就这点来说 schedule 相比就做的非常好,文档写得好这点也是可以加分很多的,如果需求真的简单又没啥特别的,我可能还是建议直接使用 schedule 好了,简单易懂。

如何把以前的脚本直接就拿来用呢?无论这几个方案,都会让你提供一个函数,还有定时规则,如果有参数,也可以提交参数。原来的 .py 文件,基本上是会在底部 main 模块这里面写点东西,好点的可能会是一个独立函数,不好的情况,可能就会是还有很多代码。

改是不可能再去改原有代码了的啦,最好直接拿来就用,那么这样的话,其实最好就是选 subprocess 了,使用 subprocess.run([‘python’, ‘filename.py’]) 这样的代码去启动任何你想启动的脚本,不需要对原有代码做任何改动,这样最好不过了。

把 subprocess.run 当作给任务的函数,需要启动的文件名命令作为参数传给它:

"""
Demonstrates how to use the Tornado compatible scheduler to schedule a job that executes on 3
second intervals.
"""

from datetime import datetime
import os
import time

from tornado.ioloop import IOLoop
from apscheduler.schedulers.tornado import TornadoScheduler
import subprocess


def tick():
    print('Tick! The time is: %s' % datetime.now())


if __name__ == '__main__':
    scheduler = TornadoScheduler()

    scheduler.add_job(tick, 'interval', seconds=300)
    scheduler.add_job(subprocess.run, 'interval', minutes=5, args=(['python', 'helloworld.py'], ))
    scheduler.add_job(subprocess.run, 'cron', hour=12, minute=20, args=(['python', 'helloworld.py'], ))

    scheduler.start()
    print('Press Ctrl+{0} to exit'.format('Break' if os.name == 'nt' else 'C'))

    # Execution will block here until Ctrl+C (Ctrl+Break on Windows) is pressed.
    try:
        IOLoop.instance().start()
    except (KeyboardInterrupt, SystemExit):
        pass

这样就使用一个 Scheduler 把 cron 完全替代了,这样便可以很方便去启动一个容器来运行它,不再有什么特别的命令操作和权限操作了,实现简单跨平台,而且也可以实现测试与运行两不误,更新升级的速度也会快很多。

额外地,还想提一点:也不必说所有脚本就都必须放在这一个容器里面跑,一台机器你可以开多个容器,比如有的可能是比较早期开发的 python 2 版本的程序,而有的是 python 3 版本的程序,你可以分别创建容器去跑,这样环境隔离,就不必纠结机器上面的 python 版本的问题了,而且随时可以选择你想要的 python 版本来运行。或者甚至可以一个容器只跑一个任务,有多少个任务开多少个容器去跑,起停任务就直接起停容器就好,无需对代码进行编辑。

当然,实际操作中根据具体情况来抉择,简单点就放一起,分开比较合适的,那就分开,反正环境隔离,起停随意,没啥问题好纠结的。