香港服务器上用 Ubuntu 22.04 部署网站:从“高并发图片处理压力过大”到“GPU 加速 + 对象存储”的稳定方案

凌晨 1:27,我正准备关上香港机房的机柜门,监控屏忽然一片红。独立站亚洲促销刚开 20 分钟,图片处理服务 5xx 飙升、CPU 接近 100%,Nginx 的 upstream timed out 像弹幕一样刷屏。运营在群里喊“首屏白屏、转化跌了 17%”。我在机房过道坐下,给自己冲了杯冰的美式——决定把这次“图片风暴”一次性解决掉:把现有 CPU 版的缩放/裁剪/压缩链路,迁到 GPU 加速,同时把“热维度”的结果沉到 对象存储,再交给 CDN + 前端缓存 消化。
下面是我当晚到第二天落地的完整过程与后续一周优化结果:架构图、硬件选型、Ubuntu 22.04 上的部署细节、代码、表格数据、踩坑与修复。本文全部为一线实操记录,能跑、能复现。
1. 现状与目标
现状痛点
- 图片转换(JPEG/WEBP 缩放、裁剪、质量压缩)全部在 CPU 上走 libjpeg-turbo + OpenCV,高峰期单实例 QPS < 300。
- 请求模型:On-the-fly 动态生成 + Nginx 本地缓存,命中率有限;Cache Stampede 频发。
- 源图在本地 NVMe,容量吃紧;跨机房回源时延不稳定。
目标
- 在不改业务 URL 规则的情况下,把主耗时操作迁到 GPU。
- 把生成后的派生图(多规格、多格式)沉到 S3 兼容对象存储(同城/同 DC),首击慢、次击极快。
- 建立缓存与锁双保险,彻底解决“击穿”和“惊群”。
2. 最终架构(简图)
Client/CDN → Cloudflare Cache → Nginx(边缘/前端缓存)
→ Image API(FASTAPI, GPU) ↔ Redis(Lock + Metadata)
↔ MinIO(S3兼容, 原图桶/派生图桶, 生命周期)
关键路径
Nginx 命中:直接回 CDN
Nginx 未命中:走 Image API
Image API 查询派生图是否已存在于 S3;
若有:S3 流式回源 → Nginx 缓存 → CDN
若无:拉原图 → GPU 处理 → 回写 S3 → 回源 → 缓存 → CDN
3. 硬件与环境(香港机房,Ubuntu 22.04 LTS)
3.1 计算与网络
| 节点 | 角色 | CPU | GPU | 内存 | 本地盘 | 网卡 | OS/内核 |
|---|---|---|---|---|---|---|---|
| g1 / g2 | Image API + GPU | AMD EPYC 7443P (24C/48T) | NVIDIA A10 24GB | 128GB | 2TB NVMe (PM9A3) | 10GbE (X710) | Ubuntu 22.04 / 5.15 |
| fe1 | Nginx 前端 | Xeon Silver 4310 | - | 64GB | 1TB NVMe | 10GbE | Ubuntu 22.04 |
| s3-[1..4] | MinIO 集群 | EPYC 7302P | - | 64GB | 4×12TB SATA + 1TB NVMe(元数据/缓存) | 10GbE | Ubuntu 22.04 |
GPU 型号也测试过 T4;A10 的性价比和 24GB 显存对批处理更友好。
3.2 核心软件版本
NVIDIA 驱动 535.x,CUDA 12.x,nvidia-container-toolkit
Docker 24.x / containerd,FastAPI 0.110+,uvicorn
OpenCV with CUDA(自行编译,开启 -D WITH_CUDA=ON)
Redis 7.x,MinIO RELEASE.2024-xx,Nginx 1.24 LTS
Cloudflare(CDN,启用 Tiered Cache + Cache Reserve)
4. 对象存储设计(MinIO / S3 兼容)
我们在同 DC 部署 4 节点 MinIO,EC: 4/0(后期可扩 6/2),冷热分层用桶策略控制。命名规范如下:
| 桶 | 用途 | Key 规则 | 生命周期 |
|---|---|---|---|
origin-images |
原图 | brand/sku/<sha256>.<ext> |
永久保留 |
derived-images |
派生图 | <hash(origin+params)>/<w>x<h>/q<q>.<fmt> |
30~90 天自动过期 |
所有对象都写入 ETag(内容散列)与 Cache-Control: public, max-age=31536000, immutable,方便 CDN 持久缓存。
MinIO 初始化(示例)
# 4 节点各持有一个数据盘目录
docker run -d --name minio1 --restart always -p 9001:9000 \
-v /data1/minio1:/data \
-e "MINIO_ROOT_USER=admin" -e "MINIO_ROOT_PASSWORD=StrongPass!" \
minio/minio server http://minio{1...4}/data
# 创建桶与版本控制
mc alias set local http://127.0.0.1:9001 admin StrongPass!
mc mb local/origin-images
mc mb local/derived-images
mc version enable local/origin-images
mc ilm add --expiration-days 60 local/derived-images
5. Nginx 前端(缓存与回源)
要点
- 强制长缓存 + 带参数的 Cache Key;
- 后端 499/5xx 时的 stale-while-revalidate;
- 避免 Vary 失控,确保 Accept/Accept-WebP 场景下命中稳定。
配置片段
proxy_cache_path /var/cache/nginx/img levels=1:2 keys_zone=imgcache:10g
inactive=30d max_size=200g use_temp_path=off;
map $arg_fmt $img_mime {
default image/jpeg;
webp image/webp;
avif image/avif;
}
server {
listen 80;
server_name images.example.hk;
location ~ ^/img/(?<w>\d+)x(?<h>\d+)/(.*)$ {
set $fmt $arg_fmt;
set $qual $arg_q;
proxy_cache imgcache;
proxy_cache_lock on;
proxy_ignore_headers Expires Cache-Control;
proxy_cache_valid 200 301 302 30d;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# 参数化缓存键,避免惊群
proxy_cache_key "$scheme://$host$uri?w=$w&h=$h&fmt=$fmt&q=$qual";
proxy_set_header Accept "";
proxy_set_header X-Img-Width $w;
proxy_set_header X-Img-Height $h;
proxy_set_header X-Img-Fmt $fmt;
proxy_set_header X-Img-Qual $qual;
proxy_pass http://image-api;
add_header Content-Type $img_mime;
}
}
upstream image-api {
server 10.0.0.21:8080 max_fails=2 fail_timeout=10s;
server 10.0.0.22:8080 max_fails=2 fail_timeout=10s;
keepalive 256;
}
6. GPU 图片服务(FastAPI + OpenCV CUDA)
说明:我们把解码/编码仍留在 CPU(配 libjpeg-turbo),把最重的缩放/变换放到 GPU(cv2.cuda),整体 TPS 已翻倍以上。生产里进一步可引入 nvJPEG 解码(C++/DALI),但上手成本较高,这里给出可快速落地的 Python 方案。
6.1 依赖与容器
FROM nvidia/cuda:12.3.1-cudnn-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y \
build-essential cmake git wget unzip \
libjpeg-turbo-progs libjpeg-turbo8-dev libpng-dev \
python3 python3-pip
# 编译 OpenCV(+CUDA) 省略,或使用已构建的内部镜像
COPY requirements.txt /tmp/
RUN pip3 install -r /tmp/requirements.txt
COPY app /app
WORKDIR /app
CMD ["uvicorn", "main:app", "--host=0.0.0.0", "--port=8080", "--workers=4"]
requirements.txt
fastapi==0.111.0
uvicorn[standard]==0.30.0
boto3==1.34.0
redis==5.0.4
numpy==1.26.4
# opencv-python 无 CUDA,请使用自编译wheel或者在镜像中自行编译安装
docker-compose.yml
services:
image-api:
build: .
restart: always
ports: ["8080:8080"]
environment:
- AWS_ACCESS_KEY_ID=xxx
- AWS_SECRET_ACCESS_KEY=yyy
- S3_ENDPOINT_URL=http://10.0.0.31:9001
- S3_REGION=us-east-1
- S3_BUCKET_ORIGIN=origin-images
- S3_BUCKET_DERIVED=derived-images
- REDIS_HOST=10.0.0.10
deploy:
resources:
reservations:
devices:
- capabilities: [gpu]
# Docker Compose V2 可直接:
# gpus: all
6.2 FastAPI 处理核心(简化示例,可直接用)
# app/main.py
import io, hashlib, time
import boto3, redis
import numpy as np
import cv2
from fastapi import FastAPI, Request, Response, HTTPException
S3 = boto3.client("s3", endpoint_url=os.getenv("S3_ENDPOINT_URL"),
region_name=os.getenv("S3_REGION"))
BUCKET_ORI = os.getenv("S3_BUCKET_ORIGIN")
BUCKET_DER = os.getenv("S3_BUCKET_DERIVED")
R = redis.Redis(host=os.getenv("REDIS_HOST"), port=6379, db=0)
app = FastAPI()
def s3_head(bucket, key):
try:
return S3.head_object(Bucket=bucket, Key=key)
except S3.exceptions.ClientError:
return None
def s3_get(bucket, key):
obj = S3.get_object(Bucket=bucket, Key=key)
return obj["Body"].read()
def s3_put(bucket, key, body, content_type):
S3.put_object(Bucket=bucket, Key=key, Body=body,
ContentType=content_type,
CacheControl="public, max-age=31536000, immutable")
def derive_key(origin_hash, w, h, fmt, q):
base = f"{origin_hash}:{w}x{h}:{fmt}:{q}".encode()
return f"{hashlib.sha256(base).hexdigest()}/{w}x{h}/q{q}.{fmt}"
def decode_cpu(img_bytes):
arr = np.frombuffer(img_bytes, np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) # BGR / BGRA
if img is None:
raise ValueError("imdecode failed")
return img
def resize_gpu(img, w, h):
# img: HxWxC (uint8, BGR)
gpu_mat = cv2.cuda_GpuMat()
gpu_mat.upload(img)
interp = cv2.INTER_AREA if (w < img.shape[1] or h < img.shape[0]) else cv2.INTER_CUBIC
resized = cv2.cuda.resize(gpu_mat, (w, h), interpolation=interp)
out = resized.download()
return out
def encode_cpu(img, fmt, quality):
if fmt == "webp":
ok, enc = cv2.imencode(".webp", img, [cv2.IMWRITE_WEBP_QUALITY, quality])
ctype = "image/webp"
elif fmt == "avif":
# 需编译 OpenCV/插件支持;或改走 libavif 外挂
raise NotImplementedError("avif not enabled in this build")
else:
ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality,
cv2.IMWRITE_JPEG_OPTIMIZE, 1])
ctype = "image/jpeg"
if not ok:
raise ValueError("imencode failed")
return enc.tobytes(), ctype
@app.get("/img/{w}x{h}/{path:path}")
def process(w: int, h: int, path: str, request: Request):
fmt = (request.headers.get("X-Img-Fmt") or request.query_params.get("fmt") or "jpg").lower()
q = int((request.headers.get("X-Img-Qual") or request.query_params.get("q") or 85))
# 1) 原图定位:我们用 path 即为原图 key,生产可做映射表
origin_key = path
# 2) 原图签名(可用 head ETag)
head = s3_head(BUCKET_ORI, origin_key)
if not head:
raise HTTPException(404, "origin not found")
origin_hash = head["ETag"].strip('"') # 直接用 ETag 简化
# 3) 目标派生 key
dkey = derive_key(origin_hash, w, h, fmt, q)
# 4) 短锁防惊群
lock_key = f"lock:{dkey}"
if not R.set(lock_key, "1", nx=True, ex=30):
# 有人已在生成,稍等回查
for _ in range(10):
time.sleep(0.3)
if s3_head(BUCKET_DER, dkey):
body = s3_get(BUCKET_DER, dkey)
return Response(content=body, media_type=f"image/{fmt}")
# 超时后继续自己做
pass
try:
# 5) 命中则快速返回
if s3_head(BUCKET_DER, dkey):
body = s3_get(BUCKET_DER, dkey)
return Response(content=body, media_type=f"image/{fmt}")
# 6) 拉原图并处理
origin_bytes = s3_get(BUCKET_ORI, origin_key)
img = decode_cpu(origin_bytes)
out = resize_gpu(img, w, h)
body, ctype = encode_cpu(out, fmt, q)
# 7) 回写并返回
s3_put(BUCKET_DER, dkey, body, ctype)
return Response(content=body, media_type=ctype)
finally:
R.delete(lock_key)
要点
- 用 ETag(origin) + 参数生成派生图 key,天然去重。
- Redis 短锁 + S3 回查,解决“惊群”。
- Cache-Control 写死长效,交给 CDN/Nginx 控制刷新策略。
7. 预热与异步批处理
我们对首页、频道页、商品详情的常用尺寸做异步预热,提高命中。
简单的 Celery 任务(示意)
from celery import Celery
import requests
celery = Celery('warm', broker='redis://10.0.0.10:6379/1')
COMMON_SIZES = [(200,200), (400,400), (800,800)]
FORMATS = ["jpg", "webp"]
@celery.task
def warmup_image(path):
for w,h in COMMON_SIZES:
for fmt in FORMATS:
url = f"http://images.example.hk/img/{w}x{h}/{path}?fmt={fmt}&q=85"
try:
requests.get(url, timeout=3)
except Exception:
pass
电商上新时触发 warmup_image.delay(path) 即可。
8. 监控与告警
应用指标:FastAPI /metrics(Prometheus),记录 duration_bucket、s3_put/get 错误率、锁等待时间。
GPU 指标:dcgm-exporter,关注 SM 利用率、显存占用、P-state。
前端:Nginx stub_status、cache hit/miss,Cloudflare 命中率与回源量。
SLO:p95 < 120ms(派生图命中),生成首击 < 500ms。
9. 压测与效果(真实落地一周后)
压测场景:50% 命中、50% 首击生成,商品图平均 1800×1800,输出 800×800,jpg/webp 混合。
| 指标 | 改造前(CPU) | 改造后(GPU+S3) | 变化 |
|---|---|---|---|
| 峰值 QPS(单可用区) | ~850 | 2,400+ | ↑ 2.8x |
| 生成首击 p95 | 420 ms | 115 ms | ↓ 72% |
| 二击/命中 p95 | 95 ms | 21 ms | ↓ 78% |
| CPU 占用(g1/g2) | 85% | 35% | - |
| GPU 占用(SM 平均) | - | 48% | - |
| CDN 回源率 | 31% | 8% | ↓ 74% |
| 峰值带宽回源 | 2.1 Gbps | 0.6 Gbps | ↓ 71 |
注意:二击延迟主要受 Nginx 本地缓存 与 CDN 命中 影响;把派生图沉到 S3 后,跨可用区扩展变得更轻松。
10. 现场踩坑与修复
容器内找不到 GPU
现象:nvidia-smi: command not found,CUDA driver not found
解决:安装 nvidia-container-toolkit,Compose 增加 gpus: all;确保宿主驱动与镜像 CUDA 版本兼容。
OpenCV 没启用 CUDA
现象:cv2.cuda.getCudaEnabledDeviceCount() == 0
解决:源码编译 OpenCV,开启 -D WITH_CUDA=ON -D ENABLE_FAST_MATH=1 -D CUDA_FAST_MATH=1 -D OPENCV_DNN_CUDA=ON;镜像里确认 libcuda.so 可见。
AVIF 支持缺失
现象:imencode .avif 失败
解决:暂用 WebP/JPEG;若强需求,落地 libavif + aom/kvazaar 外挂编码模块或独立 sidecar 服务。
Cache 键不稳定
现象:fmt/q 参数顺序/缺省导致命中抖动
解决:统一 Nginx map 归一化参数;后端默认值一致;在前端强制 proxy_cache_key 固定格式。
S3 签名失败(时钟漂移)
现象:SignatureDoesNotMatch 偶发
解决:所有节点装 chrony,与 MinIO/NTP 对齐;容器继承宿主时间。
对象生命周期误删
现象:派生图 7 天后被清理,热点回源增加
解决:把生命周期从 7 天调到 30~90 天;同时对 top N URL 做热度保护(标签或路径前缀)。
MTU 与丢包
现象:夜间批量预热时 S3 PUT 重传
解决:机柜交换机 MTU 9000,宿主 + 容器统一;如跨运营商启用 PMTU Discovery,避免强设 9000。
11. 运维清单(Ubuntu 22.04)
NVIDIA 与容器工具
sudo apt-get update
sudo apt-get install -y ubuntu-drivers-common
sudo ubuntu-drivers autoinstall # 安装推荐驱动
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/ubuntu22.04/libnvidia-container.list \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo systemctl restart docker
服务托管(systemd 示例)
[Unit]
Description=Image API
After=network.target docker.service
Requires=docker.service
[Service]
Restart=always
ExecStart=/usr/bin/docker compose -f /opt/image-api/docker-compose.yml up --build
ExecStop=/usr/bin/docker compose -f /opt/image-api/docker-compose.yml down
[Install]
WantedBy=multi-user.target
12. 性能进一步提升的方向(可选)
- nvJPEG/DALI 上 GPU 解码,CPU 只负责轻量任务;
- 批处理(动态合批,多请求合一轮次);
- 零拷贝:S3 拉流→Pinned Memory→GPU;
- AVIF Pipeline:将 AV1 编码侧车化,异步落库;
- 去中心化缓存:引入 Redis Cluster 存储派生图元数据与热度,配合 CDN Key 短期提升热命中。
第二个凌晨,我终于把机柜门关上了
第二天夜里,同档期活动,比前晚峰值更高。Nginx 命中率稳定在 92%+,GPU 的 SM 利用率在 50% 左右徘徊,派生图回源量肉眼可见地降了。运营说,“首屏不卡了,转化回正了”。我把最后一条告警关掉,给自己放了首歌,坐在香港机房的冷风里,看着那块久违的全绿面板。
这件事让我确认了一点:对独立站来说,图片不是静态资源——它是算力问题。把算力放对地方,把结果放进对象存储,其他的一切(缓存、CDN、扩容)就会变得简单。
附:关键表格一览
A. URL 与派生图 Key 规范
| 维度 | 示例 | 说明 |
|---|---|---|
| 访问 URL | /img/800x800/brand/sku/abc.jpg?fmt=webp&q=85 |
业务路径不变 |
| 原图 Key | brand/sku/abc.jpg |
origin-images 桶 |
| 派生图 Key | sha256(etag+params)/800x800/q85.webp |
derived-images 桶 |
B. Nginx 缓存策略
| 规则 | 值 |
|---|---|
proxy_cache_valid |
200 301 302 30d |
cache_key |
规范化参数后的固定串 |
use_stale |
error/timeout/updating/5xx |
C. 监控阈值(告警)
| 指标 | 阈值 |
|---|---|
| Image API 5xx 比例 | > 0.5%(5 分钟) |
| p95 首击处理 | > 500ms |
| S3 PUT 失败率 | > 1% |
| GPU 温度 | > 80℃(持续 2 分钟) |
参考执行顺序(落地 Checklist)
- 部署 MinIO + 桶策略与生命周期
- 编译 OpenCV CUDA,构建镜像,部署 FastAPI
- 接入 Redis 短锁,验证派生图回写与命中
- 切 Nginx 到新后端,开启 proxy_cache 与 use_stale
- 配置 CDN 长缓存与缓存键规范
- 上线预热任务,观察首击/二击占比
- 接入 DCGM + Prometheus + Grafana,设告警