03 | 离线部署 FastAPI + Ollama(qwen2:0.5b):从 Docker 联调到 Kubernetes 验收

本文整理自一次真实的部署与排错日志(2026-10-09 ~ 2026-10-10),命令与输出尽量保持原样。日志中过长的部分用 ... 标注了省略;文中标注"补充"的内容为整理时根据日志推断或补全,非原始日志。


总览

部署意图

把一个本地 LLM 推理服务拆成两个独立组件,并最终跑在 Kubernetes 上:

  • FastAPI 服务(llm-serving) :对外提供 OpenAI 风格的 /v1/chat/completions 和 /health 接口,内部转发请求给 Ollama。
  • Ollama 服务(llm-serving-ollama) :负责实际推理,模型 qwen2:0.5b 提前打进镜像,做到"离线可用"------容器启动后不需要再联网拉模型。

两个组件各自一个镜像、一个 Deployment、一个 Service,通过集群内 DNS 名互相访问。

目标架构

复制代码
Ingress
   │
   ▼
llm-serving Service :8000
   │
   ▼
FastAPI Pod
   │
   │ http://llm-serving-ollama:11434
   ▼
llm-serving-ollama Service
   │
   ▼
Ollama Pod
   │
   ▼
qwen2:0.5b

过程概览

阶段 内容 产物
一 构建 FastAPI 镜像 llm-serving:0.2.0
二 通过国内镜像源拉取 Ollama,本地下载模型,打包成离线 Ollama 镜像 llm-serving-ollama:0.1.0
三 确认两个镜像都已就绪 docker images 输出
四 用 Docker 专用网络联调两个容器,验证 /health 与聊天接口 联调通过
五 在 Kubernetes(Helm 部署)中验收功能与资源,对照配置完善清单 Deployment / Service / Ingress 运行正常

验证结果

  • Ollama 离线镜像单独运行后,/api/tags 能直接列出 qwen2:0.5b,证明模型已在镜像内。
  • Docker 专用网络中,/health 返回 {"status":"ok","service":"llm-serving","ollama":"ok"},说明 FastAPI 能通过容器名访问 Ollama。
  • /v1/chat/completions 返回完整的 chat.completion 响应,Ollama 日志中可以看到 /api/chat 被 FastAPI 容器(172.20.0.3)调用。
  • Kubernetes 中两个 Deployment 均 1/1 Ready,两个 Pod Running,Service 为 ClusterIP,Ingress 已分配地址,接口调用成功。

环境信息(来自日志)

  • 宿主机:MacBook,Apple M4,Docker Desktop(docker:desktop-linux)
  • 项目目录:/Users/igwanglyang/Documents/GitHub/llm-serving
  • 宿主机 Ollama 版本 0.40.1(Metal GPU);镜像内 Ollama 版本 0.40.2(纯 CPU,约 7.7 GiB 内存)
  • Kubernetes 命名空间:llm-serving,通过 Helm 管理(日志中出现 sh.helm.release.v1.llm-serving.v2 ~ v11)

一、构建 FastAPI 镜像

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker build -t llm-serving:0.2.0 .
[+] Building 6.5s (10/10) FINISHED                                                                                                                                                docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                                                              0.0s
 => => transferring dockerfile: 354B                                                                                                                                                              0.0s
 => [internal] load metadata for docker.io/library/python:3.12-slim                                                                                                                               6.4s
 => [internal] load .dockerignore                                                                                                                                                                 0.0s
 => => transferring context: 2B                                                                                                                                                                   0.0s
 => [1/5] FROM docker.io/library/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1                                                                         0.0s
 => => resolve docker.io/library/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1                                                                         0.0s
 => [internal] load build context                                                                                                                                                                 0.0s
 => => transferring context: 562B                                                                                                                                                                 0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                                                                     0.0s
 => CACHED [3/5] COPY requirements.txt .                                                                                                                                                          0.0s
 => CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt                                                                                                                               0.0s
 => CACHED [5/5] COPY app ./app                                                                                                                                                                   0.0s
 => exporting to image                                                                                                                                                                            0.0s
 => => exporting layers                                                                                                                                                                           0.0s
 => => writing image sha256:1f08145802dec9055418502ecf278f4bd9d054c490b6ba4bf468234fc4c7fdf4                                                                                                      0.0s
 => => naming to docker.io/library/llm-serving:0.2.0                                                                                                                                              0.0s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/rfnhj7ohfrgdnp0ofh0yxcwnw

