C4 容器图(Container):完整指南与示例
如果你只为系统画一张架构图,那就画容器图(Container)。它是 C4 模型的第 2 层,而在实践中,它是四个层级里使用得最多的一个——因为它直接对应工程团队真正构建、部署和运维的东西:Web 应用、API、数据库、消息中间件。
本指南涵盖创建一张好的 C4 容器图所需的一切:它是什么(以及它不是什么——不,C4 中的 container 不是 Docker 容器),它应该包含什么,一个针对典型 SaaS 产品的完整实战示例,毁掉大多数容器图的常见错误,以及如何手工创建或从代码库生成一张容器图。
如果你对 C4 模型完全陌生,请先阅读我们的 C4 模型完整指南,再回到这里。
什么是 C4 容器图?
容器图是由 Simon Brown 创建的 C4 模型第 2 层。它放大到系统上下文图中代表你系统的那一个方框,揭示其内部的高层技术构建块。
在 C4 术语中,**容器(container)**是任何可独立部署或可运行的单元:它执行代码或存储数据,运行在自己的进程中(或拥有自己的存储生命周期),并且原则上可以独立于系统的其余部分进行部署。
这个定义涵盖了诸如此类的东西:
- 运行在用户浏览器中的单页应用(React、Vue、Angular)
- 服务端 Web 应用(Next.js、Rails、Django)
- 移动应用(iOS 应用、Android 应用)
- 后端 API 服务(Go API、Node.js 服务、Spring Boot 服务)
- 数据库(PostgreSQL、MongoDB、MySQL)
- 缓存(Redis、Memcached)
- 消息中间件或队列(Kafka、RabbitMQ、SQS)
- 后台 worker 或定时任务进程
- 文件或对象存储(S3 桶、MinIO)
- 无服务器函数(AWS Lambda、Cloud Functions)
判定标准很简单:它是否运行在自己的进程中,或是否持有自己的数据? 编译进同一个二进制文件的两个 Go 包属于同一个容器。两个独立部署的服务则是两个容器——即便它们共享同一个代码仓库。
如需一个简明的参考定义,请参阅我们术语表中的容器图条目。
C4 容器不是 Docker 容器
这是最常见的混淆点,所以我们来明确地把它说清楚。
C4 模型早于 Docker 的主流采用,而 C4 中"container"一词含义更宽泛:系统的一个运行时级别的构建块。它与 Docker 的重叠是巧合且局部的:
- 一个 C4 容器可能运行在某个 Docker 容器内部。你的 Go API 大概就是。
- 一个 C4 容器也可能作为一个普通的操作系统进程、一个浏览器标签页、一个移动应用或一个托管云服务来运行。这些都不是 Docker 容器。
- 一个 Docker 容器甚至可以承载多个 C4 容器(开发镜像里的一个应用加上内嵌的数据库),尽管这种情况并不常见。
换个说法:Docker 容器是一种打包与部署技术。C4 容器是一种架构抽象。运行在 Chrome 中的 React SPA 是一个 C4 容器,但它永远不会是 Docker 容器。一个 RDS PostgreSQL 实例是一个 C4 容器,而 Docker 在这里根本无处可寻。
当你在一张 C4 图上把某个方框标为"容器"时,你表达的是"这是我系统中一个可独立运行或可独立部署的部分"——仅此而已。
容器图该包含什么
一张好的容器图恰好展示三类信息:
1. 容器本身
你系统边界内的每一个可部署或可运行单元,每个都标注它的名称、它的职责,以及它的技术选型:"订单服务(Go)"、"会话缓存(Redis)"、"Web 应用(React SPA)"。技术标签不是装饰——它构成了这张图一半的价值。它能让一位新工程师或平台团队在不打开十二个代码仓库的情况下推理整个系统。
2. 关系与协议
容器之间的箭头,每条都标注流动的内容和方式:"读写订单(SQL)"、"发布事件(AMQP)"、"发起 API 调用(HTTPS/JSON)"。通信协议在这一层很重要,因为它们驱动着运维层面的关切——网络策略、延迟预算、重试语义、故障模式。
3. 直接上下文
来自上下文图的用户和外部系统,保留在图的边缘,让读者能看到请求如何进入和离开系统。你不必再详细记录它们;它们只是锚点。
这张图是给谁看的?
容器图的受众是技术人员:加入团队的开发者、做结构性决策的架构师,以及规划部署、监控和容量的运维/平台工程师。它正是诸如"这两个服务应该共享一个数据库吗?"或"如果 Redis 挂了会有什么坏掉?"这类对话真正发生的层级。非技术干系人应该去看上下文图。
什么不该出现
- 内部模块、类或包——它们属于组件图(第 3 层)
- 负载均衡器、VPC、Kubernetes 节点等基础设施细节——它们属于部署图
- 每一个微小的交互——如果某个关系在架构上没有意义,就别画它
容器图示例:一个 SaaS Web 产品
我们来为一个虚构的 SaaS 产品构建一个完整的容器图示例——"InvoiceHub",一个基于 Web 的开票工具。这种形态(SPA + API + 数据库 + 缓存 + worker + 队列)描述了现实世界中极大一部分 Web 产品,所以你很可能可以直接套用。
容器
| 容器 | 技术 | 职责 |
|---|---|---|
| Web 应用 | React SPA | 客户用来创建和发送发票的界面 |
| API 应用 | Go (Fiber) | 业务逻辑、认证、供 SPA 消费的 REST API |
| 数据库 | PostgreSQL | 账户、发票、付款的记录系统(system of record) |
| 缓存 | Redis | 会话存储以及发票摘要的热路径缓存 |
| 消息队列 | RabbitMQ | 将慢任务(PDF 渲染、邮件)与 API 请求解耦 |
| 后台 Worker | Go | 消费队列消息:渲染 PDF、发送邮件、同步支付状态 |
关系
[Customer] --> [Web Application (React SPA)] : Uses (HTTPS)
[Web Application] --> [API Application (Go)] : Makes API calls (HTTPS/JSON)
[API Application] --> [Database (PostgreSQL)] : Reads/writes invoices and accounts (SQL/TCP)
[API Application] --> [Cache (Redis)] : Reads/writes sessions and cached summaries (RESP)
[API Application] --> [Message Queue (RabbitMQ)] : Publishes invoice.created, email.requested (AMQP)
[Background Worker (Go)] --> [Message Queue] : Consumes jobs (AMQP)
[Background Worker] --> [Database (PostgreSQL)] : Updates job and payment status (SQL/TCP)
[Background Worker] --> [Email Service (SendGrid)] : Sends invoice emails (HTTPS/REST)
[API Application] --> [Payment Gateway (Stripe)] : Creates payment links, receives webhooks (HTTPS/REST)
这张图告诉了你什么
把这份清单回读一遍,注意它回答了多少个具体的工程问题:
- 状态存在哪里? 两个地方:PostgreSQL(持久)和 Redis(易失)。如果你曾争论过会话能否在 Redis 重启后存活,这张图把这个问题摆到了明面上。
- 故障的波及范围有多大? worker 和 API 共享数据库。RabbitMQ 宕机意味着发票仍然能被创建,但邮件会堆积在队列里——这是设计使然。
- 信任边界在哪里? SPA 运行在不受信任的客户端设备上;它能做的一切都要经过 API 的认证。
- 运维需要运行什么? 六样东西,以及它们的协议。这就是你的监控清单,大体上也就是你的 docker-compose 文件。
一位新开发者能在两分钟内吸收完这些。这正是 C4 模型第 2 层的全部意义。
常见的容器图错误
大多数容器图都以为数不多的几种可预测的方式失败。
把容器和组件搞混
如果你的容器图上出现了"OrderController"、"InvoiceRepository"或"AuthMiddleware",那你就向内多放大了一层。那些是组件——容器内部的构建块——它们属于第 3 层的组件图。检验方法:它能独立部署或运行吗?一个 repository 类不能。让每张图停留在一个缩放层级;混合层级是生产一张无法阅读的图的最快途径。
遗漏数据存储
团队往往只画自己写过代码的东西,而忘记数据库、缓存和队列也是容器。一张缺少数据存储的容器图,恰好隐藏了架构师和运维工程师最需要的信息:状态存在哪里、什么是共享的、什么是单点故障。如果你的系统用了 PostgreSQL、Redis 和 S3,这三个都要画上图。
画的是部署而非运行时结构
容器图不是基础设施图。负载均衡器、Kubernetes pod、自动伸缩组、可用区,以及副本数量都属于部署层面的关切——C4 为此另有一张补充性的部署图。容器图回答的是"逻辑上的运行时部件有哪些,它们如何通信?",而不是"多少个实例运行在哪里?"。因为你跑了三个副本就画三个一模一样的"API"方框,只会增加噪音,而不增加信息。
关系没有标签或含糊不清
一条只写着"使用"的箭头浪费了图的潜力。"发起 API 调用(HTTPS/JSON)"、"发布订单事件(AMQP)"、"读写会话(RESP)"——动词加协议把一张图变成了文档。
任其腐烂
最具破坏性的错误根本不在图本身。一张十八个月前画的容器图,如果还显示着你早已拆成四个服务的那个单体,就是在主动误导每一位新读者。过时的架构文档比没有文档更糟——这正是为什么让图与代码保持同步,比它画得多漂亮更重要。
如何创建容器图
手工方式
你能在一小时内构建出一张扎实的容器图:
- 从上下文图起步。 把用户和外部系统留在边缘。
- 列出可部署单元。 逐一检查你的代码仓库、docker-compose 文件、云控制台。一切作为独立进程运行或存储数据的东西都是候选。
- 显式地加上数据存储。 数据库、缓存、队列、对象存储。
- 画出关系。 对每一对通信的容器,加一条带动词短语和协议的箭头。
- 标注技术。 每个方框里都写上名称和技术。
- 与团队评审。 这引发的讨论("等等,worker 是直接跟 Stripe 通信的?")通常比图本身更有价值。
什么工具都行——Structurizr、带 C4 扩展的 PlantUML、draw.io,甚至一块白板。表示法远不及内容以及保持其与时俱进的纪律重要。
用 Archyl
手工方式有一个结构性弱点:它捕捉的是一个快照,而软件不会静止不动。Archyl 从相反的方向来处理容器图——它从代码中推导出模型:
- 从代码库进行 AI discovery(AI 发现)。 连接一个代码仓库,Archyl 的 AI discovery 会分析你的代码结构、配置和依赖清单,提议一份 C4 模型草稿——系统、容器、组件,以及它们之间的关系。你审阅并批准这些建议,而不必凭记忆画方框。
- 漂移检测让它诚实。 模型一旦存在,Archyl 会持续地将记录在案的容器与代码库实际呈现的内容做比对,并给出一个漂移分数。当有人拆分了一个服务,或把 RabbitMQ 换成了 Kafka,你会从仪表盘上得知,而不是六个月后从一场一头雾水的入职会议中得知。
- 架构即代码(Architecture as Code)。 更偏好文本?你可以用 YAML 定义完整的 C4 模型——容器、技术、关系——把它与代码一起做版本管理,让 Archyl 渲染出可交互的图。图的变更也像其他一切那样走 pull request。
无论哪种方式,目标都一样:一张今天准确、下个季度依然准确的容器图。
常见问题
C4 容器和 Docker 容器是一回事吗?
不是。C4 容器是一种架构抽象:系统中任何可独立部署或可运行的单元,比如 Web 应用、API、数据库或消息中间件。Docker 容器则是一种打包技术。许多 C4 容器恰好以 Docker 容器的形式部署,但也有大量并非如此——React SPA 运行在浏览器里,移动应用运行在手机上,托管数据库作为云服务运行。共用一个名字只是历史上一个不幸的巧合。
什么是 C4 模型第 2 层?
C4 模型的第 2 层就是容器图。它放大到一个软件系统(第 1 层上下文图中那个唯一的方框),展示其内部的可部署/可运行单元、它们的技术选型,以及它们用来通信的协议。它位于上下文图(第 1 层)和组件图(第 3 层)之间。
一张容器图应该展示多少个容器?
没有硬性规则,但超过 15-20 个容器,可读性会迅速下降。如果你的系统确实有更多,就拆分视图:每个子系统画一张容器图,或在视觉上把相关的容器分组。如果你有上百个,那你多半是在记录多个系统,需要在上下文层级用独立的 C4 模型把它们链接起来。
每个微服务都应该是一个独立的容器吗?
是的——按定义如此。每一个可独立部署的服务都是它自己的容器,如果你遵循"每服务一库(database-per-service)",那么每个服务的数据库也是。这也是一个有用的"嗅探测试":如果你的"微服务"因为共享进程或无法独立部署而画不成独立的容器,那它们可能是一个分布式单体。
准备好生成你的容器图,而不是手工画它了吗?免费试用 Archyl,几分钟内从你的代码库得到一份 C4 模型。继续探索 C4 系列:什么是 C4 模型?完整指南 | C4 系统上下文图指南 | C4 组件图指南。