平台工程与内部开发者平台:概念与应用
1. 总览
当 service 数量增加时,每个团队都必须自己处理 CI/CD、基础设施、日志、安全和文档。他们把大量时间花在重复的工作上,而不是编写功能。平台工程通过将这些工作整合到一个内部开发者平台 (IDP) 中来解决这个问题,让开发者可以自助服务。
读完这篇文章后,你将理解这个概念以及为什么它受关注。你也将能够通过 Spring Boot starter、服务目录和一个简单的 policy check 构建一条小的 “golden path”。文章最后是不应该投入平台工程的情况。
2. 什么是平台工程?
平台工程是指一个专门的团队 (platform team) 构建和运维一套工具、流程和内部服务。产品团队 (stream-aligned team) 使用这套系统来构建、部署和运维 service,而不需要深入理解底层基础设施的每一层。
2.1. 内部开发者平台
IDP 是 platform team 创建的产品。它通常不是单一的工具。它是一个集成层,整合了许多现有的东西:Git、CI、Kubernetes、cloud、secret manager、observability。开发者通过 portal、CLI、template 或者仅仅是 repo 中的一个配置文件与这个集成层交互。
2.2. 三个核心思想
- Golden path (paved road): 推荐的和预先支持的路径,用于执行常见任务,例如“创建新的 REST service”。遵循这条路径又快又安全。你也可以走其他路径,但团队要自己承担责任。
- Self-service: 开发者可以自己创建 service、数据库或测试环境,而不需要提工单等待其他人。
- Platform as a product: 内部开发者是客户。Platform team 必须采访他们、衡量使用程度并优先处理 backlog,就像真正的产品团队一样。
2.3. 与 DevOps 有什么区别?
DevOps 是一种文化:编写代码的团队也负责运维(“you build it, you run it”)。平台工程不是替代 DevOps。它是在大规模实现 DevOps 的一种方式。与其强制每个团队都成为 Kubernetes、Terraform 和 IAM 专家,platform team 将这些知识打包成易用的抽象。
3. 为什么这个话题现在受到关注?
有几个实际的原因,与任何特定工具无关:
- 认知负担增加: 一个现代 service 可能需要 Dockerfile、Kubernetes manifest、pipeline、dashboard、alert、安全策略和 secret 配置。每样都有很多选择。
- 各队各有各的风格: 如果没有统一标准,每个 service 记录日志的格式不同,命名 metric 的方式也不同。当发生事故时,值班人员很难找出那个 service 属于哪个团队。
- 安全和合规要求: 扫描依赖项、签署 artifact、控制访问。如果这些都内置在 golden path 中,就容易统一应用。
- 缓慢的入职流程: 新人需要知道“在哪里创建 service、如何部署、在哪里查看日志”。一个好的 platform 可以在一个地方回答这些问题。
4. IDP 的常见组件
| 组件 | 作用 |
|---|---|
| Service catalog | Service 列表、所有者、文档、依赖项 |
| Software template | 仅用几个参数按标准创建新项目 |
| 共享 CI/CD | 预配置的 pipeline 用于构建、测试、扫描、部署 |
| Self-service 基础设施 | 通过声明式配置创建数据库、队列、bucket |
| 默认 observability | Service 启动时即可获得日志、指标、trace |
| Guardrail / policy | 自动检查强制规则 |
你不需要从一开始就拥有所有这些。以下部分将说明三个小部分:用于 golden path 的 Spring Boot starter、catalog descriptor 和 policy check。
5. 通过 Spring Boot starter 实现 Golden path
标准化 Java service 最经济的方法是发布一个内部 starter。添加这个 starter 的 service 将自动拥有标准化的元数据:名称、所有者团队、重要性级别。元数据通过 actuator 展示。
5.1. 依赖项
内部 starter 需要以下依赖项:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Actuator 被标记为 optional。不使用 actuator 的 service 仍然可以使用 starter。
5.2. Properties 和 descriptor
package dev.nonla.platform;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("platform.service")
public record PlatformServiceProperties(String name, String owner, String tier) {
public PlatformServiceProperties {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("platform.service.name 是必需的");
}
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("platform.service.owner 是必需的");
}
if (tier == null || tier.isBlank()) {
tier = "standard";
}
}
}
package dev.nonla.platform;
public record ServiceDescriptor(String name, String owner, String tier) {
}
record 的 compact constructor 做了两件事:
- 强制必须声明
name和owner。缺少其中之一,应用就无法启动。这正是一个 guardrail:没有“孤立的” service。 - 如果不声明,为
tier分配默认值standard。
5.3. 自动配置
package dev.nonla.platform;
import java.util.Map;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@AutoConfiguration
@EnableConfigurationProperties(PlatformServiceProperties.class)
public class PlatformServiceAutoConfiguration {
@Bean
@ConditionalOnMissingBean
ServiceDescriptor serviceDescriptor(PlatformServiceProperties props) {
return new ServiceDescriptor(props.name(), props.owner(), props.tier());
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(InfoContributor.class)
static class ActuatorConfiguration {
@Bean
InfoContributor platformInfoContributor(ServiceDescriptor descriptor) {
return builder -> builder.withDetail("platform", Map.of(
"name", descriptor.name(),
"owner", descriptor.owner(),
"tier", descriptor.tier()));
}
}
}
逐部分解释:
@ConditionalOnMissingBean允许 service 定义自己的ServiceDescriptor。当真正需要时,这是“逃离” golden path 的方式。- 当
InfoContributor在 classpath 上时,嵌套类ActuatorConfiguration才会被加载。这样不使用 actuator 的 service 就不会遇到ClassNotFoundException。
要让 Spring Boot 3 找到这个自动配置,你需要在文件 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 中注册它:
dev.nonla.platform.PlatformServiceAutoConfiguration
在 service 端,例如 order-service,只需要添加 starter 和几行配置:
platform.service.name=order-service
platform.service.owner=team-checkout
platform.service.tier=critical
management.endpoints.web.exposure.include=health,info
management.info.env.enabled=false
调用 GET /actuator/info 将获得:
{"platform":{"name":"order-service","owner":"team-checkout","tier":"critical"}}
从这里,监控工具或 catalog 可以扫描这个端点来了解谁拥有哪个 service。
5.4. 测试 starter 的行为
platform 团队必须仔细测试 starter。这里的一个错误会影响所有使用它的 service。ApplicationContextRunner 帮助快速测试自动配置,而无需启动整个 web server:
package dev.nonla.platform;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
class PlatformServiceAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PlatformServiceAutoConfiguration.class));
@Test
void givenNameAndOwner_whenStarting_thenDescriptorUsesDefaultTier() {
runner.withPropertyValues(
"platform.service.name=order-service",
"platform.service.owner=team-checkout")
.run(ctx -> {
assertThat(ctx).hasSingleBean(ServiceDescriptor.class)
.hasSingleBean(InfoContributor.class);
assertThat(ctx.getBean(ServiceDescriptor.class).tier())
.isEqualTo("standard");
});
}
@Test
void givenMissingOwner_whenStarting_thenContextFails() {
runner.withPropertyValues("platform.service.name=order-service")
.run(ctx -> {
assertThat(ctx).hasFailed();
assertThat(ctx.getStartupFailure())
.rootCause()
.hasMessageContaining("platform.service.owner");
});
}
}
第一个测试确认默认值和 InfoContributor 的注册。第二个测试确认 guardrail:缺少 owner 时 context 无法启动,错误消息清楚地指出缺少的属性。
6. Service catalog 和 guardrail
6.1. Repo 中的 Catalog descriptor
大多数 IDP 将 service 的元数据存储在 repo 中,与代码相邻。以下是开源代码 portal 中常见的 descriptor 类型:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: order-service
description: 处理订单和支付
tags:
- java
- spring-boot
spec:
type: service
lifecycle: production
owner: team-checkout
system: checkout
dependsOn:
- resource:orders-db
providesApis:
- order-api
在 Git 中保存这个文件有两个好处。元数据与代码一起审查,不会像被遗忘的 wiki 页面那样偏离现实。Portal 只需要读取这个文件就能显示 service 的所有者、依赖项和 API。
6.2. 简单的 Policy check
Guardrail 不一定需要使用复杂的 policy engine。初期阶段,在 CI 中运行一段代码足以阻止配置错误:
package dev.nonla.platform.policy;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
public class ServicePolicy {
private static final Set<String> ALLOWED_TIERS = Set.of("critical", "standard", "experimental");
public record ServiceSpec(String name, String owner, String tier, int replicas) {
}
public List<String> violations(ServiceSpec spec) {
List<String> errors = new ArrayList<>();
if (!spec.name().matches("[a-z][a-z0-9-]{2,40}")) {
errors.add("名称必须是 kebab-case,3-41 个字符");
}
if (!spec.owner().startsWith("team-")) {
errors.add("所有者必须是团队,不是个人");
}
if (!ALLOWED_TIERS.contains(spec.tier())) {
errors.add("未知的级别:" + spec.tier());
}
if ("critical".equals(spec.tier()) && spec.replicas() < 2) {
errors.add("关键 service 需要至少 2 个副本");
}
return errors;
}
}
对于输入 new ServiceSpec("Order_Service", "alice", "critical", 1),函数返回:
[名称必须是 kebab-case,3-41 个字符, 所有者必须是团队,不是个人, 关键 service 需要至少 2 个副本]
重点是错误消息必须清楚说明需要修改什么。好的 guardrail 指导开发者修复错误,而不仅仅是阻止他们。
7. 实际应用:从哪里开始?
- 首先采访开发者。 询问他们花最多时间在哪里:创建新 service、等待环境分配还是调试 pipeline。优先考虑最紧迫的问题,而不是最有趣的工具。
- 从“最小可行平台”开始。 有时只需要一个模板 repo、一个共享 pipeline 和一个文档页面。Portal 可以后来再做。
- 选择单一的 golden path。 例如“REST service 用 Spring Boot 部署到 Kubernetes”。先做好这条路径,然后再扩展。
- 测量。 跟踪使用 platform 的团队数量、从创建 repo 到首次部署的时间,以及开发者的定期反馈。如果没人自愿使用,这说明产品还不够好。
- 保持逃生路线。 Golden path 应该是最简单的路径,而不是唯一的强制路径。如第 5.3 节所示,
@ConditionalOnMissingBean是这个原则的一个小例子。 - 清晰的版本控制和弃用。 Starter、template 和共享 pipeline 都是 API。破坏兼容性的更改需要通知并提供升级路径。
8. 常见错误和何时不应使用
常见的错误:
- 在有内容之前构建 portal。 一个漂亮的 portal,但 catalog 为空且 template 过时,会很快被遗忘。
- 把 ops 团队改名为 platform team。 如果开发者仍然需要提工单等待,那还不是自助服务。
- 过度抽象。 完全隐藏 Kubernetes 会导致开发者在出现问题时无法调试。Platform 应该减少认知负担,但不应该消除查看底层的能力。
- 通过规定而不是价值来强制。 如果必须下命令人才会使用,那说明 platform 还没有解决真正的问题。
- “做所有事情”的 Starter。 一个拉入太多依赖项和隐式配置的 starter 会导致版本冲突和难以升级。
注意: 平台工程有相当大的固定成本。你需要人来构建、维护和支持 platform 本身。
什么时候不应该投资单独的 IDP:
- 小公司,只有几个团队和几个 service。一个好的 README 加上共享 pipeline 通常就足够了。
- 产品仍在寻求 product-market fit,架构经常变化。过早标准化会锁定即将改变的选择。
- 云的托管服务已经满足需求。自己重新构建类似的层只会增加维护工作。
- 没有人愿意担任 platform 的 product owner。缺少倾听开发者的人,platform 很容易变成一套没人想用的工具集合。
9. 结论
平台工程不是新工具。这是组织工作的一种方式:一个团队将内部开发者的体验视为产品,然后将基础设施知识打包成 golden path 和自助服务。你已经看到了三个小部分:标准化元数据的 Spring Boot starter 加上测试、位于 repo 中的 catalog descriptor 以及在 CI 中运行的 policy check。
从小处开始,真实测量,只有在开发者真正使用时才扩展。如果你的组织还很小,一个好的 template 和一个共享 pipeline 可以带来大部分好处,而不需要完整的 platform。