跳转到正文
zeno's blog
返回

云原生基础:Twelve-Factor App 作为应用设计契约

Table of contents

Open Table of contents

TL;DR

Twelve-Factor App(2011, Heroku)定义了 SaaS 应用与运行平台之间的契约:声明式配置、无状态进程、环境无关的构建产物、日志作为事件流。它不是银弹——环境变量存密钥有安全隐患、严格无状态忽略了合理的本地缓存、Admin Processes 在 K8s 时代已过时——但其核心洞察(代码与配置分离、进程与状态分离、构建与运行分离)至今仍是云原生应用的设计基线。


背景:它解决什么问题

2011 年,Adam Wiggins 和 Heroku 团队基于部署数十万 SaaS 应用的经验,提炼出 Twelve-Factor App 方法论。当时的痛点:

不遵守的后果(具体的 failure mode)

违反的 Factor故障模式
Config 硬编码生产密钥泄露到 GitHub,导致数据泄露事故
有状态进程服务重启后用户 session 丢失,水平扩展时请求路由到没有状态的实例
不分离 Build/Run”热修复”直接改生产代码,无法回滚,无法追溯变更
Dev/Prod 不一致开发用 SQLite,生产用 PostgreSQL,查询行为不一致导致生产 bug
日志写本地文件容器重启后日志丢失,多实例日志分散无法聚合排查

十二要素详解

I. Codebase — 一份代码,多处部署

原则:一个应用对应一个代码仓库(Git repo),可以有多个 deploy(production、staging、dev)。

违反的后果

K8s 映射:GitOps(ArgoCD/Flux)——单一 repo 作为 source of truth,通过不同的 overlay/kustomize 生成不同环境的部署配置。

Go 实践:Go Module 天然一个 go.mod 对应一个模块。monorepo 中每个服务有独立的 go.mod 或使用 workspace。共享代码提取为独立的 Go module,通过 go get 依赖,不是 copy-paste。

II. Dependencies — 显式声明,隔离依赖

原则:所有依赖必须在清单中显式声明,不依赖系统全局安装的任何东西。

违反的后果

K8s 映射:容器镜像天然解决了这个问题。DockerfileFROM 指定基础镜像,COPY 嵌入应用及其全部依赖。镜像是一个完全自包含的可执行单元。

Go 实践:Go 在这个 factor 上有天然优势:

FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server

FROM scratch
COPY --from=builder /server /server
ENTRYPOINT ["/server"]

FROM scratch —— 最终镜像里只有一个二进制文件,没有 shell、没有包管理器、没有任何多余的东西。这是 Go 对 Factor II 最极致的体现。

III. Config — 配置存储在环境中

原则:配置(数据库连接串、API 密钥、每环境的参数)通过环境变量注入,不写在代码里。判断标准:代码能否随时开源而不泄露任何凭据?

违反的后果

K8s 映射

Go 实践

// 最简方案:直接读环境变量(小项目够用)
port := os.Getenv("PORT")
if port == "" {
    log.Fatal("PORT environment variable is required") // fail-fast,不要给默认值
}

// 中等复杂度:结构化配置 + 环境变量
type Config struct {
    Port        string `env:"PORT" envDefault:"8080"`
    DatabaseURL string `env:"DATABASE_URL,required"`
    LogLevel    string `env:"LOG_LEVEL" envDefault:"info"`
}
// 使用 github.com/caarlos0/env/v11 解析

// 大型项目:Viper(支持环境变量 + 配置文件 + 远程配置)
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv()
viper.BindEnv("database_url")

注意:原始 12-factor 推荐环境变量是”每个变量独立管理”,反对 development/production 这样的分组。这在变量多了之后不现实。实际工程中 ConfigMap + 结构化配置文件是更好的方案。

IV. Backing Services — 后端服务是可插拔的附加资源

原则:数据库、消息队列、缓存、SMTP、对象存储——无论是本地运行还是第三方托管,在代码中一视同仁,通过 URL/凭据连接,随时可替换。

违反的后果

K8s 映射

Go 实践:通过接口抽象后端服务,连接信息从配置注入。

// 定义接口,不绑定具体实现
type MessageBroker interface {
    Publish(ctx context.Context, topic string, msg []byte) error
    Subscribe(ctx context.Context, topic string) (<-chan []byte, error)
}

