Table of contents
Open Table of contents
- TL;DR
- 背景:它解决什么问题
- 十二要素详解
- I. Codebase — 一份代码,多处部署
- II. Dependencies — 显式声明,隔离依赖
- III. Config — 配置存储在环境中
- IV. Backing Services — 后端服务是可插拔的附加资源
- V. Build, Release, Run — 构建、发布、运行严格分离
- VI. Processes — 进程无状态,数据外置
- VII. Port Binding — 通过端口绑定导出服务
- VIII. Concurrency — 通过进程模型扩展
- IX. Disposability — 快启动、优雅关闭
- X. Dev/Prod Parity — 开发、预发、生产尽可能一致
- XI. Logs — 日志是事件流,不是文件
- XII. Admin Processes — 管理任务作为一次性进程运行
- 超越 12-Factor:15-Factor 与 16-Factor
- 12-Factor 与 Kubernetes 的对照表
- 批评与细微差别
- Go 微服务 12-Factor 合规检查表
- Pitfalls
- 延伸阅读
TL;DR
Twelve-Factor App(2011, Heroku)定义了 SaaS 应用与运行平台之间的契约:声明式配置、无状态进程、环境无关的构建产物、日志作为事件流。它不是银弹——环境变量存密钥有安全隐患、严格无状态忽略了合理的本地缓存、Admin Processes 在 K8s 时代已过时——但其核心洞察(代码与配置分离、进程与状态分离、构建与运行分离)至今仍是云原生应用的设计基线。
背景:它解决什么问题
2011 年,Adam Wiggins 和 Heroku 团队基于部署数十万 SaaS 应用的经验,提炼出 Twelve-Factor App 方法论。当时的痛点:
- 环境耦合:应用绑死在特定服务器上,“在我机器上能跑”是常态
- 配置散落:数据库密码硬编码在代码里,或者散落在各种格式的配置文件中
- 部署靠人:没有自动化,部署是手动操作,每次都是冒险
- 扩容靠猜:垂直扩展(加内存加 CPU)到顶了,水平扩展不知道从哪开始
- 雪花服务器:每台服务器都是手工养大的”宠物”,不可复现
不遵守的后果(具体的 failure mode):
| 违反的 Factor | 故障模式 |
|---|---|
| Config 硬编码 | 生产密钥泄露到 GitHub,导致数据泄露事故 |
| 有状态进程 | 服务重启后用户 session 丢失,水平扩展时请求路由到没有状态的实例 |
| 不分离 Build/Run | ”热修复”直接改生产代码,无法回滚,无法追溯变更 |
| Dev/Prod 不一致 | 开发用 SQLite,生产用 PostgreSQL,查询行为不一致导致生产 bug |
| 日志写本地文件 | 容器重启后日志丢失,多实例日志分散无法聚合排查 |
十二要素详解
I. Codebase — 一份代码,多处部署
原则:一个应用对应一个代码仓库(Git repo),可以有多个 deploy(production、staging、dev)。
违反的后果:
- 多个 repo 存放同一个应用的代码 → 版本分裂,合并地狱
- 多个应用共享一个 repo 的业务代码 → 分布式单体,无法独立部署
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 — 显式声明,隔离依赖
原则:所有依赖必须在清单中显式声明,不依赖系统全局安装的任何东西。
违反的后果:
- 依赖 ImageMagick 等系统工具但没声明 → 换台机器就跑不起来
- 依赖全局安装的库版本 → 升级系统库导致应用崩溃
K8s 映射:容器镜像天然解决了这个问题。Dockerfile 中 FROM 指定基础镜像,COPY 嵌入应用及其全部依赖。镜像是一个完全自包含的可执行单元。
Go 实践:Go 在这个 factor 上有天然优势:
go.mod+go.sum= 依赖声明 + 校验- 静态编译:
CGO_ENABLED=0 go build产出单个二进制,零运行时依赖 - 不需要 virtualenv、bundler 等隔离工具——编译后就是一个独立可执行文件
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 密钥、每环境的参数)通过环境变量注入,不写在代码里。判断标准:代码能否随时开源而不泄露任何凭据?
违反的后果:
- 生产数据库密码提交到 Git → 安全事故
- 配置文件散落在
/etc/app/、~/.config/、./config.yaml→ 排查问题要翻遍文件系统 - 配置和代码耦合 → 改个端口号要重新构建镜像
K8s 映射:
- ConfigMap:非敏感配置(端口、日志级别、功能开关)
- Secret:敏感配置(密码、证书、API key)——注意 K8s Secret 默认只是 base64 编码,不是加密。生产环境应配合 Sealed Secrets、Vault、AWS Secrets Manager
- 注入方式:环境变量或挂载为文件。文件挂载支持热更新(ConfigMap 变更后 kubelet 自动更新挂载的文件),环境变量不支持
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/凭据连接,随时可替换。
违反的后果:
- 代码里写死
localhost:3306→ 无法切换到云数据库 - MySQL 特有语法散落在业务代码中 → 无法迁移到 PostgreSQL
- 数据库挂了需要换新实例 → 改代码重新部署
K8s 映射:
- Service 资源抽象了后端服务的地址(DNS 名而非 IP)
- ExternalName Service 将外部服务映射为集群内 DNS
- Operator(如 CloudNativePG、Redis Operator)管理有状态后端服务的生命周期
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 — 构建、发布、运行严格分离
原则:
- Build:代码 + 依赖 → 可执行产物(编译、打包)
- Release:产物 + 配置 → 不可变的发布版本(带唯一 ID)
- Run:启动发布版本的进程
每个 release 不可变。要修改必须创建新 release。
违反的后果:
- SSH 到生产服务器直接改代码 → 无法追溯变更、无法回滚、其他实例状态不一致
- 构建产物包含配置 → 同一份代码无法部署到不同环境
K8s 映射:
- Build →
docker build+ push to registry(产出不可变镜像,tag 用 Git SHA) - Release → K8s Deployment manifest(镜像 + ConfigMap/Secret)
- Run →
kubectl apply/ ArgoCD sync → Pod 启动
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)中。
违反的后果:
- 用户 session 存在进程内存里 → 进程重启 session 丢失,水平扩展时 sticky session 成为瓶颈
- 文件上传存在本地磁盘 → 另一个实例读不到
- 内存缓存当做数据源 → 重启后数据丢失
K8s 映射:
- Pod 随时会被驱逐、重调度、水平扩展——无状态是前提
- 需要状态的用 StatefulSet + PersistentVolume(但这是基础设施层面,应用本身仍应无状态)
- Session 用 Redis(通过 Service 连接),文件用对象存储(S3/MinIO)
Go 实践:Go 的 goroutine + channel 天然适合无状态请求处理。避免全局变量存储请求相关状态。
重要细微差别:无状态 ≠ 不能有内存数据。以下场景完全合理:
- 本地缓存(如
sync.Map、groupcache)作为性能优化层,cache miss 时回退到后端服务 - 计算中间结果在单个请求生命周期内缓存
- 连接池是进程级状态,但它是基础设施,不是业务状态
关键判断标准:进程死了重启后,用户是否丢失数据或功能? 如果是,说明状态管理有问题。
VII. Port Binding — 通过端口绑定导出服务
原则:应用是完全自包含的,通过绑定端口对外提供服务。不依赖外部 Web 服务器(如 Apache、Nginx)的运行时注入。
违反的后果:
- 依赖 Tomcat 容器部署 WAR 包 → 应用无法独立运行,环境耦合
- 必须装 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 进程)。
违反的后果:
- 所有逻辑跑在一个进程里 → 垂直扩展有天花板,CPU/内存混合争抢
- 自己管理守护进程 → 进程挂了没人拉起来
K8s 映射:
Deployment的replicas就是进程实例数- HPA(Horizontal Pod Autoscaler)基于 CPU/内存/自定义指标自动扩缩
- 不同 workload 用不同的 Deployment:
web-deployment、worker-deployment
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 后完成在途请求再退出)。
违反的后果:
- 启动慢(分钟级)→ 扩容响应不及时,滚动更新耗时过长
- 不处理 SIGTERM → K8s 等 30 秒后 SIGKILL 强杀进程,在途请求全部失败
- Worker 不把任务放回队列 → 任务丢失
K8s 映射:
terminationGracePeriodSeconds(默认 30s):SIGTERM 到 SIGKILL 的时间窗口preStophook:在 SIGTERM 之前执行(如从服务注册中心摘除)- Readiness Probe:停止接收新请求
- Liveness Probe:检测进程是否存活
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(开发和生产是否用同样的后端服务)。
违反的后果:
- 开发用 SQLite,生产用 PostgreSQL → 开发时过的测试在生产中 SQL 语法不兼容
- 开发不部署,运维不写代码 → 问题反馈链条断裂
- staging 环境半年没更新 → 形同虚设
K8s 映射:
- Docker 镜像在所有环境完全相同,只有 ConfigMap/Secret 不同
- Helm chart / Kustomize overlay 管理环境差异(副本数、资源限制、域名)
- Tilt / Skaffold:开发时在本地 K8s 集群(minikube/kind)运行,贴近生产
Go 实践:
- 开发和生产用同一个编译后的二进制,通过环境变量控制行为
- docker-compose 在本地运行 PostgreSQL、Redis、Kafka,而不是用 mock 或 in-memory 替代品
- 集成测试用 testcontainers-go 启动真实的数据库/中间件容器
XI. Logs — 日志是事件流,不是文件
原则:应用不管日志的路由和存储。每个进程把日志写到 stdout/stderr,由运行环境负责收集、聚合、存储。
违反的后果:
- 日志写到
/var/log/app.log→ 容器重启后丢失,多实例分散在各个节点 - 应用自己管日志轮转 → 磁盘写满、权限问题、影响主业务
K8s 映射:
- 容器 stdout/stderr → kubelet 收集 → 节点上的日志文件
- DaemonSet 日志收集器(Fluent Bit / Fluentd)→ 集中式日志系统(Loki / ELK / CloudWatch)
- 应用只需写 stdout,其余全由平台处理
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 等管理任务,应使用与应用相同的代码、配置和环境运行,作为一次性进程执行。
违反的后果:
- 用不同版本的代码跑迁移 → schema 和代码不匹配
- 管理脚本有自己的配置文件 → 配置漂移
- SSH 进服务器手动跑 SQL → 不可追溯,容易出错
K8s 映射:
- Job:一次性任务(如数据迁移)
- CronJob:定时任务(如数据清理)
- 使用与应用相同的镜像,不同的入口命令
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
- 先设计 API 契约(OpenAPI spec / protobuf),再写实现
- 团队间通过 API 契约解耦,前后端/服务间可并行开发
- 这个 factor 在微服务架构中尤其关键——服务边界就是 API 边界
Factor 14: Telemetry(遥测)
- 原始 12-factor 只提到了 Logs,但现代可观测性需要三大支柱:Metrics、Logs、Traces
- 应用应该暴露健康检查端点、导出指标(Prometheus)、传播 trace context(OpenTelemetry)
- 不可观测的系统 = 不可运维的系统
Factor 15: Authentication & Authorization(认证与授权)
- 安全不是事后补丁,必须内建
- 服务间通信需要 mTLS 或 token 验证
- RBAC/ABAC 应该是应用架构的一部分
对原有 factor 的修订:
- Config 拆分为 Configuration 和 Credentials,凭据需要更严格的管理(Vault 等)
- Build/Release/Run 增加 Design 阶段,强调 API 设计前置
- Logs 升级为 Observability,不只是日志
Google 的 16-Factor for AI(2025)
Google Cloud 在 2025 年提出了针对 AI 应用的四个新 factor:
| 新 Factor | 核心思想 |
|---|---|
| Prompts as Code | AI prompt 是应用逻辑的一部分,需要版本控制、测试、review |
| State as a Service | AI 应用的对话上下文、模型状态外置为服务 |
| Observability for Non-determinism | AI 输出不确定,需要记录 prompt/response/token/tool-use,比传统应用更复杂 |
| Trust and Safety by Design | Prompt injection 是新的 SQL injection,需要输入/输出过滤和多层防御 |
12-Factor 与 Kubernetes 的对照表
| Factor | K8s 是否自动解决 | 开发者仍需做什么 |
|---|---|---|
| I. Codebase | 否 | Git 仓库管理、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 用环境变量存密钥有安全隐患
环境变量的问题:
- 子进程会继承全部环境变量 → 不受信任的子进程可以读到密钥
- 进程崩溃时 core dump 可能包含环境变量
kubectl describe pod或日志系统可能意外暴露环境变量- 环境变量无法热更新(需要重启 Pod)
实际做法:密钥用 Vault / AWS Secrets Manager / GCP Secret Manager,通过 CSI Driver 挂载为文件或通过 sidecar 注入。ConfigMap 用于非敏感配置。纯环境变量只适合简单场景。
“无状态”不等于”没有内存数据”
12-factor 说”进程无状态、无共享”,但过度解读会导致荒谬的设计:
- 合理的进程级状态:连接池、本地缓存(加速热点数据读取,miss 时回退到数据库)、配置解析后的内存结构
- 不合理的进程级状态:用户 session、上传文件、业务计数器
判断标准不是”有没有内存数据”,而是**“进程死了重启后用户体验是否受损”**。
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
根据工程实践经验,按违反频率排序:
- Config(III)— 仍然有人把密码硬编码在代码里或提交到 Git
- Dev/Prod Parity(X)— 开发用 mock/H2/SQLite,生产用真实服务
- Logs(XI)— 日志写文件而非 stdout
- Processes(VI)— Session 存内存、依赖本地文件系统
- 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
- 把 12-Factor 当教条而非指南 — 它是 2011 年 Heroku PaaS 的最佳实践总结,不是永恒真理。在容器/K8s 时代,有些 factor 已被平台吸收(Port Binding),有些需要增强(Config → Secrets Management),有些需要扩展(Telemetry)
- 环境变量存密钥 — 原文推荐环境变量存所有配置,但密钥通过环境变量传递有安全风险。生产环境应使用 Vault/Sealed Secrets
- 过度追求无状态 — 本地缓存(
sync.Map、groupcache)完全合理。判断标准是”进程挂了用户是否受损”,不是”进程内有没有内存数据” - 忽视优雅关闭 — 最常见的生产事故之一。Go 里忘记处理 SIGTERM,K8s 滚动更新时在途请求全部 502
- Dev/Prod 工具不一致 — 用 SQLite 开发、PostgreSQL 生产是最经典的陷阱。SQL 方言差异会在上线时才暴露
- 日志写文件 — 容器时代日志必须走 stdout。写文件意味着容器重启后丢失,多实例无法聚合
- 管理任务绕过应用代码 — 直接连数据库跑 SQL 修数据,绕过了应用的业务逻辑验证,容易造成数据不一致
- 把 ConfigMap 当数据库用 — ConfigMap 有 1MB 大小限制,不适合存大量数据。它是配置,不是存储
延伸阅读
- 原文:12factor.net
- Kevin Hoffman,《Beyond the Twelve-Factor App》, O’Reilly, 2016
- Google Cloud, Rethinking the Twelve-Factor App framework for AI, 2025
- 12-Factor App — 12 years later — 逐条评估现代相关性