要点:

  • 基础镜像 python:3.12-slim,依赖安装与代码复制都命中了缓存(CACHED),所以构建只花了几秒。
  • 把 COPY requirements.txt 和 pip install 放在 COPY app 之前,是为了让改代码时不用重装依赖。

二、构建离线 Ollama 镜像

思路:先在宿主机准备好模型文件,再把模型目录 COPY 进基于官方 Ollama 的镜像。这样镜像自带模型,容器启动后无需联网。

2.1 下载 Ollama 基础镜像(使用 DaoCloud 镜像源)

先确认镜像源可达(返回 401 是正常的,说明 registry 在线,只是需要 token):

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % curl -I --max-time 10 https://docker.m.daocloud.io/v2/
HTTP/2 401 
server: nginx
date: Fri, 09 Oct 2026 17:05:15 GMT
content-type: application/json; charset=utf-8
content-length: 73
www-authenticate: Bearer realm="https://m.daocloud.io/auth/token",service="docker.m.daocloud.io"
docker-distribution-api-version: registry/2.0

拉取镜像:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker pull docker.m.daocloud.io/ollama/ollama:latest 
latest: Pulling from ollama/ollama
90812e242c93: Pull complete 
2a5871fefe49: Pull complete 
ecbacac73fe2: Pull complete 
cf86a6b81b3e: Pull complete 
Digest: sha256:b86366bb528bbf7f1424435d165028497a5b69bf6ddb4fa5a87102e2b79f44fb
Status: Downloaded newer image for docker.m.daocloud.io/ollama/ollama:latest
docker.m.daocloud.io/ollama/ollama:latest

补充 :后面打包时 Dockerfile 用的是 FROM ollama/ollama:latest(见构建日志里的 docker.io/ollama/ollama:latest)。如果本地没有这个名字的镜像,需要先给镜像源拉下来的镜像打个同名标签,例如:

bash 复制代码
docker tag docker.m.daocloud.io/ollama/ollama:latest ollama/ollama:latest

原始日志中没有记录这一步,请按你的实际情况处理。

2.2 下载离线模型 qwen2:0.5b

① 在宿主机上启动一个专用的 Ollama 服务

用独立端口 11435(避开可能已在运行的默认实例),并把模型目录指到项目内的 ollama/models,这样下载的模型文件会直接落在项目目录里,后面 COPY 就能用:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % cd /Users/igwanglyang/Documents/GitHub/llm-serving

OLLAMA_HOST=127.0.0.1:11435 \
OLLAMA_MODELS="$PWD/ollama/models" \
ollama serve
time=2026-10-10T01:30:18.626+08:00 level=INFO source=routes.go:2373 msg="server config" env="map[... OLLAMA_HOST:http://127.0.0.1:11435 ... OLLAMA_MODELS:/Users/igwanglyang/Documents/GitHub/llm-serving/ollama/models ...]"
time=2026-10-10T01:30:18.628+08:00 level=INFO source=images.go:989 msg="total blobs: 0"
time=2026-10-10T01:30:18.628+08:00 level=INFO source=images.go:998 msg="total unused blobs removed: 0"
time=2026-10-10T01:30:18.630+08:00 level=INFO source=routes.go:2435 msg="Listening on 127.0.0.1:11435 (version 0.40.1)"
time=2026-10-10T01:30:18.634+08:00 level=INFO source=runner.go:60 msg="discovering available GPUs..."
time=2026-10-10T01:30:19.255+08:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M4" libdirs="" driver=0.0 pci_id="" type=iGPU total="17.8 GiB" available="17.8 GiB"
time=2026-10-10T01:30:19.255+08:00 level=INFO source=routes.go:2485 msg="vram-based default context" total_vram="17.8 GiB" default_num_ctx=4096
...

② 另开一个终端,拉取模型

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % cd /Users/igwanglyang/Documents/GitHub/llm-serving

OLLAMA_HOST=127.0.0.1:11435 ollama pull qwen2:0.5b
pulling manifest 
pulling 8de95da68dc4: 100% ▕██████████████████████████████████████████████████████████████████████████████ ▏ 352 MB/352 MB  9.3 MB/s      0s
pulling 2184ab82477b: 100% ▕███████████████████████████████████████████████████████████████████████████████▏  488 B                         
verifying sha256 digest 
writing manifest 
success 

