FastAPI + Vue3 全栈项目 Kubernetes 部署实战教程
从零开始,将 FastAPI 后端 + Vue3 前端 + MySQL + Redis 全栈应用完整部署到 Kubernetes 集群。
涵盖镜像构建、containerd 运行时适配、存储卷配置、故障排错等生产实战内容。
目录
- [1. 项目架构概览](#1. 项目架构概览)
- [2. 环境准备](#2. 环境准备)
- [3. 项目代码结构](#3. 项目代码结构)
- [4. Kubernetes 资源配置说明](#4. Kubernetes 资源配置说明)
- [5. 部署脚本](#5. 部署脚本)
- [6. 完整部署流程](#6. 完整部署流程)
- [7. 访问验证](#7. 访问验证)
- [8. 故障排查手册](#8. 故障排查手册)
- [9. 常用运维命令](#9. 常用运维命令)
- [10. 总结](#10. 总结)
1. 项目架构概览
1.1 技术栈
| 层级 | 技术 | 说明 |
|---|---|---|
| 后端 | FastAPI + SQLAlchemy (async) + Uvicorn | Python 异步 Web 框架 |
| 前端 | Vue 3 + Vite + Pinia + Vant UI | 单页面应用 |
| 数据库 | MySQL 8.0 | 持久化存储 |
| 缓存 | Redis 7 | 热点数据缓存 |
| 反向代理 | Nginx | 前端静态资源 + API 代理 |
| 容器编排 | Kubernetes v1.31 | 3 节点集群 |
| 容器运行时 | containerd 2.2 | 无 Docker 环境 |
| 网络 | Cilium CNI | 容器网络 |
1.2 K8s 部署架构图
┌──────────────────────────────────────────────────┐
│ 客户端浏览器 │
│ http://192.168.194.21:30080 │
└──────────────────────┬───────────────────────────┘
│ NodePort 30080
┌──────────────────────▼───────────────────────────┐
│ Frontend Service (NodePort) │
│ frontend.news-app.svc │
│ Port: 80 → NodePort: 30080 │
└──────────────────────┬───────────────────────────┘
│
┌──────────────────────▼───────────────────────────┐
│ Frontend Deployment (2 replicas) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Nginx (反向代理) │ │
│ │ ├── / → Vue3 静态文件 │ │
│ │ └── /api/* → backend:8000 │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────┘
│ /api/* 代理
┌──────────────────────▼───────────────────────────┐
│ Backend Service (ClusterIP) │
│ backend.news-app.svc:8000 │
└──────────────────────┬───────────────────────────┘
│
┌──────────────────────▼───────────────────────────┐
│ Backend Deployment (2 replicas) │
│ ┌─────────────────────────────────────────────┐ │
│ │ FastAPI + Uvicorn (:8000) │ │
│ │ ├── /api/news/* 新闻接口 │ │
│ │ ├── /api/user/* 用户接口 │ │
│ │ └── /api/favorite/* 收藏接口 │ │
│ └──────────────────────┬──────────┬───────────┘ │
└─────────────────────────┼──────────┼─────────────┘
│ │
┌───────────▼──┐ ┌───▼───────────┐
│ MySQL (1副本) │ │ Redis (1副本) │
│ :3306 │ │ :6379 │
│ hostPath PV │ │ ClusterIP │
│ 固定在 master │ │ │
└───────────────┘ └────────────────┘
1.3 K8s 资源清单
| 资源类型 | 名称 | 用途 |
|---|---|---|
| Namespace | news-app |
隔离所有资源 |
| Secret | news-secret |
MySQL 密码等敏感配置 |
| ConfigMap | nginx-config |
Nginx 反向代理配置 |
| ConfigMap | mysql-init-sql |
数据库初始化 SQL |
| PersistentVolume | mysql-pv |
MySQL 数据持久化 (hostPath) |
| PersistentVolumeClaim | mysql-pvc |
PV 绑定请求 |
| Deployment | mysql |
MySQL 8.0 单副本 |
| Service | mysql |
MySQL ClusterIP |
| Deployment | redis |
Redis 7 单副本 |
| Service | redis |
Redis ClusterIP |
| Deployment | backend |
FastAPI 后端 2 副本 |
| Service | backend |
后端 ClusterIP |
| Deployment | frontend |
Nginx+Vue 前端 2 副本 |
| Service | frontend |
前端 NodePort |
2. 环境准备
2.1 集群环境
本文基于以下真实环境完成部署:
| 项目 | 配置 |
|---|---|
| 操作系统 | Red Hat Enterprise Linux 10.1 |
| 内核版本 | 6.12.0 |
| Kubernetes | v1.31.14 |
| 容器运行时 | containerd 2.2.3 |
| 网络插件 | Cilium |
| 节点数 | 3 台 |
节点信息:
| 主机名 | 角色 | IP 地址 |
|---|---|---|
| master | control-plane | 192.168.194.21 |
| node1 | worker | 192.168.194.22 |
| node2 | worker | 192.168.194.23 |
2.2 前置检查
在 master 节点上执行以下命令,确认环境就绪:
bash
# 1. 检查集群版本
kubectl version --short
# 2. 检查节点状态(所有节点必须 Ready)
kubectl get nodes -o wide
# 3. 确认容器运行时
crictl ps 2>/dev/null && echo "使用 containerd/CRI-O"
# 4. 检查是否有 StorageClass(本文方案有无均可)
kubectl get storageclass
# 5. 检查是否有 Ingress Controller(本文方案有无均可)
kubectl get pods -A | grep -i ingress
2.3 安装 Docker(用于构建镜像)
注意:集群使用 containerd 运行容器,但我们仍需要 Docker 来构建镜像。
bash
# RHEL/CentOS 安装 Docker
yum install -y docker
systemctl start docker
systemctl enable docker
# 验证
docker --version
3. 项目代码结构
3.1 目录结构
fastapi/
├── database.sql # MySQL 建表 + 种子数据
├── docker-compose.yml # 原 Docker Compose 部署文件
│
├── toutiao_backend/ # FastAPI 后端
│ ├── Dockerfile # 后端镜像构建文件
│ ├── .dockerignore # ⚠️ 注意:需要修复!
│ ├── requirements.txt # Python 依赖
│ ├── main.py # 应用入口
│ ├── config/
│ │ ├── db_config.py # MySQL 连接配置(读环境变量)
│ │ └── cache_conf.py # Redis 连接配置(读环境变量)
│ ├── models/ # SQLAlchemy ORM 模型
│ ├── schemas/ # Pydantic 请求/响应模型
│ ├── routers/ # API 路由
│ ├── crud/ # 数据库操作
│ ├── cache/ # ⚠️ Redis 缓存模块
│ └── utils/ # 工具函数
│
├── xwzx-news/ # Vue 3 前端
│ ├── Dockerfile # 前端镜像构建文件(多阶段)
│ ├── nginx.conf # Nginx 反向代理配置
│ └── src/ # Vue 源码
│
└── k8s/ # Kubernetes 部署配置
├── namespace.yaml # 命名空间
├── secret.yaml # 密钥
├── configmap.yaml # Nginx 配置
├── mysql.yaml # MySQL PV+PVC+Deployment+Service
├── redis.yaml # Redis Deployment+Service
├── backend.yaml # FastAPI Deployment+Service
├── frontend.yaml # Nginx+Vue Deployment+Service
├── deploy.sh # 一键部署脚本
└── build-and-import.sh # 镜像构建与分发脚本
3.2 关键配置文件
3.2.1 后端 Dockerfile
文件路径:toutiao_backend/Dockerfile
dockerfile
FROM python:3.12-slim
WORKDIR /app
# 安装 gcc(bcrypt 需要编译)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制应用代码
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
3.2.2 ⚠️ .dockerignore 修复(重要!)
文件路径:toutiao_backend/.dockerignore
错误内容(修复前):
dockerignore
__pycache__
*.pyc
.venv
.env
.git
.gitignore
*.md
test_*
.idea
cache # ❌ 这行会把 cache/ 源代码目录也排除掉!
正确内容(修复后):
dockerignore
__pycache__
*.pyc
.venv
.env
.git
.gitignore
*.md
test_*
.idea
错误说明: 第 10 行的
cache原本想排除__pycache__目录,但它同时匹配了项目中的cache/源码目录(包含news_cache.py等 Redis 缓存模块)。这会导致构建出的镜像缺少cache模块,后端启动时抛出ModuleNotFoundError: No module named 'cache'。
3.2.3 前端 Dockerfile(多阶段构建)
文件路径:xwzx-news/Dockerfile
dockerfile
# 阶段一:编译 Vue 应用
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
ARG VITE_API_BASE_URL=
RUN VITE_API_BASE_URL=${VITE_API_BASE_URL} npm run build
# 阶段二:Nginx 运行时
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 Nginx 配置(运行时会通过 ConfigMap 覆盖)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
3.2.4 前端 Nginx 配置
文件路径:xwzx-news/nginx.conf(此内容会通过 K8s ConfigMap 挂载覆盖)
nginx
server {
listen 80;
server_name localhost;
# 前端静态文件(Vue History 模式)
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API 反向代理到后端 Service
location /api/ {
proxy_pass http://backend:8000; # ← K8s Service 名称
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;
}
}
3.2.5 数据库配置(读环境变量)
文件路径:toutiao_backend/config/db_config.py
python
import os
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession, create_async_engine
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_USER = os.getenv("DB_USER", "root")
DB_PASSWORD = os.getenv("DB_PASSWORD", "123456")
DB_NAME = os.getenv("DB_NAME", "news_app")
ASYNC_DATABASE_URL = f"mysql+aiomysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
async_engine = create_async_engine(
ASYNC_DATABASE_URL,
echo=True,
pool_size=10,
max_overflow=20
)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
3.2.6 Redis 配置(读环境变量)
文件路径:toutiao_backend/config/cache_conf.py
python
import os
import redis.asyncio as redis
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
redis_client = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB,
decode_responses=True
)
4. Kubernetes 资源配置说明
4.1 命名空间(namespace.yaml)
yaml
apiVersion: v1
kind: Namespace
metadata:
name: news-app
将所有资源放在 news-app 命名空间下,实现逻辑隔离。后续所有资源都通过 namespace: news-app 归属到此命名空间。
4.2 密钥(secret.yaml)
yaml
apiVersion: v1
kind: Secret
metadata:
name: news-secret
namespace: news-app
type: Opaque
stringData:
mysql-root-password: "123456"
mysql-user: "root"
mysql-password: "123456"
mysql-database: "news_app"
说明: 使用
stringData字段可以直接写明文,K8s 会自动进行 Base64 编码存储。生产环境建议使用kubectl create secret命令行创建或使用 Sealed Secrets。
4.3 Nginx 配置 ConfigMap(configmap.yaml)
yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: news-app
data:
default.conf: |
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /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_set_header X-Forwarded-Proto $scheme;
}
}
关键设计:
proxy_pass http://backend:8000中的backend是 K8s Service 的名称。由于前端和后端在同一个 namespace(news-app),K8s 内部 DNS 会自动将backend解析为后端的 ClusterIP。
4.4 MySQL(mysql.yaml)
MySQL 是最复杂的资源,包含 4 个 K8s 对象:
4.4.1 PersistentVolume(主机路径卷)
yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: mysql-pv
namespace: news-app
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /data/mysql
说明: 由于集群没有 StorageClass,无法自动创建 PV。我们手动创建一个
hostPath类型的 PV,数据存储在节点的/data/mysql目录。注意:
hostPath适合开发/测试环境。生产环境应使用 NAS、Ceph RBD 或云厂商的 StorageClass。
4.4.2 PersistentVolumeClaim(存储声明)
yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pvc
namespace: news-app
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
4.4.3 Deployment + Service
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: news-app
labels:
app: mysql
spec:
replicas: 1 # 数据库单副本
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
nodeSelector: # 固定在 master 节点
kubernetes.io/hostname: master
tolerations: # 容忍 master 污点
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: news-secret
key: mysql-root-password
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: news-secret
key: mysql-database
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --init-connect=SET NAMES utf8mb4
- --skip-character-set-client-handshake
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql # 数据目录
- name: mysql-init
mountPath: /docker-entrypoint-initdb.d # 初始化脚本目录
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe: # 存活探针
exec:
command:
- mysqladmin
- ping
- -h
- localhost
- -u
- root
- -p123456
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe: # 就绪探针
exec:
command:
- mysqladmin
- ping
- -h
- localhost
- -u
- root
- -p123456
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: mysql-data
persistentVolumeClaim:
claimName: mysql-pvc
- name: mysql-init
configMap:
name: mysql-init-sql
---
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: news-app
spec:
selector:
app: mysql
ports:
- port: 3306
targetPort: 3306
type: ClusterIP
关键设计决策说明:
| 设计点 | 决策 | 原因 |
|---|---|---|
nodeSelector |
固定到 master | hostPath PV 在 master 上,Pod 必须调度到同一节点 |
tolerations |
容忍 control-plane 污点 | master 节点默认有 NoSchedule 污点,不加 toleration Pod 无法调度 |
| 初始化方式 | ConfigMap 挂载 SQL | MySQL 镜像会自动执行 /docker-entrypoint-initdb.d/ 下的 .sql 文件 |
| 初始化 ConfigMap | mysql-init-sql |
由 deploy.sh 脚本从 database.sql 文件动态创建 |
4.5 Redis(redis.yaml)
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: news-app
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
livenessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: news-app
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
type: ClusterIP
说明: Redis 作为缓存服务,不持久化数据,使用最简单的 Deployment + ClusterIP 部署方式。
4.6 后端(backend.yaml)
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: news-app
labels:
app: backend
spec:
replicas: 2 # 2 副本高可用
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: news-backend:latest # 自定义镜像
imagePullPolicy: IfNotPresent # 不从仓库拉取
ports:
- containerPort: 8000
env:
- name: DB_HOST
value: "mysql" # ← K8s Service 名称
- name: DB_PORT
value: "3306"
- name: DB_USER
valueFrom:
secretKeyRef:
name: news-secret
key: mysql-user
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: news-secret
key: mysql-password
- name: DB_NAME
valueFrom:
secretKeyRef:
name: news-secret
key: mysql-database
- name: REDIS_HOST
value: "redis" # ← K8s Service 名称
- name: REDIS_PORT
value: "6379"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "300m"
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: news-app
spec:
selector:
app: backend
ports:
- port: 8000
targetPort: 8000
type: ClusterIP # 仅集群内部访问
关键配置说明:
| 配置项 | 值 | 说明 |
|---|---|---|
imagePullPolicy |
IfNotPresent |
镜像已通过 ctr import 导入到 containerd,不从在线仓库拉取 |
DB_HOST |
mysql |
K8s 内部 DNS,解析为 MySQL Service 的 ClusterIP |
REDIS_HOST |
redis |
K8s 内部 DNS,解析为 Redis Service 的 ClusterIP |
replicas |
2 |
两个副本提供基本的高可用和负载分担 |
4.7 前端(frontend.yaml)
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: news-app
labels:
app: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: news-frontend:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf # 只覆盖这一个文件
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: nginx-config
configMap:
name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: news-app
spec:
selector:
app: frontend
type: NodePort # 对外暴露访问
ports:
- port: 80
targetPort: 80
nodePort: 30080 # 固定 NodePort 端口
关键配置说明:
| 配置项 | 值 | 说明 |
|---|---|---|
type |
NodePort |
没有 Ingress Controller 时,通过节点 IP 直接访问 |
nodePort |
30080 |
固定端口,访问 http://<任意节点IP>:30080 |
subPath |
default.conf |
只覆盖 nginx 配置文件,不影响容器内其他文件 |
5. 部署脚本
5.1 一键部署脚本(deploy.sh)
bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
NAMESPACE="news-app"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
# ---------- step 1: 检查前置条件 ----------
info "检查前置条件..."
command -v kubectl >/dev/null 2>&1 || { error "请先安装 kubectl"; exit 1; }
# ---------- step 2: 创建 namespace ----------
info "创建 Namespace..."
kubectl apply -f "${SCRIPT_DIR}/namespace.yaml"
# ---------- step 3: 从 database.sql 创建 ConfigMap ----------
info "创建 MySQL 初始化脚本 ConfigMap..."
kubectl create configmap mysql-init-sql \
--from-file=init.sql="${PROJECT_DIR}/database.sql" \
--namespace="${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
# ---------- step 4: 部署所有资源 ----------
info "部署 Kubernetes 资源..."
kubectl apply -f "${SCRIPT_DIR}/secret.yaml"
kubectl apply -f "${SCRIPT_DIR}/configmap.yaml"
kubectl apply -f "${SCRIPT_DIR}/mysql.yaml"
kubectl apply -f "${SCRIPT_DIR}/redis.yaml"
kubectl apply -f "${SCRIPT_DIR}/backend.yaml"
kubectl apply -f "${SCRIPT_DIR}/frontend.yaml"
# ---------- step 5: 等待所有 Pod 就绪 ----------
info "等待 MySQL 就绪 (最长 120s)..."
kubectl wait --for=condition=ready pod -l app=mysql -n "${NAMESPACE}" --timeout=120s
info "等待 Redis 就绪 (最长 60s)..."
kubectl wait --for=condition=ready pod -l app=redis -n "${NAMESPACE}" --timeout=60s
info "等待 Backend 就绪 (最长 90s)..."
kubectl wait --for=condition=ready pod -l app=backend -n "${NAMESPACE}" --timeout=90s
info "等待 Frontend 就绪 (最长 60s)..."
kubectl wait --for=condition=ready pod -l app=frontend -n "${NAMESPACE}" --timeout=60s
# ---------- step 6: 输出访问信息 ----------
echo ""
info "============================================"
info " 部署完成!"
info "============================================"
echo ""
info "Pod 状态:"
kubectl get pods -n "${NAMESPACE}" -o wide
echo ""
info "Service 列表:"
kubectl get svc -n "${NAMESPACE}"
echo ""
info "访问地址 (NodePort):"
echo " http://192.168.194.21:30080 (master)"
echo " http://192.168.194.22:30080 (node1)"
echo " http://192.168.194.23:30080 (node2)"
5.2 镜像构建与分发脚本(build-and-import.sh)
bash
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
IMAGE_BACKEND="news-backend:latest"
IMAGE_FRONTEND="news-frontend:latest"
# K8s 所有节点
NODES=("192.168.194.21" "192.168.194.22" "192.168.194.23")
# 获取当前节点 IP(避免 SSH 到自己)
LOCAL_IP=$(hostname -I | awk '{print $1}')
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
# ---------- 检测构建工具 ----------
BUILD_CMD=""
if command -v docker &>/dev/null; then
BUILD_CMD="docker"
elif command -v podman &>/dev/null; then
BUILD_CMD="podman"
elif command -v nerdctl &>/dev/null; then
BUILD_CMD="nerdctl"
else
error "未找到 docker / podman / nerdctl,请先安装其中之一"
exit 1
fi
info "使用构建工具: ${BUILD_CMD}"
# ---------- 构建后端镜像 ----------
info "构建后端镜像..."
cd "${PROJECT_DIR}/toutiao_backend"
${BUILD_CMD} build -t "${IMAGE_BACKEND}" .
# ---------- 构建前端镜像 ----------
info "构建前端镜像..."
cd "${PROJECT_DIR}/xwzx-news"
${BUILD_CMD} build --build-arg VITE_API_BASE_URL="" -t "${IMAGE_FRONTEND}" .
# ---------- 导出镜像 ----------
info "导出镜像..."
${BUILD_CMD} save -o /tmp/news-backend.tar "${IMAGE_BACKEND}"
${BUILD_CMD} save -o /tmp/news-frontend.tar "${IMAGE_FRONTEND}"
# ---------- 分发到所有节点 ----------
# 先处理本地节点(不需要 scp 到自己)
info "在本地导入镜像到 containerd..."
ctr -n k8s.io images import /tmp/news-backend.tar
ctr -n k8s.io images import /tmp/news-frontend.tar
# 再处理远程节点
for node in "${NODES[@]}"; do
if [ "${node}" = "${LOCAL_IP}" ]; then
continue # 本地已处理,跳过
fi
info "分发镜像到 ${node}..."
scp /tmp/news-backend.tar /tmp/news-frontend.tar root@"${node}":/tmp/
info "在 ${node} 上导入镜像到 containerd..."
ssh root@"${node}" "ctr -n k8s.io images import /tmp/news-backend.tar && ctr -n k8s.io images import /tmp/news-frontend.tar && rm -f /tmp/news-backend.tar /tmp/news-frontend.tar"
done
# ---------- 清理本地 ----------
rm -f /tmp/news-backend.tar /tmp/news-frontend.tar
info "验证各节点镜像:"
for node in "${NODES[@]}"; do
echo -n " ${node}: "
if [ "${node}" = "${LOCAL_IP}" ]; then
crictl images | grep news- || echo "(无)"
else
ssh root@"${node}" "crictl images | grep news-" || echo "(无)"
fi
done
echo ""
info "下一步: 运行 ./deploy.sh 部署应用"
脚本设计要点:
- 本地节点先在循环外单独处理(不 scp、不删文件),远程节点在循环中 scp → SSH 导入 → 远程清理
- 本地 tar 文件在循环全部结束后才删除,避免远程节点 scp 时源文件已被清理
- 支持
docker/podman/nerdctl三种构建工具- 构建完成后验证各节点是否成功导入
6. 完整部署流程
第一步:将项目代码拷贝到 master 节点
如果你在开发机(Windows/Mac)上,先通过 SCP 将项目拷贝到 master 节点:
bash
# Windows 开发机上执行(PowerShell 或 WSL)
scp -r D:/claude-code-demo/fastapi root@192.168.194.21:/root/news-app/
如果代码已经在 master 节点上,跳过此步。
第二步:修复 .dockerignore(必须!)
bash
cd /root/news-app/toutiao_backend
# 确认 .dockerignore 中没有裸的 "cache" 行
cat .dockerignore
# 正确内容应为(只排除 __pycache__,不排除 cache/ 源码目录):
# __pycache__
# *.pyc
# .venv
# .env
# .git
# .gitignore
# *.md
# test_*
# .idea
如果看到单独一行的 cache,用编辑器删除该行。
第三步:创建 MySQL 数据目录
bash
mkdir -p /data/mysql
此目录对应 mysql.yaml 中 hostPath PV 的路径。
第四步:构建镜像并分发到所有节点
bash
cd /root/news-app/k8s
chmod +x build-and-import.sh
./build-and-import.sh
这一步会:
- 用 Docker 构建
news-backend:latest镜像(约 135MB) - 用 Docker 构建
news-frontend:latest镜像(约 25MB) - 导出为 tar 文件
- 复制到 node1、node2
- 通过
ctr -n k8s.io images import导入到所有节点的 containerd
第五步:部署到 Kubernetes
bash
cd /root/news-app/k8s
chmod +x deploy.sh
./deploy.sh
如果 deploy.sh 执行出错(namespace 未创建导致),可以手动按顺序执行:
bash
# 1. 创建 Namespace
kubectl apply -f namespace.yaml
# 2. 创建 MySQL 初始化 ConfigMap
kubectl create configmap mysql-init-sql \
--from-file=init.sql=../database.sql \
--namespace=news-app \
--dry-run=client -o yaml | kubectl apply -f -
# 3. 部署所有资源
kubectl apply -f secret.yaml
kubectl apply -f configmap.yaml
kubectl apply -f mysql.yaml
kubectl apply -f redis.yaml
kubectl apply -f backend.yaml
kubectl apply -f frontend.yaml
第六步:等待 Pod 就绪
bash
# 实时查看 Pod 启动状态
kubectl get pods -n news-app -o wide -w
按 Ctrl+C 退出 watch。所有 Pod 的 READY 列显示 1/1 且 STATUS 为 Running 即表示成功。
正常输出示例:
NAME READY STATUS RESTARTS AGE IP NODE
backend-596df5cf6f-xxxxx 1/1 Running 0 2m 10.0.2.85 node1
backend-596df5cf6f-yyyyy 1/1 Running 0 2m 10.0.0.158 node2
frontend-65cbb4b469-xxxxx 1/1 Running 0 2m 10.0.2.156 node1
frontend-65cbb4b469-yyyyy 1/1 Running 0 2m 10.0.0.91 node2
mysql-8484d459d8-xxxxx 1/1 Running 0 3m 10.0.1.50 master
redis-c59784688-xxxxx 1/1 Running 0 2m 10.0.2.52 node1
7. 访问验证
7.1 浏览器访问
部署成功后,通过任意节点的 IP + NodePort 访问:
http://192.168.194.21:30080
http://192.168.194.22:30080
http://192.168.194.23:30080
7.2 API 功能验证
bash
# 测试后端 API
kubectl port-forward -n news-app svc/backend 8000:8000 &
# 测试接口
curl http://localhost:8000/
# 返回: {"message":"Hello World"}
curl http://localhost:8000/api/news/categories
# 返回: 新闻分类列表
curl -X POST http://localhost:8000/api/user/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
# 返回: 登录成功 + token
7.3 验证前端 Nginx 代理
bash
# 通过 NodePort 测试 API 代理
curl http://192.168.194.21:30080/api/news/categories
# 应返回与直接调用后端相同的结果
8. 故障排查手册
以下是部署过程中可能遇到的常见错误及解决方案。
错误 1:Backend CrashLoopBackOff --- ModuleNotFoundError: No module named 'cache'
错误日志:
ModuleNotFoundError: No module named 'cache'
File "/app/crud/news.py", line 5, in <module>
from cache.news_cache import get_cached_categories
原因: .dockerignore 文件中有一行单独的 cache,Docker 构建时把 cache/ 源码目录排除了。
解决:
bash
# 编辑 toutiao_backend/.dockerignore,删除裸的 "cache" 行
# 修复后应该是:
# __pycache__
# *.pyc
# ...
# .idea
# (没有单独的 "cache")
# 重新构建并分发镜像
cd /root/news-app/toutiao_backend
docker build -t news-backend:latest .
# 重新导出并分发
docker save -o /tmp/news-backend.tar news-backend:latest
# 导入到本地
ctr -n k8s.io images import /tmp/news-backend.tar
# 分发到其他节点
scp /tmp/news-backend.tar root@192.168.194.22:/tmp/
ssh root@192.168.194.22 "ctr -n k8s.io images import /tmp/news-backend.tar"
scp /tmp/news-backend.tar root@192.168.194.23:/tmp/
ssh root@192.168.194.23 "ctr -n k8s.io images import /tmp/news-backend.tar"
# 重建 Pod
kubectl delete pods -l app=backend -n news-app
错误 2:MySQL Pending --- pod has unbound immediate PersistentVolumeClaims
错误信息:
Warning FailedScheduling 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims.
排查步骤:
bash
# 检查 PVC 状态
kubectl get pvc -n news-app
# STATUS: Pending → 说明 PV 未创建或无 StorageClass
# 检查 StorageClass
kubectl get storageclass
# No resources found → 确认没有 StorageClass
原因: 集群没有部署 StorageClass,PVC 无法动态创建 PV。
解决:
bash
# 方法一:使用 hostPath PV(本文方案)
mkdir -p /data/mysql
kubectl apply -f mysql.yaml # 已包含 PV 定义
# 方法二:安装 local-path-provisioner
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml
kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true
错误 3:MySQL Pending --- node(s) had untolerated taint
错误信息:
Warning FailedScheduling 0/3 nodes are available:
1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: },
2 node(s) didn't match Pod's node affinity/selector.
原因: MySQL 配置了 nodeSelector: kubernetes.io/hostname: master,但 master 节点有 control-plane 污点(NoSchedule),Pod 无法调度。
排查污点:
bash
kubectl describe node master | grep -i taint
# 输出: Taints: node-role.kubernetes.io/control-plane:NoSchedule
解决: 在 Deployment 的 spec.template.spec 下添加 tolerations:
yaml
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
已在 mysql.yaml 中修复。
错误 4:build-and-import.sh 分发镜像报错 --- No such file or directory
错误信息:
scp: stat local "/tmp/news-backend.tar": No such file or directory
原因: 旧版脚本在循环中对所有节点先 scp 再导入,本地节点导入后立即 rm -f 删掉了 tar 文件,导致后续远程节点 scp 时源文件已不存在。
解决: 将本地节点的导入提到循环之前执行,远程节点在循环中 scp → SSH 导入 → 远程清理。本地 tar 文件等所有节点处理完毕后再删除。
错误 5:configmap 创建失败 --- namespaces not found
错误信息:
Error from server (NotFound): error when creating "STDIN": namespaces "news-app" not found
原因: 部署脚本中 kubectl create configmap 在 kubectl apply -f namespace.yaml 之前执行。
解决: 确保 namespace 先创建:
bash
kubectl apply -f namespace.yaml
# 然后再创建 ConfigMap
kubectl create configmap mysql-init-sql \
--from-file=init.sql=../database.sql \
--namespace=news-app \
--dry-run=client -o yaml | kubectl apply -f -
已在新版 deploy.sh 中调整顺序。
错误 6:镜像拉取失败 --- ImagePullBackOff
错误信息:
Failed to pull image "news-backend:latest": ... pull access denied
原因: imagePullPolicy 未设置或设置为 Always,K8s 尝试从 Docker Hub 拉取自定义镜像。
解决: 在 Deployment 中设置:
yaml
imagePullPolicy: IfNotPresent
已在所有自定义镜像的 Deployment 中配置。
9. 常用运维命令
9.1 查看状态
bash
# 查看所有 Pod
kubectl get pods -n news-app -o wide
# 查看所有 Service
kubectl get svc -n news-app
# 查看所有资源
kubectl get all -n news-app
# 查看 PV/PVC
kubectl get pv,pvc -n news-app
# 实时监控 Pod 变化
kubectl get pods -n news-app -w
9.2 查看日志
bash
# 查看后端日志
kubectl logs -l app=backend -n news-app --tail=50
# 实时查看后端日志
kubectl logs -l app=backend -n news-app -f
# 查看前端日志
kubectl logs -l app=frontend -n news-app
# 查看 MySQL 日志
kubectl logs -l app=mysql -n news-app
# 查看上一个崩溃容器的日志
kubectl logs -l app=backend -n news-app --previous
9.3 进入容器调试
bash
# 进入后端容器
kubectl exec -it -n news-app $(kubectl get pod -n news-app -l app=backend -o jsonpath='{.items[0].metadata.name}') -- /bin/bash
# 进入 MySQL 容器
kubectl exec -it -n news-app $(kubectl get pod -n news-app -l app=mysql -o jsonpath='{.items[0].metadata.name}') -- mysql -u root -p123456
9.4 扩缩容
bash
# 后端扩容到 3 个副本
kubectl scale deployment backend -n news-app --replicas=3
# 缩容到 1 个
kubectl scale deployment backend -n news-app --replicas=1
9.5 滚动重启
bash
# 重启后端(滚动更新模式)
kubectl rollout restart deployment backend -n news-app
# 重启前端
kubectl rollout restart deployment frontend -n news-app
9.6 端口转发(本地调试)
bash
# 转发后端到本机
kubectl port-forward -n news-app svc/backend 8000:8000
# 转发前端到本机
kubectl port-forward -n news-app svc/frontend 8080:80
# 转发 MySQL 到本机
kubectl port-forward -n news-app svc/mysql 3306:3306
9.7 卸载应用
bash
# 删除整个 namespace(级联删除所有资源)
kubectl delete namespace news-app
# 或逐个删除资源文件
kubectl delete -f k8s/
10. 总结
10.1 部署清单速查
| 序号 | 步骤 | 命令 |
|---|---|---|
| 1 | 拷贝代码到 master | scp -r fastapi root@master:/root/news-app/ |
| 2 | 修复 .dockerignore | 删除 cache 行 |
| 3 | 创建数据目录 | mkdir -p /data/mysql |
| 4 | 构建并分发镜像 | cd k8s && ./build-and-import.sh |
| 5 | 部署应用 | ./deploy.sh |
| 6 | 检查 Pod | kubectl get pods -n news-app |
| 7 | 访问 | http://192.168.194.21:30080 |
10.2 核心知识点总结
-
containerd 与 Docker 的关系
- K8s 1.24+ 默认使用 containerd 运行容器
- 构建镜像仍需要 Docker / podman / nerdctl
- 导入镜像到 containerd:
ctr -n k8s.io images import <file>.tar - 查看 containerd 镜像:
crictl images
-
K8s 内部服务发现
- 同 namespace 下 Service 名称即为 DNS:
mysql、redis、backend - 应用代码中不需要 IP,直接用 Service 名称连接
- 同 namespace 下 Service 名称即为 DNS:
-
存储方案选型
- 有 StorageClass:直接创建 PVC,自动分配 PV
- 无 StorageClass:手动创建 hostPath PV(开发/测试)
- hostPath PV 需配合 nodeSelector 将 Pod 固定在指定节点
-
污点与容忍
- master 节点默认有
NoSchedule污点 - 需要将 Pod 调度到 master 时,必须添加对应的
tolerations
- master 节点默认有
-
镜像拉取策略
IfNotPresent:本地有就用本地(适合手动导入的场景)Always:每次都从仓库拉取(适合有私有镜像仓库的场景)Never:只使用本地镜像,没有就报错
-
ConfigMap 的使用场景
- Nginx 配置(运行时覆盖容器内配置)
- MySQL 初始化脚本(SQL 文件挂载到 initdb 目录)
- 使用
subPath挂载单个文件而不覆盖整个目录
10.3 生产环境改进建议
| 当前方案 | 生产建议 | 原因 |
|---|---|---|
| hostPath PV | CSI 驱动 (NAS/Ceph/云盘) | 数据高可用、节点故障自动迁移 |
| NodePort | Ingress Controller + 域名 | SSL 终止、负载均衡、灰度发布 |
| Secret 明文 | Sealed Secrets / Vault | 敏感信息加密、GitOps 友好 |
| 单副本 MySQL | StatefulSet + 主从 | 数据库高可用 |
| 手动导入镜像 | 私有镜像仓库 (Harbor) | 镜像版本管理、安全扫描 |
| 资源限制偏低 | 根据压测结果调整 | 避免 OOM Kill |
全文完。 按照本文的步骤操作,你应该能够在自己的 Kubernetes 集群上成功部署这个 FastAPI + Vue3 全栈应用。如有问题,可以查阅第 8 节故障排查手册。