2GB 内存跑深度学习模型?在 99 元服务器上的部署实战

背景

今年买了阿里云 99 元/年的新老同享 ECS,想在上面部署 SonoX — 一个基于 Switch 半监督学习框架的超声影像病灶分割与分类平台。

服务器配置如下:

项目规格
实例型号ecs.e-c1m1.large
CPU2 vCPU(物理单核超线程)
内存2 GB
公网带宽3 Mbps (出) / 200 Mbps (入)
系统Debian 13.5
费用99 元/年

项目本身的资源需求:

组件规模
分割模型 (UNet)4 个数据集 × 6 个半监督变体 + baseline = 28 个,每个 ~7MB
分类模型ViT-Base ~328MB + 融合头 ~15MB + Radiomics ~6MB
模型权重总大小~540MB
后端代码单文件 ~770 行 FastAPI

核心矛盾:~540MB 模型权重 + PyTorch 运行时,要塞进 2GB 内存的服务器里,还要留余地给推理计算。

下面记录优化过程中踩过的坑和实际有效的措施。

选 Debian 而不是 Ubuntu

同一台机器,Ubuntu Server 基础安装就要吃掉 ~400MB(snapd、networkd 等),而 Debian 最小化安装只需 ~128MB。二者都用 apt,二进制兼容,迁移成本几乎为零。

UbuntuDebian
基础内存占用~400MB~128MB
Snap 守护进程有 (~50MB)
默认服务

在 2GB 服务器上,Debian 比 Ubuntu 省出约 250MB — 对一个内存紧张的深度学习服务来说,这就是能跑和不能跑的区别。

关闭 crashkernel,回收「公摊内存」

开机 free -h 一看,标称 2GB 的服务器实际可用只有约 1.65GB。少掉的 ~350MB 主要来自两块:内核自身开销 + crashkernel 预留。 对个人项目来说,服务器真 crash 了直接重启即可,不需要 kdump。

1
2
3
4
5
6
7
# 查看 kdump 占用
sudo kdump-config show

# 编辑 /etc/default/grub,删除 crashkernel=... 那段
# 然后更新 grub 并重启
sudo update-grub
sudo reboot

重启后可用内存涨到 ~1.8GB,找回 100MB+。

创建 Swap:最后的保险丝

上面的优化都是在物理内存上腾挪。但模型推理是尖刺负载——平时风平浪静,一次分类请求可能瞬间多占几百 MB。2GB 物理内存没有容错空间,一旦超了就 OOM Kill。

加一个 2GB 的 Swap 文件作为缓冲:

1
2
3
4
5
6
7
8
# 创建 2GB Swap 文件
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# 写入 /etc/fstab 实现开机自动挂载
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

验证:

1
2
3
4
free -h
#               total        used        free      shared  buff/cache   available
# Mem:           1.6Gi       451Mi       627Mi       2.5Mi       685Mi       1.1Gi
# Swap:          2.0Gi          0B       2.0Gi

但阿里云的磁盘性能本来就很垃圾,开启 Swap 纯属心理安慰聊胜于无吧。

模型加载策略:懒加载 + 用完即释放

28 个分割模型如果全部预加载,内存直接爆炸。核心策略:

分割模型:按数据集按需加载。用户请求 lymph-node 时才加载该数据集下的 6 个模型,处理完后根据配置决定是否卸载。

分类模型:更大的 ViT (328MB) 采用懒加载 — 第一次分类请求到达时才加载,使用完毕后立即释放:

1
2
3
4
5
6
7
def classify(image, mask):
    _ensure_cls_models()      # 首次调用才加载
    try:
        # ... 推理 ...
    finally:
        if not KEEP_CLS_MODELS_LOADED:
            unload_cls_models()  # 用完释放

实际运行中,平时内存占用约 500MB(仅常驻一个数据集的分割模型),峰值不超过 1.4GB。比全量预加载节省约 300-400MB。

CPU 动态量化:328MB ViT 降到 ~85MB

