跳转到内容

Module 16: DevOps 与部署

📖 深度参考手册 — 本模块属于理论参考,非主线必读。 主线学习路径见 README.md。 当你在项目实战中遇到相关问题时,回来查阅。

代码写完只是开始,让它可靠地跑在生产环境才是挑战。DevOps 是开发(Dev)和运维(Ops)的融合,目标是让代码从开发到上线的过程更快、更安全、更可靠。本模块覆盖容器化、编排、CI/CD、部署策略等核心实践,帮你建立”代码→生产”的完整认知。


定义:Docker 是一种容器化技术,核心概念有三个:镜像(Image)是打包好的应用程序及其所有依赖的只读模板,类似于一个安装包;容器(Container)是镜像运行起来的实例,类似于安装后正在运行的程序;Dockerfile 是一个文本文件,定义了如何一步步构建镜像。Docker 的核心价值是将”在我机器上能跑”变成”在任何机器上都能跑”——因为容器内包含了运行所需的一切:代码、运行时、系统工具、系统库。

为什么重要:没有 Docker 之前,部署一个应用需要在目标服务器上手动安装各种依赖,不同版本的语言运行时、数据库客户端、系统库经常冲突,“在我电脑上明明能跑”是开发团队最常见的痛苦。Docker 彻底解决了环境一致性问题——开发、测试、生产用的是同一个镜像,消除了环境差异带来的 bug。同时,Docker 极大简化了新人入职的环境搭建,从”花两天配环境”变成”一条命令启动”。

案例所有系统 — 开发环境统一,新人入职 docker compose up 一键启动。

以 Hotel Reservation 系统为例,一个典型的 Dockerfile:

# 第一阶段:构建(使用完整的 Go 环境)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download # 先下载依赖(利用 Docker 缓存层)
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
# 第二阶段:运行(使用最小的基础镜像)
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

关键点解释:

  • 多阶段构建:第一阶段用完整的 Go 环境编译代码,第二阶段只复制编译好的二进制文件到一个极小的 Alpine 镜像。最终镜像大小从 ~1GB 压缩到 ~20MB。
  • 缓存层优化:先 COPY go.mod go.sumRUN go mod download,这样只要依赖没变,Docker 就会复用缓存,不用每次都重新下载依赖。
  • CGO_ENABLED=0:编译出静态链接的二进制文件,不依赖任何系统库,可以在最精简的镜像中运行。

常用命令:

Terminal window
docker build -t hotel-reservation:v1 . # 构建镜像
docker run -p 8080:8080 hotel-reservation:v1 # 启动容器
docker ps # 查看运行中的容器
docker logs <container-id> # 查看容器日志
docker exec -it <container-id> sh # 进入容器内部
docker stop <container-id> # 停止容器

先想一想 🤔 为什么 Dockerfile 中要把 COPY go.mod go.sumCOPY . . 分成两步,而不是一开始就 COPY . . 然后再 go mod download

点击查看解析

这是 Docker 缓存层优化 的关键技巧。Docker 的每一条指令都会创建一个”层”(layer),如果某一层的输入没有变化,Docker 会直接复用缓存。

  • 如果先 COPY . .,那么只要任何一个 .go 文件改了(哪怕只改了一行注释),Docker 就认为这一层变了,后续所有层(包括 go mod download)都要重新执行。
  • 如果先 COPY go.mod go.sum,只要依赖没变(这两个文件没变),go mod download 就会命中缓存,跳过下载。而源代码的变化只影响后面的 COPY . .go build

在实际开发中,依赖变动的频率远低于代码变动。这个优化可以把构建时间从几分钟压缩到几秒。


定义:Docker Compose 是一个多容器编排工具,通过一个 YAML 文件定义所有服务(应用、数据库、Redis、消息队列等),然后用一条命令(docker compose up)全部启动。核心概念包括:services 定义各个服务及其配置,volumes 持久化数据(容器删除后数据不丢),networks 隔离网络(不同服务组之间不能互相访问),depends_on 控制启动顺序(确保数据库先于应用启动)。

为什么重要:一个真实的应用几乎不可能只有一个容器。Web 应用需要数据库,需要缓存,可能还需要消息队列、搜索引擎。手动一个一个 docker run 并配置网络连接既繁琐又容易出错。Docker Compose 让整个开发环境的定义变成了一个版本控制的文件,团队成员 clone 代码后 docker compose up 就能启动完整的本地开发环境。

