把本地 AI 英语学习项目部署到云服务器:我的 Docker 镜像与 1Panel 上线实录

本地跑通 English Learning Agent 时,我以为搬上腾讯云就是把代码传过去,再启动一次。等我打开部署清单,才发现要搬的不只是代码:前端页面、后端接口和听写音频要装进同一个容器,词汇书检索用的 embedding 模型也得跟着走。

本地能跑,只说明开发机上的条件凑齐了;服务器得重新算一遍账。这篇文章记录我怎样把项目做成 Docker 镜像,传到腾讯云,再交给 1Panel 做容器编排和反向代理。

一、本地能跑,不代表服务器接得住

我的目标机是一台 4 核 Ubuntu 服务器,总内存 3.4GB,可用约 1.4GB,磁盘还剩 17GB。听着不算紧,可上面已经跑着 Halo、Gitea、PostgreSQL、Meilisearch、openresty 和 frpc。再让它现场下载依赖、编译前端、拉向量模型,我不太想拿现有服务的余量去试。

项目的 Compose 给新容器设了 1g 内存上限。加载向量模型后实测常驻约 600MB,运行倒有空间;构建是另一回事。构建阶段会同时经历 Node 前端依赖、Python 依赖、CPU 版 torch 和模型下载,峰值和耗时都不能只看运行时。

网络也不配合。目标机能访问腾讯云 PyPI 源和 CPU 版 torch 的下载地址,却连不上 huggingface.co,连 hf-mirror.com 也会超时。如果只把代码传上去,等容器启动时再拉模型,词汇书检索就会卡在下载这一步。

所以我选择在开发机构建,把模型预置进镜像,让服务器只负责运行,不安装构建工具链,也不额外加 swap。服务器并非绝对不能构建,只是这次没有必要让它承担这件事。

开发机也没一路畅通:直连 Docker Hub 同样超时。我把基础镜像切到实测能拉取的加速站,pip 则走腾讯云源。2026 年 9 月 26 日,带向量栈的镜像在开发机上完整构建,并通过了容器内接口检查;它是 2.33GB,压缩导出后约 528MB。本机和目标机都是 x86_64,于是构建好再打包上传,是当时更稳妥的搬法。

本地环境能把项目启动起来,只能证明代码和开发机相处得不错;要搬到服务器,还得把依赖、模型、数据和权限一起算进去。

二、先把项目装进一个真正能运行的镜像

这个项目最后采用的是多阶段构建。第一阶段用 Node 22 Alpine 安装前端依赖,执行 npm run build,产出 Vue 前端页面;第二阶段换成 Python 3.13 slim,安装后端依赖,再把前端的 dist 目录复制进去。