在无 GPU 的 CPU 服务器上跑 ViT-Base 推理是一件奢侈的事。PyTorch 的动态量化可以把模型中的 nn.Linear 权重从 float32 压缩到 int8,内存占用减少约 75%,推理速度反而提升

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import torch

def _maybe_quantize_dynamic(module, module_name):
    engine = "fbgemm"  # x86 CPU 最佳
    torch.backends.quantized.engine = engine
    module = torch.quantization.quantize_dynamic(
        module, {torch.nn.Linear}, dtype=torch.qint8
    )
    return module

# 对 ViT、RadiomicsNN、ClassificationHead 三个模型都做量化
cls_model_vit = _maybe_quantize_dynamic(cls_model_vit, "classification ViT")

配合 SONOX_CPU_THREADS=1(限制 PyTorch 线程数避免争抢),在 2 vCPU 上单次分类推理约 3-5 秒,内存峰值可控。

磁盘缓存:避免重复推理

SonoX 的 API 按原始文件名标识同一张图片。如果用户反复查看同一张图的 segmentation/classification 结果,完全没有必要重新跑一遍推理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
CACHE_DIR = Path("cache")

def _cache_path(dataset, filename):
    return CACHE_DIR / dataset / f"{filename}.json"

def _read_cache(dataset, filename):
    # 命中则直接返回 JSON,跳过全部模型推理
    ...

def _write_cache(dataset, filename, data):
    # 推理完成后写入缓存
    ...

async def api_segment(image, dataset, filename):
    if filename:
        cached = _read_cache(dataset, filename)
        if cached and "segment" in cached:
            return cached["segment"]  # 直接返回,0 计算
    # ... 否则正常推理并写缓存

缓存文件存于 cache/ 目录,JSON 格式,一条记录约 200KB(含 base64 图片 overlay)。使用 run.sh 重启服务时自动清理。

前后端拆分后,给 API 补上 HTTPS

GitHub Pages 必须请求 HTTPS API。否则会触发 mixed content,或把裸 IP 解析成相对路径:

1
2
错误: https://jinggqu.github.io/SonoX/47.119.180.6/api/datasets
正确: https://47.119.180.6/api/datasets

做法很简单:后端只监听 127.0.0.1:8000,Nginx 对外提供 80/443,证书交给 certbot,安全组放行 80/443

后端与 Nginx

后端仅监听本机端口:

1
2
3
4
cd ~/proj/SonoX
tmux new -d -s sonox-api \
  'export HOST=127.0.0.1 PORT=8000 SONOX_SERVE_FRONTEND=0 && ./run.sh'
curl http://127.0.0.1:8000/api/health

安装并配置 Nginx:

1
2
apt update
apt install -y nginx

新建配置 /etc/nginx/sites-available/sonox-api:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server {
    listen 80;
    listen [::]:80;
    server_name 47.119.180.6;

    location /.well-known/acme-challenge/ { root /var/www/html; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name 47.119.180.6;

    ssl_certificate /etc/letsencrypt/live/47.119.180.6/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/47.119.180.6/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        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;
    }
}

重载 Nginx

1
2
3
ln -sf /etc/nginx/sites-available/sonox-api /etc/nginx/sites-enabled/sonox-api
nginx -t
systemctl reload nginx

证书与续期

1
2
3
4
5
6
7
8
9
apt install -y python3 python3-venv libaugeas0
python3 -m venv /opt/certbot
/opt/certbot/bin/pip install --upgrade pip certbot
ln -sf /opt/certbot/bin/certbot /usr/bin/certbot

mkdir -p /var/www/html
certbot certonly --webroot -w /var/www/html -d 47.119.180.6
nginx -t
systemctl reload nginx

证书路径:

1
2
/etc/letsencrypt/live/47.119.180.6/fullchain.pem
/etc/letsencrypt/live/47.119.180.6/privkey.pem

自动续期:

1
2
3
cat >/etc/cron.d/sonox-certbot-renew <<'EOF'
0 */12 * * * root certbot renew --quiet --deploy-hook 'systemctl reload nginx'
EOF