服务端对应的日志(多次重复拉取时,已下载的部分会很快完成):

text 复制代码
[GIN] 2026/10/10 - 01:30:40 | 200 |      403.75µs |       127.0.0.1 | HEAD     "/"
time=2026-10-10T01:30:44.771+08:00 level=INFO source=download.go:182 msg="downloading 8de95da68dc4 in 4 100 MB part(s)"
[GIN] 2026/10/10 - 01:30:45 | 200 |  4.989851666s |       127.0.0.1 | POST     "/api/pull"
...
[GIN] 2026/10/10 - 01:32:46 | 200 | 14.932196208s |       127.0.0.1 | POST     "/api/pull"

③ 确认模型和文件

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % OLLAMA_HOST=127.0.0.1:11435 ollama list
du -sh ollama/models
find ollama/models -maxdepth 3 -type f
NAME          ID              SIZE      MODIFIED      
qwen2:0.5b    6f48b936a09f    352 MB    3 seconds ago    
336M    ollama/models
ollama/models/blobs/sha256-6f48b936a09f7743c7dd30e72fdb14cba296bc5861902e4d0c387e8fb5050b39
...
ollama/models/blobs/sha256-2184ab82477bc33a5e08fa209df88f0631a19e686320cce2cfe9e00695b2f0e6
ollama/models/metadata/sha256-8de95da68dc485c0889c205384c24642f83ca18d089559c977ffc6a3972a71a8.json

中间省略的是 4 个 blob 文件(sha256-62fbfd9e...、sha256-f02dd72b...、sha256-c156170b...、sha256-8de95da6...),其中 8de95da6... 是约 352 MB 的模型权重本体。ollama/models 总计 336M。

2.3 打包为离线镜像

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % cd /Users/igwanglyang/Documents/GitHub/llm-serving

docker build \
  -f ollama/Dockerfile \
  -t llm-serving-ollama:0.1.0 \
  ollama
[+] Building 5.7s (7/7) FINISHED                                                                                        docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                    0.0s
 => => transferring dockerfile: 174B                                                                                                    0.0s
 => [internal] load metadata for docker.io/ollama/ollama:latest                                                                         0.0s
 => [internal] load .dockerignore                                                                                                       0.0s
 => => transferring context: 2B                                                                                                         0.0s
 => [internal] load build context                                                                                                       4.1s
 => => transferring context: 352.25MB                                                                                                   4.1s
 => [1/2] FROM docker.io/ollama/ollama:latest                                                                                           0.1s
 => [2/2] COPY models/ /models/                                                                                                         0.7s
 => exporting to image                                                                                                                  0.8s
 => => exporting layers                                                                                                                 0.8s
 => => writing image sha256:8c5617c32f48c6efe1d184a51dbb99c6f8b421303bc4462967907c1a29deec8b                                            0.0s
 => => naming to docker.io/library/llm-serving-ollama:0.1.0                                                                             0.0s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/jg8r3bqh96t1c7fpzccbbk0j8

补充 :日志里没有贴出 ollama/Dockerfile 的内容。从构建步骤(两步:FROM + COPY models/ /models/,174B)以及后面容器日志里的 OLLAMA_MODELS:/models 推断,它大致是下面这样,仅供参考:

dockerfile 复制代码
FROM ollama/ollama:latest
ENV OLLAMA_MODELS=/models
COPY models/ /models/

构建上下文传输了 352.25MB,正好对应模型文件体积。另外,第一次运行这个构建命令时,输出停在了 Building 5.3s (1/2)(日志被截断,看起来是构建被打断或终端输出未结束),随后重新执行才得到上面的完整成功结果。

2.4 单独测试并删除测试容器

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker run -d \
  --name ollama-offline-test \
  -p 11436:11434 \
  llm-serving-ollama:0.1.0
