Day 57 / 共 68 天 · 阶段 11 部署

Docker:把 Agent 装进集装箱

阶段 10 你让 Agent 可信、可观测、省钱了,可它现在还只活在你的笔记本上。今天进入部署篇第一课——Docker:把你的 Agent 服务连同 Python、依赖包、配置,统统打包成一个标准「集装箱」,做到「在我电脑上能跑,搬到任何服务器上也一模一样能跑」,彻底告别「在我这儿是好的呀」的甩锅现场。学完你能把 Day 09 写的 FastAPI 服务打成镜像一键跑起来;明天(Day 58)学怎么用 K8s 同时调度一大堆这样的集装箱。

📍 你在阶段 11(部署 D57-61)的位置
D57 Docker D58 K8s 基础 D59 CI/CD&IaC D60 云上部署 D61 AI 网关
💡 用一个类比兜住今天(今天全程沿用「海运集装箱」的世界观) 部署一个程序 = 把货物海运到世界各地Docker 镜像(image) = 一个装好货、封好口的标准集装箱(里面塞好了程序 + Python + 所有依赖);容器(container) = 用这个集装箱在某个港口实际卸货运行的一份实例;Dockerfile = 装箱说明书(告诉工人先放什么、再放什么);镜像仓库(registry) = 集装箱码头(存放和分发箱子)。集装箱的伟大之处:不管箱里装的是家具还是电器,轮船、吊车、卡车都用同一套标准搬运——Docker 让所有程序都能被同一套方式部署。
L01

为什么要 Docker:告别「在我这儿是好的」

🤔 痛点你本地跑得好好的 Agent,发给同事或部到服务器上,立马报错:Python 版本不对、少装了某个包、系统缺个字体库……来回折腾一整天。这就是经典的「环境不一致」地狱。
💡 本质Docker 把「程序 + 它需要的一切环境」一起打包,搬到哪里都自带环境,不再依赖目标机器装了啥。就像海运集装箱:发货前把货和填充物一起封进标准箱,不管到哪个港口,吊车都能原样搬下来,里面的东西一点没变。一次打包,处处运行(build once, run anywhere)。
没 Docker:环境各异会翻车 | 有 Docker:自带环境,一致 ❌ 只发代码 我的电脑:Python 3.11 ✅ 服务器:Python 3.8 ❌ 「在我这儿是好的呀」 ✅ 发整个集装箱 箱里已含 Python+依赖 任何机器卸下来都一样 一次打包,处处运行
图注:Docker 解决的不是「代码对不对」,而是「环境一不一致」——这是部署最大的一类坑。
👶 Docker 和虚拟机(VM)有啥不一样?虚拟机是「连整台电脑(含操作系统)一起虚拟」,又大又慢,像给每件货配一整艘船;Docker 容器共享宿主机的操作系统内核,只打包程序和依赖,轻量得多,秒级启动,像共用一艘船、各占一个标准箱位。所以 Docker 能在一台机器上同时跑几十个容器。
L02

镜像 vs 容器:模具和产品

🤔 痛点刚学 Docker 最容易懵的两个词:image(镜像)和 container(容器),到处都在说,分不清谁是谁。
💡 本质镜像 = 封好的集装箱(静态的、只读的模板);容器 = 用这个箱子在港口实际卸货运行的一份实例(动态的、在跑的)。一个镜像可以启动出好多个容器,就像同一张图纸(还记得 Day05 的 class/object 吗?)造出好多台机器。镜像是「死的模具」,容器是「活的产品」。
一个镜像 → 启动出多个容器(各自独立运行) 📦 镜像 image 封好的模板(只读) 🚢 容器 1(运行中) 🚢 容器 2(运行中) 🚢 容器 3(运行中)
图注:想加机器抗流量?用同一个镜像多起几个容器就行——明天 K8s 干的就是这件事。
📝 举个例子:命令里体会两者的关系 docker build → 得到一个镜像(封箱);
docker run 镜像名 → 启动一个容器(卸货运行);
docker ps → 看现在有哪些容器在跑(哪些箱子正在被使用)。build 是名词工厂,run 是动词现场。
L03

Dockerfile:一份装箱说明书

