Rust AI CLI 的 Docker 化部署:把工具和依赖模型一起打包

📅 2026/7/11 9:53:05 👁️ 阅读次数 📝 编程学习
Rust AI CLI 的 Docker 化部署:把工具和依赖模型一起打包

Rust AI CLI 的 Docker 化部署:把工具和依赖模型一起打包

标签:AI、AI CLI、Rust、Docker、部署

一、从一次"在我机器上能跑"说起

前阵子我做了一个 Rust 写的小命令行工具,功能是读取一段本地代码,然后调用本地大模型(Ollama)对代码做审查。工具本身不复杂,代码量也就一千多行。自己在 Mac 上跑得好好的,结果发给一个朋友试用,对方折腾了半小时愣是没跑起来。

问题出在哪?不是代码写得烂,是环境不一致

我下意识觉得对方和我一样,本地装了 Ollama、拉好了模型、配好了 Rust 环境。但实际情况是:他没装 Ollama,模型当然也没有,连 Rust 工具链都是两个月前装的,版本对不上。那一刻我突然理解了为什么 Docker 这种东西会存在。

这篇文章就记录一下,我是怎么把一个 Rust AI CLI 工具,连同模型依赖一起,打包进 Docker 镜像的。不是什么高深的东西,就是我踩坑之后的一点整理。

二、理清依赖关系:到底要打包什么

动手之前,先把自己工具的依赖链条画清楚。很多时候部署出问题,不是 Dockerfile 写错了,是没搞清楚自己的工具到底依赖了哪些东西

flowchart TD A["Rust AI CLI 工具"] --> B["系统依赖"] A --> C["运行时依赖"] A --> D["AI 模型依赖"] B --> B1["glibc / musl"] B --> B2["OpenSSL / 网络库"] B --> B3["CA 证书"] C --> C1["本地模型服务 (Ollama)"] C --> C2["模型权重文件"] D --> D1["qwen2.5-coder:7b"] D --> D2["模型已在 Ollama 注册"] E["用户环境"] -.->|"问题:不一致!"| B1 E -.->|"问题:可能没有"| C1 E -.->|"问题:需手动拉取"| D1

图上很清晰:依赖分三层。系统依赖(glibc、OpenSSL 这些),运行时依赖(Ollama 服务),模型依赖(具体的模型文件)。

之前我只打包了 Rust 编译出来的二进制文件,那只是冰山一角。真正的部署,得把上面三层全部搞定。

三、多阶段构建:把镜像做小

Docker 多阶段构建是我后来才学会的技巧。一开始我是直接在镜像里装 Rust 编译器,结果镜像大小直奔 2GB。后来改成两阶段构建,瘦身效果很明显。

下面是我最终用的 Dockerfile,核心思路是:编译阶段用完整的 Rust 镜像,运行阶段用 Alpine 的精简镜像

# ========== 第一阶段:编译 Rust 程序 ========== # 使用官方 Rust 镜像作为编译环境 FROM rust:1.78-alpine AS builder # 安装编译所需的系统依赖 # Alpine 用的是 musl libc,不是 glibc,静态编译更省事 RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig # 设置工作目录 WORKDIR /app # 先复制 Cargo.toml 和 Cargo.lock,利用 Docker 缓存层 # 这样只有依赖变化时才重新下载,日常改代码不会每次都下依赖 COPY Cargo.toml Cargo.lock ./ # 创建一个空的 src/main.rs 先骗过 cargo build # 这一步只编译依赖,不编译业务代码 RUN mkdir src && echo "fn main() {}" > src/main.rs RUN cargo build --release && rm -rf src # 现在复制真正的源代码并编译 COPY src ./src # 编译最终二进制,使用静态链接避免运行时缺 .so 文件 RUN cargo build --release --target x86_64-unknown-linux-musl # ========== 第二阶段:运行环境 ========== # 使用 Alpine 作为最小的基础镜像 FROM alpine:3.20 # 安装运行时需要的基础库 # ca-certificates:HTTPS 请求需要 # curl:用于拉取和初始化 Ollama RUN apk add --no-cache ca-certificates curl bash # 安装 Ollama(轻量级,单二进制文件) # 版本需要与本地测试环境保持一致 RUN curl -fsSL https://ollama.com/install.sh | sh # 从编译阶段复制编译好的二进制 COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/code-review-cli /usr/local/bin/code-review-cli # 创建非 root 用户运行(安全的好习惯) RUN adduser -D -h /home/appuser appuser USER appuser WORKDIR /home/appuser # 暴露 Ollama 的默认端口(如果需要外部访问) EXPOSE 11434 # 启动脚本:先启动 Ollama 服务,拉取模型,然后保持运行 COPY entrypoint.sh /usr/local/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"]

对应的entrypoint.sh启动脚本:

#!/bin/bash # 启动脚本:负责启动 Ollama 服务并拉取模型 set -e # 任何命令失败就退出 echo "=== 启动 Ollama 服务 ===" # 后台启动 Ollama 服务 ollama serve & # 获取 Ollama 进程的 PID,方便后续管理 OLLAMA_PID=$! echo "=== 等待 Ollama 服务就绪 ===" # 轮询等待 Ollama API 可用,最多等 30 秒 for i in $(seq 1 30); do if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then echo "Ollama 服务已就绪" break fi echo "等待中... ($i/30)" sleep 1 done echo "=== 检查并拉取模型 ===" # 如果模型不存在就拉取,model_name 可从环境变量传入 MODEL="${AI_MODEL:-qwen2.5-coder:7b}" if ! ollama list | grep -q "$MODEL"; then echo "模型 $MODEL 不存在,开始拉取..." ollama pull "$MODEL" echo "模型拉取完成" else echo "模型 $MODEL 已存在,跳过拉取" fi echo "=== 工具就绪 ===" # 把 Ollama PID 传给前台,保持容器运行 wait $OLLAMA_PID

这里有几个我在踩坑过程中学到的细节:

  1. Alpine + musl:用 glibc 编译的二进制在 Alpine 上跑不了,用 musl 做静态链接就解决了。
  2. 层缓存:先复制 Cargo.toml 再复制 src,这样只有依赖变化时才重新编译依赖,日常开发改代码时编译飞快。
  3. 非 root 用户:容器里用 root 跑是很多安全问题的根源,养成习惯切换成普通用户。

四、模型层缓存策略:避免每次拉几GB模型

模型文件动不动几个 G,如果你的 CI 每次都重新拉模型,不说时间,流量费都够呛。

我用的办法是用 Docker 的多阶段缓存把模型单独放一层:

# ========== 模型准备层(可缓存) ========== FROM alpine:3.20 AS model-fetcher # 安装 Ollama RUN curl -fsSL https://ollama.com/install.sh | sh # 启动 Ollama 在后台,拉取指定模型,然后停止 # 这样做的好处是:模型层的哈希只取决于模型名称和版本 RUN ollama serve & \ sleep 5 && \ ollama pull qwen2.5-coder:7b && \ kill %1 # ========== 最终镜像层 ========== FROM alpine:3.20 # ... 安装运行时依赖 ... # 从模型层复制 Ollama 的数据目录(包含已下载的模型) COPY --from=model-fetcher /root/.ollama /home/appuser/.ollama # 从编译层复制二进制 COPY --from=builder /app/target/release/code-review-cli /usr/local/bin/ # 启动 Ollama 即可使用已有模型,无需重新拉取 CMD ["ollama", "serve"]

这样子,只有模型版本变了,模型层才会重新构建。日常改代码的构建时间从"等着拉 4GB 模型"变成了"秒级编译"。

我画个图总结一下整个镜像的分层结构:

graph TB subgraph "最终镜像(约 5-8 GB)" direction TB L1["层 1:Alpine 基础系统 (~5MB)"] L2["层 2:运行时依赖 (curl, bash, ca-cert) (~10MB)"] L3["层 3:Ollama 二进制 (~800MB, 可缓存)"] L4["层 4:AI 模型文件 (~4GB, 可缓存)"] L5["层 5:Rust 编译产物 (~10MB, 频繁更新)"] L6["层 6:启动脚本 + 配置 (~1KB)"] end Build1["改代码 → 重建"] -.->|"只重建层 5,6"| L5 Build2["改模型 → 重建"] -.->|"重建层 4,5,6"| L4 Build3["改 Ollama 版本"] -.->|"重建层 3,4,5,6"| L3 style L1 fill:#e8f5e9 style L2 fill:#e8f5e9 style L3 fill:#fff3e0 style L4 fill:#ffebee style L5 fill:#e3f2fd style L6 fill:#f3e5f5

五、总结

回顾整个折腾过程,几点我觉得对我这种新手来说比较重要的认知:

  1. "能跑"和"能交付"是两码事。自己机器上能跑不算数,别人能一键跑起来的才是能交付的。
  2. Docker 不是银弹,但对于环境一致性确实是好工具。以前我觉得 Docker 是运维才需要学的东西,现在发现不管写什么工具,能打包成镜像就方便太多。
  3. 多阶段构建是基础操作,不是高级技巧。它能把编译依赖和运行环境分离,镜像大小能差出几十倍。
  4. 模型层的缓存很重要。AI 工具部署的特殊性在于模型文件巨大,必须单独处理缓存策略。
  5. Alpine + musl 的组合对于 Rust 的静态编译非常友好,基本不用操心 .so 文件缺失的问题。

我还是个新手,上面这些都是踩过坑才学到的。如果你也在做类似的事情,希望这些经验能帮你少走点弯路。有什么说得不对的地方,也欢迎指正。