02a791767c15ee281f5e590c5156deb1d91ed9d408616d6a042eb2abf74d4957
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % curl http://localhost:11436/api/tags
{"models":[{"name":"qwen2:0.5b","model":"qwen2:0.5b","modified_at":"2026-10-09T17:36:31.470145772Z","size":352164041,"digest":"6f48b936a09f7743c7dd30e72fdb14cba296bc5861902e4d0c387e8fb5050b39","details":{"parent_model":"","format":"gguf","family":"qwen2","families":["qwen2"],"parameter_size":"494.03M","quantization_level":"Q4_0","context_length":32768,"embedding_length":896,"runner":"ggml"},"capabilities":["completion"]}%
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker rm -f ollama-offline-test
ollama-offline-test

/api/tags 直接列出了 qwen2:0.5b(量化 Q4_0、参数量 494.03M),说明模型确实已经在镜像里,没有联网下载。


三、确认两个镜像

再次构建 FastAPI 镜像(依然全部命中缓存),并列出镜像:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker build -t llm-serving:0.2.0 .
[+] Building 4.4s (10/10) FINISHED                                                                                                                                                docker:desktop-linux
 ...
 => CACHED [5/5] COPY app ./app                                                                                                                                                                   0.0s
 => exporting to image                                                                                                                                                                            0.0s
 => => exporting layers                                                                                                                                                                           0.0s
 => => writing image sha256:7b8f009cae8cf775f72d9e74237e1a627ec22d1b428ff066ebafa2e1c74a1b10                                                                                                      0.0s
 => => naming to docker.io/library/llm-serving:0.2.0                                                                                                                                              0.0s
bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker images | grep llm-serving
llm-serving-ollama    0.1.0    8c5617c32f48   17 minutes ago   4.58GB
llm-serving           0.2.0    7b8f009cae8c   26 hours ago     200MB
llm-serving           0.1.1    6f5b68138c97   26 hours ago     200MB
llm-serving           0.1.0    ...

原始输出的列间距很宽,这里压缩了空白。注意两点:

  • FastAPI 镜像约 200MB ,Ollama 离线镜像约 4.58GB(官方 Ollama 基础镜像本身就很大,再加上 352MB 模型)。
  • 第二次构建后 llm-serving:0.2.0 的镜像 ID 从 1f08145802de... 变成了 7b8f009cae8c...,但内容层全部是 CACHED,通常是镜像配置元数据(如构建时间等)变化所致。

四、用 Docker 专用网络联调

在上 Kubernetes 之前,先用 Docker 网络模拟"两个 Service 之间用名字互相访问"的场景。

4.1 创建专用网络

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker network create llm-serving-net
38e47cbd349775901f8c241156685be5645f3a9d79902a342d6f6f66c504cb07

自定义 bridge 网络自带 DNS,容器之间可以直接用容器名互相访问。

4.2 启动 Ollama 容器

容器名取 llm-serving-ollama,与后面 Kubernetes 中的 Service 名保持一致:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker run -d \
  --name llm-serving-ollama \
  --network llm-serving-net \
  -p 11436:11434 \
  llm-serving-ollama:0.1.0
900a3b083555760786fa68159f40dd8e8bdc7c5d2c223bed5d5b0345435bf48b

4.3 启动 FastAPI 容器(含一次命名冲突)

通过环境变量告诉 FastAPI:Ollama 在哪里、默认用哪个模型。

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker run -d \
  --name llm-serving \
  --network llm-serving-net \
  -p 8000:8000 \
  -e OLLAMA_BASE_URL=http://llm-serving-ollama:11434 \
  -e DEFAULT_MODEL=qwen2:0.5b \
  llm-serving:0.2.0
1c073af4bc1132b8637bd5da86eede1d0666fcfbdd4713034bcdc87613fb0983

随后同样的命令又执行了两次,报了容器名冲突(此前的容器已经占用了 llm-serving 这个名字):

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker run -d \
  --name llm-serving \
  --network llm-serving-net \
  -p 8000:8000 \
  -e OLLAMA_BASE_URL=http://llm-serving-ollama:11434 \
  -e DEFAULT_MODEL=qwen2:0.5b \
  llm-serving:0.2.0
docker: Error response from daemon: Conflict. The container name "/llm-serving" is already in use by container "1c073af4bc1132b8637bd5da86eede1d0666fcfbdd4713034bcdc87613fb0983". You have to remove (or rename) that container to be able to reuse that name.

Run 'docker run --help' for more information

尝试用 docker remove 删除,但这不是合法命令:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker remove llm-serving
docker: unknown command: docker remove

Run 'docker --help' for more information

正确做法是 docker rm -f,删除后重新启动:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker rm -f llm-serving
llm-serving
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker run -d \          
  --name llm-serving \
  --network llm-serving-net \
  -p 8000:8000 \
  -e OLLAMA_BASE_URL=http://llm-serving-ollama:11434 \
  -e DEFAULT_MODEL=qwen2:0.5b \
  llm-serving:0.2.0
ebbca645f028454391e8de65486fc58e9a0c60f22612bc99b11f9ff0c9ea0922

小结 :Docker 容器名全局唯一,重复 docker run --name xxx 会冲突;删除容器用 docker rm(强制删除运行中的用 -f),没有 docker remove 这个子命令。

4.4 健康检查与聊天接口验证

健康检查 :ollama: ok 表示 FastAPI 已经能通过 http://llm-serving-ollama:11434 访问到 Ollama。

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % curl http://localhost:8000/health
{"status":"ok","service":"llm-serving","ollama":"ok"}%

聊天补全:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen2:0.5b",
    "messages": [
      {
        "role": "user",
        "content": "100 word Explain Kubernetes Service."
      }
    ]
  }'
{"id":"chatcmpl-0f46884e531b","object":"chat.completion","created":1791568826,"model":"qwen2:0.5b","choices":[{"index":0,"message":{"role":"assistant","content":"Kubernetes Service, as its name implies, allows applications to run in containers, creating a highly flexible platform that can scale horizontally to handle increased load or expand in response to changing requirements. It is the most widely adopted approach for managing containerized applications, allowing developers to easily add new components or modify existing ones without having to manually modify the codebase. Kubernetes Service allows developers to easily deploy, scale, and manage applications, providing a powerful tool for managing complex applications with dynamic behavior and scalability. By leveraging Kubernetes Service, organizations can enhance performance, scalability, and flexibility of their applications, enabling them to handle increasing traffic load and adapt to changing requirements."},"finish_reason":"stop"}],"usage":{"prompt_tokens":16,"completion_tokens":131,"total_tokens":147}}%

0.5B 的小模型回答流畅但内容不够准确(比如把 Service 说成"运行容器的平台"),这里的目的是验证链路通畅,而不是模型质量。

4.5 查看两个容器的日志

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker logs llm-serving
docker logs llm-serving-ollama

FastAPI 日志:

text 复制代码
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     192.168.65.1:62195 - "GET /health HTTP/1.1" 200 OK
INFO:     192.168.65.1:44825 - "POST /v1/chat/completions HTTP/1.1" 200 OK

Ollama 日志 (节选,完整日志中还有大量 llama_model_loader / print_info 模型参数输出,已省略):

text 复制代码
Couldn't find '/root/.ollama/id_ed25519'. Generating new private key.
Your new public key is: 

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhECwQbywKPHXwbKlBE77t3+naUbZKTFFllZar2Zpny

time=2026-10-09T17:57:52.849Z level=INFO source=routes.go:2373 msg="server config" env="map[... OLLAMA_HOST:http://0.0.0.0:11434 ... OLLAMA_MODELS:/models ... OLLAMA_NUM_PARALLEL:1 ...]"
time=2026-10-09T17:57:52.849Z level=INFO source=routes.go:2375 msg="Ollama cloud disabled: false"
time=2026-10-09T17:57:52.853Z level=INFO source=images.go:989 msg="total blobs: 0"
time=2026-10-09T17:57:52.854Z level=INFO source=images.go:998 msg="total unused blobs removed: 0"
time=2026-10-09T17:57:52.855Z level=INFO source=routes.go:2435 msg="Listening on [::]:11434 (version 0.40.2)"
time=2026-10-09T17:57:52.861Z level=INFO source=runner.go:60 msg="discovering available GPUs..."
time=2026-10-09T17:57:52.978Z level=INFO source=types.go:50 msg="inference compute" id=cpu library=cpu compute="" name=cpu description=cpu libdirs=ollama driver="" pci_id="" type="" total="7.7 GiB" available="7.6 GiB"
time=2026-10-09T17:57:52.978Z level=INFO source=routes.go:2485 msg="vram-based default context" total_vram="0 B" default_num_ctx=4096
time=2026-10-09T17:57:54.316Z level=INFO source=model_recommendations.go:177 msg="model recommendations cache sleep scheduled" wait=4h31m18.884575993s consecutive_failures=0
time=2026-10-09T17:57:59.458Z level=WARN source=model_show_cache.go:327 msg="failed to hydrate cloud model show cache" model=nemotron-3-ultra error="Post \"https://ollama.com:443/api/show?ts=1791568676\": context deadline exceeded"
time=2026-10-09T17:57:59.721Z level=WARN source=model_show_cache.go:327 msg="failed to hydrate cloud model show cache" model=kimi-k3 error="Post \"https://ollama.com:443/api/show?ts=1791568676\": context deadline exceeded"
time=2026-10-09T17:57:59.894Z level=WARN source=model_show_cache.go:327 msg="failed to hydrate cloud model show cache" model=minimax-m3 error="Post \"https://ollama.com:443/api/show?ts=1791568676\": context deadline exceeded"
[GIN] 2026/10/09 - 18:00:05 | 200 |    6.494209ms |      172.20.0.3 | GET      "/api/tags"
time=2026-10-09T18:00:24.197Z level=INFO source=sched.go:1166 msg="disabling mmap for llama-server load by default" model=/models/blobs/sha256-8de95da68dc485c0889c205384c24642f83ca18d089559c977ffc6a3972a71a8 reason=cpu
time=2026-10-09T18:00:24.197Z level=INFO source=server.go:107 msg="using llama-server for model" model=/models/blobs/sha256-8de95da68dc485c0889c205384c24642f83ca18d089559c977ffc6a3972a71a8
...
llama_model_loader: - kv   0:                       general.architecture str              = qwen2
llama_model_loader: - kv   1:                               general.name str              = Qwen2-0.5B-Instruct
llama_model_loader: - kv   2:                          qwen2.block_count u32              = 24
llama_model_loader: - kv   3:                       qwen2.context_length u32              = 32768
...
print_info: file format = GGUF V3 (latest)
print_info: file type   = Q4_0
print_info: file size   = 330.17 MiB (5.61 BPW) 
...
print_info: model params          = 494.03 M
print_info: general.name          = Qwen2-0.5B-Instruct
...
llama_context: n_ctx                 = 4096
...
srv  llama_server: model loaded
srv  llama_server: listening on http://127.0.0.1:40247
srv  update_slots: all slots are idle
time=2026-10-09T18:00:25.231Z level=INFO source=llama_server.go:1374 msg="llama-server started in 1.03 seconds"
...
slot print_timing: id  0 | task 0 | prompt eval time =      34.70 ms /    16 tokens (    2.17 ms per token,   461.13 tokens per second)
slot print_timing: id  0 | task 0 |        eval time =    1449.23 ms /   131 tokens (   11.15 ms per token,    89.70 tokens per second)
slot print_timing: id  0 | task 0 |       total time =    1483.93 ms /   147 tokens
slot print_timing: id  0 | task 0 |    graphs reused =        130
slot      release: id  0 | task 0 | stop processing: n_tokens = 146, truncated = 0
srv  update_slots: all slots are idle
[GIN] 2026/10/09 - 18:00:26 | 200 |  2.574404376s |      172.20.0.3 | POST     "/api/chat"

日志里值得注意的几点:

  • 调用来源 :GET /api/tags(健康检查)和 POST /api/chat(聊天)都来自 172.20.0.3,即 FastAPI 容器在专用网络中的地址,证明容器间是通过网络名互通的。
  • 模型从镜像内加载 :模型路径是 /models/blobs/sha256-8de95da6...,与镜像里 OLLAMA_MODELS=/models 对应。
  • 纯 CPU 推理 :Docker Desktop 的 Linux 容器里用不到 Apple 的 Metal GPU,日志显示 library=cpu。即便如此,0.5B 小模型也能跑到约 89.7 tokens/s ,整个请求 2.57s(含首次加载模型 1.03s)。
  • 一些无害的告警 :failed to hydrate cloud model show cache ... context deadline exceeded 是 Ollama 尝试访问 ollama.com 的云模型信息超时,不影响本地推理;Couldn't find '/root/.ollama/id_ed25519'. Generating new private key. 只是容器首次启动自动生成身份密钥,也属正常。

4.6 清理现场

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % docker rm -f llm-serving llm-serving-ollama
docker network rm llm-serving-net
llm-serving
llm-serving-ollama
llm-serving-net

五、Kubernetes 阶段:功能验收与配置完善

Docker 联调通过后,进入阶段 2:在集群里验收功能,并对照"生产化配置清单"检查还缺什么。

5.1 接口功能验证

通过 Ingress / 本地端口访问 /v1/chat/completions,显式带上 "stream": false:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % curl -sS http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen2:0.5b",
    "messages": [
      {
        "role": "user",
        "content": "Explain Kubernetes Service in 3 sentences."
      }
    ],
    "stream": false
  }'
{"id":"chatcmpl-bfc6a4630554","object":"chat.completion","created":1791576033,"model":"qwen2:0.5b","choices":[{"index":0,"message":{"role":"assistant","content":"Kubernetes Service enables deployment of a pod as an application service across all nodes in Kubernetes cluster. It allows applications to be deployed across multiple nodes in a Kubernetes cluster."},"finish_reason":"stop"}],"usage":{"prompt_tokens":17,"completion_tokens":34,"total_tokens":51}}%