案例Hotel Reservation — docker-compose.yml 定义:Go 应用 + PostgreSQL + Redis,本地开发一键启动。

version: "3.8"
services:
# Go 后端应用
app:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://postgres:password@db:5432/hotel?sslmode=disable
- REDIS_URL=redis://cache:6379
- JWT_SECRET=dev-secret-key
depends_on:
db:
condition: service_healthy # 等数据库真正就绪,而非仅启动
cache:
condition: service_started
restart: unless-stopped
# PostgreSQL 数据库
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: hotel
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data # 数据持久化
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# Redis 缓存
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
volumes:
pgdata: # 命名卷:PostgreSQL 数据
redisdata: # 命名卷:Redis 数据

关键点解释:

  • depends_on + healthcheck:仅用 depends_on 只保证容器启动顺序,不保证服务就绪。加上 condition: service_healthy 配合 healthcheck,才能确保数据库真正可以接受连接后再启动应用。
  • 命名卷(named volumes)pgdataredisdata 是持久化存储。即使 docker compose down 停止所有容器,数据依然保留。只有 docker compose down -v 才会删除卷。
  • 服务名即主机名:在 Docker Compose 的网络中,服务名(dbcache)自动成为 DNS 名称。所以应用可以用 db:5432 连接数据库,而非硬编码 IP。

常用命令:

Terminal window
docker compose up -d # 后台启动所有服务
docker compose logs -f app # 实时查看 app 服务的日志
docker compose ps # 查看所有服务状态
docker compose down # 停止并删除所有容器(保留数据卷)
docker compose down -v # 停止并删除所有容器和数据卷
docker compose restart app # 只重启 app 服务

先想一想 🤔 如果 Hotel Reservation 还需要加一个 Kafka 消息队列(用于异步处理预订确认邮件),你会怎么修改这个 docker-compose.yml

点击查看解析

services 中新增 Kafka 和 Zookeeper(Kafka 依赖 Zookeeper),并让 app 依赖 Kafka:

services:
# ...原有的 app、db、cache...
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

然后在 app 服务中添加环境变量 KAFKA_BROKERS=kafka:9092,并在 depends_on 中加入 kafka。这就是 Docker Compose 的优势——加一个组件只需要几行配置,团队成员 git pulldocker compose up 就自动有了完整的新环境。


定义:Kubernetes(简称 K8s)是一个容器编排平台,用于自动化部署、伸缩和管理容器化应用。核心概念包括:Pod(最小部署单元,包含一个或多个紧密关联的容器)、Service(为一组 Pod 提供稳定的网络入口,Pod 重启 IP 变了,Service 地址不变)、Deployment(声明式管理 Pod 的副本数,定义”我要 3 个副本”,K8s 自动维护)、Ingress(HTTP 层的路由规则,把外部请求分发到不同 Service)。K8s 的核心能力是:自动伸缩(根据 CPU/内存/自定义指标增减 Pod)、滚动更新(不停机发布新版本)、自愈(容器挂了自动重启、节点挂了自动迁移 Pod)、服务发现(Pod 之间通过 Service 名互相访问)。

为什么重要:Docker Compose 适合单机开发和小规模部署,但当应用需要跑在多台机器上、需要根据流量自动伸缩、需要零停机更新时,Docker Compose 就力不从心了。K8s 提供了生产级别的容器管理能力。但 K8s 本身复杂度很高——何时需要 K8s:单机 Docker Compose 够用时不要上 K8s,多节点 + 需要自动伸缩 + 需要高可用才考虑。很多中小项目用一台服务器 + Docker Compose 就能撑很久。

案例YouTube — 转码服务需要根据上传量自动扩缩容,适合 K8s。

用户上传视频高峰(晚上8-11点):
→ 上传量是平时的10倍
→ K8s HPA (Horizontal Pod Autoscaler) 检测到转码Pod的CPU使用率>70%
→ 自动将转码Pod从3个扩展到30个
→ 高峰结束后,CPU使用率降低
→ 自动缩回到3个Pod
→ 节省了大量计算资源成本
如果没有K8s自动伸缩:
→ 要么始终保持30个实例(浪费钱)
→ 要么保持3个实例(高峰时用户等半天才能看到视频)

K8s 核心资源示意:

# Deployment: 声明式管理Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: transcoder
spec:
replicas: 3 # 我要3个副本
selector:
matchLabels:
app: transcoder
template:
metadata:
labels:
app: transcoder
spec:
containers:
- name: transcoder
image: youtube/transcoder:v2.1
resources:
requests:
cpu: "500m" # 请求0.5个CPU核
memory: "512Mi"
limits:
cpu: "2000m" # 最多用2个CPU核
memory: "2Gi"
---
# Service: 稳定的网络入口
apiVersion: v1
kind: Service
metadata:
name: transcoder-svc
spec:
selector:
app: transcoder
ports:
- port: 80
targetPort: 8080
---
# HPA: 自动水平伸缩
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: transcoder-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: transcoder
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # CPU超过70%就扩容

先想一想 🤔 Chat System 的 WebSocket 长连接服务适合用 K8s 的自动伸缩吗?有什么需要特别注意的问题?

点击查看解析

WebSocket 长连接服务可以用 K8s 自动伸缩,但有两个关键问题需要处理:

  1. 连接亲和性:WebSocket 是有状态的长连接,用户 A 连在 Pod-1 上。如果 K8s 要缩容并移除 Pod-1,上面所有的连接都会断开。需要实现优雅关闭(graceful shutdown):Pod 被删除前,先通知客户端重新连接到其他 Pod,等所有连接迁移完毕再关闭。

  2. 伸缩指标:CPU/内存不是好的伸缩指标——WebSocket 连接主要消耗的是文件描述符和内存,一个 Pod 可能 CPU 很低但已经达到最大连接数。应该用自定义指标(如当前连接数 / 最大连接数比例)来触发伸缩。

  3. 负载均衡:普通的 HTTP 负载均衡是按请求分发的,但 WebSocket 连接一旦建立就固定在一个 Pod 上。新的连接会分到新 Pod,但旧连接不会自动迁移,可能导致 Pod 间连接数不均匀。


定义:CI(Continuous Integration,持续集成)是指每次代码提交都自动触发构建和测试,尽早发现集成问题——如果10个人各写各的代码,最后合到一起才发现冲突和bug,修复成本极高。CD 有两层含义:持续交付(Continuous Delivery)是测试通过后自动部署到 staging 环境,由人工确认后再上生产;持续部署(Continuous Deployment)是测试通过后自动部署到生产环境,不需要人工干预。典型流程:push 代码 → 触发 CI → 跑 lint + test → 构建 Docker 镜像 → 推送到镜像仓库 → 部署到 staging → 人工确认 → 部署到 production

为什么重要:没有 CI/CD 的团队,部署是一件让人紧张的大事——手动构建、手动测试、手动上传到服务器、手动重启服务,每一步都可能出错。有了 CI/CD,部署变成了一件无聊的小事——代码合并到 main 分支,几分钟后自动上线。部署频率从”每月一次”变成”每天多次”,每次变更更小、风险更低、出问题更容易定位。

案例所有系统 — 以 Hotel Reservation 为例,一个完整的 GitHub Actions CI/CD 配置。

.github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# 第一步:代码质量检查 + 测试
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: hotel_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Lint
uses: golangci/golangci-lint-action@v4
- name: Test
run: go test ./... -race -coverprofile=coverage.out
env:
DATABASE_URL: postgres://postgres:testpass@localhost:5432/hotel_test?sslmode=disable
- name: Check coverage
run: |
coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${coverage}%"
# 覆盖率低于60%则失败
if (( $(echo "$coverage < 60" | bc -l) )); then
echo "Coverage below 60%!"
exit 1
fi
# 第二步:构建并推送 Docker 镜像(仅 main 分支)
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
# 第三步:部署到 staging
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging # GitHub Environment(可配置审批)
steps:
- name: Deploy to staging
run: |
# 通过 SSH 连接到 staging 服务器,拉取最新镜像并重启
"docker pull ghcr.io/${{ github.repository }}:${{ github.sha }} && \
docker compose up -d"
# 第四步:部署到 production(需要人工审批)
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # 配置了 Required Reviewers
steps:
- name: Deploy to production
run: |
"docker pull ghcr.io/${{ github.repository }}:${{ github.sha }} && \
docker compose up -d"

先想一想 🤔 为什么 Docker 镜像要同时打两个 tag(latest${{ github.sha }}),而不只用 latest