// main.go 中根据配置选择实现
func newBroker(cfg Config) (MessageBroker, error) {
    switch cfg.BrokerType {
    case "kafka":
        return kafka.New(cfg.BrokerURL)
    case "nats":
        return nats.New(cfg.BrokerURL)
    default:
        return nil, fmt.Errorf("unsupported broker type: %s", cfg.BrokerType)
    }
}

V. Build, Release, Run — 构建、发布、运行严格分离

原则

每个 release 不可变。要修改必须创建新 release。

违反的后果

K8s 映射

Go 实践:Go 的编译模型天然契合——go build 产出单个二进制,加上 ldflags 注入版本信息:

go build -ldflags "-X main.version=$(git rev-parse --short HEAD) \
                   -X main.buildTime=$(date -u +%Y%m%d%H%M%S)" \
    -o server ./cmd/server

VI. Processes — 进程无状态,数据外置

原则:应用进程是无状态的(stateless)、无共享的(share-nothing)。任何需要持久化的数据都存在后端服务(数据库、Redis)中。

违反的后果

K8s 映射

Go 实践:Go 的 goroutine + channel 天然适合无状态请求处理。避免全局变量存储请求相关状态。

重要细微差别:无状态 ≠ 不能有内存数据。以下场景完全合理:

关键判断标准:进程死了重启后,用户是否丢失数据或功能? 如果是,说明状态管理有问题。

VII. Port Binding — 通过端口绑定导出服务

原则:应用是完全自包含的,通过绑定端口对外提供服务。不依赖外部 Web 服务器(如 Apache、Nginx)的运行时注入。

违反的后果

K8s 映射:容器天然是端口绑定的。containerPort 声明端口,Service 做负载均衡,Ingress 做外部路由。这个 factor 在容器时代基本被平台自动解决。

Go 实践:Go 标准库 net/http 天然就是自包含的 HTTP 服务器,不需要任何外部容器:

srv := &http.Server{
    Addr:         ":" + os.Getenv("PORT"),
    Handler:      mux,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())

同样适用于 gRPC:

lis, _ := net.Listen("tcp", ":"+os.Getenv("GRPC_PORT"))
grpcServer := grpc.NewServer()
pb.RegisterMyServiceServer(grpcServer, &server{})
grpcServer.Serve(lis)

VIII. Concurrency — 通过进程模型扩展

原则:通过运行多个进程实例来水平扩展,不是靠单进程内加线程/加内存。不同类型的工作用不同类型的进程(Web 进程、Worker 进程)。

违反的后果

K8s 映射

Go 实践:Go 的并发模型(goroutine + channel)在单进程内已经很强。但 12-factor 强调的是跨进程水平扩展。两者不矛盾:单个 Go 进程用 goroutine 处理高并发请求,整体架构通过多实例水平扩展。

# K8s HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-server
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

IX. Disposability — 快启动、优雅关闭

原则:进程可以随时启动和停止。要求快速启动(秒级)和优雅关闭(收到 SIGTERM 后完成在途请求再退出)。

违反的后果

K8s 映射

Go 实践:这是 Go 云原生开发中最重要的模式之一。

func main() {
    // 1. 创建可取消的 context,监听 SIGINT/SIGTERM
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // 2. 启动 HTTP 服务器
    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    // 3. 等待终止信号
    <-ctx.Done()
    stop() // 允许第二次 Ctrl+C 强制退出
    log.Println("shutting down gracefully...")

    // 4. 给在途请求一个完成的时间窗口(预留 K8s grace period 的 80%)
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("server forced to shutdown: %v", err)
    }

    // 5. 关闭其他资源(数据库连接、消息队列等)
    db.Close()
    log.Println("server exited cleanly")
}

Go 的启动速度优势:编译后的 Go 二进制启动时间通常在毫秒级(没有 JVM 启动、没有解释器初始化、没有依赖加载),天然契合 Disposability 要求。

X. Dev/Prod Parity — 开发、预发、生产尽可能一致

原则:缩小三个 gap——时间 gap(代码到生产的周期)、人员 gap(开发和运维是否同一批人)、工具 gap(开发和生产是否用同样的后端服务)。

违反的后果

K8s 映射

Go 实践

XI. Logs — 日志是事件流,不是文件

原则:应用不管日志的路由和存储。每个进程把日志写到 stdout/stderr,由运行环境负责收集、聚合、存储。

违反的后果

K8s 映射

Go 实践:使用 log/slog(Go 1.21+ 标准库),结构化日志输出到 stdout。

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

