Week 4 Day 22:Docker Compose - 一键部署整个Agent栈

💡 容器化不是为了炫技,而是让"在我机器上能跑"变成"在任何机器上都能跑"。Docker Compose让你用一个YAML文件启动api + postgres + redis + qdrant整套系统。

第一部分:问题驱动

🤔 问题1:为什么需要容器化?

引导问题:

  1. 你的Agent依赖Go 1.22、Postgres 15、Redis 7、Qdrant 1.9,新同事入职要装多久?
  2. 开发机是macOS ARM64,生产是Linux AMD64,go build出来的二进制能直接跑吗?
  3. 你改了postgres配置,如何保证团队所有人同步?
  4. 如果一个服务崩了,怎么自动重启?

答案揭示:

  • 环境一致性:Dockerfile定义运行环境,从dev到prod完全一致
  • 隔离:每个服务在自己的容器里,互不影响(Redis不会污染Postgres)
  • 可移植docker compose up一键启动,不需要手动装任何东西
  • 声明式:YAML定义期望状态,Docker保证达成

你应该理解:

  • 容器 ≠ 虚拟机:共享宿主kernel,启动秒级
  • Image是模板,Container是运行实例
  • Compose是多容器编排工具,底层仍是Docker

🤔 问题2:为什么Go的Dockerfile要用多阶段构建?

引导问题:

  1. 一个基础的golang:1.22镜像有多大?(~800MB)
  2. 你的Go二进制本身有多大?(~20MB)
  3. 生产环境需要go编译器吗?
  4. 攻击面越大越安全还是越危险?

答案揭示:

单阶段(不推荐):

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:服务之间怎么找到彼此?

引导问题:

  1. api需要连postgres,url应该写localhost:5432吗?
  2. 如果postgres还没准备好,api启动会崩吗?
  3. 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"]

关键点讲解:

  1. 层缓存优化:先copy go.mod再copy代码,改代码不需要重新下载依赖
  2. -ldflags="-w -s":去掉符号表,二进制小30%
  3. CGO_ENABLED=0:静态链接,alpine就能跑
  4. 非root用户:即使被攻破,权限也有限
  5. 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


第四部分:自测清单

  • 我能解释为什么多阶段构建?
  • 为什么要用非root用户运行?
  • depends_on和healthcheck的关系?
  • Volume和Bind Mount什么时候用哪个?
  • 服务间怎么通信,为什么不用localhost?
  • .env.env.example的区别?

第五部分:作业

任务1:编写Dockerfile

  • 多阶段构建,最终镜像<50MB
  • 非root用户运行
  • 带HEALTHCHECK
  • 验证:docker images看大小

任务2:完整docker-compose.yml

  • 4个服务:api、postgres、redis、qdrant
  • 所有服务带healthcheck
  • 数据用volume持久化
  • docker compose up -d一键启动

任务3:验证端到端

  • curl localhost:8080/health返回ok
  • 进入postgres容器能看到表
  • 重启compose,数据还在
  • docker compose down后数据还在

任务4:故障演练

  • 手动docker kill agent-postgres
  • 观察api行为(应该fail fast并重启)
  • 验证restart policy生效

第六部分:常见问题解答

Q: 为什么我的容器启动后立刻退出?

A: 检查:

  1. CMD进程是前台还是后台?Docker需要前台进程
  2. 看日志:docker compose logs api
  3. 二进制是否静态编译?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 预告

明天我们会:

  1. 集成OpenTelemetry,为每个请求打trace
  2. 关键metrics:LLM延迟、tool耗时、error rate
  3. Jaeger UI可视化调用链
  4. Prometheus + Grafana监控dashboard

准备问题:

  • 为什么结构化日志不够,还需要trace?
  • 一个Agent请求涉及多少个span?
  • error rate和p99延迟,哪个更重要?