点击查看解析

两个 tag 服务于不同目的:

  • latest:始终指向最新构建的镜像,方便开发环境快速拉取最新版本。
  • ${{ github.sha }}(如 abc123def):每次构建唯一的标识符,用于可追溯性和回滚

如果只用 latest,一旦新版本出问题,你无法快速回滚到”上一个版本”——因为 latest 已经被覆盖了。而有了 commit SHA 作为 tag,回滚就是一条命令:docker pull ghcr.io/repo:上一次的sha

在生产部署中,永远不要用 latest tag,而应该用精确的版本号或 commit SHA。latest 的含义是模糊的(“最新”是什么时候的最新?),而 SHA 是确定的。


定义:部署策略决定了新版本代码如何替换旧版本。常见策略有四种:滚动更新(Rolling Update)——逐步用新版本替换旧版本实例,过程中新旧版本共存,K8s 的默认策略;蓝绿部署(Blue-Green)——同时维护两套完整环境(蓝色=当前版本,绿色=新版本),部署时切换流量到绿色环境,如果出问题一秒切回蓝色;金丝雀发布(Canary Release)——先把新版本只暴露给一小部分用户(如 1%),观察一段时间没有异常再逐步扩大到全量;Feature Flag(功能开关)——代码已经上线到生产环境,但功能通过配置开关控制是否对用户可见,最灵活但需要额外的开关管理系统。

为什么重要:最简单的部署方式是”停机更新”——关掉旧版本、部署新版本、启动。但对于 Hotel Reservation 这样的系统,停机意味着用户无法下单,直接损失收入。不同的部署策略在部署速度、风险控制、资源成本、回滚速度之间有不同的取舍,选择合适的策略是保障系统可用性的关键。

案例Hotel Reservation — 不能停机,用蓝绿部署或金丝雀发布。

Hotel Reservation 部署新版本(增加了"取消预订"功能):
方案一:滚动更新
时间线: Pod-1(v1) Pod-2(v1) Pod-3(v1)
→ 替换 Pod-1: Pod-1(v2) Pod-2(v1) Pod-3(v1) ← 新旧版本共存
→ 替换 Pod-2: Pod-1(v2) Pod-2(v2) Pod-3(v1)
→ 替换 Pod-3: Pod-1(v2) Pod-2(v2) Pod-3(v2) ← 全部更新完毕
风险: 如果v2有bug,最多影响1/3的流量(一次替换一个Pod)
回滚: 需要等K8s逐步替换回v1,相对较慢
方案二:蓝绿部署
蓝环境(当前): v1(正在服务用户)
绿环境(待命): 部署v2,跑自动化测试
→ 测试通过 → 负载均衡器将流量从蓝切到绿
→ v2有问题?→ 一秒切回蓝环境
代价: 需要双倍的服务器资源
好处: 切换瞬间完成,回滚也是瞬间
方案三:金丝雀发布
→ 先让1%的流量走v2(比如只有某个地区的用户)
→ 监控30分钟:错误率、响应时间、预订成功率
→ 一切正常 → 扩大到10% → 50% → 100%
→ 发现问题 → 立即将1%切回v1,影响范围极小
好处: 风险最低,问题只影响很少用户
代价: 发布过程较长,需要完善的监控
策略回滚速度资源开销风险控制复杂度
滚动更新中等(逐步回滚)低(少量额外实例)中等
蓝绿部署极快(切流量)高(双倍资源)中等
金丝雀发布快(切回小流量)极高
Feature Flag即时(关开关)无额外资源极高中等(需开关管理)

先想一想 🤔 Gaming Leaderboard 系统适合用哪种部署策略?为什么?

点击查看解析

金丝雀发布最合适。原因:

  1. 排行榜对正确性极其敏感——如果新版本有 bug 导致分数计算错误或排名异常,对玩家体验的影响是灾难性的(想象你从第1名突然变成第1000名)。金丝雀发布让问题只影响 1% 的玩家。

  2. 蓝绿部署虽然回滚快,但切换的一瞬间所有用户都受影响。如果新版本有微妙的排名计算 bug,在蓝绿切换的那一刻就是全量暴露。

  3. Feature Flag 也很适合——把新的排名算法放在 Flag 后面,先对内部测试用户开启,验证无误再逐步放量。这比金丝雀更灵活,因为可以按用户维度(而不只是流量百分比)控制。

