把 Python 定时脚本装进 Docker:一份生产级 Dockerfile 与七个踩坑点
为什么要容器化一个定时脚本
一个 Python 定时脚本从「今天能跑」到「半年后还能跑」,中间隔着的不是功能,而是环境。服务器换了一台、系统 Python 从 3.9 升到 3.12、某天 pip install 装到了新版本依赖、日志写满磁盘把机器拖死——这些都是我真实遇到过的问题。
容器化能一次性解决其中大部分:镜像把依赖钉死,时区和运行用户随镜像走,日志统一到 stdout,重启策略交给 Docker。下面这份模板是我在几台机器上反复改出来的,可以直接套用。
最小可用 Dockerfile
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TZ=Asia/Shanghai \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends tzdata tini \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN useradd -m -u 1000 app && chown -R app:app /app
USER app
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "-m", "job.main"]
看着不长,但每一行都在填一个坑。
坑点一:设了 TZ 环境变量,时间还是差八小时
ENV TZ=Asia/Shanghai 只影响调用 localtime 的程序,而 slim 镜像里根本没有时区数据库。必须装 tzdata 并把 /etc/localtime 指过去,两者缺一不可。
排查方法很简单:进容器执行 date,如果还是 UTC,就是只设了环境变量没设系统时区。
坑点二:docker logs 一片空白
Python 默认按块缓冲 stdout,任务跑完或者缓冲区满才输出。定时脚本往往几分钟就退出,结果日志一行都看不到。加 PYTHONUNBUFFERED=1 解决。
同时建议日志只走 stdout,不要自己再写文件:
import logging, sys
logging.basicConfig(
level=logging.INFO,
stream=sys.stdout,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
落盘交给 Docker 的日志驱动,配合 max-size / max-file 做轮转,比自己在代码里切分文件省事得多。
坑点三:容器停不下来,要等 10 秒超时被强杀
容器里的 1 号进程负责转发信号。如果你的启动命令是 shell 形式(CMD python job.py),1 号进程是 /bin/sh,它不会把 SIGTERM 传给 Python,Docker 等 10 秒后直接 SIGKILL,正在写的文件就断在半路。
两个修法:用 exec 形式(JSON 数组)启动,或者干脆加 tini 做 1 号进程,它还会顺手回收僵尸进程。
坑点四:切成非 root 后,挂载目录写不进去
镜像里用 uid 1000 的 app 用户,宿主机的 ./data 往往属于 root 或其他 uid,一写就 Permission denied。两种处理:
- 宿主机上执行
chown -R 1000:1000 ./data - 或者构建时把 uid 做成构建参数,按宿主机的实际 uid 生成
不建议为了省事直接 USER root,一旦脚本被利用,root 容器基本等于把宿主机交出去。
坑点五:三个月后重新构建,装出来的是另一个程序
requirements.txt 里写 requests 和写 requests==2.32.3,是两种命运。生产镜像一定要锁死版本,最好再加一份 constraints.txt 管住间接依赖。
另外两点:PIP_NO_CACHE_DIR=1 能让镜像小几十 MB;--no-install-recommends 能少装一堆用不到的包。
坑点六:进程活着,不等于任务成功
restart: unless-stopped 只管进程退出。定时脚本更常见的失败是「进程还在、但每轮都抛异常」。这时健康检查要检查业务状态,而不是端口:
HEALTHCHECK --interval=5m --timeout=10s --start-period=2m \
CMD python -c "import sys,time,pathlib; p=pathlib.Path('/app/data/last_ok'); sys.exit(0 if time.time()-p.stat().st_mtime < 7200 else 1)"
再配合一条简单的告警,比事后翻日志高效得多。
坑点七:定时到底放容器里还是放宿主机
两种做法我都用过:
- **宿主机 cron +
docker exec**:调度直观,但容器重建后 exec 可能打到不存在的容器上。 - **容器内 APScheduler 常驻**:调度跟着镜像走,重启后自动恢复;代价是要处理「同一时刻只跑一份」的问题。
如果只跑单实例,容器内调度更省心。一旦要多副本,就必须加分布式锁(Redis 的 SET NX PX 就够用),否则同一分钟会跑出好几份结果,把下游数据写花。
一份可以直接用的 compose
services:
job:
build: .
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- LOG_LEVEL=INFO
volumes:
- ./data:/app/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
小结
把上面的内容压成一张检查表:镜像里装 tzdata 且设置 /etc/localtime;PYTHONUNBUFFERED=1;exec 形式启动或加 tini;非 root 运行并处理好卷权限;依赖锁版本;日志走 stdout 并配置轮转;健康检查检查业务状态;多副本必须加锁。
做完这八件事,脚本才算真的「放上去不用管」。
- 点赞
- 收藏
- 关注作者
评论(0)