🤔 痛点镜像里到底该装什么、按什么顺序装?总不能手动一件件塞。需要一份可复现的「说明书」。
💡 本质Dockerfile 就是「装箱说明书」:一行一条指令,从上到下告诉 Docker「先拿个什么底座、再拷贝代码、再装依赖、最后启动命令怎么写」。照着它,任何人任何时候都能构建出一模一样的镜像。像宜家家具的图解说明:第一步、第二步……照做就还原。
# Dockerfile —— 每一行是一条「装箱指令」,从上往下执行
FROM python:3.11-slim          # 1) 底座:一个已装好 Python 3.11 的精简系统
WORKDIR /app                   # 2) 在箱子里建个 /app 文件夹当工作台
COPY requirements.txt .        # 3) 先只拷贝依赖清单(为了利用缓存,见下讲)
RUN pip install -r requirements.txt   # 4) 照清单装依赖(Day05 的 -r 还记得吗)
COPY . .                       # 5) 再把项目所有代码拷进箱子
EXPOSE 8000                    # 6) 声明:这个服务对外用 8000 端口
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
                               # 7) 箱子启动时默认跑的命令(启动 FastAPI 服务)
指令大白话
FROM用哪个现成底座开始(别从零造系统)
WORKDIR进到箱内哪个文件夹干活
COPY把宿主机的文件拷进箱子
RUN构建镜像时执行的命令(如装依赖)
EXPOSE声明对外端口(文档作用为主)
CMD容器启动时默认跑的命令

👶 小白:为什么不直接 COPY . . 一步拷完,还要先单独拷 requirements.txt?

👨‍🏫 老师:为了「省构建时间」!Docker 是一层一层缓存的:只要 requirements.txt 没变,「装依赖」那层就直接复用缓存,不用重装(装依赖往往最慢)。如果你把代码和依赖清单一起拷,那改一行代码就会让「装依赖」那层缓存失效,每次都得重装几分钟。先拷不常变的(依赖清单),后拷常变的(代码)——这是 Dockerfile 的黄金顺序,下一讲还会碰到。

L04

build & run:封箱与卸货

🤔 痛点写好 Dockerfile 了,怎么真的把它变成一个能跑的服务?
💡 本质两个核心动作:docker build = 照说明书封箱(生成镜像);docker run = 把箱子拉到港口卸货运行(启动容器)。再加个端口映射,就能从你电脑的浏览器访问箱子里的服务。
# 1) 封箱:照当前目录的 Dockerfile 构建镜像,起名 my-agent,版本 v1
docker build -t my-agent:v1 .
#         └ -t 给镜像起名字:标签      └ . 表示「用当前目录的 Dockerfile」

# 2) 卸货运行:用镜像启动一个容器
docker run -p 8000:8000 my-agent:v1
#          └ -p 宿主机端口:容器端口  把箱内 8000 接到你电脑的 8000

# 现在浏览器打开 http://localhost:8000/docs 就能访问箱子里的 FastAPI 了!

# 常用查看命令:
docker images        # 看有哪些镜像(有哪些箱子模板)
docker ps            # 看正在运行的容器(哪些箱子在跑)
docker logs 容器ID   # 看某个容器的日志(对应 Day55 的可观测)
docker stop 容器ID   # 停掉一个容器
👶 -p 8000:8000 两个 8000 啥区别?冒号左边是你电脑(宿主机)的端口,右边是箱子内部的端口。它俩不一定相同:-p 9000:8000 意思是「箱子里服务在 8000,我想从电脑的 9000 访问它」。集装箱是封闭的,不做端口映射,外面根本连不进箱里——这是新手最常见的「服务起来了却访问不到」的原因。
L05

实战:把一个 Agent 服务打成镜像

🤔 痛点道理都懂,拿自己的 Agent 试试呢?尤其它还要读 API key,这种秘密怎么进箱子又不泄露?
💡 本质把 Day 09 的 FastAPI Agent 服务 + 一个 requirements.txt + 一个 Dockerfile 放一起,就能打包。关键铁律:API key 等秘密绝不写死进镜像(镜像会被分发,等于把钥匙贴门上),而是运行时用环境变量传进去。就像集装箱可以标准运输,但贵重钥匙你随身带、到港口再交。
# main.py —— 一个最小 Agent 服务(复习 Day09 FastAPI)
import os
from fastapi import FastAPI
app = FastAPI()

API_KEY = os.environ.get("LLM_API_KEY")   # ✅ 从环境变量读,不写死在代码里

@app.get("/ask")
def ask(q: str):
    # 这里正常调你的 Agent;演示就直接回显
    return {"question": q, "answer": f"(用 key 尾号 …{str(API_KEY)[-4:]} 生成的回答)"}

@app.get("/health")     # 健康检查接口:K8s / 负载均衡靠它判断「箱子还活着吗」
def health():
    return {"status": "ok"}
# 构建 + 运行,运行时用 -e 把 key 作为环境变量注入(不进镜像)
docker build -t my-agent:v1 .
docker run -p 8000:8000 -e LLM_API_KEY="sk-你的密钥" my-agent:v1
#                        └ -e 名=值:运行时才把秘密塞进箱子