实际上,成熟的游戏公司通常是 金丝雀 + Feature Flag 组合使用。


定义:环境管理是指为软件开发和运行的不同阶段维护独立的运行环境。常见的环境分层包括:开发环境(Development)——开发者本地或共享的开发服务器,数据是假的,可以随意折腾;测试环境(Testing/Staging)——模拟生产环境的配置,用于跑自动化测试和人工验收;预发环境(Pre-production)——和生产环境配置完全一致,连真实的数据库(只读副本),最后一道关卡;生产环境(Production)——面向真实用户的环境。每个环境之间必须严格隔离:不同的数据库实例、不同的 API Key、不同的日志级别、不同的特性开关。

为什么重要:环境隔离不做好会出大事。常见的事故包括:开发者在测试时误连了生产数据库(删除了真实用户数据)、测试环境用了生产环境的第三方 API Key(导致真实用户收到测试邮件)、生产环境的日志级别设为 Debug(日志量暴增撑爆磁盘)。良好的环境管理通过配置隔离来防止这些问题。

案例Hotel Reservation — 开发环境用内存数据库,测试环境用独立 PostgreSQL,生产环境用高可用集群。

各环境配置对比:
开发环境 (development):
DATABASE_URL=postgres://localhost:5432/hotel_dev
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
STRIPE_KEY=sk_test_xxx ← 测试密钥,不会真正扣款
EMAIL_PROVIDER=console ← 邮件只打印到控制台,不发出去
RATE_LIMIT=off ← 关闭限流,方便调试
测试环境 (staging):
DATABASE_URL=postgres://staging-db:5432/hotel_staging
REDIS_URL=redis://staging-cache:6379
LOG_LEVEL=info
STRIPE_KEY=sk_test_xxx ← 仍然是测试密钥
EMAIL_PROVIDER=sendgrid ← 真正发邮件(但发到测试邮箱)
[email protected] ← 所有邮件都发到这个地址
RATE_LIMIT=on
生产环境 (production):
DATABASE_URL=postgres://prod-primary:5432/hotel_prod ← 高可用集群
REDIS_URL=redis://prod-cache-cluster:6379 ← Redis集群
LOG_LEVEL=warn ← 只记录警告和错误
STRIPE_KEY=sk_live_xxx ← 真实密钥!!
EMAIL_PROVIDER=sendgrid ← 发给真实用户
RATE_LIMIT=on

配置管理方案对比:

方案适用场景示例
环境变量(.env 文件)小项目、Docker ComposeDATABASE_URL=... 写在 .env 文件里
按环境的配置文件中型项目config.dev.yamlconfig.prod.yaml
配置中心(Consul/etcd)大型分布式系统动态更新配置,不用重启服务
云平台 Secret Manager敏感信息(密码、API Key)AWS Secrets Manager、GCP Secret Manager

先想一想 🤔 为什么不能把数据库密码直接写在代码里或提交到 Git 仓库?应该怎么管理?

点击查看解析

把密码提交到 Git 是最常见的安全事故之一。即使你后来删除了提交,Git 历史中仍然保留着密码(除非用 git filter-branch 重写历史,非常麻烦)。GitHub 上有大量的自动化机器人专门扫描公开仓库中的 AWS Key、数据库密码等敏感信息。

正确的做法(从简单到完善):

  1. .env 文件 + .gitignore:密码写在 .env 文件里,.gitignore 确保不提交。提供 .env.example(只有 key 没有 value)让团队成员参考。
  2. CI/CD 平台的 Secrets 功能:GitHub Actions Secrets、GitLab CI Variables 等,在流水线中注入,不会出现在日志中。
  3. 云平台 Secret Manager:AWS Secrets Manager、HashiCorp Vault 等。应用启动时从 Secret Manager 拉取密码,支持自动轮换。

额外安全措施:用 git-secretspre-commit 钩子,在提交时自动扫描是否包含密码模式(如 sk_live_AKIA 开头的 AWS Key),阻止提交。