5.2 验收 Kubernetes 资源

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % kubectl get deploy,pods,svc,ingress -n llm-serving
kubectl get pvc,sa,secret -n llm-serving
NAME                                 READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/llm-serving          1/1     1            1           27h
deployment.apps/llm-serving-ollama   1/1     1            1           103m

NAME                                     READY   STATUS    RESTARTS   AGE
pod/llm-serving-64856b46ff-qztkx         1/1     Running   0          8m30s
pod/llm-serving-ollama-df84fbbfc-xqdjn   1/1     Running   0          103m

NAME                         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)     AGE
service/llm-serving          ClusterIP   10.104.107.206   <none>        8000/TCP    27h
service/llm-serving-ollama   ClusterIP   10.104.228.49    <none>        11434/TCP   103m

NAME                                    CLASS   HOSTS               ADDRESS         PORTS   AGE
ingress.networking.k8s.io/llm-serving   nginx   llm-serving.local   10.104.86.242   80      27h
NAME                         SECRETS   AGE
serviceaccount/default       0         27h
serviceaccount/llm-serving   0         95m

NAME                                        TYPE                 DATA   AGE
secret/sh.helm.release.v1.llm-serving.v10   helm.sh/release.v1   1      13m
secret/sh.helm.release.v1.llm-serving.v11   helm.sh/release.v1   1      8m30s
secret/sh.helm.release.v1.llm-serving.v2    helm.sh/release.v1   1      27h
secret/sh.helm.release.v1.llm-serving.v3    helm.sh/release.v1   1      27h
secret/sh.helm.release.v1.llm-serving.v4    helm.sh/release.v1   1      27h
secret/sh.helm.release.v1.llm-serving.v5    helm.sh/release.v1   1      27h
secret/sh.helm.release.v1.llm-serving.v6    helm.sh/release.v1   1      103m
secret/sh.helm.release.v1.llm-serving.v7    helm.sh/release.v1   1      95m
secret/sh.helm.release.v1.llm-serving.v8    helm.sh/release.v1   1      90m
secret/sh.helm.release.v1.llm-serving.v9    helm.sh/release.v1   1      24m

