之前写了篇 Docker 配代理的文章,把 Docker 拉镜像慢的坑给填了。这次来聊聊一个更实际的话题——咋把 Python 项目正经地 Docker 化部署。

说实话,刚开始搞 Docker 的时候,我写的 Dockerfile 那叫一个粗糙,镜像动辄 1G+,构建还贼慢。后来踩了不少坑,才算摸出一套相对靠谱的写法。今天就从基础到进阶,一次性给大伙儿捋清楚。

一个最基础的 Dockerfile

咱先从最简单的来,假设你有个 Python 项目,结构大概长这样:

1
2
3
4
5
6
my-project/
├── app/
│ ├── __init__.py
│ └── main.py
├── requirements.txt
└── Dockerfile

最基本的 Dockerfile 写法:

1
2
3
4
5
6
7
8
9
10
FROM python:3.10-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "-m", "app.main"]

看着挺简单对吧?但这其实就是个”能跑”的版本,离”好用”还差得远。

这里用的是 python:3.10-slim 而不是 python:3.10。slim 版本基于 Debian 精简版,体积小了一大截,一般场景够用了。如果你的项目需要编译 C 扩展,可能还得加上 build-essential

层缓存优化——别小看这几行顺序

上面的 Dockerfile 里,我 故意把 COPY requirements.txt 放在 COPY . . 前面,这不是随便写的,是有讲究的。

Docker 构建镜像的时候是按层缓存的。如果某一层的输入没变,Docker 就直接用缓存,不会再执行。问题来了——如果你把 COPY . . 放前面,那你改一行代码,pip install 那层就得重新跑,每次构建都得重新下载所有依赖,坑爹不?

所以正确做法是:先 COPY requirements.txt,执行 pip install,再 COPY 整个项目代码。这样只要你没改依赖文件,pip install 那层就会命中缓存,构建速度飞起。

1
2
3
4
5
6
# 第一层:依赖文件(很少变动)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 第二层:项目代码(经常变动)
COPY . .

.dockerignore——别把垃圾塞进镜像

好家伙,之前有一次构建镜像,发现咋这么大?一查才发现,COPY . ..git__pycache__venv 全 TM 的拷进去了。

所以项目根目录下一定要加一个 .dockerignore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.git
.gitignore
__pycache__
*.pyc
*.pyo
venv/
.env
.venv/
*.egg-info
dist/
build/
.idea/
.vscode/
node_modules/

这玩意儿的作用跟 .gitignore 类似,就是告诉 Docker 构建的时候忽略哪些文件。不加 .dockerignore,你的镜像可能凭空大几百 MB

多阶段构建——进阶玩法

接下来是重头戏。如果你的项目有需要编译的依赖(比如 psycopg2numpy 之类的),构建阶段需要 build-essential 这些编译工具,但运行的时候根本用不到。多阶段构建就是为了解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# ============ 阶段一:构建 ============
FROM python:3.10-slim AS builder

WORKDIR /app

# 安装编译依赖(只在构建阶段存在)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*

# 创建虚拟环境
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ============ 阶段二:运行 ============
FROM python:3.10-slim AS runtime

WORKDIR /app

# 从构建阶段拷贝虚拟环境
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# 拷贝项目代码
COPY . .

CMD ["python", "-m", "app.main"]

这样搞出来的好处是:最终镜像里没有编译工具链,体积能小一半build-essential 那一套加起来好几百 MB 呢,运行时根本不需要。

原理很简单:Docker 只会把最后一个阶段打包成镜像,前面阶段的东西用完就扔了。但通过 COPY --from=builder 可以把需要的产物(这里是虚拟环境)拷过来。

踩坑点合集

1. 非 root 用户运行

默认情况下,容器里的进程是以 root 身份跑的。这在生产环境是个安全隐患——万一容器被攻破,攻击者直接就是 root。

1
2
3
4
5
# 创建非 root 用户
RUN useradd --create-home --shell /bin/bash appuser

# 切换到该用户
USER appuser

把这两行加在 COPY . . 之后、CMD 之前就行。

注意文件权限问题!如果你先 COPY 代码再切换用户,appuser 可能没权限写某些目录。需要提前用 chown 处理好,或者在 COPY 之前就切换用户。

2. 时区问题

容器默认是 UTC 时区,日志时间和咱们差 8 个小时,排查问题的时候看着贼难受。

1
2
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

或者更简单,直接设置环境变量让 Python 程序自己处理:

1
ENV TZ=Asia/Shanghai

不过建议还是用上面 ln -snf 的方式,这样系统层面的时区也是对的。

3. 代理配置

有些兄弟的服务器网络环境特殊,pip install 走不了公网,得配代理。这个问题我之前那篇文章已经详细讲过了,这里简单提一下:

1
2
3
4
# 在 pip install 之前配置代理
ARG HTTP_PROXY
ARG HTTPS_PROXY
RUN pip install --no-cache-dir -r requirements.txt

构建的时候这样传:

1
2
3
docker build --build-arg HTTP_PROXY=http://your-proxy:port \
--build-arg HTTPS_PROXY=http://your-proxy:port \
-t myapp .

更详细的 Docker 代理配置(包括 daemon.json 全局代理、镜像加速等),可以看我之前写的这篇文章:Docker 配置代理实战

docker-compose.yml 示例

光有 Dockerfile 还不够,实际部署一般还得配合 docker-compose。来一个完整的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
version: "3.8"

services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: my-python-app
restart: unless-stopped
ports:
- "8000:8000"
environment:
- TZ=Asia/Shanghai
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
- REDIS_URL=redis://redis:6379/0
volumes:
- ./logs:/app/logs
depends_on:
- db
- redis

db:
image: postgres:15-alpine
container_name: my-postgres
restart: unless-stopped
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"

redis:
image: redis:7-alpine
container_name: my-redis
restart: unless-stopped
ports:
- "6379:6379"

volumes:
pgdata:

几个要点:

  • restart: unless-stopped 让容器意外退出后自动重启,生产环境必备
  • volumes 把日志目录挂载出来,方便排查问题,不用每次都 docker exec 进去看
  • depends_on 控制启动顺序,但要注意它只保证容器启动,不保证服务就绪。如果你的应用对数据库连接有时序要求,代码里还是要做好重连逻辑

最终版 Dockerfile

把上面的知识点整合一下,来一个生产可用的完整版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
FROM python:3.10-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.10-slim

ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY . .

RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

CMD ["python", "-m", "app.main"]

总结

回顾一下这篇文章的核心要点:

  1. 基础镜像选 slim 版,别用完整版,省空间
  2. requirements.txt 单独 COPY + pip install,利用层缓存加速构建
  3. .dockerignore 必须有,别把无关文件塞进镜像
  4. 多阶段构建分离编译和运行环境,减小镜像体积
  5. 非 root 用户运行,安全第一
  6. 时区、代理这些坑提前踩好

Docker 化部署这事儿,说难不难,但细节很多。希望这篇文章能帮你少走点弯路,别像我当初一样踩坑踩到怀疑人生。

参考资料