
TongueDiagnosis 这个项目我从年初就开始写了最初在本机直接用 uvicorn 跑服务Python 环境、OpenCV 底层库、CUDA 版本、Node 前端各自为政换一台电脑就重新折腾一遍实在熬不住。趁着假期我用 Docker 把整个项目做了完整的容器化改造从 Dockerfile 到 docker-compose 编排把后端、前端、模型推理、MySQL、Redis 全部装进容器里。这篇文章就把本地部署 Docker 容器化实战的完整过程记录下来从需求拆解、镜像规划、Compose 编排再到后续排查问题给准备把 AI 项目容器化的同学一个可以参考的路线。1. 项目拆解与容器化改造思路1.1 TongueDiagnosis 到底做了什么事TongueDiagnosis 是一个中医舌诊辅助分析项目用户上传一张舌面照片系统通过深度学习模型自动识别舌色、舌苔厚薄、齿痕、裂纹等特征然后给出对应的体质分析与健康建议。核心功能是一套图像分类/多标签识别流程但真正工程化之后就不只是模型这么简单了前端是 Vue 3 Element Plus负责上传图片、展示诊断结果和历史记录后端是 FastAPI提供 REST API处理登录、诊断记录、文件上传、调用模型服务模型推理服务基于 PyTorch加载训练好的 ResNet50 权重对舌象图片做预处理和推理数据库用的 MySQL 保存用户和诊断记录Redis 做热数据缓存。这里我要先说明一下舌诊结果只做健康参考不能替代线下医生的专业诊断。项目定位是 AI 辅助分析不是医疗设备这一点在部署文档和前端页面上我都会写清楚。1.2 为什么我坚持用 Docker 容器化最初我也觉得“本地部署而已直接跑 Python 不就行了”。等到第二次换机器部署时我就后悔了。问题主要集中在三处第一底层依赖太杂。OpenCV 需要 libgl1、libglib2.0-0 这些系统库PyTorch 对 CUDA 版本有硬性要求MySQL 和 Redis 又要单独装光靠 requirements.txt 根本管不住。第二环境不一致。本机 Python 3.10 能跑到另一台机器 Python 3.8 就报语法兼容问题同一个模型在本机用 GPU 跑到别人那里没有 GPU代码又没做 CPU 回退直接崩。第三迁移成本高。用户要看演示我得一个包一个包装装完还要把前端 build 产物挂到 Nginx 目录里过程又长又容易错。用 Docker 之后一堆服务变成几个镜像docker compose up一把梭。我整理了一张对照表部署时和同事沟通效率高很多对比项传统本地部署Docker 容器化环境一致性依赖本机系统状态换机器就翻车镜像与环境绑定跑哪都一样依赖冲突Python、CUDA、系统库互相打架容器隔离互不干扰启动部署手动安装配置按文档一步步来compose 一条命令拉起迁移打包源码和文档新机器重新编译镜像导出或拉取即开即用GPU 支持每台机器单独装 NVIDIA 驱动NVIDIA Container Toolkit 统一管理团队协作“在我电脑上是好的”环境可复现问题可复现1.3 部署架构与镜像规划容器化改造不能一上来就写 Dockerfile得先把服务边界理清。我最终把项目拆成五个服务frontendNginx 容器托管前端静态文件并把/api请求反向代理给后端backendFastAPI 容器负责业务逻辑model-servicePyTorch 推理容器独立部署的原因是这个服务对 GPU 敏感单独拆分后可以按需分配资源mysqlMySQL 8.0 容器持久化业务数据redisRedis 7 容器做缓存和简单队列。镜像规划上提前定好命名规则否则构建完一堆latest自己都分不清。我用的规则是tongue/服务名:版本号比如tongue/backend:v1.0.0。基础镜像优先选官方的 ssl 版本不要在同一个镜像里既装 Node 又装 Python除非你想让镜像体积突破 3GB。2. 本地部署前的准备工作2.1 硬件、系统与软件环境要求容器化并不能消除硬件门槛尤其是 AI 推理服务。我本地开发机是一台 Ubuntu 22.04 的台式机配置供你参考资源最低要求推荐配置说明CPU4 核8 核以上推理和编译前端都会吃 CPU内存8GB16GB 以上模型加载 多个容器并发运行磁盘20GB40GB SSDPyTorch 基础镜像约 6GB还需存模型文件GPU可选NVIDIA 显卡 8GB 显存没有 GPU 也能跑但推理延迟会高很多操作系统Linux / macOS / WindowsUbuntu 22.04Windows 建议用 WSL2 配合 Docker Desktop另外要注意如果要用 GPU宿主机必须提前装好 NVIDIA 驱动nvidia-smi命令能正常输出才行。容器里的 CUDA 只负责运行时和宿主驱动做了一层适配别指望镜像能把驱动也打包进去。2.2 安装 Docker 和 ComposeUbuntu 上我这次直接用官方脚本装的Docker 23 以上版本默认自带 Compose v2不需要单独装docker-composecurl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh sudo systemctl enable docker sudo systemctl start docker装完以后检查一下docker --version docker compose version如果输出都正常再把当前用户加入 docker 组省得每次敲命令都要 sudosudo usermod -aG docker $USER newgrp docker国内网络环境下拉取 Docker Hub 镜像经常超时。可以在/etc/docker/daemon.json里配置 registry mirror然后重启 Docker{ registry-mirrors: [ https://docker.m.daocloud.io, https://hub-mirror.c.163.com ] }注意镜像加速地址有时效性以你实际能访问到的稳定地址为准。配置完执行sudo systemctl restart docker才会生效。2.3 项目目录结构与基础镜像规划我改造后的项目目录长这样TongueDiagnosis/ ├── backend/ │ ├── app/ │ │ ├── main.py │ │ ├── models.py │ │ └── routers/ │ ├── Dockerfile │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ └── ... │ ├── Dockerfile │ ├── nginx.conf │ └── package.json ├── model-service/ │ ├── inference.py │ ├── Dockerfile │ └── requirements.txt ├── mysql/ │ └── init.sql ├── models/ │ └── tongue_resnet50.pth ├── .env └── docker-compose.yml基础镜像选型如下backendpython:3.9-slim体积可控装依赖够用frontend构建阶段用node:18-alpine运行阶段用nginx:1.25-alpinemodel-servicepytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime。这个镜像很大但省去了手工装 CUDA 和 PyTorch 的痛苦mysqlmysql:8.0redisredis:7-alpine。3. 核心服务容器化实操3.1 后端 FastAPI 服务容器化后端是业务核心Dockerfile 写起来没什么黑魔法但有两个坑必须提前处理一是 OpenCV 的系统依赖二是 pip 依赖装得慢。看 DockerfileFROM python:3.9-slim WORKDIR /app RUN apt-get update apt-get install -y --no-install-recommends \ libgl1 \ libglib2.0-0 \ tzdata \ rm -rf /var/lib/apt/lists/* ENV TZAsia/Shanghai COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd -m appuser USER appuser EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这里有几个细节值得说libgl1和libglib2.0-0是 OpenCV 读取和处理图片时的底层依赖不装的话导入 cv2 必报libGL.so.1: cannot open shared object file这是 Python 容器里最常见的问题之一。设置TZAsia/Shanghai是为了让日志时间和数据库时间戳保持同步不然你看到诊断记录创建的“当前时间”老是差 8 小时。我最后建了一个普通用户appuser避免容器内的进程以 root 身份运行生产环境建议都这么做。requirements.txt里面要有这些核心依赖fastapi0.110.0 uvicorn[standard]0.29.0 opencv-python-headless4.9.0.80 Pillow10.2.0 python-multipart0.0.9 sqlalchemy2.0.25 pymysql1.1.0 redis5.0.1 python-dotenv1.0.1注意后端我用了opencv-python-headless它能避免和前端页面抢 GUI 依赖也顺手减少了体积。如果要用cv2.imshow这类可视化功能才需要完整版推理服务里一般用 headless 足够。3.2 前端 Vue 项目与 Nginx 容器化前端容器化最省事的方案是构建阶段产出一个只包含静态文件的 nginx 镜像。用多阶段构建能避免把 node_modules 和数百 MB 的构建缓存带进最终镜像。FROM node:18-alpine AS build-stage WORKDIR /app COPY package*.json ./ RUN npm install --registryhttps://registry.npmjs.org COPY . . RUN npm run build FROM nginx:1.25-alpine RUN rm -rf /usr/share/nginx/html/* COPY --frombuild-stage /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD [nginx, -g, daemon off;]nginx.conf 是前后端打通的关键server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend: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_read_timeout 300s; } location / { try_files $uri $uri/ /index.html; } }这里最容易出错的是proxy_pass http://backend:8000/末尾这个斜杠。带斜杠表示把/api/前缀替换成/比如请求/api/health后端收到的是/health。如果去掉这个斜杠后端会收到/api/health路由对不上就会 404。另外try_files $uri $uri/ /index.html是 Vue Router 的 history 模式必需的不然刷新某个子页面会直接 502 或显示 Nginx 默认页面。3.3 模型推理服务容器化与 GPU 支持模型推理服务我单独拆了一个容器因为它对运行环境的要求最苛刻。PyTorch 的 torch、torchvision 和 CUDA 版本必须匹配自己从纯 Python 基础镜像去装不仅慢还容易因为 CUDA 版本号不一致导致训练和推理结果不一致。我直接基于 PyTorch 官方镜像省心太多FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENV MODEL_PATH/models/tongue_resnet50.pth ENV PORT8080 EXPOSE 8080 CMD [python, inference.py]inference.py里加载模型时我做了 CPU 和 GPU 的双重判断import os import torch import torch.nn as nn from torchvision import models, transforms from PIL import Image import numpy as np DEVICE torch.device(cuda if torch.cuda.is_available() else cpu) model models.resnet50() num_classes 6 # 舌色、苔色等标签数量按需调整 model.fc nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load(os.environ[MODEL_PATH], map_locationDEVICE)) model.to(DEVICE) model.eval() def preprocess(image_bytes): img Image.open(image_bytes).convert(RGB) transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) return transform(img).unsqueeze(0).to(DEVICE) def predict(image_bytes): tensor preprocess(image_bytes) with torch.no_grad(): outputs model(tensor) probs torch.softmax(outputs, dim1) return probs.cpu().numpy().tolist()模型文件tongue_resnet50.pth我通过数据卷挂载到/models目录而不是塞进镜像里。理由很简单模型是 200MB 级别的大文件如果考进镜像每次改代码都要重新构建镜像还得把 200MB 文件重新 COPY 一遍又慢又占空间。4. docker-compose 编排与一键启动4.1 编写完整的 docker-compose.yml拆完单服务镜像最后一步是用 compose 把它们组织起来。我的docker-compose.yml核心部分长这样version: 3.8 services: mysql: image: mysql:8.0 container_name: tongue-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: tongue TZ: Asia/Shanghai volumes: - mysql_data:/var/lib/mysql - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro ports: - 3306:3306 healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -p${MYSQL_ROOT_PASSWORD}] interval: 5s timeout: 5s retries: 10 redis: image: redis:7-alpine container_name: tongue-redis restart: unless-stopped ports: - 6379:6379 volumes: - redis_data:/data backend: build: context: ./backend container_name: tongue-backend restart: unless-stopped environment: DB_HOST: mysql DB_PORT: 3306 DB_USER: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} REDIS_HOST: redis MODEL_SERVICE_URL: http://model-service:8080 UPLOAD_DIR: /app/uploads volumes: - ./backend/uploads:/app/uploads ports: - 8000:8000 depends_on: mysql: condition: service_healthy redis: condition: service_started model-service: build: context: ./model-service container_name: tongue-model restart: unless-stopped environment: MODEL_PATH: /models/tongue_resnet50.pth PORT: 8080 volumes: - ./models:/models ports: - 8080:8080 deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] frontend: build: context: ./frontend container_name: tongue-web restart: unless-stopped ports: - 8088:80 depends_on: - backend volumes: mysql_data: redis_data:.env文件放敏感信息不提交到代码仓库MYSQL_ROOT_PASSWORDyour_strong_password4.2 网络、数据卷与依赖顺序细节这段代码里埋了几个细节第一compose 默认会建立一个 bridge 网络服务之间可以直接用服务名作为域名访问。所以前端 Nginx 里写http://backend:8000后端里写http://model-service:8080这些名字不是乱写的必须和 compose 中的服务名一致。第二MySQL 和 Redis 的 3306、6379 端口本来可以不暴露给宿主机但我暴露出来是为了排查问题方便。真实环境如果只有本机访问可以把ports改成只绑定 127.0.0.1比如127.0.0.1:3306:3306避免局域网里其他人直接连数据库。第三MySQL 初始化脚本init.sql是挂载到/docker-entrypoint-initdb.d/目录里的只有数据卷第一次创建时才会执行。如果后续改了 init.sql要么手动进容器执行要么把mysql_data卷删了重建否则不会自动生效。第四depends_on只是控制启动顺序并不能保证依赖服务已经就绪。所以 MySQL 要配healthcheckbackend 用condition: service_healthy来等它真正可用。Redis 这种本身启动快的服务用service_started就够。4.3 一键构建启动与验证执行构建和启动docker compose up -d --build第一次构建会比较久后台能看到镜像一层层下载。构建完成后查看运行状态docker compose ps正常情况应该看到五个容器都在Up状态。接着验证接口curl http://localhost:8000/api/health curl http://localhost:8088/api/health如果前后端不通可以先看日志暂时不用到处翻代码docker compose logs -f backend docker compose logs -f frontend日常停止和清理docker compose stop # 保留数据卷下次 start 快速拉起 docker compose down # 删除容器保留数据卷 docker compose down -v # 连数据卷一起删谨慎操作5. 常见问题排查与避坑实录5.1 镜像拉取超时构建失败症状docker compose build执行到某个 RUN 命令时一直卡住或者直接报 timeout。排查思路先分清是拉取基础镜像超时还是 pip 装依赖超时。如果是拉基础镜像按照第 2.2 节配置 registry mirror然后执行docker compose build --pull重新拉。如果是 pip 本身慢比较快的方案是在 Dockerfile 里临时指定国内镜像RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple这个 URL 可以根据你自己的网络环境替换。注意固定依赖版本不要用否则哪天某个依赖发了新版本构建出来的镜像可能跑挂而你自己完全没动过代码。5.2 容器里读不到 GPU症状容器能起来但日志里报CUDA error: no kernel image is available for execution on the device或者torch.cuda.is_available()返回 False而宿主机nvidia-smi正常。原因宿主机缺少 NVIDIA Container Toolkit。解决sudo apt-get install -y nvidia-container-toolkit sudo nvidia-ctk runtime configure --runtimedocker sudo systemctl restart docker然后再跑一个简单的 CUDA 容器验证docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi验证通过后再docker compose up -d --build。如果你用的是旧版 Docker Compose不支持deploy.resources块可以回落成runtime: nvidia再加environment: NVIDIA_VISIBLE_DEVICES: all。5.3 模型文件挂载不上、中文乱码容器内有时会报找不到模型文件最常见原因是你启动 compose 的目录不对。compose 文件里的相对路径是相对于 compose 文件本身所在目录的不是相对于你当前终端目录。建议用pwd确认一遍或者直接把./models改成绝对路径。中文乱码主要出现在数据库里。首先确认 MySQL 容器环境变量加了TZ: Asia/Shanghai其次建库建表时字符集要指定utf8mb4。init.sql 开头可以加CREATE DATABASE IF NOT EXISTS tongue DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE tongue;还有一个小坑如果你在 Windows 上写 init.sql 再挂载到 Linux 容器文件编码和换行符可能导致 SQL 执行报错。统一保存成 UTF-8换行符用 LF不要用 CRLF。5.4 前端能开但接口 404/502症状打开http://localhost:8088能看到前端页面但登录或查询接口全部报错。排查顺序我是这样的先用 curl 直接访问后端接口确认 backend 本身是否正常curl http://localhost:8000/api/health如果后端正常再看 Nginx 反向代理配置。重点检查两点proxy_pass末尾有没有正确的/后端服务名写的是不是backend要和 compose 服务名一致。如果接口报 502 Bad Gateway多半是 Nginx 容器没法解析backend这个主机名。这个时候docker exec -it tongue-web curl http://backend:8000/api/health可以帮你快速定位是 DNS 解析问题还是后端没起来。另外一个容易被忽略的点是 FastAPI 的 CORS 中间件。虽然 Nginx 已经做了同源代理但如果你跳过后端直接访问localhost:8000调试前端在8088端口请求就会出现跨域。开发阶段可以在后端加一个宽松的 CORS 配置from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[*], allow_headers[*], )生产环境建议把allow_origins收敛成自己的域名不要全开。6. 部署之后的维护与优化思考6.1 镜像瘦身和构建加速整套服务一次搭建完成后我顺手做了两件优化第一件是加.dockerignore。在backend、model-service、frontend各自目录下加一个把__pycache__、.git、node_modules、uploads都排除掉避免 COPY 阶段把一堆没用的文件带进构建上下文构建速度提升特别明显。__pycache__/ *.pyc .git/ .env node_modules/ dist/第二件是尽量把不经常变动的依赖安装放在 Dockerfile 靠前的层。Docker 有层缓存机制只要COPY requirements.txt之前的内容没变后续构建就不会重新 pip install。我调整之后只改业务代码时重建 backend 镜像一分钟内就能完成。6.2 数据备份与平滑更新MySQL 数据都在mysql_data这个命名卷里删除容器不会丢但卷被误删就全没了。我加了定时备份docker exec tongue-mysql mysqldump -uroot -p$MYSQL_ROOT_PASSWORD tongue backup_$(date %Y%m%d).sql后续更新代码也比较顺滑docker compose build backend docker compose up -d backendcompose 检测到镜像 ID 变化会重建容器旧的容器自动删掉服务不中断太久。如果修改了 compose 文件直接docker compose up -d就会按新的配置重建受影响的服务。6.3 后续迭代的几个想法这次容器化落地之后我还在琢磨几个优化点一是模型服务目前还是直接加载 PyTorch 权重后面可以考虑转成 ONNX基础镜像能小很多推理速度也会更快代价是重新做精度对齐测试。二是引入模型版本管理。现在模型文件是一个挂载卷更新模型就是替换文件万一新模型效果不行还得回滚。后面可以做成models/tongue_v1.pth和models/tongue_v2.pth并行通过环境变量切换版本。三是给前端 Nginx 加 HTTPS。本地部署暂时无所谓但如果要让别人公网访问没有 HTTPS 会非常尴尬。Nginx 容器里挂证书再加个443端口映射就行整体方案不用改。最后说一句切身感受如果你也是一个人维护本地 AI 项目别一开始就把服务拆得特别碎。先一个 compose 把后端、模型、前端、数据库串起来跑通然后再按需拆服务、做镜像优化。这次部署最大的体会是Docker 容器化的价值不是“把代码塞进 Dockerfile”而是提前想清楚服务边界和数据依赖让每一步部署都变成可重复、可解释的操作而不是靠玄学。