从输出可以读出:

  • 两个 Deployment 都是 1/1 :llm-serving(FastAPI,已运行 27h)和 llm-serving-ollama(Ollama,103 分钟前新加入)。
  • Service 均为 ClusterIP :llm-serving:8000 和 llm-serving-ollama:11434,与架构图一致,只有 FastAPI 通过 Ingress 对外暴露(nginx 类,Host 为 llm-serving.local)。
  • ServiceAccount :除 default 外多了一个专用的 llm-serving(95 分钟前创建),对应清单中"明确 Pod 使用的身份"。
  • Helm 发布历史 :sh.helm.release.v1.llm-serving.v2 ~ v11 是 Helm 每次 upgrade 留下的 release 记录,当前已到第 11 个版本,说明这套配置是通过 Helm 反复迭代出来的。
  • PVC :kubectl get pvc 在输出里没有对应的资源行,说明当前还没有创建 PVC。这是合理的------模型已经打进镜像,不依赖持久卷,所以清单里写的是"按实际数据持久化需求配置"。

5.3 配置完善清单

下表是阶段 2 要对照检查的资源。"对应文件"一列在原始笔记中为空,以下为补充 ,按常见 Helm Chart 目录结构填写,请按你项目的实际文件名调整;"当前状态"一列依据上面的 kubectl 输出判断。

