
1. 先别急着写Dockerfile想清楚这三件事上个月我把一个内部Python Web服务从裸机环境往Docker Nginx搬迁前后折腾了三天。过程中踩了一堆坑也把之前很多想当然的部署认知纠正过来了。这篇文章就把整套思路写下来给正准备做Python Web应用部署、或者已经在Docker里跑但总感觉不踏实的同学参考。先说结论Python Web应用用Docker Nginx部署核心价值不是跟上潮流而是解决三个很实际的问题——环境一致性、进程托管、入口流量控制。Python项目的依赖和Python版本向来是老大难换个机器跑不起来是常态Docker把我这台机器能跑固化成了镜像Nginx则负责把外部请求转给容器里的应用进程同时把静态文件、超时、压缩这些杂活接过去。这篇文章不聊K8s那种重型编排聚焦在单台服务器上用最直接的方式把一套可复现的流程讲清楚。1.1 为什么我坚持用Docker Nginx的组合很多人问我直接用systemd跑Gunicorn不是也行吗确实行但有个绕不开的问题——复现成本。你在一台服务器上手工装Python、建虚拟环境、装依赖、调Gunicorn配置这套流程如果没写文档三个月后你自己都记不全就算写了文档别人照着做也可能因为系统版本差异翻车。Dockerfile把这个过程变成了代码镜像构建成功的那一刻应用就拥有了一个可搬运的运行环境。Nginx在组合里的位置同样关键。Python Web框架自带的开发服务器Flask的app.run、Django的runserver处理并发能力有限而Gunicorn/uWSGI这类WSGI服务器虽然能扛并发却不擅长处理慢请求、大文件上传、静态文件缓存这些边缘问题。Nginx站在最前面把请求分流给后端容器同时把静态资源拦截下来直接返回这样应用进程能腾出手来专注处理业务逻辑。还有一个很多人忽视的点安全。Nginx可以做请求体大小限制、超时控制、IP白名单这些策略写在配置文件里清晰可控。如果你直接把Gunicorn暴露到公网这些能力要么没有要么得靠应用代码自己实现既不安全也不优雅。1.2 单机部署还需要考虑什么进程模型和服务粒度确定技术栈之后先别着急写Dockerfile想清楚应用该怎么跑。Python Web应用在Docker容器里通常不是直接跑Flask/Django的dev server而是用Gunicorn同步框架或Uvicorn异步框架启动多个worker进程。这里有个常见误区Docker容器里同时跑Nginx和应用进程一个容器干两个事。我强烈建议拆成两个容器一个装Nginx一个装Python应用。原因很简单扩缩容和故障隔离。如果你将来想扩展Python应用的实例数量只需要把应用容器多复制几份Nginx负载均衡就能把请求分散过去如果应用容器崩了重启它不会影响Nginx容器前端服务依然能返回一个友好的错误页面而不是直接拒绝连接。另外要考虑的是服务器上已有的服务。如果你这台服务器还跑着MySQL、Redis之类的中间件我建议这些也容器化但不是必须。对于轻量项目我习惯把数据库放在宿主机或者单独的容器里应用和Nginx用docker compose一起管理这样一条命令就能拉起整个Web服务栈。1.3 端口规划和目录规划上线前最值得花时间的地方很多部署翻车都是因为端口和目录规划没想清楚。我的建议是应用容器内部监听8000端口不要使用80或443因为这些端口要留给NginxNginx容器映射宿主机的80和443端口应用容器不需要映射宿主机的任何端口它只对Nginx容器可见。这样做的好处是应用完全在一个内部网络里外部无法直接访问只有Nginx能跟它通信。目录规划上我习惯在服务器上建一个/opt/myapp目录下面放app项目代码、data持久化数据、logs日志、nginx反向代理配置docker compose文件放在/opt/myapp根目录。这样无论构建、备份还是迁移一个目录就能覆盖全部。提示不要把代码和配置文件散落在不同路径比如/root下放代码、/etc/nginx下放自定义配置。一旦出问题排查起来非常痛苦。目录即边界把项目的所有相关文件锁在一个根目录里后续所有操作都会轻松很多。2. Dockerfile要写成能上生产的版本结构想清楚了接下来才是真正的实操。Dockerfile的写法直接决定了镜像大小、构建速度和运行稳定性。我第一次写Dockerfile的时候也很随意pip install -r requirements.txt完事结果镜像体积大、构建巨慢、容器启动还偶发异常。后来踩了几次坑才总结出一套适合Python Web应用的稳健写法。2.1 多阶段构建把编译环境和运行环境分开Python项目经常会带一些需要编译的依赖比如psycopg2、lxml、pydantic-core。如果直接在运行镜像里编译你需要装一堆构建工具gcc、python3-dev、libpq-dev等镜像体积轻松超过1GB。多阶段构建的思路是第一个阶段用完整的基础镜像装依赖、编译扩展第二个阶段用精简的slim镜像只拷贝编译好的依赖和应用代码。拿一个FastAPI项目举例我的Dockerfile通常长这样# ---- 构建阶段 ---- FROM python:3.11-slim AS builder WORKDIR /build 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 --prefix/install -r requirements.txt # ---- 运行阶段 ---- FROM python:3.11-slim AS runtime RUN useradd --create-home --shell /sbin/nologin appuser WORKDIR /srv/app COPY --frombuilder /install /usr/local COPY app ./app USER appuser EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000, --workers, 2]这里有个细节构建阶段把依赖装到指定的/install目录运行阶段通过COPY --frombuilder把这些文件拷到系统路径。这样做的好处是运行阶段不需要执行任何pip install启动速度会快很多镜像里也干净。2.2 依赖安装提速两种思路配合使用依赖安装慢大部分时间浪费在重复下载上。Docker构建是有层缓存的只要requirements.txt没变RUN pip install这一层就会直接命中缓存不会重复执行。但如果你动不动就把全部代码COPY进去再统一安装依赖缓存策略就失效了。我的习惯是先用COPY requirements.txt .单独把这一个文件拷进去再执行依赖安装最后COPY项目代码。这样只要依赖清单不变后续构建哪怕代码改了一百遍依赖层都走缓存。第二种提速思路是使用国内镜像源。如果你在服务器上构建镜像pip install从PyPI默认源拉包可能会很慢。在requirements.txt旁边放一个pip.conf或者在Dockerfile里这样写RUN pip install --prefix/install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple镜像源的选择取决于你的服务器位置我常用清华源或阿里源效果都很明显。还有一个小技巧如果你的项目里有大量编译型依赖可以考虑把requirements.txt按编译型依赖和纯Python依赖拆分编译型依赖单独一层装纯Python依赖放后面。这样即使换了一个编译型依赖的版本纯Python依赖层依然能命中缓存。2.3 非root用户、健康检查和启动命令这是最容易忽略的一块也是能不能上生产的关键分水岭。容器里默认是root用户但我不建议让应用进程以root身份运行。一旦应用代码有漏洞攻击者拿到shell后就是容器里的最高权限配合不当的挂载配置宿主机都有可能被影响。Docker官方提供的python:slim镜像并没有默认非root用户所以我通常在Dockerfile里手动创建RUN useradd --create-home --shell /sbin/nologin appuser然后通过USER appuser切换。健康检查则是看起来能跑和真正能服务的分界线。容器启动后不一定代表应用已经就绪。比如Gunicorn/Uvicorn加载应用需要几秒钟如果Nginx此时立刻转发请求大概率会碰到502。我给容器加了HEALTHCHECK指令用Python自带的urllib去请求应用的/healthz接口HEALTHCHECK --interval10s --timeout5s --retries5 \ CMD python -c import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/healthz, timeout3) or exit(1)这个健康检查接口需要在应用代码里实现返回一个200 OK即可。它不仅能帮Nginx判断容器是否ready将来接K8s或者自定义重启策略时也能作为最可靠的依据。启动命令这一块我见过很多人直接在Dockerfile的CMD里写python app.py这在开发环境没问题但生产环境我强烈建议换成Gunicorn或Uvicorn。用Uvicorn的话最好指定--workers比如--workers 2单worker在处理并发请求时容易出现阻塞。worker数量不要盲目跟CPU核心数一致我习惯用CPU核心数乘以2加1的公式但上限不超过4因为容器本身还承担着其他系统开销。3. Nginx反向代理和容器网络要一起调镜像构建好了接下来是让Nginx把请求转给应用容器。这一步很多人被绕晕的点在于Nginx到底应该怎么访问到Python容器。答案在Docker网络里搞清楚网络模型反向代理基本就成功了一半。3.1 容器网络为什么应用不要映射端口我用docker compose管理应用和Nginxcompose文件的核心部分是这样的version: 3.8 services: webapp: build: ./app restart: unless-stopped environment: - ENVproduction volumes: - ./logs:/srv/app/logs networks: - appnet expose: - 8000 nginx: image: nginx:stable-alpine restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./data/static:/srv/static - ./data/media:/srv/media networks: - appnet networks: appnet: driver: bridge注意webapp用的是expose而不是ports。expose的意思是告诉Docker这个容器对外提供8000端口服务但实际上并不会映射到宿主机只有位于同一个自定义网络里的其他容器才能访问它。nginx则用ports把宿主机的80/443端口映射进来。这个配置的好处是Python应用对公网完全不可见外部流量唯一入口就是Nginx。自定义桥接网络有一个额外的好处容器间可以通过服务名互相解析。也就是说Nginx容器里可以用http://webapp:8000直接访问Python容器不需要关心IP地址。如果哪天Python容器重建导致IP变了Nginx配置完全不用动。这个能力来自Docker内置的DNS解析。3.2 Nginx配置文件一个能直接用的server块Nginx的配置我不喜欢整一堆抽象概念直接给一份能用的。放在./nginx/conf.d/default.conf里upstream webapp_backend { server webapp:8000; } server { listen 80; server_name example.com; client_max_body_size 50m; location /static/ { alias /srv/static/; expires 7d; access_log off; } location /media/ { alias /srv/media/; expires 30d; } location / { proxy_pass http://webapp_backend; 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; proxy_connect_timeout 10s; proxy_read_timeout 60s; } access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log warn; }解释几个关键点。proxy_pass http://webapp_backend用的是upstream里定义的后端地址webapp就是compose里Python服务的名字。proxy_set_header X-Forwarded-For和X-Forwarded-Proto特别重要因为应用在容器里看不到真实的客户端IP和请求协议如果你在Flask或FastAPI里需要记录用户IP或者要判断用户是否走了HTTPS这几个头就是唯一来源。client_max_body_size我设置为50m因为应用涉及上传功能。如果你是纯API或者普通Web站点这个值可以调小一点防止有人直接灌大文件把Nginx内存拖垮。proxy_read_timeout要注意跟应用的处理时间匹配如果应用里有一个长时间运行的请求比如导出报表60秒不够就要调大否则Nginx会提前断掉连接应用那边还在傻傻处理。3.3 静态文件、媒体文件和缓存的协作Python Web框架Django尤其明显处理静态文件和媒体文件的能力非常弱。如果你让Django直接反代静态资源性能会大打折扣。我用Nginx直接把/static/和/media/两个路径拦截下来去宿主机挂载的./data/static和./data/media里找文件根本不会往后端转发。这里有个坑Django的collectstatic命令收集静态文件到STATIC_ROOT如果你期望Nginx能返回这些静态文件得保证STATIC_ROOT对应的目录是宿主机./data/static的映射。也就是说容器内把静态文件写到了/srv/app/static通过volume挂载到宿主机./data/static然后Nginx再挂载同一个宿主目录把它映射到/srv/static两条链路才算接通。没有这个协同你会看到页面HTML加载出来了但CSS和图片全部404。我用一个小方案打通这条链路在docker compose里给Python应用和Nginx都挂载同一个宿主目录。比如Python容器里../data/static:/srv/app/staticNginx容器里../data/static:/srv/static。这样应用执行collectstatic后文件落在宿主机Nginx直接就能捞到。媒体文件上传同理应用把上传内容写到共享卷里Nginx负责访问两边权限都得给对避免出现容器内能读宿主机目录权限不足的尴尬。4. 上线前后最容易翻车的几个细节整个架构跑通之后你以为就结束了没那么简单。我把上线的Python应用实际运行了一段时间才陆陆续续发现下面这些细节没处理好早晚要炸。这一节的内容是踩过坑之后整理的每条都是真实经验。4.1 环境变量不要把密钥写进镜像或代码新手最容易犯的错误就是把数据库密码、SECRET_KEY、第三方API密钥直接写在Dockerfile里或者在docker compose里明文配置。这样做镜像一旦被push到镜像仓库或者分享出去密钥就等于泄露了。我的做法是docker compose里通过env_file引用一个app.env文件这个文件加入.gitignore不进版本库。compose文件里只写env_file: - ./app.envapp.env内容是KEYvalue格式DATABASE_URLpostgresql://user:passworddb:5432/myapp SECRET_KEYxxxxx这样代码里通过os.environ读取环境变量镜像本身完全是白板即使被别人拿到也拿不到任何敏感信息。服务器上的app.env文件权限我设为600只允许管理员读取。还有一件事如果你在settings.py或者配置类里写了默认值比如SECRET_KEY os.getenv(SECRET_KEY, dev-secret)上线后一定要记住删掉这个默认回退逻辑。否则一旦环境变量没设对应用会用默认密钥启动虽然能跑但安全存在巨大隐患。4.2 日志Docker日志与Nginx日志怎么配合排查问题时日志是救命稻草。Docker的docker logs container能看到Python应用的标准输出和标准错误但两个日志流会混在一起。我在应用代码里把日志按级别分流INFO级别以上输出到stdoutERROR级别单独输出到stderr然后让Docker的json-file驱动按容器收集。这样用docker logs --since 1h能看到最近一小时的应用日志不用登录服务器翻文件。日志也不能无限增长。我建议在docker compose里给logging加rotations配置logging: driver: json-file options: max-size: 20m max-file: 5max-size 20m意味着每个日志文件到20MB就切割max-file 5保留最近5份。对大多数中小型Web应用而言这个配置足够避免磁盘被日志撑爆。Nginx的access log也很关键但它默认写在容器里的/var/log/nginx容器一删除就没了。我通过volume把宿主机./logs/nginx映射进去这样Nginx的access log和error log都会持久化到宿主机。排查用户到底有没有请求成功哪个接口被频繁刷这类问题时直接查Nginx access log比进容器敲命令方便太多。4.3 重启策略与自动拉起Docker的restart怎么设才合适容器进程崩溃了怎么办Docker有一个重启策略在compose里用restart字段控制。我的建议是unless-stopped除非管理员手动停止否则无论是异常退出还是服务器重启Docker都会自动把容器拉起来。比always更合理的地方在于如果你确实想停掉服务执行docker compose stop后容器不会因为策略又自动起来方便维护。但要清醒一点restart: unless-stopped解决的是进程退出的场景解决不了容器还活着但应用死锁的场景。比如Django连接池耗尽进程还在接口全部超时Docker看容器是running状态不会重启。所以我把第四节说的HEALTHCHECK也在compose里显式配置healthcheck: test: [CMD, python, -c, import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/healthz, timeout3)] interval: 30s timeout: 5s retries: 3 start_period: 10s如果应用假死健康检查连续失败后Docker会重启容器。配合Nginx的反代逻辑即使后端在重启窗口内有短暂的服务中断健康检查也能尽量减少影响。这个配置虽然不能做到零停机但对单机部署的小型服务来说已经能扛住大多数故障场景。5. 从构建到回滚一套能复用的发布流程环境、镜像、网络、Nginx都准备完了剩下来要考虑的是以后每次更新版本怎么发布。我见过很多团队手工替换代码再重启容器流程一乱出了问题根本不知道现在线上跑的是哪个版本。这里分享一套我实测下来比较顺手的发布流程不一定适合所有团队但足够给单机部署一个清晰参考。5.1 一键构建并部署脚本化发布我以一个Dockerfile docker compose项目为例发布脚本放在/opt/myapp/deploy.sh核心内容大概是#!/bin/bash set -euo pipefail cd $(dirname $0) echo 拉取代码 git pull origin main echo 构建镜像 docker compose build --pull webapp echo 重启服务 docker compose up -d --no-deps nginx webapp echo 清理旧镜像 docker image prune -f脚本的关键在于docker compose build --pull和docker compose up -d --no-deps这两个命令的组合。--pull确保构建用的基础镜像尽量是最新的减少基础镜像过旧带来的安全风险--no-deps告诉compose只重建webapp和nginx不会动其他依赖服务比如数据库。如果你服务依赖了数据库容器这句非常关键——避免每次发布数据库都被重建数据都搞没了。发布之前我还会做一个配置校验docker compose config能检查compose文件语法是否正确。Nginx配置文件也可以用docker exec nginx nginx -t检查。这两步都过了再执行up能避免配置文件写错导致服务起不来的尴尬。5.2 升级回滚怎么确保旧版本能最快恢复发布的时候总会遇到新版本有bug的情况。单机部署不能像K8s那样优雅地滚动但我用镜像tag的方式实现了一个简单可用的回滚机制。每次构建镜像都打上两个tag一个是latest一个是带时间戳的版本号比如myapp:20250126-1530。docker compose文件里默认引用image: myapp:latest升级失败的时候我只需要改compose文件里的镜像版本号指向上一个时间戳tag再执行docker compose up -d --no-deps webapp就能快速回退到上一版。前提是构建脚本每次都要保留上一版镜像不能急着清空。我的策略是保留最近5个tag超过的再删。还有一个小技巧数据库结构变更和代码回滚要分开处理。如果新版本改了数据库字段回滚代码后数据库结构还在旧代码很可能因为字段不兼容而报错。因此涉及数据库变更的发布我会先把backup.sql备份好回滚的时候不仅切换镜像还顺手把数据库恢复到发布前的备份。这个流程虽然土但非常可靠。5.3 实测下来排查问题的最快路线最后分享一套我排障时固定的自查顺序遇到网站打不开接口500这种问题按这个顺序排查基本能在几分钟内定位问题先看Nginx容器是否活着docker ps | grep nginx挂了就看日志再看应用容器是否活着docker ps | grep webapp如果显示restarting说明健康检查不通过或者进程崩溃用curl在宿主机测Nginx端口curl -I http://127.0.0.1确认是不是Nginx层的问题进Nginx容器测后端连通性docker exec nginx curl http://webapp:8000/healthz直连后端看是不是应用本身无响应看日志docker logs --tail 100 webapp和docker logs --tail 100 nginx一起看两边时间戳对齐很容易发现是哪一层断了。这套顺序的核心逻辑是从外到内、逐层排除先确认用户请求有没有到Nginx再确认Nginx有没有把请求转发到后端最后定位后端应用的问题。不要一上来就翻应用日志很多情况下问题出在网络层或者Nginx配置层白白浪费时间。我在实际使用中发现把Python Web应用部署到Docker Nginx这套组合后最明显的体感是迁移成本大幅降低了换一台新服务器只要把/opt/myapp整个目录拷过去执行docker compose up -d服务就能原样跑起来几乎不用调任何环境相关的东西。当然这套方案也不是万能的如果服务流量大到你需要在多台机器间做负载均衡那就得考虑再加一层负载均衡器甚至要把容器编排升级到Swarm或K8s了。但对大部分中小型Python Web项目来说今天的这套单机Docker Nginx部署方案已经足够扛住日常流量也让维护者晚上能睡个好觉。