上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-26 09:14 阅读量:2801


凌晨 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,设告警
目录结构
全文