资源 目的 对应文件(补充,示例) 当前状态
Namespace 隔离应用资源 templates/namespace.yaml(或 helm --create-namespace) 已存在 llm-serving
ServiceAccount 明确 Pod 使用的身份 templates/serviceaccount.yaml 已创建 llm-serving
ConfigMap / Secret 分离普通配置和敏感配置 templates/configmap.yaml、templates/secret.yaml 输出中仅见 Helm release Secret,应用级配置待确认
PVC 按实际数据持久化需求配置 templates/pvc.yaml 未创建(模型已内置镜像)
Deployment / Service 分别管理 FastAPI 和 Ollama templates/deployment.yaml、templates/service.yaml、templates/ollama-deployment.yaml、templates/ollama-service.yaml 已就绪
Ingress 对外暴露 FastAPI API templates/ingress.yaml 已就绪(llm-serving.local)
requests / limits 控制 CPU、内存使用 写在 Deployment 的 resources 字段 待配置/待确认
HPA 按指标扩缩容;需具备可用指标源 templates/hpa.yaml 待配置(需先有 metrics-server 等指标源)
NetworkPolicy 限制不必要的 Pod 网络访问 templates/networkpolicy.yaml 待配置

补充建议:

  • requests / limits:Ollama 即使是 0.5B 模型,日志显示容器内占用也就几百 MiB(模型 + KV cache + 计算缓冲约 225 MiB),可以据此给 Ollama 设置内存 requests/limits,并为 FastAPI 设置较小的值。
  • HPA :Ollama 的并行度默认是 OLLAMA_NUM_PARALLEL:1,扩容 FastAPI 副本前要先想清楚瓶颈其实在 Ollama,盲目扩 FastAPI 意义不大。
  • NetworkPolicy:合理的最小策略是------只允许 Ingress Controller 访问 FastAPI:8000,只允许 FastAPI 访问 Ollama:11434。

