核心提要:本实战基于 Ubuntu 22.04 环境,完整实现"MNIST 手写数字识别模型开发→Docker 容器封装→K8s 集群部署→API 调用"全流程。采用 PyTorch 构建模型、FastAPI 封装 API 接口,通过 Docker 固化运行环境,借助 K8s 实现服务的稳定部署与弹性伸缩,最终可通过 HTTP 接口提交手写数字图像,获取识别结果。全程聚焦实操,配套完整代码与配置文件,新手可直接跟随步骤落地。
一、前置准备
1. 环境要求
| 环境/工具 | 版本要求 | 核心作用 |
|---|---|---|
| 操作系统 | Ubuntu 22.04 LTS | 实操基础环境 |
| Docker | 20.10+ | 封装模型服务运行环境 |
| K8s 集群 | 1.24+(单节点/minikube) | 部署并管理模型服务 |
| Python | 3.8-3.10 | 开发模型与 API 接口 |
| PyTorch | 2.0+ | 构建 MNIST 识别模型 |
| FastAPI | 0.100+ | 封装模型为 HTTP API 接口 |
2. 前置工具安装(Ubuntu 环境)
# 1. 安装 Docker 并启动
sudo apt update -y
sudo apt install docker.io -y
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER # 配置当前用户免sudo使用docker
newgrp docker # 生效配置
# 2. 安装 K8s 单节点集群(minikube,新手推荐)
# 安装 kubectl
sudo apt install -y apt-transport-https ca-certificates curl
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/google.gpg
echo "deb https://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt update -y
sudo apt install -y kubectl
# 安装 minikube(启动单节点K8s集群)
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
minikube start --driver=docker # 以Docker为驱动启动K8s集群
kubectl get nodes # 验证节点状态,显示Ready即正常
# 3. 安装 Python 及依赖
sudo apt install python3-pip -y
pip3 install torch torchvision fastapi uvicorn pillow python-multipart -i https://pypi.tuna.tsinghua.edu.cn/simple
二、核心原理梳理
-
模型层面:MNIST 是手写数字(0-9)识别数据集,用 PyTorch 构建简单卷积神经网络(CNN),训练后保存模型文件(.pth);
-
API 层面:用 FastAPI 封装模型推理逻辑,提供 POST 接口(/predict),接收图像文件,返回识别结果;
-
Docker 层面:将 Python 环境、模型文件、API 代码打包为镜像,避免环境依赖问题;
-
K8s 层面:通过 Deployment 部署容器镜像,创建 Service(NodePort 类型)暴露 API 接口,实现服务稳定运行。
三、操作步骤
步骤 1:开发 MNIST 模型并保存
1. 模型训练代码(train_mnist.py)
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# 1. 数据预处理
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # MNIST 数据集均值和标准差
])
# 加载 MNIST 数据集
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
# 2. 构建简单 CNN 模型
class MNISTModel(nn.Module):
def __init__(self):
super(MNISTModel, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.conv1(x)
x = torch.relu(x)
x = self.conv2(x)
x = torch.relu(x)
x = torch.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = torch.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
output = torch.log_softmax(x, dim=1)
return output
# 3. 模型训练配置
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MNISTModel().to(device)
criterion = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 4. 训练过程
def train(model, train_loader, optimizer, criterion, epoch):
model.train()
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
if batch_idx % 100 == 0:
print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} ({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}')
# 5. 测试模型
def test(model, test_loader, criterion):
model.eval()
test_loss = 0
correct = 0
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
output = model(data)
test_loss += criterion(output, target).item() # 累加损失
pred = output.argmax(dim=1, keepdim=True) # 获取预测结果
correct += pred.eq(target.view_as(pred)).sum().item() # 统计正确数
test_loss /= len(test_loader.dataset)
print(f'\nTest set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({100. * correct / len(test_loader.dataset):.1f}%)\n')
# 6. 执行训练(5个epoch,新手可减少epoch加快训练)
for epoch in range(1, 6):
train(model, train_loader, optimizer, criterion, epoch)
test(model, test_loader, criterion)
# 7. 保存模型(仅保存参数,减小模型体积)
torch.save(model.state_dict(), "mnist_model.pth")
print("模型保存完成:mnist_model.pth")
2. 执行训练并保存模型
# 执行训练脚本(首次运行会下载MNIST数据集,约11MB)
python3 train_mnist.py
# 训练完成后,当前目录会生成:
# - mnist_model.pth(模型权重文件)
# - data/ 目录(MNIST数据集)
简化方案:若不想手动训练,可直接下载预训练模型权重:curl -O https://download.openmmlab.com/mmclassification/v0/mnist/mnist_baseline_epoch10_accuracy0.9857.pth -o mnist_model.pth
步骤 2:用 FastAPI 封装模型为 API 接口
1. API 服务代码(main.py)
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import JSONResponse
import torch
import torch.nn as nn
from torchvision import transforms
from PIL import Image
import io
# 1. 初始化 FastAPI 应用
app = FastAPI(title="MNIST 手写数字识别 API", version="1.0")
# 2. 加载模型结构(与训练时一致)
class MNISTModel(nn.Module):
def __init__(self):
super(MNISTModel, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.conv1(x)
x = torch.relu(x)
x = self.conv2(x)
x = torch.relu(x)
x = torch.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = torch.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
output = torch.log_softmax(x, dim=1)
return output
# 3. 加载模型权重并设置为推理模式
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MNISTModel().to(device)
model.load_state_dict(torch.load("mnist_model.pth", map_location=device))
model.eval() # 切换为推理模式,禁用Dropout
# 4. 图像预处理(与训练时一致)
transform = transforms.Compose([
transforms.Grayscale(num_output_channels=1), # 确保为单通道图像
transforms.Resize((28, 28)), # 调整为MNIST输入尺寸(28x28)
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
# 5. 定义 API 接口(POST 方法,接收图像文件)
@app.post("/predict", summary="手写数字识别")
async def predict(file: UploadFile = File(...)):
try:
# 读取图像文件
image_data = await file.read()
image = Image.open(io.BytesIO(image_data))
# 图像预处理
input_tensor = transform(image).unsqueeze(0) # 添加batch维度(1,1,28,28)
input_tensor = input_tensor.to(device)
# 模型推理(禁用梯度计算,加快速度)
with torch.no_grad():
output = model(input_tensor)
pred = output.argmax(dim=1, keepdim=True).item() # 获取预测数字
# 返回结果
return JSONResponse(
content={
"status": "success",
"filename": file.filename,
"predict_result": int(pred), # 识别出的数字(0-9)
"message": "识别成功"
}
)
except Exception as e:
return JSONResponse(
content={
"status": "error",
"message": f"识别失败:{str(e)}"
},
status_code=500
)
# 6. 健康检查接口(K8s 探针使用)
@app.get("/health", summary="服务健康检查")
async def health_check():
return {"status": "healthy", "service": "mnist-api"}
if __name__ == "__main__":
import uvicorn
# 启动服务,监听0.0.0.0:8000(允许容器外部访问)
uvicorn.run(app, host="0.0.0.0", port=8000)
2. 编写依赖文件(requirements.txt)
torch==2.0.1
torchvision==0.15.2
fastapi==0.103.1
uvicorn==0.23.2
pillow==10.0.1
python-multipart==0.0.6
3. 本地测试 API 接口(验证逻辑)
# 1. 启动本地 API 服务
python3 main.py
# 2. 另开终端,用 curl 测试(需准备一张手写数字图像,如 test.png)
# 示例:测试 test.png 图像
curl -X POST "http://localhost:8000/predict" -F "file=@test.png"
# 预期成功响应:
# {"status":"success","filename":"test.png","predict_result":5,"message":"识别成功"}
# 3. 测试健康检查接口
curl http://localhost:8000/health
# 预期响应:{"status":"healthy","service":"mnist-api"}


步骤 3:Docker 封装模型 API 服务
1. 编写 Dockerfile
# 基础镜像:Python 3.9 轻量版(减少镜像体积)
FROM python:3.9-slim
# 设置工作目录
WORKDIR /app
# 复制依赖文件并安装(优先复制依赖文件,利用Docker缓存)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制模型文件、API代码
COPY mnist_model.pth .
COPY main.py .
# 暴露 API 服务端口(与代码中一致,仅声明,不映射)
EXPOSE 8000
# 容器启动命令(启动 API 服务)
CMD ["python3", "main.py"]
2. 构建 Docker 镜像
# 构建镜像(-t 指定镜像名:mnist-api:v1,. 表示当前目录为构建上下文)
docker build -t mnist-api:v1 .
# 查看镜像是否构建成功
docker images | grep mnist-api
# 预期输出:mnist-api v1 xxxxxxxx 刚刚 约800MB

3. 本地测试 Docker 镜像
# 启动容器(-p 映射端口:宿主机8000→容器8000)
docker run -d -p 8000:8000 --name mnist-api-container mnist-api:v1
# 验证容器是否启动
docker ps | grep mnist-api-container
# 重复步骤 2.3 的 curl 命令测试 API,确保容器内服务正常
# 遇到numpy兼容性问题,进入Docker容器,降级numpy版本到与torchvision兼容的版本即可
# docker exec mnist-api-container pip install numpy==1.24.3 && docker restart mnist-api-container
curl -X POST "http://localhost:8000/predict" -F "file=@test.png"
# 测试完成后停止并删除容器(后续部署到K8s,本地容器可删除)
docker stop mnist-api-container
docker rm mnist-api-container

4. 推送镜像到仓库(K8s 部署必备)
# 1. 登录 Docker Hub(替换为你的Docker Hub账号)
docker login -u 你的DockerHub账号
# 2. 重命名镜像(符合Docker Hub命名规范:账号/镜像名:标签)
docker tag mnist-api:v1 你的DockerHub账号/mnist-api:v1
# 3. 推送镜像到 Docker Hub
docker push 你的DockerHub账号/mnist-api:v1
# 推送成功后,可在Docker Hub官网查看镜像(公开可访问)
步骤 4:K8s 部署 MNIST API 服务
1. 编写 K8s 部署配置文件(mnist-deploy.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: mnist-api-deploy # Deployment 名称
labels:
app: mnist-api # 标签(用于关联Service)
spec:
replicas: 2 # 部署2个Pod副本(高可用)
selector:
matchLabels:
app: mnist-api
template:
metadata:
labels:
app: mnist-api
spec:
containers:
- name: mnist-api-container # 容器名称
image: 你的DockerHub账号/mnist-api:v1 # 替换为你的镜像地址
ports:
- containerPort: 8000 # 容器内端口(与API服务一致)
# 健康检查(K8s探针,确保服务正常)
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30 # 启动30秒后开始检查
periodSeconds: 10 # 每10秒检查一次
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
---
# 暴露 API 接口(NodePort 类型,外部可访问)
apiVersion: v1
kind: Service
metadata:
name: mnist-api-service
spec:
type: NodePort # 类型:NodePort(暴露节点端口供外部访问)
selector:
app: mnist-api # 关联上面的Deployment
ports:
- port: 8000 # Service 内部端口
targetPort: 8000 # 容器端口(与Deployment一致)
nodePort: 30080 # 节点暴露端口(范围:30000-32767,可自定义)
2. 执行 K8s 部署
# 1. 应用部署配置文件
kubectl apply -f mnist-deploy.yaml
# 2. 查看 Deployment 状态
kubectl get deployments
# 预期输出:mnist-api-deploy 2/2 2 2 刚刚
# 3. 查看 Pod 状态(确保2个Pod都为Running)
kubectl get pods
# 预期输出:
# mnist-api-deploy-xxxxxx-xxxxx 1/1 Running 0 10s
# mnist-api-deploy-xxxxxx-xxxxx 1/1 Running 0 10s
# 4. 查看 Service 状态
kubectl get svc mnist-api-service
# 预期输出:
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# mnist-api-service NodePort 10.109.xxx.xxx <none> 8000:30080/TCP 20s
3. 验证 K8s 服务可用性
# 1. 获取 K8s 节点 IP(minikube 环境)
minikube ip
# 预期输出:192.168.49.2(示例,以实际输出为准)
# 2. 测试 API 接口(地址:节点IP:30080,端口为Service的nodePort)
curl -X POST "http://192.168.49.2:30080/predict" -F "file=@test.png"
# 预期成功响应:
# {"status":"success","filename":"test.png","predict_result":5,"message":"识别成功"}
# 3. 测试负载均衡(K8s会自动分发请求到2个Pod)
for i in {1..5}; do curl -X POST "http://192.168.49.2:30080/predict" -F "file=@test.png"; echo; done
步骤 5:API 调用完整示例(多语言)
1. Python 调用示例
import requests
# API 地址(替换为你的 K8s 节点IP和端口)
api_url = "http://192.168.49.2:30080/predict"
# 读取图像文件并调用 API
with open("test.png", "rb") as f:
files = {"file": f}
response = requests.post(api_url, files=files)
# 解析响应结果
result = response.json()
print(result)
if result["status"] == "success":
print(f"识别结果:{result['predict_result']}")
else:
print(f"错误信息:{result['message']}")
2. Postman 调用示例
- 打开 Postman 工具(若未安装,可从官网下载并安装),在顶部请求方法下拉框中选择 POST 方法;
- 在请求地址栏输入 K8s 暴露的 API 地址,格式为:
http://K8s节点IP:30080/predict(替换为实际的 K8s 节点 IP,30080 为前文 Service 配置的 nodePort); - 切换到 Body 标签页,勾选 form-data 选项(表单数据格式,与 API 接口接收参数格式匹配);
- 在 Key 输入框中填写
file(需与 API 代码中定义的参数名一致),点击 Key 右侧的下拉框,选择 File 类型; - 在 Value 列对应的输入框中点击 Select Files,从本地选择一张手写数字图像(如 test.png,建议为单通道灰度图、尺寸接近 28x28);
- 点击 Postman 右上角的 Send 按钮,发送请求;
- 查看响应结果:在页面下方的 Response 区域,若请求成功,会显示与 curl 测试一致的 JSON 响应,包含
status: "success"、predict_result(识别出的数字)等信息;若失败,会显示错误提示信息,可根据提示排查问题(如图像格式错误、API 地址不正确等)。
四、常见问题排查
问题 1:K8s Pod 启动失败(状态为 ErrImagePull/ImagePullBackOff)
原因:镜像地址错误、镜像未推送成功、K8s 集群无法访问镜像仓库。
解决方案: 1. 检查 mnist-deploy.yaml 中的 image 地址是否正确(替换为你的 DockerHub 账号); 2. 重新推送镜像:docker push 你的DockerHub账号/mnist-api:v1; 3. 测试集群是否能拉取镜像:kubectl exec -it 任意运行中的Pod -- docker pull 你的DockerHub账号/mnist-api:v1。
问题 2:API 调用提示 Connection refused
原因:K8s Service 端口错误、节点防火墙拦截、Pod 未正常运行。
解决方案: 1. 用 kubectl get svc mnist-api-service 确认 nodePort 为 30080; 2. 关闭 Ubuntu 防火墙:sudo ufw disable; 3. 用 kubectl get pods 确认所有 Pod 为 Running 状态,异常 Pod 查看日志:kubectl logs 异常Pod名称。
问题 3:识别结果错误/失败
原因:输入图像不符合要求(非单通道、尺寸不对)、模型训练不充分。
解决方案: 1. 确保输入图像为手写数字(0-9)、单通道灰度图、尺寸接近 28x28; 2. 重新训练模型(增加 epoch 数量,如训练 10 个 epoch)。
问题 4:K8s 健康检查失败(Pod 反复重启)
原因:/health 接口未正常响应、initialDelaySeconds 过短(服务未启动完成)。
解决方案: 1. 本地启动容器测试 /health 接口:curl http://localhost:8000/health; 2. 修改 mnist-deploy.yaml 中的 initialDelaySeconds 为 60(延长启动等待时间),重新部署:kubectl apply -f mnist-deploy.yaml。
五、总结与进阶方向
1. 核心成果
完成"模型开发→Docker 封装→K8s 部署→API 调用"全流程,实现了 MNIST 模型的容器化部署和高可用 API 服务,可通过 HTTP 接口快速实现手写数字识别。
2. 进阶方向
-
性能优化:添加 GPU 支持(修改 Dockerfile 为 GPU 基础镜像,K8s 配置 GPU 资源),提升推理速度;
-
弹性伸缩:配置 K8s HPA(Horizontal Pod Autoscaler),根据 CPU/内存使用率自动调整 Pod 数量;
-
监控告警:集成 Prometheus + Grafana 监控 API 接口 QPS、延迟、错误率,配置告警规则;
-
安全加固:为 API 接口添加认证(如 Token 验证),使用 Ingress 替代 NodePort 暴露接口。