这样做的好处很直接:构建前端需要的 Node 环境不会留在最终运行镜像里。一个 uvicorn 进程在容器里同时提供三样东西:/api/v1/* 后端接口、/static/* 听写音频缓存,以及带 history 路由回退的前端 SPA。

开发机先遇到的是基础镜像。auth.docker.io 和 registry-1.docker.io 都是 8 秒超时,Docker Desktop 也没有配置镜像加速站。实际拉取测试里,docker.m.daocloud.io 能拿到官方 Node 基础镜像,hub.rat.dev 也可以作为备选;docker.1panel.live 虽然用 curl 发 GET 能返回 200,但 Docker 拉取时发 HEAD 会得到 403,我没有用它。

Dockerfile 把基础镜像写成了 ARG,所以构建时可以只替换来源,不改项目文件:

cd <仓库根目录>
docker build \
  --build-arg NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine \
  --build-arg PYTHON_IMAGE=docker.m.daocloud.io/library/python:3.13-slim \
  --build-arg PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ \
  -t english-learning-agent:1.3.0 .

这里的 tag 要和 docker-compose.yml 里的 image: 完全一致,现在是 english-learning-agent:1.3.0。Compose 同时有 build:,并不代表每次 up 都会替我重建;本地已经有同名镜像时,它可能直接使用现成的。tag 对不上,折腾半天跑的可能还是旧镜像。

CPU 机器不该背着 CUDA 依赖

服务器没有 GPU。如果照普通 PyPI Linux 版 torch 安装,torch==2.14.0 会带进 4 个 nvidia-* CUDA 包,镜像会从几百 MB 直接抬到数 GB。项目的安装脚本让 torch 单独走 CPU-only 索引:

docker run --rm --entrypoint pip english-learning-agent:1.3.0 list | grep -i nvidia
# 期望:没有任何输出

docker run --rm --entrypoint python english-learning-agent:1.3.0 -c "import torch; print(torch.__version__)"
# 期望:2.14.0+cpu

词汇书检索还依赖 all-MiniLM-L6-v2。目标服务器运行时无法稳定访问 Hugging Face,所以模型在构建阶段就下载到镜像里的 .venv/models/all-MiniLM-L6-v2。模型文件有好几种格式,但项目只需要 model.safetensors;安装脚本用白名单限制下载内容,避免把 PyTorch、TensorFlow、ONNX 等重复权重全塞进去。

这里我还踩过一次顺序问题:HF_HUB_OFFLINE=1 必须放在模型下载之后。提前设成离线模式,构建阶段的下载会直接报 OfflineModeIsEnabled;下载完成后再设置,运行期缺模型就会立即报错,不会去连一个本来就不通的域名。

实测结果很能说明取舍:不加 .dockerignore 时构建上下文有 1.24GB,使用 .dockerignore 后压到 2.19MB;带向量栈的镜像构建约 4 分钟,大小 2.33GB。用 WITH_EMBEDDINGS=0 可以把镜像压到 487MB,构建约 2 分钟,但词汇书 RAG 检索会提示 Embedding 模型未加载。我的项目需要这项功能,所以最终保留了向量栈。

三、把镜像从开发机搬到服务器

在开发机上确认容器内的 /health、材料接口和词汇书接口都返回 200 后,我没有让服务器重新拉一遍依赖,而是把镜像直接搬过去。

带向量栈的镜像有 2.33GB,直接传不太划算。我先用 docker save 导出,再接上 gzip 压缩:

docker save english-learning-agent:1.3.0 | gzip > ela.tar.gz   # 约 528MB
scp ela.tar.gz tencent:/tmp/

ssh tencent
gunzip -c /tmp/ela.tar.gz | sudo docker load
sudo docker images | grep english-learning-agent

这几步的顺序不能乱:本机打包,传到服务器的 /tmp/,登录服务器后解压并交给 docker load,最后查镜像列表确认 tag 已经导入。

服务器上的 ubuntu 用户有 sudo 权限,但不在 docker 组里。我实测直接执行 docker load 会报:

permission denied while trying to connect to the Docker API

这不是压缩包坏了,也不是上传漏了文件,而是当前用户没有访问 Docker socket 的权限。查镜像时同样要用 sudo docker images。代码有改动后,重新执行导出、上传、导入三步即可。

我在 Windows 开发机上还遇到过一个容易误判的现象:从宿主机 curl 容器端口,头几个请求可能卡 7~15 秒;进入容器请求同一个端点只要 0.04 秒。原因是 Docker Desktop 的端口转发预热,不是应用突然变慢。Linux 服务器上的反代直接访问 127.0.0.1,没有这个问题,我没有为了开发机的几次慢请求去改应用。

四、1Panel 编排之前,先把服务器上的数据摆好

镜像里没有业务数据。data/*.db 被 Git 忽略,如果只导入镜像、不拷数据库,容器当然可以启动,但 English Learning Agent 的六个学习步骤会变成空的。

我把项目目录放在 /opt/english-learning-agent。SQLite 数据、听写音频和应用数据都留在宿主机上,通过卷挂载进入容器,而不是跟镜像一起封死。用 1Panel 创建编排时,面板会把编排文件放进自己的目录树,所以服务器专用的 docker-compose.server.yml 使用绝对路径;相对路径跟着面板目录走,很容易挂错位置。

容器以 uid 10001 的非 root 用户运行,三个写入目录必须提前准备好:

ssh tencent
cd /opt/english-learning-agent
sudo install -d -o 10001 -g 10001 data backend/static/audio backend/data

然后把开发机上真实的 SQLite 库传到服务器,再修正文件属主:

scp data/english_learning.db tencent:/opt/english-learning-agent/data/

ssh tencent
cd /opt/english-learning-agent
sudo chown 10001:10001 data/english_learning.db

scp 上传的文件默认属于登录用户。只把目录改成 10001、忘了改数据库文件,应用仍然可能报 unable to open database file。这类错误看起来像数据库坏了,实际只是容器用户没有写权限。

没有现成数据库也能先启动空库,再灌入种子材料:

sudo docker compose exec english-learning-agent \
  python scripts/seed_demo_materials.py

在 1Panel 里,我从「容器 → 编排 → 创建编排」导入服务器编排文件。这里要注意两份 Compose 的职责:开发 / 反代主路径的 docker-compose.yml 带 build:,端口是 127.0.0.1:8001:8000;服务器专用的 docker-compose.server.yml 不带 build:,因为镜像已经提前 load 好了。它的默认映射是 0.0.0.0:8000:8000,用于直接 IP 加端口访问。

我最终采用域名加 HTTPS 的反代入口,所以要使用回环绑定的 8001 方案。若在 1Panel 里原样加载服务器专用编排文件,就必须按它实际暴露的 8000 端口检查和反代,不能拿 8001 的命令去判断 8000 映射是否健康。端口差异先核对清楚,比盯着面板上的“运行中”可靠。

启动后,我按顺序检查容器和接口:

cd /opt/english-learning-agent
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs -f --tail=50
curl -s http://127.0.0.1:8001/health

容器内部端口始终是 8000;8001 只是宿主机给反向代理使用的入口。首次启动还要建表和创建 FTS5 索引,健康检查特意留了 40 秒的 start_period,不能刚启动几秒就急着下结论。

五、用 1Panel 反向代理把端口藏起来

容器能访问后,我还不能把 IP:端口 直接当成最终入口。访问者记不住端口,HTTP 也没有加密,登录 cookie 和请求内容都不适合裸奔。

我先给域名配置 DNS,让它指向服务器,然后打开 http://<服务器IP>:8090 进入 1Panel,依次点击「网站 → 创建网站 → 反向代理」。代理配置是:

字段值
域名自己准备的域名,先解析到服务器
代理地址http://127.0.0.1:8001
发送域名$host

反向代理有点像写字楼前台:访问者只需要找到域名,1Panel 再把请求转到楼里的应用。这里的 127.0.0.1 能直接访问到应用,是因为反代容器采用了 network=host,它和宿主机共用网络栈,不需要额外接一张容器网络。

网站创建好后,我在站点设置里开启 HTTPS,由 1Panel 自动申请证书。到这里,浏览器才有了一个像样的访问入口。

不过能打开页面,不代表慢请求都能跑完。LLM 单次调用的超时是 60 秒;如果正文为空且被截断,适配器会自动升额重试,最坏三次就是 180 秒。nginx 默认的读取超时只有 60 秒,不改的话,“AI 定级”“精读分析”可能在应用还正常处理时就返回 504。

所以我在站点「配置文件」的 location 块里加入:

proxy_read_timeout    200s;
proxy_send_timeout    200s;
proxy_connect_timeout 30s;
client_max_body_size  20m;

20m 是给 PDF、Word 词汇书上传准备的。材料还没进应用,就先被反代层拦掉,会让人误以为上传接口坏了。

打开站点后,我进入「系统设置」,填好 LLM Provider、API Key 和模型并保存。现在的 Key 会以加密记录写入 SQLite,ELA_MASTER_KEY 负责在应用需要时解开它;它不应该写进镜像或文章。生产编排里的 SESSION_COOKIE_SECURE=true 也要保留,因为主入口是 HTTPS;本地用 HTTP 开发时则应为 false,否则会出现“登录看似成功,刷新后每个请求都 401”。

六、上线后的检查,才是部署的收尾

镜像能启动时,我很容易松一口气。但复盘 English Learning Agent 的部署配置后,我发现容器显示“运行中”,只说明进程起来了;账号能不能登录、AI 功能能不能用、数据出了问题能不能恢复,还得另外确认。

最容易漏掉的是 ELA_MASTER_KEY。用户填写的 LLM Key 会加密入库,应用要靠这把主密钥解开它们。上线前要提供它,并像保管数据库一样单独保管;我不会把实际值写进仓库、镜像或文章。Compose 通过变量插值把它注入容器,缺少时会拒绝启动,而不是留下一个悄悄解不开旧 Key 的服务。

备份也不能只看数据库。指南里的 tar 命令会打包 data 和 backend/data,但主密钥不在这两个目录里,必须另存一份。否则即使库文件还在,里面加密的 Key 也无法恢复,只能请用户重新填写。

日常排查可以从这里开始:

sudo docker compose logs -f --tail=200
sudo tar czf ela-backup-$(date +%F).tar.gz data backend/data

账号的顺序同样不能反。生产环境默认关闭开放注册,所以部署后要先用建号脚本创建管理员,再用这个账号登录,确认六步学习流程可用,才考虑是否开放注册。我的建号命令是:

sudo docker compose exec english-learning-agent \
  python scripts/create_user.py --username <你的用户名> --admin

建号脚本不传密码会等待交互输入,放进非交互命令里可能一直卡住;要么在真正的交互终端里运行,要么显式传入密码。密码本身不应该出现在文章或脚本记录里。

Cookie 还有一个很容易误判的地方:两份生产 Compose 的 SESSION_COOKIE_SECURE 都是 true,适合 HTTPS 域名。如果改走 HTTP 直连,登录可能看起来成功,后续请求却因为浏览器不回传 Secure cookie 而返回 401。这不是镜像坏了,而是入口协议和安全配置不匹配。当前取舍是保留 HTTPS 主入口的防降级保护。

容器本身也做了收口:使用非 root 用户运行,并设置 no-new-privileges,不让进程在容器内再提权。不过非 root 也有代价,宿主机挂载目录的属主不对,数据库就可能打不开。这个代价值得接受,但必须在部署前把权限准备好。

回头看,这次上线可以拆成几件能检查的事:密钥能解、账号能进、流程能走、日志能查、数据能还原。本地能跑不等于上线,镜像能启动也不等于项目能用。