Week 4 Day 22:Docker Compose - 一键部署整个Agent栈
💡 容器化不是为了炫技,而是让"在我机器上能跑"变成"在任何机器上都能跑"。Docker Compose让你用一个YAML文件启动api + postgres + redis + qdrant整套系统。
第一部分:问题驱动
🤔 问题1:为什么需要容器化?
引导问题:
- 你的Agent依赖Go 1.22、Postgres 15、Redis 7、Qdrant 1.9,新同事入职要装多久?
- 开发机是macOS ARM64,生产是Linux AMD64,go build出来的二进制能直接跑吗?
- 你改了postgres配置,如何保证团队所有人同步?
- 如果一个服务崩了,怎么自动重启?
答案揭示:
- 环境一致性:Dockerfile定义运行环境,从dev到prod完全一致
- 隔离:每个服务在自己的容器里,互不影响(Redis不会污染Postgres)
- 可移植:
docker compose up一键启动,不需要手动装任何东西
- 声明式:YAML定义期望状态,Docker保证达成
你应该理解:
- 容器 ≠ 虚拟机:共享宿主kernel,启动秒级
- Image是模板,Container是运行实例
- Compose是多容器编排工具,底层仍是Docker
🤔 问题2:为什么Go的Dockerfile要用多阶段构建?
引导问题:
- 一个基础的
golang:1.22镜像有多大?(~800MB)
- 你的Go二进制本身有多大?(~20MB)
- 生产环境需要go编译器吗?
- 攻击面越大越安全还是越危险?
答案揭示:
单阶段(不推荐):
FROM golang:1.22
COPY . .
RUN go build -o api
CMD ["./api"]
# 最终镜像 ~900MB,包含编译器、源码、git历史
多阶段(推荐):
# Stage 1: 编译(用完就丢)
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o api ./cmd/api
# Stage 2: 运行时(只带二进制)
FROM alpine:3.19
COPY --from=builder /build/api /api
CMD ["/api"]
# 最终镜像 ~30MB
收益:
- 镜像小 → pull快、存储省
- 无编译器 → 攻击面小
- 无源码 → IP保护
🤔 问题3:服务之间怎么找到彼此?
引导问题:
- api需要连postgres,url应该写
localhost:5432吗?
- 如果postgres还没准备好,api启动会崩吗?
- Postgres的数据存在容器里,容器删了数据还在吗?
答案揭示:
- Service Discovery:Compose自动创建网络,服务名即DNS(
postgres:5432)
- Healthcheck + depends_on:等依赖服务ready再启动
- Volume:数据持久化到宿主目录,容器删数据还在
第二部分:动手实现
✅ 版本1:基础Dockerfile
# Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /build
# 先copy go.mod go.sum,利用Docker层缓存
COPY go.mod go.sum ./
RUN go mod download
# 再copy源码
COPY . .
# 编译(静态链接,无CGO)
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s" \
-o /build/api \
./cmd/api
# --- 运行时阶段 ---
FROM alpine:3.19
# 加CA证书(调HTTPS需要)
RUN apk --no-cache add ca-certificates tzdata
# 非root用户运行(安全)
RUN addgroup -S agent && adduser -S agent -G agent
USER agent
WORKDIR /app
COPY --from=builder /build/api /app/api
EXPOSE 8080
# 用HEALTHCHECK让Docker知道服务活没活
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
CMD ["/app/api"]
关键点讲解:
- 层缓存优化:先copy
go.mod再copy代码,改代码不需要重新下载依赖
-ldflags="-w -s":去掉符号表,二进制小30%
CGO_ENABLED=0:静态链接,alpine就能跑
- 非root用户:即使被攻破,权限也有限
- HEALTHCHECK:Docker会主动探活
测试:
docker build -t agent-api .
docker run -p 8080:8080 agent-api
curl http://localhost:8080/health
✅ 版本2:docker-compose.yml - 完整Agent栈
# docker-compose.yml
version: "3.9"
services:
# ---------- API服务 ----------
api:
build:
context: .
dockerfile: Dockerfile
container_name: agent-api
ports:
- "8080:8080"
environment:
- POSTGRES_DSN=postgres://agent:agent_pwd@postgres:5432/agentdb?sslmode=disable
- REDIS_ADDR=redis:6379
- QDRANT_ADDR=qdrant:6334
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LOG_LEVEL=info
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
qdrant:
condition: service_started
restart: unless-stopped
networks:
- agent-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
# ---------- Postgres ----------
postgres:
image: postgres:15-alpine
container_name: agent-postgres
environment:
- POSTGRES_USER=agent
- POSTGRES_PASSWORD=agent_pwd
- POSTGRES_DB=agentdb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agent -d agentdb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- agent-net
# ---------- Redis ----------
redis:
image: redis:7-alpine
container_name: agent-redis
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- agent-net
# ---------- Qdrant(向量DB)----------
qdrant:
image: qdrant/qdrant:v1.9.0
container_name: agent-qdrant
ports:
- "6333:6333" # HTTP
- "6334:6334" # gRPC
volumes:
- qdrant_data:/qdrant/storage
environment:
- QDRANT__SERVICE__GRPC_PORT=6334
networks:
- agent-net
# ---------- Jaeger(可选,为Day 23准备)----------
jaeger:
image: jaegertracing/all-in-one:1.54
container_name: agent-jaeger
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
environment:
- COLLECTOR_OTLP_ENABLED=true
networks:
- agent-net
volumes:
postgres_data:
redis_data:
qdrant_data:
networks:
agent-net:
driver: bridge
逐段讲解:
services.api:
build.context: 用当前目录的Dockerfile构建
environment: 注入配置,${OPENAI_API_KEY}从宿主环境读取
depends_on.condition: service_healthy:等postgres的healthcheck通过才启动
restart: unless-stopped:崩溃自动重启
services.postgres:
volumes: postgres_data是命名volume,持久化数据
./migrations:/docker-entrypoint-initdb.d:ro:启动时自动执行SQL
pg_isready:Postgres官方探活命令
services.redis:
--appendonly yes:AOF持久化
--maxmemory-policy allkeys-lru:内存满了用LRU淘汰(做缓存理想)
services.qdrant:
- 6334是gRPC端口,Go客户端用这个
- 数据存在
qdrant_data volume
networks.agent-net:
- 自定义bridge网络,服务间通过服务名互访
- 和宿主网络隔离
✅ 版本3:环境变量与.env文件
问题: OPENAI_API_KEY不该写在yaml里(会提交到git)。
# .env(加入.gitignore)
OPENAI_API_KEY=sk-xxx
POSTGRES_PASSWORD=strong_password
LOG_LEVEL=info
# .env.example(提交到git,给团队看格式)
OPENAI_API_KEY=sk-your-key-here
POSTGRES_PASSWORD=change-me
LOG_LEVEL=info
Compose会自动加载.env。
✅ 版本4:迁移脚本与初始化
-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_messages_conv ON messages(conversation_id);
Postgres容器首次启动会执行/docker-entrypoint-initdb.d/下所有SQL。
✅ 版本5:常用命令
# 启动(后台)
docker compose up -d
# 查日志(跟随)
docker compose logs -f api
# 只重建api
docker compose up -d --build api
# 进入容器
docker compose exec api sh
docker compose exec postgres psql -U agent agentdb
# 停止但保留数据
docker compose stop
# 停止并删除容器(volume保留)
docker compose down
# 连volume一起删(慎用)
docker compose down -v
# 查看服务状态
docker compose ps
第三部分:关键概念
Volume vs Bind Mount
# Volume(Docker管理,推荐用于数据库)
volumes:
- postgres_data:/var/lib/postgresql/data
# Bind Mount(宿主目录挂载,适合开发时改代码)
volumes:
- ./migrations:/docker-entrypoint-initdb.d:ro
- ./src:/app/src # 改代码容器里立刻看到
| 类型 |
性能 |
可移植 |
场景 |
| Volume |
好 |
好 |
生产数据 |
| Bind |
看宿主 |
差 |
开发代码 |
depends_on的三种condition
depends_on:
postgres:
condition: service_started # 只等启动(默认)
redis:
condition: service_healthy # 等healthcheck通过
migrate:
condition: service_completed_successfully # 等exit 0
生产注意:service_started不代表服务ready,必须配healthcheck。
Network模式
# bridge(默认,隔离网络)
# host(共享宿主网络,性能好但无隔离)
# none(无网络)
networks:
agent-net:
driver: bridge
服务间用服务名通信:api访问postgres用postgres:5432,不是localhost。
第四部分:自测清单
第五部分:作业
任务1:编写Dockerfile
任务2:完整docker-compose.yml
任务3:验证端到端
任务4:故障演练
第六部分:常见问题解答
Q: 为什么我的容器启动后立刻退出?
A: 检查:
CMD进程是前台还是后台?Docker需要前台进程
- 看日志:
docker compose logs api
- 二进制是否静态编译?alpine缺glibc会崩
Q: depends_on为什么不够用?
A: depends_on只保证启动顺序,不等服务ready。必须配合healthcheck和condition: service_healthy。api代码也应该重试连数据库。
Q: 镜像改了但容器没更新?
A: docker compose up -d默认用已有镜像。加--build强制重建:docker compose up -d --build
Q: 生产环境该用Compose还是K8s?
A:
- 单机、小规模:Compose够用
- 多机、需要自动扩缩容、滚动升级:K8s
- 生产Go Agent起步阶段用Compose完全OK
Q: 如何在docker内调试?
A:
docker compose exec api sh # 进容器
docker compose logs -f api # 跟日志
docker stats # 看CPU/内存
docker inspect agent-api # 看详细配置
配套算法题
1. Best Time to Buy and Sell Stock (LC 121) - Easy
题目:给一个价格数组,只能买卖一次,求最大利润。
思路:一次遍历,维护到目前为止的最小值和最大利润。
func maxProfit(prices []int) int {
minPrice := math.MaxInt32
maxProfit := 0
for _, p := range prices {
if p < minPrice {
minPrice = p
} else if p - minPrice > maxProfit {
maxProfit = p - minPrice
}
}
return maxProfit
}
类比Agent:min price像"最低延迟baseline",每次请求更新max profit(优化空间)。
2. Jump Game (LC 55) - Medium
题目:数组每个元素表示能跳的最大步数,判断能否到终点。
思路:贪心,维护能到达的最远位置。
func canJump(nums []int) bool {
maxReach := 0
for i := 0; i < len(nums); i++ {
if i > maxReach {
return false
}
if i + nums[i] > maxReach {
maxReach = i + nums[i]
}
}
return true
}
时间复杂度:O(n)
3. Gas Station (LC 134) - Medium
题目:环形加油站,判断能否跑完一圈,返回起点。
思路:如果总油>=总耗,一定有解。从某点开始,不够了就换下一个。
func canCompleteCircuit(gas []int, cost []int) int {
totalTank, currTank, start := 0, 0, 0
for i := 0; i < len(gas); i++ {
diff := gas[i] - cost[i]
totalTank += diff
currTank += diff
if currTank < 0 {
start = i + 1
currTank = 0
}
}
if totalTank < 0 {
return -1
}
return start
}
关键洞察:如果从A出发到B断了,A-B之间任一点出发都会更早断,所以起点必须在B+1后。
下一步:Day 23 预告
明天我们会:
- 集成OpenTelemetry,为每个请求打trace
- 关键metrics:LLM延迟、tool耗时、error rate
- Jaeger UI可视化调用链
- Prometheus + Grafana监控dashboard
准备问题:
- 为什么结构化日志不够,还需要trace?
- 一个Agent请求涉及多少个span?
- error rate和p99延迟,哪个更重要?