ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Docker+Nginx部署Python Web应用:从Dockerfile到上线全攻略

用Docker+Nginx部署Python Web应用:从Dockerfile到上线全攻略 1. 部署前先想清楚为什么非要用 Docker Nginx1.1 这套组合到底解决了什么问题Python Web 应用部署最老派的玩法是直接在一台服务器上装 Python 环境、pip install 依赖、然后用 systemd 挂一个 Gunicorn 进程。这套玩法在小项目上完全够用但一旦环境复杂起来就非常难受。换一台机器、换一个 Python 版本、换一次操作系统所有依赖都要重新折腾一遍经常是开发环境跑得好好的一到生产就各种诡异报错。Docker 解决的是环境一致的问题。把 Python 版本、系统依赖、pip 安装的包、启动命令全部写进 Dockerfile构建成镜像后镜像到哪儿跑起来都是一样的。它本质上就是把应用 运行时 依赖整个打包带走。你不需要再去纠结服务器上到底是 Ubuntu 还是 DebianPython 是 3.10 还是 3.11因为镜像里已经写死了。Nginx 解决的则是对外服务的问题。Python 应用本身一般跑在 Gunicorn 这类 WSGI 服务器上监听内网端口比如 8000。你不能直接把应用裸奔着暴露到公网一方面 WSGI 服务器直接面对公网流量容易出问题另一方面端口管理、HTTPS 证书、静态资源分发这些事需要更专业的工具。Nginx 作为反向代理把公网请求收进来该转给 Gunicorn 的转给 Gunicorn该直接回静态文件的直接回各司其职。我见过不少人一上来就纠结要不要用 Docker其实这个问题应该反过来问你是不是经常遇到环境不一致、依赖冲突、部署回滚困难如果是答案就很简单用。如果你只想在一台固定的服务器上长期跑一个简单项目也不打算迁移那不用也行但一旦项目生长起来该补的课迟早要补上。1.2 什么时候不需要这套组合什么时候必须用先泼一盆冷水不是任何项目都非 Docker Nginx 不可。适合简化部署的场景有三个特征项目是单一 Flask/Django 应用、流量很小、发布频率很低。这种场景直接用 systemd Gunicorn 就能撑住。我最初几个项目就是这么跑的写一个 Systemd Unit 文件开个三五个 worker管理者后台和定时任务也能稳定跑几个月。但一旦出现以下信号之一就说明该切换到容器化方案了依赖里有编译型包pandas、numpy、pydantic 这类在开发机装得好好的服务器上死活装不上因为编译工具链或 Python 版本不一致需要同时部署 Web 应用、Redis、MySQL、Celery worker 等多个服务手动管理互相打架想在一台服务器上跑多个项目端口冲突、Python 版本冲突、依赖互相污染需要频繁发布希望回滚像切换镜像标签一样简单至于 Nginx但凡应用要暴露到公网、要上 HTTPS、要做静态资源缓存它就绕不开。我也见过直接把 Flask 跑在公网 5000 端口上的做法内网测试无所谓公网这么干基本就是等着被扫描、被薅流量、被漏洞打穿。所以我的结论是Docker 负责让应用走哪儿都一样Nginx 负责让应用对公网体面两个配合起来部署从玄学变成流水线。2. Python Web 应用的 Docker 化改造2.1 一个能直接跑的 Dockerfile 长什么样Dockerfile 是整个容器化的核心。下面这个例子以 Flask Gunicorn 为例换成 Django 也差不多只是 CMD 里的 Gunicorn 参数要加项目配置FROM python:3.11-slim WORKDIR /app RUN apt-get update apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [gunicorn, app:app, -b, 0.0.0.0:8000, -w, 4, --timeout, 60]逐个说几个关键点基础镜像选 slim 版本而不是完整版。完整镜像带一堆用不到的开发工具体积差出一倍不止。等你后面用上多阶段构建体积还能进一步压缩。apt-get 装 build-essential 是因为很多依赖包安装时需要现场编译。如果确定项目不需要编译任何 C 扩展可以删掉这行镜像体积更小。pip install 加 --no-cache-dir避免把 pip 的下载缓存打进镜像同样是体积优化。CMD 用 gunicorn别用 Flask 自带的开发服务器 python app.py。Flask 内置服务器是开发用的性能差、默认单进程、也没有并发防护能力拿到生产环境就是事故隐患。写完之后在项目根目录构建docker build -t my-web-app:latest .构建成功后先本地跑一遍验证docker run -p 8000:8000 my-web-app:latestcurl 一下 http://localhost:8000 能正常返回页面说明镜像本身没有基础问题。这一步做扎实了后面上服务器基本不会出幺蛾子。2.2 requirements.txt 的版本锁定与依赖治理部署踩坑的重灾区是 requirements.txt 里没锁版本。比如你写的是 Flask2.0一个月后 Flask 发新版本pip 在构建镜像时解析到最新版行为变了应用就炸了。我的做法是在开发环境生成一份锁定的完整清单并且只装项目真正用到的顶层包不要图省事一把梭。pip freeze requirements.txt要注意 pip freeze 会把所有隐式依赖也打进去。比如手动装了 flask它依赖 click、jinja2、werkzeugfreeze 会把它们全部列出来。这其实是好事构建镜像时只安装一遍版本和开发环境完全一致最稳妥。缺点是一堆带 的版本号看着不美观但丑换稳是值得的。Django 项目格外注意一点不要在生产构建镜像时跑数据库迁移。镜像应该只负责打包代码和依赖迁移这种依赖数据库状态的运行时动作应该在容器启动后或发布流程里单独执行否则很容易出现新版本代码配旧数据库 schema 的尴尬状态。2.3 多阶段构建把镜像从 1G 压到 200M镜像体积本地玩可能没人在意传服务器和镜像仓库时就显形了。我见过一个 Django 项目直接 pip 装了一堆东西镜像打到 1.5G每次发布光传镜像就等十分钟非常难受。多阶段构建的原理很好理解先在一个工具齐全的镜像里编译依赖最后只把产物拷进一个干净的精简镜像里。中间用的编译工具不会残留到最终产物里# 阶段一构建依赖 FROM python:3.11-slim AS builder WORKDIR /app RUN apt-get update apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt # 阶段二运行镜像 FROM python:3.11-slim WORKDIR /app COPY --frombuilder /app/wheels /app/wheels COPY --frombuilder /usr/lib/x86_64-linux-gnu/libpq.so* /usr/lib/x86_64-linux-gnu/ RUN pip install --no-cache-dir /app/wheels/* rm -rf /app/wheels COPY . . EXPOSE 8000 CMD [gunicorn, app:app, -b, 0.0.0.0:8000, -w, 4, --timeout, 60]这个写法里pip wheel 先把依赖批量打成 wheel 包第二阶段直接安装打包好的 wheel不需要任何编译工具最终镜像里自然也不会有 gcc、make 这类体积大户。实测下来Django 常用依赖的镜像可以从 1G 级别降到 200M 左右。注意如果你的服务器是 ARM 架构部分云主机、开发板COPY libpq 那行的路径要做调整。在苹果芯片的 Mac 上构建镜像时默认会带 amd64 和 arm64 两个架构层传到服务器跑最省事的做法是构建时指定 --platform linux/amd64避免架构不匹配导致的诡异行为。3. Nginx 反向代理与静态资源分发3.1 最小可用的反向代理配置Nginx 在这套架构里扮演门口接待员。它监听 80 端口所有外部请求先到它这里再由它按规则转给后端容器或静态文件目录。最基础的一份配置长这样server { listen 80; server_name example.com www.example.com; client_max_body_size 50m; location /static/ { alias /var/www/myapp/static/; expires 30d; add_header Cache-Control public, no-transform; } location /media/ { alias /var/www/myapp/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }几个配置项逐个解释server_name 用来区分同机多站。多个域名挂一台服务器时Nginx 就是靠它做虚拟主机分流。client_max_body_size 是上传体积上限。不配置默认只有 1M用户传图传附件直接 413。静态文件 alias 指向容器外的目录Nginx 直接读磁盘文件不经过 Python 进程速度飞快。代理头信息非常重要。Gunicorn 和后端应用收到的是 Nginx 转来的请求不传 X-Forwarded-For后端日志看不到真实用户 IP不传 X-Forwarded-ProtoDjango 的 HTTPS 判断会出错生成 URL 全变成 http 前缀跳转和回调全乱套。3.2 静态文件放容器里还是放宿主机这是新手最容易纠结的问题。我的建议分三类处理第一类是项目代码里自带的 static 资源比如 CSS、JS、图片。最省心的方式是构建镜像时 COPY 进去再用 Docker volume 挂载到宿主机目录Nginx 的 alias 指向这个目录。这样发版时静态文件跟着代码走服务器上不用手动同步。第二类是用户上传的 media 文件。这类必须挂到宿主机目录否则容器重启后文件全丢。Docker 容器是无状态的容器可写层里的内容在容器销毁后不复存在。很多人上传的用户头像重启一次全没了原因就是写在了容器可写层里。compose 里这样挂载services: web: image: my-web-app:latest volumes: - /data/myapp/media:/app/media宿主机 /data/myapp/media 与容器 /app/media 打通Nginx 对应位置location /media/ { alias /data/myapp/media/; }第三类是动态生成的临时文件比如报表 PDF、缩略图。建议由后端写到共享目录Nginx 读取并且定期清理防止磁盘被撑满。3.3 Nginx 的经典坑SSL 证书替换不生效热词里有一条nginx替换ssl证书不生效这个我到现场排查过多回也自己踩过。把新证书文件覆盖到证书路径nginx -t 测试通过systemctl reload 也执行了但浏览器拿到的还是旧证书。原因大概率是 Nginx 配置里的证书路径没变文件名也没变覆盖文件后 worker 进程仍然持有旧文件的句柄。简单说磁盘上的文件换了内存里被 worker 加载的证书还是旧的reload 不一定刷新到这个状态。解决思路有三条把配置改成指向新文件名比如 fullchain_new.pem不同文件名会强制 Nginx 重新加载完整配置。替换证书后用 nginx -s reload有的场景还需要重启整个 Nginx 进程才能彻底生效。更规范的做法是用 symlink 指向当前版本的证书文件替换时换 symlink 指向而不是直接覆盖文件内容。ln -s /etc/letsencrypt/live/example.com/fullchain.pem /etc/nginx/certs/fullchain.pem换证书时只更新 symlink 指向再 reload就不会出现看似替换了但没生效的问题。经验之谈涉及 HTTPS 的任何操作先跑 nginx -t 验证配置再做 reload。强制 reload 瞬间配置有误可能导致服务中断。4. 用 docker-compose 编排整套服务4.1 为什么需要一个编排文件单容器部署简单但现实里很少只有一个容器。Web 应用 MySQL Redis Celery worker几个服务互相依赖。手动一个个 docker run 太痛苦也没法把整套环境配置沉淀成文件。docker-compose.yml 解决的就是这个问题一份 YAML 描述所有服务、网络、数据卷、环境变量一条命令拉起整套一条命令拆掉。一个常见的 compose 文件version: 3.8 services: web: build: . container_name: myapp_web restart: always ports: - 8000:8000 environment: - DB_HOSTdb - DB_PORT5432 - REDIS_HOSTredis depends_on: - db - redis volumes: - /data/myapp/media:/app/media db: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: your_password MYSQL_DATABASE: myapp MYSQL_USER: myapp MYSQL_PASSWORD: your_password volumes: - db_data:/var/lib/mysql ports: - 3306:3306 redis: image: redis:7-alpine restart: always command: [redis-server, --appendonly, yes] volumes: - redis_data:/data volumes: db_data: redis_data:两个关键细节restart: always 保证服务崩溃或服务器重启后自动拉起生产环境必备。depends_on 只控制启动顺序不保证数据库已经完全就绪。Python 应用启动时连不上 MySQL需要在代码里做重试或者配合健康检查。4.2 环境变量与数据卷的正确用法把数据库密码、密钥直接写进 compose 文件等于把秘密贴在门框上。更稳的做法是用 .env 文件services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}项目目录放一个 .env 文件docker compose 读取时会自动加载。注意 .env 要写进 .gitignore千万不能提交到仓库。这个坑我见过不止一次数据库密码被人扫到直接把服务器薅成矿机。数据卷这块重点说持久化。MySQL、Redis 这类有状态服务数据目录必须挂载到 volume 或宿主机目录否则容器销毁数据就没了。上面配置里的 db_data 和 redis_data 是命名卷docker compose down 不会删除它们服务重建后数据还在。用宿主机目录还是命名卷我的习惯是数据库用命名卷便于隔离管理用户上传的媒体文件用宿主机目录方便备份和 Nginx 直读。4.3 日志管理的血泪经验容器日志默认写到宿主机的 JSON 文件里时间长了能把磁盘塞满。我经历过磁盘被日志写满导致整个服务器卡死的深夜从此所有 compose 服务都配置日志轮转services: web: logging: driver: json-file options: max-size: 10m max-file: 3单份日志超过 10M 自动切新文件最多保留 3 份。配合 cron 任务定期清理历史镜像和未使用卷磁盘问题基本能杜绝。5. 从零到上线的完整部署流程5.1 服务器基础准备服务器到手后第一步不是装 Docker而是做基础安全配置禁用 root 密码登录改用密钥登录修改 SSH 默认端口安装 fail2ban 防爆破防火墙只开放 80、443、SSH 端口这些完成后再装 Dockercurl -fsSL https://get.docker.com | sh装完执行sudo systemctl enable docker sudo systemctl start docker顺手把当前用户加入 docker 组避免每次敲 sudosudo usermod -aG docker $USER然后重新登录。Docker 装好后把项目放到服务器两种常用方式git 拉代码再构建镜像或者直接把项目目录打包传上去。我强烈推荐 git 方式发布流程可追溯、可回滚。手动打包传文件第二天你自己都不知道服务器上跑的是哪个版本。注意官方安装脚本在某些网络受限环境会失败。如果遇到改用包管理器安装 docker.io或者配置镜像加速源别在官方脚本上硬磕。5.2 构建镜像与启动服务假设项目已经在服务器 /opt/myapp 下cd /opt/myapp docker compose build docker compose up -d首次构建比较慢之后有层缓存会快很多。但需要注意docker compose build 的缓存命中率取决于 Dockerfile 里 COPY 的顺序。经验是 COPY requirements.txt 放在 COPY . 之前这样只有依赖文件变化才会重装 pip纯代码变更走的是后面几层构建秒级完成。启动后检查docker compose ps服务是 up 状态后在宿主机上 curl 容器端口curl -I http://127.0.0.1:8000能收到响应说明应用已就绪。此时 Nginx 的 proxy_pass 指向 http://127.0.0.1:8000外部流量从 Nginx 进入。5.3 Nginx 配置与接管公网流量我的习惯是 Nginx 直接装在宿主机不套容器。容器跑 Nginx 虽然也可以但宿主机安装更省心维护也更直观。安装、配置、重载三步apt update apt install -y nginxvim /etc/nginx/sites-available/myappnginx -t systemctl reload nginx写配置时 server_name 改成你的实际域名。第一步先 HTTP 跑通再接入 HTTPS。不要一上来就同时配证书出了问题排查难度翻倍。接 HTTPS 时建议直接用 certbot 之类的自动化工具签发证书并把续期做成定时任务。证书续期脚本跑完后自动 reload Nginx 即可。自动续期这里有个容易忽略的点如果证书文件放在 Docker 卷目录里续期后容器里的 Nginx 不一定能感知到需要把证书目录挂载进容器或者用宿主机 Nginx。5.4 发布流程固化跑过几次手动部署后建议把流程固化成脚本代码推送到仓库服务器上 git pulldocker compose builddocker compose down docker compose up -d请求健康检查接口确认回滚也很简单新镜像有问题把 compose 里 image 标签指回上一个版本重新 up。docker images | grep myapp看历史镜像挑上一次正常的 tag 回滚。我的习惯是每次构建打清晰 tag比如 myapp:20250217_v1.2不用 latest。latest 飘忽不定出问题时根本不知道回滚到哪个版本。6. 常见问题与排查技巧实录6.1 Gunicorn worker 数量怎么定这个有参考公式不是随手填。Gunicorn 官方推荐 (2 x CPU 核心数) 1。但实际操作中如果应用是 CPU 密集型、重度依赖 Python 的 GIL这个公式只能当起点还是要压测后调整。我的做法是在一台 2 核服务器上先开 4 个 worker然后用 wrk 或 ab 简单压一下观察 CPU、内存和响应时间。如果 CPU 没打满但响应已经变慢说明代码里有太多同步阻塞加 worker 救不了得从代码层面优化。如果 CPU 打满且响应变差先考虑加配置或加机器。Gunicorn 的 timeout 参数也值得注意。默认 30 秒如果应用里有导出文件、调外部 API 这类耗时操作容易触发 worker 超时被杀。调高到 60 或 120 能避免误杀但调太高也会把慢接口的问题藏住。我的习惯是调到 60然后持续观察日志里的超时记录。6.2 容器网络与端口冲突排查场景Nginx 要转发到容器端口但容器启动后宿主机 curl 不通。先检查一件事docker ps看端口映射是否生效。如果容器用了 --network host它直接共享宿主机网络Gunicorn 监听 0.0.0.0:8000 即可Nginx 直接转发到该端口。如果是默认 bridge 网络就需要 -p 8000:8000 做端口映射。另一个常见问题是在 Windows/Mac 上用 Docker Desktop 做端口映射形态和 Linux 上完全不同。Docker Desktop 的虚拟化机制决定了 127.0.0.1:8000 不一定能直接访问容器需要检查 docker desktop 的网络配置。这也是我建议生产环境直接上 Linux 的原因之一部署行为最接近文档描述少一层抽象。6.3 数据库连不上、容器退出这类基本功排查我把这类问题整理成速查表症状排查方向常见解决手段容器反复重启docker logs 看退出日志补环境变量、修正启动命令应用能起但连不上数据库确认 DB_HOST 是否为服务名把数据库 host 改为 compose 服务名上传文件后刷新丢失检查是否有 volume 挂载将 media 目录挂到宿主机Nginx 转发 502确认容器端口映射与 proxy_pass 一致调整 Nginx 后端地址或端口映射磁盘很快被占满检查容器日志和镜像残留配 log rotate、定期清理旧镜像很多问题本质上都是没想清楚容器生命周期或者配置不一致。排查时按进程在不在 - 端口通不通 - 环境变量对不对 - 数据是否在卷上的顺序推进基本能定位九成问题。6.4 Docker Desktop 与 Linux 服务器的行为差异热词里有docker desktop 安装教程说明很多人在本机折腾 Docker。我多说一句Docker Desktop 在 Windows/Mac 上本质是跑在一个轻量虚拟机里文件挂载、端口映射、网络模式都有额外一层转换。本机能跑通不代表 Linux 服务器上一定能直接跑通。典型的差异包括文件路径大小写敏感、挂载目录权限不同、容器内文件变化在宿主机不一定实时可见。我的建议是本机 Docker Desktop 只用来开发调试最终部署验证一定要在 Linux 环境做一遍哪怕用一台低配 VPS 跑通再交付生产。7. 性能优化与安全加固7.1 静态资源缓存与压缩前面提的 expires 和 Cache-Control 是第一步。生产环境建议打开 gzipgzip on; gzip_vary on; gzip_comp_level 5; gzip_types text/plain text/css application/json application/javascript application/xml image/svgxml;开启后CSS/JS/JSON 体积能减少 60% 以上。但注意别把图片也送进 gzipJPEG/PNG 本身已经压缩过再压只能说浪费时间。压缩级别也别调太高实测 gzip_comp_level 5 和 9 的压缩收益差距很小但 CPU 开销差距明显5 是性价比较优的选择。7.2 容器安全加固的几条基线安全不是保证绝对但几条基线一定要做所有业务容器不要开启特权模式数据库端口不要暴露到公网只在容器内部网络互访。compose 里把 db 的 ports 删掉只保留内部网络访问即可环境变量里不存明文敏感信息配合 .env 或密钥管理工具容器进程用非 root 用户运行Dockerfile 里加 USER 指令定期更新基础镜像基础镜像里的系统库同样存在漏洞Dockerfile 里加这一小段收益很高RUN groupadd -r app useradd -r -g app app USER app别小看这一行。容器被攻破后如果是 root 权限攻击者几乎可以直接控制宿主机如果是普通应用用户操作空间小得多。这个成本极低的操作能挡掉相当一部分自动化的横向渗透。7.3 健康检查与监控的起步配置部署上线只是开始后续维护才是日常。我强烈建议至少做两件低成本的事给容器加 HEALTHCHECK。compose 启动时没有健康检查容器内部进程虽然活着但可能已经无响应Docker 不会主动重启它。在 Dockerfile 里加HEALTHCHECK --interval30s --timeout5s --retries3 \ CMD curl -f http://localhost:8000/health || exit 1前提是应用里有对应的 /health 端点。这个端点平时没人访问但关键时刻能自动触发重启相当于给服务上了一道保险。日志集中处理方面至少把 Docker 日志输出到宿主机目录方便 grep 排查。服务多起来后再考虑接入集中式日志系统。别等到出问题时才发现日志找不着了。7.4 进阶多站点与开发环境的 Nginx 配置热词里提到本地 虚拟机 多端口 nginx 开发环境多站点自定义域名配置这个场景我也经常用。开发阶段一台机器上跑多个项目靠端口区分很混乱不如用自定义域名区分。方法是在 /etc/hosts 里加映射127.0.0.1 project1.local project2.local然后 Nginx 里建两个 server 块server { listen 80; server_name project1.local; location / { proxy_pass http://127.0.0.1:8001; } } server { listen 80; server_name project2.local; location / { proxy_pass http://127.0.0.1:8002; } }开发体验直接上一个台阶不再需要记端口不同项目用不同域名访问配置清晰互不干扰。生产环境的多个站点也是同一套逻辑只是把 server_name 换成真实域名把 proxy_pass 指向对应容器或进程即可。我自己在部署这条路上踩过的坑很多都是觉得懂了一动手就翻车。Docker Nginx 这套组合本身不难难的是把每个环节的小细节都吃透。比如容器无状态导致文件丢失、证书替换不生效、日志撑爆磁盘这些坑一次两次踩下来就知道怎么回事了。希望这篇记录能让你少走些弯路把自己的 Python 应用稳稳当当地跑起来。
返回列表