# 验证:另开一个终端
curl "http://localhost:8000/health"          # → {"status":"ok"}
curl "http://localhost:8000/ask?q=你好"        # → 看到回答
📝 举个例子:新增的 /health 为什么重要 线上环境(负载均衡、K8s)会每隔几秒 GET /health 一次,如果连不上就认为这个箱子挂了,自动重启或不再往它发请求。「健康检查接口 + 环境变量传密钥」是容器化服务的两个标配,面试和实战都常考。
👶 别忘了 .dockerignore建一个 .dockerignore 文件(用法像 .gitignore),写上 .venv__pycache__.env.git——避免把本地虚拟环境、密钥文件、一堆缓存也拷进镜像,既让镜像变小,又防止 .env 里的密钥意外进箱。
L06

镜像瘦身 + docker compose 一瞥

🤔 痛点随手一打,镜像动不动 1~2 GB,推送慢、启动慢、还占空间。而且真实项目往往不止一个箱子(比如 Agent 服务 + Redis 缓存 + 向量库),一个个手动 run 太累。
💡 本质两个进阶点:① 瘦身——用 -slimalpine 小底座、只拷必要文件、用 .dockerignore,让箱子尽量轻;② docker compose——用一个 docker-compose.yml 文件描述「我要几个箱子、怎么连」,一句 docker compose up 全部起来。就像一次运输多个集装箱,用一张总清单统一调度,不用一个个喊吊车。
瘦身招数效果
底座用 python:3.11-slim 而非完整版基础镜像小几百 MB
先拷 requirements、后拷代码(上一讲)改代码不用重装依赖,构建快
写好 .dockerignore不把 .venv/缓存/.git 拷进去
多阶段构建(multi-stage)构建用的工具不进最终镜像,进阶再学
# docker-compose.yml(示意):一次描述「Agent 服务 + Redis 缓存」两个箱子
# services:
#   agent:
#     build: .                    # 用当前目录 Dockerfile 构建
#     ports: ["8000:8000"]
#     environment: [LLM_API_KEY]  # 从外部传密钥
#   cache:
#     image: redis:7              # 直接用现成 Redis 镜像(给 Day56 的缓存用)

docker compose up      # 一句话把上面所有箱子一起起来,它们还能互相通信
docker compose down    # 一句话全部停掉并清理
compose 语法细节别背,用时查文档。今天记住两句话就够:镜像要尽量(slim 底座 + dockerignore + 分层缓存);多个箱子用 compose 一张清单统一起停。这也是 Day56 的缓存(Redis)、向量库落地时的常见搭法。
L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • Docker 到底解决了什么问题?和虚拟机有什么区别?
  • 镜像和容器什么关系?(模具 vs 产品,一个镜像可起多个容器)
  • Dockerfile 里 FROM/COPY/RUN/CMD/EXPOSE 各干嘛?为什么先拷 requirements 再拷代码?
  • docker builddocker run 分别做什么?-p-e 是干嘛的?
  • API key 这种秘密该怎么进容器?为什么不能写死在镜像里?
  • 怎么让镜像更小?docker compose 解决什么场景?

✋ 动手 10 分钟:把一个最小服务打成镜像跑起来

建一个文件夹,放三个文件,然后 build + run:

# 1) main.py(3 行的 FastAPI)
# from fastapi import FastAPI
# app = FastAPI()
# @app.get("/health")
# def health(): return {"status": "ok"}

# 2) requirements.txt
# fastapi
# uvicorn

# 3) Dockerfile
# FROM python:3.11-slim
# WORKDIR /app
# COPY requirements.txt .
# RUN pip install -r requirements.txt
# COPY . .
# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

# 然后在该文件夹里执行:
docker build -t hello-agent:v1 .        # 封箱
docker run -p 8000:8000 hello-agent:v1  # 卸货运行
# 浏览器开 http://localhost:8000/health → 看到 {"status":"ok"} 就成功了!

还没装 Docker?去装 Docker Desktop(Mac/Windows 一键装,自带图形界面能看到你的镜像和容器)。装不了也没关系,把上面三个文件和命令读懂、能讲出每行在干嘛,就达标了。

明日预告 · Day 58:你会打一个集装箱了。可线上要抗流量,得同时跑几十个一模一样的箱子,还要它们挂了自动重启、能滚动升级不停机——手动管这么多箱子会累死。明天学 Kubernetes(K8s):集装箱的「码头调度中心」,用 Pod / Deployment / Service 三个核心概念,帮你自动编排一大群容器。零基础只要懂个大概轮廓就行。
← Day 56 · 成本治理 & 缓存 Day 58 · Kubernetes 基础 →