// 使用
slog.Info("request handled",
    slog.String("method", r.Method),
    slog.String("path", r.URL.Path),
    slog.Int("status", statusCode),
    slog.Duration("latency", time.Since(start)),
)

不要log.SetOutput(file) 写文件。不要在应用里实现日志轮转。不要fmt.Println 输出非结构化日志。

XII. Admin Processes — 管理任务作为一次性进程运行

原则:数据库迁移、数据修复脚本、REPL 等管理任务,应使用与应用相同的代码、配置和环境运行,作为一次性进程执行。

违反的后果

K8s 映射

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: myapp:v1.2.3 # 与应用相同的镜像
          command: ["/server", "migrate"]
      restartPolicy: Never

Go 实践:用 cobra 子命令实现不同的入口点:

// cmd/server/main.go
rootCmd.AddCommand(serveCmd)   // ./server serve
rootCmd.AddCommand(migrateCmd) // ./server migrate
rootCmd.AddCommand(seedCmd)    // ./server seed

同一个二进制,不同的子命令。保证代码、配置、依赖完全一致。


超越 12-Factor:15-Factor 与 16-Factor

Kevin Hoffman 的 15-Factor(2016)

Kevin Hoffman 在《Beyond the Twelve-Factor App》中提出三个新 factor,并对原有 factor 做了修订:

Factor 13: API First

Factor 14: Telemetry(遥测)

Factor 15: Authentication & Authorization(认证与授权)

对原有 factor 的修订

Google 的 16-Factor for AI(2025)

Google Cloud 在 2025 年提出了针对 AI 应用的四个新 factor:

新 Factor核心思想
Prompts as CodeAI prompt 是应用逻辑的一部分,需要版本控制、测试、review
State as a ServiceAI 应用的对话上下文、模型状态外置为服务
Observability for Non-determinismAI 输出不确定,需要记录 prompt/response/token/tool-use,比传统应用更复杂
Trust and Safety by DesignPrompt injection 是新的 SQL injection,需要输入/输出过滤和多层防御

12-Factor 与 Kubernetes 的对照表

FactorK8s 是否自动解决开发者仍需做什么
I. CodebaseGit 仓库管理、GitOps 流程
II. Dependencies部分 — 容器镜像封装依赖编写 Dockerfile、管理 go.mod
III. Config部分 — ConfigMap/Secret决定什么是配置、什么是代码;Secret 加密方案
IV. Backing Services部分 — Service 抽象通过接口解耦、连接信息配置化
V. Build/Release/Run部分 — 镜像不可变CI/CD 流水线、镜像 tag 策略
VI. Processes确保应用无状态、状态外置
VII. Port Binding — containerPort + Service基本不需要额外工作
VIII. Concurrency — Deployment replicas + HPA确保应用支持多实例运行
IX. Disposability部分 — Pod lifecycle实现 SIGTERM 处理、健康检查端点
X. Dev/Prod Parity部分 — 同镜像不同环境搭建本地 K8s 开发环境、集成测试
XI. Logs部分 — stdout 收集结构化日志、写 stdout 而非文件
XII. Admin Processes — Job/CronJob实现子命令入口

总结:K8s 让大约一半的 factor 变成了”平台默认行为”。但另一半——尤其是 Config 管理、无状态设计、优雅关闭、Dev/Prod 一致性——仍然需要开发者有意识地设计和实现。


批评与细微差别

Config 用环境变量存密钥有安全隐患

环境变量的问题:

实际做法:密钥用 Vault / AWS Secrets Manager / GCP Secret Manager,通过 CSI Driver 挂载为文件或通过 sidecar 注入。ConfigMap 用于非敏感配置。纯环境变量只适合简单场景。

“无状态”不等于”没有内存数据”

12-factor 说”进程无状态、无共享”,但过度解读会导致荒谬的设计:

判断标准不是”有没有内存数据”,而是**“进程死了重启后用户体验是否受损”**。

Admin Processes 在 K8s 时代已过时

原文建议”SSH 进服务器跑管理脚本”,这在容器编排时代是反模式。K8s Job/CronJob 是正确的替代——自动化、可追溯、与应用使用相同的镜像和配置。

Factor VII(Port Binding)已被平台吸收

在容器时代,“自包含 HTTP 服务器”是默认行为,不需要特别强调。这个 factor 的历史背景是 PHP 依赖 Apache、Java 依赖 Tomcat 的年代。现代框架(Go net/http、Node Express、Python uvicorn)天然就是自包含的。

最常被违反的 Factor

