前言
前几天的文章中,我们已经把使用 pdm 的项目用 docker 搞定了 ,那么下一步就是把完整的 DjangoStarter v3 版本用 docker 部署。
现在不像之前那么简单直接一把梭了,因为项目用了 npm, gulp 之类的工具来管理前端依赖,又使用 pdm 管理 python 依赖,所以这波我用上了多阶段构建(multi-stage build)
而且这次还把 uwsgi 给替换掉了。不过先别说 uwsgi 老归老,性能还是不错的,只不过现在已经是 asgi 时代了,wsgi 限制还是有点多,再加上我这次部署的项目用到了 channels ,所以就顺理成章用上了它推荐的 daphne 服务器,感觉还行,更多用法还在探索中,后续搞出来就写文章来记录。
在踩过很多坑之后,终于把这套玩意搞定了。
PS:折腾这玩意真是心累…感觉自己就是一个无情的网络搬运工,根据搜索引擎和官方文档搜索到的资料(现在还可以加上GPT),不断排列组合,最终形成可以用的方案?
本文记录一下折腾的过程,同时新的 docker 部署方案很快就合并入 DjangoStarter 项目的 master 分支。
一些概念
深刻理解 docker 的工作原理有助于避免很多问题
每次遇到很多坑焦头烂额之后,都会深深感觉自己还是太菜了
多阶段构建
在Dockerfile中,使用多个
FROM
指令并通过
AS
关键字为每个
FROM
指定一个名称的功能被称为
“多阶段构建”(multi-stage build)
。
多阶段构建允许你在一个Dockerfile中使用多个基础镜像,并且可以在构建过程中选择性地从某个阶段复制构建结果到另一个阶段。这样做的好处是,你可以在前面的阶段中使用一个较大的镜像来进行构建工作(例如编译代码),然后在最终阶段中只保留必要的文件,将它们放入一个更小的基础镜像中,以减少最终镜像的大小。
多阶段构建极大地简化了构建复杂镜像的流程,同时也有助于保持最终镜像的体积较小。
有几点要注意的:
-
每个阶段之间不需要指明依赖关系,build 的时候会同时进行构建,只有遇到
COPY --from=<阶段名称>
或COPY --from=<阶段索引>
这个语句才会等待依赖的阶段构建完。 -
每个
FROM
指令都会启动一个新的构建阶段。这些阶段是独立的,一个阶段的环境变量、文件系统状态等不会自动传递给下一个阶段,每个阶段可以选择从任何之前的阶段中复制构建成果。 -
多阶段构建中,Dockerfile中的指令是顺序执行的,前面的阶段不会自动传递文件或状态到后面的阶段,除非明确使用
COPY --from=<阶段>
指令。
Docker Volumes 机制
Docker volumes 是由 Docker 管理的数据卷,用于在容器之间以及容器和宿主机之间持久保存和共享数据的机制。
- 即使容器被删除,保存在 volumes 中的数据仍然存在
- 可以将同一个 volume 挂载到多个容器上,实现数据的共享
- volume 数据存储在 Docker 主机的特定区域,容器只是通过挂载点访问这些数据
- 优先级(很重要) ,容器运行时挂载的 volume 会覆盖容器中相应路径的内容。(我就是因为这个覆盖的问题,导致 static-dist 里的文件一直无法更新)
所以在静态文件共享的场景下,如何保持数据一致性就很重要了。
dockerfile
直接来看看我最终搞完的 dockerfile 吧
ARG PYTHON_BASE=3.11
ARG NODE_BASE=18
# python 构建
FROM python:$PYTHON_BASE AS python_builder
# 设置 python 环境变量
ENV PYTHONUNBUFFERED=1
# 禁用更新检查
ENV PDM_CHECK_UPDATE=false
# 设置国内源
RUN pip config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple/ && \
# 安装 pdm
pip install -U pdm && \
# 配置镜像
pdm config pypi.url "https://mirrors.cloud.tencent.com/pypi/simple/"
# 复制文件
COPY pyproject.toml pdm.lock README.md /project/
# 安装依赖项和项目到本地包目录
WORKDIR /project
RUN pdm install --check --prod --no-editable
# node 构建
FROM node:$NODE_BASE as node_builder
# 配置镜像 && 安装 pnpm
RUN npm config set registry https://registry.npmmirror.com && \
npm install -g pnpm
# 复制依赖文件
COPY package.json pnpm-lock.yaml /project/
# 安装依赖
WORKDIR /project
RUN pnpm i
# gulp 构建
FROM node:$NODE_BASE as gulp_builder
# 配置镜像 && 安装 pnpm
RUN npm --registry https://registry.npmmirror.com install -g gulp-cli
# 复制依赖文件
COPY gulpfile.js /project/
# 从构建阶段获取包
COPY --from=node_builder /project/node_modules/ /project/node_modules
# 复制依赖文件
WORKDIR /project
RUN gulp move
# django 构建
FROM python:$PYTHON_BASE as django_builder
COPY . /project/
# 从构建阶段获取包
COPY --from=python_builder /project/.venv/ /project/.venv
COPY --from=gulp_builder /project/static/ /project/static
WORKDIR /project
ENV PATH="/project/.venv/bin:$PATH"
# 处理静态资源资源
RUN python ./src/manage.py collectstatic
# 运行阶段
FROM python:$PYTHON_BASE-slim-bookworm as final
# 从构建阶段获取包
COPY --from=django_builder /project/.venv/ /project/.venv
COPY --from=django_builder /project/static-dist/ /project/static-dist
ENV PATH="/project/.venv/bin:$PATH"
ENV DJANGO_SETTINGS_MODULE=config.settings
ENV PYTHONPATH=/project/src
ENV PYTHONUNBUFFERED=1
COPY src /project/src
WORKDIR /project
multi-stage build
这个 dockerfile 里有这几个构建阶段,看名字可以可以看出个大概了
- python_builder: 安装 pdm 包管理器和 python 依赖
- node_builder: 安装前端依赖
- gulp_builder: 使用 gulp 工具处理整合前端资源
- django_builder: 将前面几个容器的构建成果里拿出 python 依赖和前端资源,然后执行 collectstatic 之类的工作(当前仅此项,以后可能会增加其他的)
- final: 最终完成后用于运行和生成镜像
要点
在调试这个 dockerfile 的过程中,有一些要关键点
- 构建阶段不要使用 slim 镜像,以免环境太简陋遇到一些奇奇怪怪的问题
- 从 django_builder 阶段开始,涉及到 python 的运行了,必须将虚拟环境中的 python 路径加入环境变量
- 因为 DjangoStarter v3 开始使用新的项目结构,源代码都放在根目录的 src 目录下,所以在 final 阶段需要把这个目录加入 PYTHONPATH 环境变量,不然会遇到奇怪的包导入问题(我在用 uwsgi 时就遇到了)
-
还是 final 阶段,我还设置了
DJANGO_SETTINGS_MODULE
,PYTHONUNBUFFERED
等环境变量,不管有没有用,先保持跟开发环境一致避免遇到问题
docker-compose
我在 compose 配置里用了一些环境变量
避免了每个项目都要去修改项目名啥的
先上配置,后面再来介绍。
services:
redis:
image: redis
restart: unless-stopped
container_name: $APP_NAME-redis
expose:
- 6379
networks:
- default
nginx:
image: nginx:stable-alpine
container_name: $APP_NAME-nginx
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./media:/www/media:ro
- static_volume:/www/static-dist:ro
depends_on:
- redis
- app
networks:
- default
- swag
app:
image: ${APP_IMAGE_NAME}:${APP_IMAGE_TAG}
container_name: $APP_NAME-app
build: .
restart: always
environment:
- ENVIRONMENT=docker
- URL_PREFIX=
- DEBUG=true
# command: python src/manage.py runserver 0.0.0.0:8000
command: >
sh -c "
echo 'Starting the application...' &&
cp -r /project/static-dist/* /project/static-volume/ &&
exec daphne -b 0.0.0.0 -p 8000 -v 3 --proxy-headers config.asgi:application
"
volumes:
- ./media:/project/media
- ./src:/project/src
- ./db.sqlite3:/project/db.sqlite3
- static_volume:/project/static-volume
depends_on:
- redis
networks:
- default
volumes:
static_volume:
networks:
default:
name: $APP_NAME
swag:
external: true
几个要点
nginx
这里面我加入了 nginx 容器,用来提供 web 服务,因为之前一直使用 uwsgi ,而是要 uwsgi 的 socket 模式是没有静态文件功能的,要让 uwsgi 提供静态文件服务,除非使用 HTTP 模式,不过那样又失去了 uwsgi 的优势了。所以我一直是用 nginx 来提供静态文件访问。
不过现在已经不直接在服务器上安装 nginx 而是改成了 swag 容器一把梭,所以必须得用在 compose 里加一个 web 服务器,既然如此,就还是继续 nginx 吧,用得比较熟了。关于这个问题,之前这篇文章( 项目完成小结 - Django-React-Docker-Swag部署配置 )也有讨论到。
image name
这次 build 出来的镜像终于加上名字了…
为接下来 push 到 docker hub 铺路
数据共享
因为之前一直是在本地执行 collectstatic 然后再上传到服务器,所以不存在静态文件的问题,但一点也不优雅,对于 CICD 来说也很不友好
这次 DjangoStarter v3 也一并解决这个痛点,把前端依赖和资源管理都整合到 docker 的 build 阶段里面了,所以需要使用 docker volume 来为 app 和 nginx 容器共享这部分静态资源
正如开头说的 volume 优先级更高,导致就算后面修改了一些 static 资源,build 后重启也是用已经 mounted 到 volume 里的旧版,所以这里我把 app 容器的 /project/static-volume 挂载到共享的 static_volume ,然后再把 /project/static-dist 里的文件复制过去,而不是直接挂载到 /project/static-dist 里,这样会导致 static_volume 里的旧数据把 collectstatic 出来的文件覆盖掉。
还有其他的方式,比如每次启动前先把 static_volume 里的文件清理掉,然后再挂载,不过试了下有点折腾,我就放弃了。
关于应用服务器的选择
Django(或者说是 Python 系的后端框架)不像 .netcore, springboot 这类框架一样内置 kestrel, tomcat 之类的服务器,所以部署到生产环境只能借助应用服务器。
常见的 Django 应用服务器有 uWSGI、Gunicorn、Daphne、Hypercorn、Uvicorn 等
之前一直使用 uWSGI ,这是个功能强大且高度可配置的 WSGI 服务器,支持多线程、多进程、异步工作模式,并且具有丰富的插件支持。它的高性能和灵活性使其成为许多大型项目的首选。然而,uWSGI 的配置相对复杂,对于新手来说可能不太友好。uWSGI 的高复杂性在某些场景下可能导致配置错误或难以调试。
然后在本文的例子里,使用 uwsgi 部署一直出问题,因为之前有个项目用到了 channels ,所以这次使用了 Daphne,这是 Django Channels 项目的核心部分,是一个支持 HTTP 和 WebSocket 的 ASGI 服务器。而且也支持 HTTP2 之类的新功能,其实也挺不错的。
如果需要处理 WebSocket 连接或使用 Django Channels,Daphne 是一个理想的选择。Daphne 可以与 Nginx 等反向代理服务器结合使用,以处理静态文件和 SSL 终端。
接下来我还打算试试 Gunicorn 和基于 ASGI 的 Uvicorn,这个好像在 FastAPI 项目里用得比较多,Django 自从3.0开始支持异步功能(也就是 ASGI),所以其实可以放弃传统的 WSGI 服务器了?
其他的几个我复制一些介绍
Gunicorn (Green Unicorn) 是一个轻量级的 WSGI 服务器,专为简单易用而设计。Gunicorn 的配置简单,默认情况下就能提供较好的性能,因此在开发和生产环境中都被广泛使用。与 uWSGI 相比,Gunicorn 的学习曲线较低,适合大多数 Django 项目。
Hypercorn 是一个现代化的 ASGI 服务器,支持 HTTP/2、WebSocket 和 HTTP/1.1 等多种协议。它可以运行在多种并发模式下,如 asyncio 和 trio。Hypercorn 适用于需要使用 Django Channels、WebSocket,或者希望在未来支持 HTTP/2 的项目。
Uvicorn 是一个基于 asyncio 的轻量级、高性能 ASGI 服务器,专为速度和简洁性而设计。它支持 HTTP/1.1 和 WebSocket,同时也是 HTTP/2 的早期支持者。Uvicorn 通常与 FastAPI 搭配使用,但它同样适用于 Django,特别是当你使用 Django 的异步特性时。Uvicorn 的配置相对简单,且启动速度非常快,适合开发环境和需要异步处理的生产环境。
ASGI 已经是未来的趋势了,所以接下来还是放弃 WSGI 吧…
小结
之前折腾的时候花了很多时间,实际总结下来也没啥,就那几个关键点。但因为对 docker, WSGI, ASGI, Python运行机制等理解不够深刻,所以就导致踩了很多坑,最终靠排列组合完成了这套 docker 方案……?
就这样吧,接下来我会继续完善 DjangoStarter v3 ,最近还有其他一些关于 Django 的开发经验可以记录的。
参考资料
- 如何创建高效的 Python Docker 镜像 - https://www.linuxmi.com/python-docker-images.html