总结

这次部署的关键点:

  1. 离线镜像思路 :在宿主机用 OLLAMA_MODELS 指向项目目录下载模型,再 COPY 进基于官方 Ollama 的镜像,一次打包,处处离线可跑。
  2. 先用 Docker 专用网络联调 :容器名与未来 Kubernetes 中的 Service 名保持一致(llm-serving-ollama),同一套 OLLAMA_BASE_URL=http://llm-serving-ollama:11434 配置在 Docker 和 K8s 里都能用。
  3. 验证看三处 :/api/tags 证明模型在镜像里;/health 里的 ollama: ok 证明服务间通路;Ollama 日志里的来源 IP 与 /api/chat 证明调用链真实走通。
  4. 踩坑记录 :docker run --name 重名冲突需 docker rm -f(没有 docker remove);重复下载模型时 Ollama 会复用已有 blob。
  5. 下一步:补齐 requests/limits、HPA 与 NetworkPolicy,并确认 ConfigMap / Secret 的拆分,让清单中"待配置"的项目全部落地。
相关推荐
秋饼1 小时前
Spring AI 2.0 结构化输出企业级实战:从 BeanOutputConverter 源码到生产级解析容错
java·ai·技术分享·后端开发
goehou2 小时前
Prompt 注入实战:直接注入、间接注入和四道防线(附测试样例)
安全·ai·llm·agent·prompt注入
小马不起床3 小时前
零基础玩转 FastAPI_09:前端基础工程构建
前端·状态模式·fastapi
小马不起床3 小时前
零基础玩转 FastAPI_05:后端课程模块
python·fastapi
Linux实战笔记3 小时前
K8s 滚动部署失效?maxUnavailable 参数没搞透
kubernetes·readinessprobe·零停机·滚动部署·maxunavailable
小七-七牛开发者4 小时前
Personal Agent 真能越用越懂你吗?AdaptArena 揭示个性化 Web Agent 的失效链路
ai·agent·web agent·personal agent·adaptarena·个性化智能体
郭老二4 小时前
【docker】docker compose使用,已nginx为例
nginx·docker·容器
XLYcmy5 小时前
AI 赋能后中文与英文数据库对比分析报告 上
数据库·ai·llm·prompt·检索·wos·cnki
独码侠5 小时前
第04篇·Codex Harness 审批×权限边界:4 档策略怎么选,密钥与仓库永远碰不到
ai·agent·codex