资讯动态

Python Web 部署实战:Docker+Nginx 从服务器到 HTTPS 上线

发布时间:2026/10/21 13:18:04 来源:尧图企业网站定制
本地跑得好好的 Python 应用一上服务器就各种翻车这事我见了太多次。依赖装不全、系统 Python 版本跟开发环境对不上、进程没人守护挂了也不知道、端口一冲突直接懵圈。这套问题其实早在 2013 年我刚开始部署 Flask 应用的时候就踩过一轮折腾到最后我沉淀出一套相对省心的标准方案Docker Nginx。这篇文章就围绕这个组合把从服务器准备到 HTTPS 上线的完整流程和坑点全部过一遍覆盖 Flask 和 FastAPI 两类常见框架适合那些已经能本地跑通 Python Web 应用、但还没正经部署过线上服务的开发者参考。1. 部署方案的整体思路与选型1.1 为什么是 Docker Nginx 这个组合先说结论Docker 解决“环境不一致”和“进程守护”的问题Nginx 解决“对外入口”和“流量分发”的问题两者配合正好把部署里最痛的两块补上。我见过不少新手直接把python manage.py runserver或者uvicorn main:app挂到服务器上再用nohup放后台完事。这种玩法在开发环境没问题生产环境就是定时炸弹。runserver是 Django 自带的开发服务器性能差不说还没有并发处理能力nohup启动的进程一旦因为内存崩溃退出没有任何机制把它拉起来。Docker 的restart: unless-stopped策略加上容器的隔离能力能把这些基础问题一次性解决。Nginx 的角色很多人也不理解。Python Web 框架本身能监听端口、能返回 HTTP 响应为什么前面还要再挡一层 Nginx原因有三个。第一静态文件处理。Nginx 处理图片、CSS、JS 这类静态资源的效率远超 Python 进程它能把请求直接拦截下来返回文件不消耗 Python 的计算资源。第二TLS 终结。HTTPS 证书的安装、续期、加解密都交给 NginxPython 应用内部继续用明文 HTTP 通信降低了应用层的复杂度。第三进程管理与反向代理。Nginx 可以把请求转发给本机任何一个端口上的应用实例将来就算扩展成多个实例做负载均衡也只需要改几行配置应用本身不用动。1.2 整体架构与数据流向这套架构的数据流向很清晰用户请求先到服务器的 80/443 端口Nginx 在这里接收并解析请求。如果是静态资源Nginx 直接返回文件如果是动态请求Nginx 通过proxy_pass把请求转发给 Docker 容器内监听某个内部端口的 Python 应用进程拿到响应后再返回给用户。Python 应用进程本身不能裸奔我习惯在容器里再套一层 GunicornFlask/Django 项目或者 UvicornFastAPI 项目。Gunicorn 是 Python 生态里最成熟的 WSGI 服务器负责管理多个 worker 进程、处理并发容器里的启动命令就从python app.py变成gunicorn -w 4 -b 0.0.0.0:8000 app:app。这里要强调一个新手最容易理解错的点Docker 里的端口映射不要直接暴露到公网。比如应用容器内部监听 8000写在 compose 文件里是8000:8000这意味着服务器公网 IP 的 8000 端口直接对外可达绕过 Nginx 直达应用。正确的做法是只做容器间通信应用容器的端口只对 Nginx 可见Nginx 才是唯一的公网入口。后面我会详细演示这个配置。2. 服务器环境准备与 Docker 安装2.1 服务器基础配置我个人的习惯是选择 Ubuntu 22.04 LTS 作为服务器系统原因是 Python 和 Docker 生态对它的兼容性最好遇到问题搜到的解决方案也最多。服务器规格方面一个日活几百人的小型应用2 核 4G 内存的配置就非常充裕了如果只是个人项目或者演示项目1 核 2G 也能跑后面我会提到在这种情况下怎么避免内存不足。拿到服务器后第一件事不是装环境而是做基础安全配置。用 root 登录后先创建一个普通用户并赋予 sudo 权限adduser deploy usermod -aG sudo deploy然后配置 SSH 密钥登录。在本机生成密钥对把公钥放到服务器的~/.ssh/authorized_keys里之后所有部署操作都通过这个普通用户完成。root 远程登录一定要禁用具体做法是编辑/etc/ssh/sshd_config把PermitRootLogin改成no然后重启 sshd 服务。这一步不是可有可无的仪式公网服务器每天会被大量扫描尝试登录关掉 root 能挡掉绝大多数暴力破解。系统基础更新也别跳过sudo apt update sudo apt upgrade -y顺手再把时区设成 Asia/Shanghai。容器里默认是 UTC 时间日志里看到的时间和本地时间对不上是常事服务器层面先统一时区能省掉后面很多困惑。2.2 安装 Docker 与 Compose 插件Docker 的安装我用的是官方 apt 源不用系统自带的旧版本。步骤很简单几条命令的事sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod ar /etc/apt/keyrings/docker.gpg接着把 Docker 官方源写进 apt 配置然后安装docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin。这里重点说docker-compose-plugin它提供的是docker compose子命令跟老的docker-compose独立程序是两回事。新项目一律用docker compose语法更简洁跟 Docker CLI 的集成也更好。安装完成后把deploy用户加入docker组这样才能免 sudo 执行 docker 命令sudo usermod -aG docker deploy提示加入 docker 组后必须重新登录会话才生效。另外从安全角度讲docker 组的用户权限很大等价于 root 权限所以只在可信的部署账号上这么做。验证安装是否成功运行docker --version和docker compose version能打印出版本号就说明环境就绪了。2.3 防火墙与安全设置服务器防火墙用 UFW 管理规则控制在三条以内sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable别把 8000、5000 这些应用端口加进去理由前面说过应用端口不需要暴露到公网唯一入口就是 80 和 443。这里有个细节ufw allow OpenSSH是放行 sshd 实际监听的端口如果你的 SSH 改了非默认端口要写对应的端口号而不是这个服务名。做完这些基础工作服务器的状态是可以 SSH 登录、80/443 端口开放、Docker 就绪。到这里环境准备就结束了下面进入正题把应用装进容器。3. 把 Python 应用装进容器3.1 写一份靠谱的 DockerfileDockerfile 是整条部署链路的地基。我在多个项目里反复打磨过这份文件下面以 FastAPI Uvicorn 为例给出一个可以直接改用的模板FROM python:3.12-slim AS builder WORKDIR /app ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 RUN apt-get update apt-get install -y --no-install-recommends \ build-essential \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt FROM python:3.12-slim RUN groupadd -r app useradd -r -g app app WORKDIR /app ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 COPY --frombuilder /root/.local /root/.local COPY --chownapp:app . . ENV PATH/root/.local/bin:$PATH USER app EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这份 Dockerfile 看起来简单但每一行都有讲究。python:3.12-slim基础镜像基于 Debian slim 版本体积小日常够用。我不用alpine版本虽然它更小但很多 Python 包需要重新编译musl环境的兼容性问题能让人调试到怀疑人生生产环境省那几十 MB 磁盘不值得。PYTHONDONTWRITEBYTECODE1防止 Python 生成.pyc缓存文件PYTHONUNBUFFERED1让日志实时输出而不是攒在缓冲区里——这两个环境变量在容器场景下都是必须的尤其是第二个否则你在docker logs里看不到应用的最新输出排查问题会非常痛苦。我用的还是多阶段构建。第一阶段装编译工具build-essential把依赖包含原生扩展的包比如pydantic-core、cryptography编译好并安装到用户目录第二阶段只拷贝编译结果不带编译工具。这样最终镜像里没有 gcc 那一堆工具链大小能节省三四百 MB。依赖里用pip install --user装到/root/.local第二阶段通过COPY --frombuilder拷贝过来。容器里必须创建非 root 用户这个容易被忽略。默认以 root 运行容器一旦应用被注入恶意代码攻击者直接就拿到了容器内的最高权限。创建app用户后用USER app切换过去再把代码文件的所有权也改成这个用户权限模型就清晰了。3.2 .dockerignore 与构建验证.dockerignore必须写否则COPY . .会把本地的虚拟环境、Git 仓库、缓存文件全部拷进镜像构建慢不说镜像还大。我的模板__pycache__/ *.pyc .env .git/ .venv/ venv/ tests/ *.md写完 Dockerfile本地先构建验证docker build -t myapp:latest . docker run -d --name myapp-test -p 8000:8000 myapp:latest curl http://localhost:8000/health本地能通再往服务器上传别一上来就在服务器上试错。这里我多说一句千万不要把整个项目目录用 scp 传到服务器再构建如果项目里有大文件或者虚拟环境目录很容易传半天或者把服务器磁盘塞满。正确做法是把项目推到代码仓库服务器上git clone/git pull或者用 CI 构建好镜像推到镜像仓库服务器只负责拉取运行。个人项目没有 CI 条件的话我建议用tar把项目打包排除掉虚拟环境等目录再传输比裸拷目录可控得多。3.3 镜像瘦身与依赖管理的实操心得镜像大小直接影响服务器的拉取和启动速度。除了多阶段构建还有几个实用技巧。依赖锁定要精确。requirements.txt里我习惯把传递依赖的版本也锁死用pip freeze requirements.txt的方式从开发环境生成而不是手写几个大包名。否则今天构建镜像装到的是 1.2 版本下周同样的 Dockerfile 构建出来却装成了 1.3行为可能就不一样了排查起来非常头疼。一层层缓存也要利用好。Dockerfile 里COPY requirements.txt和RUN pip install放在拷贝全部代码之前是因为 Docker 构建会缓存每一层。只要 requirements.txt 没变pip install 这层缓存就直接命中后续改代码重新构建只需要几秒钟。如果把代码先拷进去再装依赖那怕只改了一行 Python整个依赖安装流程都得重跑一遍。注意不要在生产容器里用pip install -e .或者依赖开发模式的安装方式它会绕过 Docker 的层缓存机制每次构建都全量重装而且镜像里会残留源代码副本。4. 用 Docker Compose 把服务编排起来4.1 编写 docker-compose.yml单个容器用docker run加一堆参数还能应付一旦应用除了自身还要配数据库、缓存、队列命令就会变得不可维护。Compose 的价值就是把整个服务栈的生命周期统一管理。我的一份典型 compose 配置长这样services: app: build: . container_name: myapp restart: unless-stopped env_file: .env expose: - 8000 volumes: - static_volume:/app/staticfiles - media_volume:/app/media networks: - app_net healthcheck: test: [CMD, curl, -f, http://localhost:8000/health] interval: 30s timeout: 5s retries: 3 start_period: 10s nginx: image: nginx:1.27-alpine container_name: nginx restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./certbot/conf:/etc/letsencrypt - static_volume:/var/www/staticfiles:ro - media_volume:/var/www/media:ro depends_on: app: condition: service_healthy networks: - app_net volumes: static_volume: media_volume: networks: app_net:注意app服务只写了expose: [8000]这个指令只是声明容器对外提供这个端口并不会发布到宿主机。它在 Compose 自动创建的网络里可以被 Nginx 容器通过服务名app直接访问。这是上一节说过的“应用端口不对公网开放”的具体落地写法。Nginx 容器和 app 容器在同一个app_net网络里Nginx 配置里的proxy_pass http://app:8000就是通过 Docker 内置 DNS 解析到 app 容器的 IP。这里不要写死 IP容器的 IP 会变服务名才是稳定的。4.2 环境变量与敏感信息管理配置管理是部署里最容易翻车的地方。所有跟环境相关的变量——数据库地址、密钥、配置开关——都走.env文件由env_file指令注入到容器。.env文件不要提交进 Git 仓库服务器上手动创建一份即可。这里有个常见的理解误区env_file: .env是把文件里的变量注入成容器内环境变量Compose 本身读取.env则是为了在docker-compose.yml里做变量替换。两者不是一回事。我建议在 compose 文件里尽量不要写${VAR}形式的宿主环境替换而是统一用env_file注入这样无论在哪台机器上跑行为都一致。敏感信息的安全底线是密钥只存在于服务器而不进代码仓库。把.env加入.gitignore生产环境的密钥跟开发环境彻底分离。我见过有人在公开仓库里看到数据库密码和密钥基本一晚上服务器就被挖矿脚本光顾了这个学费太高。4.3 数据持久化、健康检查与重启策略容器是易失的镜像重建、容器删除之后容器文件系统里的数据就没了。所以凡是需要长期保存的数据——用户上传文件、数据库文件、日志——都必须放到卷volume里。static_volume和media_volume就是这种场景。Python 应用把生成的静态文件收集到/app/staticfiles把用户上传的图片存到/app/media这个目录落在命名卷里容器重建不会丢。Nginx 容器又把这些卷以只读方式挂载到自己文件系统的/var/www/下两个容器通过同一个卷实现文件共享不依赖容器间通信协议简单可靠。健康检查是我后来才补上的关键配置。healthcheck每隔 30 秒请求一次应用的/health接口连续 3 次失败就判定容器不健康。depends_on的condition: service_healthy保证 Nginx 不会在应用还没起来时就开始转发流量。没有这一步首次部署时经常出现 Nginx 起来了而应用还没就绪用户看到 502 的概率会高很多。restart: unless-stopped是进程守护的底线策略。任何原因导致的容器退出Docker 都会自动拉起它唯一例外是你手动执行了docker stop。这个策略比always更友好的一点是它在系统重启后也会恢复容器但不会在你明确停止容器后自作主张再拉起来。5. Nginx 反向代理与 HTTPS5.1 Nginx 放宿主机还是也放容器里上一节的 compose 里我把 Nginx 也放进容器这确实是我的默认选择。统一用 Compose 管理整个栈配置、日志、启停都在一个体系内非常舒服。不过 Nginx 放宿主机也是一个完全可行的方案两者各有取舍。Nginx 放容器的好处是隔离性好服务器上不用装任何 Web 服务器软件所有依赖都在镜像里固定版本。坏处是配置文件在容器里被覆盖或误改时排查起来要多绕一层。Nginx 放宿主机的最大好处是排障直观——systemctl status nginx直接看状态nginx -t测配置出了问题宿主机上直接操作对不熟悉容器运维的人来说更友好。我经历过两种方案后现在的习惯是只要服务器上只有这一个站点Nginx 放容器如果服务器上要跑多个项目、多个站点Nginx 直接装宿主机更灵活。后者可以同时代理多个容器、多个端口不改动原来的部署。下面配置示例我按宿主机方案写逻辑是通用的如果你用容器 Nginx把配置文件挂载进去即可。5.2 反向代理配置实战先安装 Nginxsudo apt install -y nginx配置文件放在/etc/nginx/sites-available/然后软链接到/etc/nginx/sites-enabled/。这个“可用/启用”双目录机制是 Debian 系的做法好处是随时可以保留多套配置、通过创建或删除软链接来切换站点。核心配置如下server { listen 80; server_name example.com www.example.com; client_max_body_size 20M; location /static/ { alias /var/www/staticfiles/; expires 30d; } location /media/ { alias /var/www/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; proxy_read_timeout 60s; } }从容器 Nginx 方案切到宿主机方案时这里有个关键变化proxy_pass的目标从http://app:8000变成了http://127.0.0.1:8000。因为宿主机 Nginx 无法直接解析 compose 里的服务名所以应用容器发布端口时必须写成127.0.0.1:8000:8000——只绑定到本机回环地址而不是8000:8000那样绑定所有网卡。同样的意思应用端口依然不对公网开放因为回环地址的外部访问不到。location /里转发的请求头不能省。Host头是必须的否则后端应用拿到的 Host 是127.0.0.1而不是你的域名Django 的ALLOWED_HOSTS校验会直接拒绝请求。X-Forwarded-For让后端能拿到真实用户 IP否则应用日志里全是 127.0.0.1。X-Forwarded-Proto告诉后端这次请求是不是 HTTPS否则重定向到登录页时可能生成http://链接而丢了 HTTPS。FastAPI 项目如果用到 WebSocketlocation /里需要额外加两行支持协议升级proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;没这两行WebSocket 连接会被 Nginx 按普通 HTTP 处理前端会出现连接建立 1 秒就断开的现象。5.3 HTTPS 证书申请与自动续期现在的应用不上 HTTPS 基本说不过去。我用 Lets Encrypt 的 certbot 来申请和续期证书流程非常标准sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d example.com -d www.example.comcertbot 会自动检测 Nginx 配置修改配置加载证书并设置 80 端口到 443 的跳转。申请成功后续期问题交给定时任务certbot 安装时会自动配置 systemd timer每天检查证书是否需要续期。我只需要定期跑一下certbot renew --dry-run确认流程正常然后注意千万别手动禁用 80 端口——续期需要用到它做 HTTP 验证这也是为什么前面防火墙里我特意放行了 80。证书续期后Nginx 需要重载才能生效。certbot 的 hook 配置里可以加上--deploy-hook systemctl reload nginx这样每次续期成功自动重载一劳永逸。注意不要复制证书文件到服务器指定路径之外的位置更不要改 https 监听端口为 8443 之类的非常规端口。保持标准端口lets encrypt 的验证流程和大多数用户的网络环境都能正常兼容。6. 上线、更新与问题排查实录6.1 首轮部署的完整操作流环境都配好了部署动作其实就几步。在服务器上git clone your-repo-url /srv/myapp cd /srv/myapp docker compose build docker compose up -dup -d一条命令拉起所有服务。之后docker compose ps查看状态docker compose logs -f跟日志。如果一切正常浏览器打开域名就能看到应用了。这一步我强烈建议在前一天构建好的镜像基础上做不要在服务器上现 bild除非是首次。生产服务器构建会拉依赖、编译原生包消耗 CPU 和内存还可能因为网络波动失败。日常更新流程是本地构建并推送到镜像仓库服务器上docker compose pull docker compose up -d整个更新过程几秒钟完成几乎无感知。关于更新策略还有一点值得展开up -d对配置没变化的容器默认不会重建。改了 Dockerfile 或者依赖之后必须手动docker compose build app重新构建再up -d生效不然很容易出现“我改了代码但线上没反应”的尴尬。为避免这个问题在更新时我习惯加上--build标记docker compose up -d --build它会先检查镜像是否有变化有变化就重建再启动。6.2 高频报错与排查思路我整理了几个出现频率最高的报错场景和排查路径都是实战踩出来的。502 Bad Gateway。这是最常见的一个。原因基本都是 Nginx 转发目标不可达后端容器没起来、容器内端口写错、proxy_pass地址写错。排查顺序是先docker compose ps看容器状态再docker compose logs app看应用是否成功启动最后在 Nginx 所在环境里curl http://127.0.0.1:8000/health直接测目标地址一步步缩范围。容器一直重启Restarting。大概率是应用启动直接崩了。docker compose logs app看退出原因常见的是数据库连不上服务没启动或连接串错误、端口被占用容器内用了别的进程监听同端口、配置文件缺失.env没放对位置。大多数情况下日志会直接告诉你原因。静态文件 404。Nginx 配置了/static/指向一个目录但目录里没文件。Django 项目需要先执行python manage.py collectstatic把静态文件收集到指定目录这一步我常放在 Dockerfile 的构建阶段做或者在应用启动命令前执行。容器挂载卷后文件要落在卷对应的目录里Nginx 才能通过共享卷读得到。数据库数据丢失。这是最痛的。数据库容器没配卷容器一删数据全没。凡是带状态的中间件数据库、Redis 持久化配置必须把数据目录挂载到命名卷或宿主机目录。这也是我前文反复强调数据持久化的原因一旦吃过大亏就不会再忘。宿主机名解析失败。Compose 里服务间通过服务名通信但容器内部要访问宿主机时localhost指向容器自己而不是宿主机。正确写法是使用host.docker.internalDocker Desktop 可用或者把宿主机 IP 通过环境变量传进去。我踩过 Redis 在宿主机上、容器里连localhost连不上的坑最后帮别人排查这个问题时也花了不少时间。6.3 日常维护与后续扩展部署上线不是终点日常维护才见功夫。几个基础操作要熟练docker compose logs -f --tail200 app # 看应用日志 docker compose ps # 看容器健康状态 docker system df # 看磁盘占用 docker image prune -f # 清理悬空镜像磁盘占用是最容易被忽视的。每次更新镜像都会留下旧的悬空镜像时间一长磁盘就满了导致数据库写入失败、容器创建失败。我习惯把docker image prune和docker builder prune -f放进每周的清理脚本里再配合磁盘告警监控基本不会出问题。日志这块容器日志默认走 Docker 的 json-file 驱动单个容器日志文件无限增长也会撑爆磁盘。在/etc/docker/daemon.json里配置{ log-driver: json-file, log-opts: { max-size: 20m, max-file: 5 } }然后重启 Docker每个容器的日志单文件就不会超过 20M最多保留 5 个轮转文件。这个配置改动小但能避免一次大故障强烈建议上线前就设好。再往后扩展的话这个架构的演进路径也很清晰应用实例从 1 个扩到多个让docker compose up -d --scale app3配合 Nginx 的upstream配置就能做到基本负载均衡引入 Redis、PostgreSQL 就加两个 service需要自动化上线流程就接 CI构建镜像推到仓库服务器用 Webhook 触发拉取和重启。这套 Docker Nginx 的基础骨架在这些场景下都不用推翻重来这也是我推荐它的核心原因。最后分享一个我自己的体会部署这件事设计比操作重要。很多人觉得部署就是把代码传到服务器跑起来但我踩过的所有大坑几乎都源于架构层面的疏忽——端口暴露、数据没持久化、健康检查缺失。这些在设计阶段想清楚操作阶段就是几条命令的事。先想清楚再动手你会少熬很多夜。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