根据工程实践经验,按违反频率排序:

  1. Config(III)— 仍然有人把密码硬编码在代码里或提交到 Git
  2. Dev/Prod Parity(X)— 开发用 mock/H2/SQLite,生产用真实服务
  3. Logs(XI)— 日志写文件而非 stdout
  4. Processes(VI)— Session 存内存、依赖本地文件系统
  5. Disposability(IX)— 不处理 SIGTERM,进程被暴力杀死

Go 微服务 12-Factor 合规检查表

Codebase
  [ ] 一个 Git repo 对应一个可独立部署的服务
  [ ] 共享代码提取为独立 Go module,通过 go get 依赖

Dependencies
  [ ] go.mod + go.sum 管理所有依赖
  [ ] CGO_ENABLED=0 静态编译(除非必须用 CGO)
  [ ] Dockerfile 使用多阶段构建,最终镜像尽量小

Config
  [ ] 零硬编码凭据(grep -r "password\|secret\|key" 应无结果)
  [ ] 通过环境变量或 ConfigMap 注入配置
  [ ] 敏感配置使用 Secret(不是明文 ConfigMap)
  [ ] 必填配置缺失时 fail-fast(启动失败),不给默认值

Backing Services
  [ ] 数据库/缓存/消息队列通过接口抽象
  [ ] 连接信息从配置注入,代码中无硬编码地址
  [ ] 更换后端服务只需改配置,不需改代码

Build, Release, Run
  [ ] CI/CD 流水线:代码 → 镜像 → 部署,三段分离
  [ ] 镜像 tag 使用 Git SHA 或语义版本号
  [ ] 不在运行中的容器里修改代码

Processes
  [ ] 无 sticky session,session 存 Redis
  [ ] 不依赖本地文件系统持久化数据
  [ ] 全局变量只用于进程级缓存/配置,不存业务状态

Port Binding
  [ ] 使用 net/http 或 gRPC 自包含服务器
  [ ] 端口从环境变量读取

Concurrency
  [ ] 支持多实例运行(无单例假设)
  [ ] 分布式锁代替进程内锁(如果需要互斥)

Disposability
  [ ] signal.NotifyContext 处理 SIGTERM
  [ ] HTTP server.Shutdown 优雅关闭
  [ ] 启动时间 < 5 秒
  [ ] /healthz 和 /readyz 端点实现

Dev/Prod Parity
  [ ] docker-compose 本地运行真实的后端服务(PostgreSQL/Redis)
  [ ] 集成测试用 testcontainers-go
  [ ] 所有环境使用相同的 Docker 镜像

Logs
  [ ] 使用 slog 输出 JSON 结构化日志到 stdout
  [ ] 不使用 log.SetOutput(file)
  [ ] 不在应用内实现日志轮转

Admin Processes
  [ ] 管理命令是同一二进制的子命令(cobra)
  [ ] 数据库迁移通过 K8s Job 执行
  [ ] 不 SSH 进容器手动操作

Pitfalls

  1. 把 12-Factor 当教条而非指南 — 它是 2011 年 Heroku PaaS 的最佳实践总结,不是永恒真理。在容器/K8s 时代,有些 factor 已被平台吸收(Port Binding),有些需要增强(Config → Secrets Management),有些需要扩展(Telemetry)
  2. 环境变量存密钥 — 原文推荐环境变量存所有配置,但密钥通过环境变量传递有安全风险。生产环境应使用 Vault/Sealed Secrets
  3. 过度追求无状态 — 本地缓存(sync.Mapgroupcache)完全合理。判断标准是”进程挂了用户是否受损”,不是”进程内有没有内存数据”
  4. 忽视优雅关闭 — 最常见的生产事故之一。Go 里忘记处理 SIGTERM,K8s 滚动更新时在途请求全部 502
  5. Dev/Prod 工具不一致 — 用 SQLite 开发、PostgreSQL 生产是最经典的陷阱。SQL 方言差异会在上线时才暴露
  6. 日志写文件 — 容器时代日志必须走 stdout。写文件意味着容器重启后丢失,多实例无法聚合
  7. 管理任务绕过应用代码 — 直接连数据库跑 SQL 修数据,绕过了应用的业务逻辑验证,容易造成数据不一致
  8. 把 ConfigMap 当数据库用 — ConfigMap 有 1MB 大小限制,不适合存大量数据。它是配置,不是存储

延伸阅读


分享这篇文章:

下一篇
Rust 异步生态(总览):从 mio 到 axum 的分层架构