定义:Git 工作流是团队使用 Git 协作开发时的分支管理策略。三种主流工作流:Git Flow——有 developfeature/*release/*hotfix/* 等多种分支角色,适合有明确版本发布周期的项目(如移动端 App、桌面软件);Trunk-Based Development——所有开发者直接在 main 分支上开发(或用极短生命周期的分支),用 Feature Flag 控制未完成功能的可见性,适合持续部署的 Web 服务;GitHub Flow——从 main 拉出 feature 分支,开发完成后提交 Pull Request,代码审查通过后合并回 main,简单直接,适合小团队。

为什么重要:混乱的分支管理是团队效率杀手。常见问题:feature 分支存在几个月不合并,最后合并时冲突满天飞;不知道哪个分支是”可以部署的”;热修复(hotfix)不知道该基于哪个分支开发。选择合适的工作流能让团队协作更顺畅,减少合并冲突,加快发布速度。

案例所有系统 — 不同阶段适合不同的工作流。

选型指南:
小团队(1-5人) + Web应用 + 持续部署
→ GitHub Flow 或 Trunk-Based
→ 例如:URL Shortener(开发团队小,功能简单,持续部署)
中大团队(5-20人) + Web应用 + 每周发布
→ GitHub Flow + 保护分支规则
→ 例如:News Feed(功能迭代频繁,但需要代码审查)
大团队(20+人) + 多版本并存 + 定期发布
→ Git Flow
→ 例如:Google Maps(移动端App需要维护多个版本)

三种工作流对比:

工作流分支复杂度适合部署频率合并冲突风险学习成本
Git Flow高(5种分支角色)低(按版本发布)高(长生命周期分支)
GitHub Flow低(main + feature)中(PR合并即部署)
Trunk-Based极低(几乎只有main)极高(每次提交都部署)低(频繁集成)中(需要Feature Flag)

先想一想 🤔 为什么 Trunk-Based Development 说”合并冲突风险低”?大家都往 main 提交,不是更容易冲突吗?

点击查看解析

直觉上”大家都往 main 提交”似乎冲突更多,但实际上恰恰相反。关键在于集成频率

  • Git Flow:feature 分支可能存在几周甚至几个月。在这段时间里,main 分支已经发生了大量变化,最后合并时可能有几十个冲突点。这就是所谓的”合并地狱”(merge hell)。
  • Trunk-Based:每个开发者每天(甚至每小时)都把代码合入 main。每次变更很小,即使有冲突也只是一两行的小冲突,很容易解决。

这就像还技术债——每天还一点利息很轻松,攒半年再还本金加利息就很痛苦。

但 Trunk-Based 的前提是团队有良好的工程实践:完善的自动化测试(保证 main 始终可部署)、Feature Flag(隐藏未完成功能)、代码审查(保证代码质量)。没有这些配套,直接在 main 上开发就是灾难。


定义:基础设施即代码(Infrastructure as Code)是用代码(而非手动在云平台控制台点击)来定义和管理基础设施资源:服务器、数据库、网络、DNS、负载均衡器等。主流工具包括:Terraform(HashiCorp 出品,多云通用,使用 HCL 语言)、Pulumi(用真正的编程语言如 TypeScript/Python/Go 写基础设施)、CloudFormation(AWS 专用,与 AWS 服务深度集成)。IaC 的核心思想是:基础设施的状态由代码定义,代码提交到 Git,变更通过 PR 审查,通过 CI/CD 自动执行。

为什么重要:手动在云平台控制台配置基础设施有三大问题:不可重复(另一个人无法精确复制你的配置步骤)、不可审计(不知道谁在什么时候改了什么)、容易出错(手动点击漏了一步就可能造成安全漏洞)。IaC 把基础设施管理变成了软件工程——版本控制、代码审查、自动化测试、持续部署,一个都不少。

案例YouTube — 全球多区域部署,手动配置不现实,必须 IaC。

YouTube 全球部署需要管理:
- 20+ 个区域的服务器集群
- 每个区域: 负载均衡器 + 应用服务器 + 数据库副本 + CDN节点
- 跨区域的网络互联
- 全球DNS流量调度
手动配置?
→ 20个区域 × 10+资源 = 200+个资源需要手动创建和配置
→ 一个人配错一个安全组规则 → 某个区域的数据库暴露在公网
→ 无法追溯"是谁改了这个配置"
用Terraform:
→ 一份代码定义一个区域的所有资源
→ 用变量控制区域差异(不同区域不同的实例数量)
→ git diff 看到每一处变更
→ PR审查确保配置正确
→ terraform apply 自动创建/更新所有资源

Terraform 示例(为 Hotel Reservation 创建数据库):

# 定义 PostgreSQL 数据库
resource "aws_db_instance" "hotel_db" {
identifier = "hotel-reservation-db"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.medium"
allocated_storage = 100
max_allocated_storage = 500 # 自动扩容到500GB
db_name = "hotel"
username = "admin"
password = var.db_password # 从变量中读取,不写死在代码里
# 高可用配置
multi_az = true # 双可用区部署
backup_retention_period = 7 # 保留7天的自动备份
# 安全配置
publicly_accessible = false # 不暴露到公网
vpc_security_group_ids = [aws_security_group.db.id]
tags = {
Environment = var.environment
Project = "hotel-reservation"
}
}
# 输出数据库连接地址
output "db_endpoint" {
value = aws_db_instance.hotel_db.endpoint
}
Terminal window
terraform init # 初始化(下载Provider插件)
terraform plan # 预览变更(只看不做)
terraform apply # 执行变更(创建/更新资源)
terraform destroy # 销毁所有资源(慎用!)

先想一想 🤔 terraform plan 显示要修改数据库的 instance_class(从 db.t3.medium 改为 db.t3.large),这个操作安全吗?

点击查看解析

取决于具体的云资源类型和变更内容。Terraform 的变更分三种:

  1. 原地更新(Update in-place):修改已有资源的属性,不会中断服务。比如修改标签(tags)。
  2. 需要停机的原地更新:比如修改 RDS 的 instance_class,AWS 需要重启数据库实例来应用新配置。terraform plan 会显示 ~ update in-place,但实际上数据库会有几分钟的不可用。
  3. 先删后建(Destroy and recreate):有些属性变更需要重建资源。terraform plan 会显示 -/+ 标记。如果是数据库被重建,所有数据都会丢失

所以,执行 terraform apply 之前必须仔细阅读 terraform plan 的输出,特别注意:

  • ~ (update) 还是 -/+ (replace)?
  • 被修改的资源是否有状态(数据库、存储卷)?
  • 对于有状态资源的变更,是否需要先做备份或设置维护窗口?

定义:回滚(Rollback)是在新版本出问题时快速切回上一个正常版本的能力。灾难恢复(Disaster Recovery)是在严重故障(硬件损坏、机房断电、人为误操作)后恢复系统和数据的能力。两个关键指标:RTO(Recovery Time Objective,恢复时间目标)——能承受多长时间不可用,比如”5分钟内必须恢复”;RPO(Recovery Point Objective,恢复点目标)——能承受丢失多少时间的数据,比如”最多丢失1分钟的数据”。RTO 和 RPO 的要求越高,实现成本越大。

为什么重要:所有系统都会出故障——这不是”如果”的问题,而是”什么时候”的问题。快速回滚能力决定了故障影响的范围和持续时间。一个没有回滚能力的系统,出了问题只能”往前修”(在巨大压力下排查和修复 bug),可能导致故障持续几小时甚至几天。而有回滚能力的系统,出了问题先回滚恢复服务(几秒到几分钟),然后从容地排查问题。

案例Hotel Reservation — RTO < 5分钟(预订服务不能长时间挂),RPO = 0(订单数据不能丢)。

Hotel Reservation 的灾难恢复方案:
RPO = 0(不能丢任何订单数据):
├─ PostgreSQL 同步复制(synchronous replication)
│ 主库写入 → 同步等待从库确认 → 才返回成功给应用
│ 代价:写入延迟增加(多等一次网络往返)
│ 收益:主库挂了,从库有完整数据
├─ WAL (Write-Ahead Log) 归档
│ 每一条数据变更日志都归档到对象存储(S3)
│ 可以恢复到任意时间点(Point-in-Time Recovery)
└─ 每日全量备份 + 持续 WAL 归档
全量备份:每天凌晨3点
WAL归档:实时连续
恢复过程:还原全量备份 → 重放WAL日志到指定时间点
RTO < 5分钟(快速恢复服务):
├─ 应用层:蓝绿部署,出问题秒级切回
├─ 数据库层:自动故障转移(failover)
│ 主库无响应 → 30秒检测 → 从库自动提升为主库
│ 应用通过DNS或代理连接,自动切换到新主库
└─ 整体演练:每季度做一次灾难恢复演练
模拟主库崩溃 → 验证自动切换 → 验证数据完整性
→ 记录实际RTO和RPO → 与目标对比 → 持续改进

回滚策略对比:

回滚方式速度适用场景
蓝绿部署切流量秒级应用代码回滚
K8s rollout undo分钟级K8s 环境的应用回滚
Docker 拉取旧镜像分钟级Docker 环境的应用回滚
数据库 PITR十分钟~小时级数据误删、数据损坏
全量备份还原小时级灾难性故障、机房级别故障

先想一想 🤔 一个开发者在 Hotel Reservation 的生产数据库上误执行了 DELETE FROM reservations WHERE status = 'confirmed'(删除了所有已确认的预订),如何恢复?

点击查看解析

这是一个典型的”人为误操作”灾难场景,恢复步骤:

  1. 立即停止应用写入(或进入维护模式),防止新数据写入覆盖 WAL 日志,使恢复更复杂。

  2. 确定误操作的精确时间。查看 PostgreSQL 日志或应用日志,找到 DELETE 语句执行的时间戳,比如 2024-03-15 14:23:45

  3. 使用 PITR(Point-in-Time Recovery)恢复到误操作前一秒

    Terminal window
    # 从备份还原到指定时间点
    recovery_target_time = '2024-03-15 14:23:44'

    这会还原最近的全量备份,然后重放 WAL 日志到指定时间点。

  4. 但直接覆盖生产库太危险——误操作之后可能有新的合法数据写入。正确做法是:

    • 把 PITR 恢复到一个临时数据库
    • 从临时库中导出被删除的 reservations 数据
    • 将这些数据合并插入回生产库
    • 处理可能的冲突(比如某个预订在被删后又被重新创建了)
  5. 事后改进

    • 生产数据库禁止直接执行 DML(只允许通过应用操作)
    • 危险操作(DELETE/UPDATE 无 WHERE 或影响大量行)需要审批
    • 重要表开启软删除(deleted_at 字段而非真正 DELETE)

练习一:为 Hotel Reservation 编写 Dockerfile + docker-compose.yml

Section titled “练习一:为 Hotel Reservation 编写 Dockerfile + docker-compose.yml”

要求:

  1. Dockerfile 使用多阶段构建,最终镜像尽可能小
  2. docker-compose.yml 包含:Go 应用、PostgreSQL、Redis
  3. 数据库要有健康检查,应用要在数据库就绪后才启动
  4. 数据持久化(volumes)
  5. 环境变量通过 .env 文件管理

提示:参考 16.1 和 14.2 的示例,但需要补充以下内容:

  • 为 Go 应用添加健康检查端点(/health
  • 考虑生产环境的镜像安全(使用非 root 用户运行)
  • 添加 .dockerignore 文件减少构建上下文大小

练习二:为 Hotel Reservation 设计完整的 CI/CD 流水线

Section titled “练习二:为 Hotel Reservation 设计完整的 CI/CD 流水线”

要求:画出从代码提交到生产部署的每一步,包括:

  1. 代码提交触发的检查(lint、test、安全扫描)
  2. Docker 镜像构建和推送
  3. Staging 环境部署和验证
  4. Production 环境部署(选择一种部署策略并说明原因)
  5. 回滚方案
参考流程图:
开发者 push 代码
┌──────────┐ 失败 ┌──────────┐
│ Lint + │──────────→│ 通知开发者 │
│ Test │ │ 修复问题 │
└────┬─────┘ └──────────┘
│ 通过
┌──────────┐
│ 构建镜像 │
│ 推送仓库 │
└────┬─────┘
┌──────────┐ 失败 ┌──────────┐
│ 部署到 │──────────→│ 自动回滚 │
│ Staging │ │ 通知团队 │
└────┬─────┘ └──────────┘
│ 验证通过
┌──────────┐
│ 人工审批 │
└────┬─────┘
│ 批准
┌──────────┐ 失败 ┌──────────┐
│ 金丝雀部署│──────────→│ 自动回滚 │
│ Production│ │ 通知团队 │
└────┬─────┘ └──────────┘
│ 监控正常
┌──────────┐
│ 全量发布 │
└──────────┘

思考问题:

  • 如果 staging 环境和 production 环境的数据库 schema 不同步怎么办?
  • 如何保证数据库迁移(migration)的安全性——既不能丢数据,又要兼容新旧版本的代码?