验证与前端配置

按顺序验证:

1
2
3
4
curl http://127.0.0.1:8000/api/health
curl -k https://127.0.0.1/api/health
curl -I http://47.119.180.6/api/health
curl -vk https://47.119.180.6/api/health

若最后一步超时,通常是阿里云安全组未放行 443/tcp。若保留 HTTP 跳转,也需放行 80/tcp

GitHub Pages 侧应该添加如下的仓库环境变量:

1
2
3
SONOX_API_BASE_URL=https://47.119.180.6
# or
SONOX_API_BASE_URL=47.119.180.6

开发环境优化

uv 镜像源

国内服务器拉 PyPI 慢到怀疑人生。在 pyproject.toml 中直接配置阿里云镜像:

1
2
3
4
[[tool.uv.index]]
name = "aliyun"
url = "https://mirrors.aliyun.com/pypi/simple/"
default = true  # 很重要

SSH 代理转发拉 GitHub 代码

从国内服务器 git clone GitHub 经常 TLS 超时。最稳定方案:SSH 远程端口转发 + Git 代理

本地 ~/.ssh/config

1
2
3
4
Host aliyun
  HostName <公网IP>
  User root
  RemoteForward 7890 127.0.0.1:7890

服务器端:

1
2
3
4
5
6
# 确认转发端口在监听
ss -tlnp | grep 7890

# 配置 Git 走本地代理
git config --global http.proxy http://127.0.0.1:7890
git config --global https.proxy http://127.0.0.1:7890

如果转发失败,检查 /etc/ssh/sshd_config 中是否开启了 AllowTcpForwarding yes

1
2
3
AllowAgentForwarding yes
AllowTcpForwarding yes
GatewayPorts yes

更省事的方案:配置 SSH Key 后用 git clone git@github.com:...,完全绕过代理。

Git 信息持久化

1
2
3
git config --global user.name "xxx"
git config --global user.email "xxx"
git config --global credential.helper store

zsh + 插件

1
2
3
4
5
apt install -y build-essential vim curl wget git tmux zsh tree lsof
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
# .zshrc 中:plugins=(git zsh-autosuggestions zsh-syntax-highlighting)

VSCode 中 Codex 插件字体大小与颜色优化

字体大小:Codex 插件对话字体默认偏小,可在 settings.json 中通过 chat.fontSizechat.editor.fontSize 调整:

1
2
"chat.fontSize": 16,
"chat.editor.fontSize": 14

字体颜色:部分主题下 Codex 对话文字呈灰色、对比度不足。根因是插件通过 --vscode-* CSS 变量继承主题颜色。排查方法:Cmd/Ctrl + Shift + PDeveloper: Open Webview Developer Tools → 点击箭头选中灰色文字 → 在 Computed 面板展开 color 变量链,找到最终指向的 --vscode-xxx-yyy 变量,去掉 --vscode- 前缀并将第一条横杠换点号即可覆盖。最终的结论就是字体颜色由 foreground 控制,将其更新为 #000000 即可。

完整配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "workbench.colorCustomizations": {
    "foreground": "#000000",
    "sideBar.foreground": "#000000",
    "descriptionForeground": "#000000",
    "chat.requestBackground": "#f0f0f0",
    "textPreformat.foreground": "#000000",
    "textBlockQuote.foreground": "#000000"
  }
}

效果总结

指标优化前优化后
系统可用内存~1.65 GB~1.80 GB
系统基础占用~400MB (Ubuntu)~128MB (Debian)
模型常驻内存~500MB (全量预加载)~150MB (按需加载)
分类模型内存~350MB (FP32)~100MB (INT8)
空闲内存占用Overflow~80%
重复请求耗时3-5s<10ms (缓存命中)

最终在 99 元/年的机器上跑起了一个包含 28 个分割模型 + 3 个分类模型的超声影像分析服务,内存有余量,响应可接受。

核心思路:省着用(懒加载)、压着用(量化)、别重复(缓存)

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy