← 返回文章列表
018 — 运维 · #ECS #Nginx #systemd #部署 #SSE · 2026-08-18 · 8 MIN READ

99 元 ECS 部署全记录:Python 3.11 共存、Nginx 反代与 SSE 缓冲陷阱

这个博客的全部家当——32 个静态页面、一个 FastAPI RAG 后端、一个本地向量库——跑在一台阿里云 ECS 经济型 e 上:2 核 2G、3M 带宽、99 元一年。这篇是完整的部署记录,每一步都附验证命令,按顺序执行可以复现。踩过的坑单独标出来。

资源账:2G 内存怎么分

先算清楚再上船。后端常驻内存 300-500MB(ChromaDB 占大头),Nginx 几十 MB,系统本身吃掉 500MB 上下。2G 物理内存是紧的,所以第一件事是加 2G Swap——不是为了让服务跑得欢,是为了让 OOM Killer 来的时候先咬 Swap 而不是咬我的进程。另外 systemd 服务里给后端加了 MemoryMax=800M,超限自动重启,防拖垮整机。

Python 3.11:旁装,不动系统 Python

Alibaba Cloud Linux 自带 Python 3.6.8,不能动——系统的包管理工具依赖它,动了的下场是 yum/dnf 当场去世。FastAPI 这边要求 3.11,方案是旁装并存:

bash
# Alibaba Cloud Linux 3:dnf 直接装
sudo dnf install -y python3.11 python3.11-pip

python3.11 --version          # 确认 3.11.x
cd /opt/blog-agent
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/

原则一句话:项目全程只用 python3.11 和 venv 里的解释器,系统 Python 当它不存在。国内服务器装依赖记得走阿里云镜像,不然 300-500MB 的依赖能装到地老天荒。

(venv 建好后第一次启动还撞了 ChromaDB 的 sqlite3 版本墙,那是一段独立的血泪史,见 ChromaDB 三座大山。)

静态站:scp + chmod 两件套

本机 npm run builddist/,传服务器。Windows 上没有 rsync,直接 scp 递归:

plaintext
scp -r dist/* root@服务器IP:/var/www/blog/

传完访问,403。原因是 Windows 传过去的文件权限继承得乱七八糟,Nginx 的 worker 用户读不到。一条命令解决:

bash
sudo chmod -R a+rX /var/www/blog

注意是大写 X:只对目录和「本来就有执行权限的文件」加执行位,普通 html/css/js 只加读位。用小写 x 会把所有文件都加上执行位,难看且不安全。

后端:systemd 托管,只听 127.0.0.1

FastAPI 由 uvicorn 跑,systemd 守护。服务文件 /etc/systemd/system/blog-agent.service

ini
[Service]
WorkingDirectory=/opt/blog-agent
ExecStart=/opt/blog-agent/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
EnvironmentFile=/opt/blog-agent/.env
MemoryMax=800M

三个点不能省:

  1. --host 127.0.0.1。后端不直接对外,只接受 Nginx 反代。绑定后验证:ss -tlnp | grep 8000 必须显示 127.0.0.1:8000,显示 0.0.0.0:8000 就是裸奔。
  2. EnvironmentFile 指向 .env,两个 API Key 不进 Git、不进命令行,文件权限 chmod 600
  3. 守护验证要做掉sudo pkill -f "uvicorn app.main" 杀掉进程,3 秒内应自动拉起。再跑一次预热查询——ChromaDB 冷启动后第一次检索很慢,别把这个慢留给第一个真实用户。

Nginx 反代:SSE 的生死开关

站点配置在 /etc/nginx/conf.d/blog.conf,反代部分:

nginx
location /api/ {
    proxy_pass http://127.0.0.1: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;

    # SSE 流式必须:关闭缓冲,否则逐字输出变成一次性返回
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 120s;
}

proxy_buffering off 是整个配置里的生死开关。Nginx 默认会把上游响应攒在缓冲区里凑够一批再发给客户端——对普通接口无感,对 SSE 是致命的:大模型逐 token 吐字,Nginx 全攒着,用户看到的就是转圈 20 秒后整段回答「啪」地一次性弹出,流式体验归零。proxy_cache off 同理,流式响应绝不能进缓存。proxy_read_timeout 120s 是给长回答留的余量,默认 60 秒对长回答不够用。

那两行 header 也不是摆设:X-Real-IP 是后端限流能分清访客的依据——不写这行,所有访客在限流器眼里都是 127.0.0.1,共享一个桶互相误伤(这篇单独讲过)。

改完 sudo nginx -t && sudo systemctl reload nginx,然后验证要走 80 端口:curl http://127.0.0.1/api/health,通了就说明反代链路 OK。

教训:一个裸 location 块成为定时炸弹

这是我差点踩实的一个坑。最初写 blog.conf 的时候,我从教程里抄了反代配置直接粘进去——只有 location /api/ {...} 这一块,外面没有 server {} 包着

nginx -t 居然没拦我(conf.d 下每个文件都被 http 块 include,裸 location 在语义上立即报错——事实上当时是报了错的,但我把报错和别的警告混在一起看漏了,reload 没生效,旧配置还在跑,一切「看起来正常」)。真正的危险在于:等哪天改了别的配置、reload 真正生效那一刻,Nginx 会拒绝启动,而那时候我可能已经忘了自己动过什么。

修复当然是把 location 塞进完整的 server 块。但教训比修复值钱:「reload 后服务还在跑」不等于「新配置生效了」,Nginx 配置错误时是拒绝加载、继续用旧配置的。所以现在的流程是:nginx -t 的输出一个字一个字读完,reload 之后必须 curl 验证真实行为。上线检查清单里这条被标成了 🔴。

上线验收:别在服务器上验收

最后一步在本机浏览器里做,不是服务器上。逐项过:首页打开、悬浮球在轨道上跑、提问逐字流出、相关文章卡片能点开、连发 11 次触发限流提示、故意改错 Key 验证降级文案。这份清单后来固化成了《上线检查清单》文档,换机器照着抄。

复盘

99 元的机器扛这个站,结论是完全够,但每一 MB 内存、每一条配置都得自己心里有数。小机器的好处是逼你把每个组件的职责想清楚:谁监听什么端口、谁能被外网碰到、谁挂了自动拉起。大机器上这些问题可以糊过去,2G 内存的机器上糊不过去——这大概是它除了便宜之外,给我的最好礼物。

Comments