Platform engineering và internal developer platform: khái niệm và áp dụng
1. Tổng quan
Khi số lượng service tăng, mỗi đội phải tự lo CI/CD, hạ tầng, logging, bảo mật và tài liệu. Họ mất nhiều thời gian cho những việc lặp lại thay vì viết tính năng. Platform engineering giải quyết vấn đề này bằng cách gom các việc đó thành một internal developer platform (IDP) cho developer tự phục vụ.
Đọc xong bài này, bạn sẽ hiểu khái niệm và lý do nó được quan tâm. Bạn cũng sẽ dựng được một “golden path” nhỏ bằng Spring Boot starter, service catalog và một policy check đơn giản. Cuối bài là các trường hợp không nên đầu tư vào platform.
2. Platform engineering là gì?
Platform engineering là việc một nhóm chuyên trách (platform team) xây và vận hành một bộ công cụ, quy trình và dịch vụ nội bộ. Các đội sản phẩm (stream-aligned team) dùng bộ này để build, deploy và vận hành service mà không cần hiểu sâu mọi lớp hạ tầng bên dưới.
2.1. Internal developer platform
IDP là sản phẩm mà platform team tạo ra. Nó thường không phải một công cụ duy nhất. Đó là một lớp tích hợp nhiều thứ có sẵn: Git, CI, Kubernetes, cloud, secret manager, observability. Developer tương tác với lớp này qua portal, CLI, template hoặc chỉ một file cấu hình trong repo.
2.2. Ba ý tưởng cốt lõi
- Golden path (paved road): con đường được khuyến nghị và hỗ trợ sẵn để làm một việc phổ biến, ví dụ “tạo REST service mới”. Đi theo con đường này thì nhanh và an toàn. Đi đường khác vẫn được, nhưng đội tự chịu trách nhiệm.
- Self-service: developer tự tạo service, database hay môi trường test mà không cần mở ticket rồi chờ người khác.
- Platform as a product: developer nội bộ là khách hàng. Platform team phải phỏng vấn họ, đo mức độ dùng và ưu tiên backlog giống một đội sản phẩm thật.
2.3. Khác gì DevOps?
DevOps là văn hoá: đội viết code cũng chịu trách nhiệm vận hành (“you build it, you run it”). Platform engineering không thay thế DevOps. Nó là một cách hiện thực DevOps ở quy mô lớn. Thay vì bắt mỗi đội trở thành chuyên gia Kubernetes, Terraform và IAM, platform team đóng gói kiến thức đó thành những abstraction dễ dùng.
3. Vì sao chủ đề này đang được quan tâm?
Có vài lý do thực tế, không phụ thuộc vào một công cụ cụ thể nào:
- Cognitive load tăng: một service hiện đại có thể cần Dockerfile, manifest Kubernetes, pipeline, dashboard, alert, chính sách bảo mật và cấu hình secret. Mỗi thứ lại có nhiều lựa chọn.
- Mỗi đội một kiểu: không có chuẩn chung thì mỗi service log một định dạng và đặt tên metric một kiểu. Khi có sự cố, người trực khó tìm ra service đó thuộc đội nào.
- Yêu cầu bảo mật và tuân thủ: quét dependency, ký artifact, kiểm soát truy cập. Những việc này dễ áp dụng đồng đều nếu được nhúng sẵn vào golden path.
- Onboarding chậm: người mới cần biết “tạo service ở đâu, deploy thế nào, xem log ở đâu”. Một platform tốt trả lời được các câu đó ở một chỗ.
4. Các thành phần thường gặp của một IDP
| Thành phần | Vai trò |
|---|---|
| Service catalog | Danh sách service, owner, tài liệu, phụ thuộc |
| Software template | Tạo project mới theo chuẩn chỉ với vài tham số |
| CI/CD dùng chung | Pipeline có sẵn build, test, scan, deploy |
| Hạ tầng self-service | Tạo database, queue, bucket qua cấu hình khai báo |
| Observability mặc định | Log, metric, trace có sẵn khi service khởi động |
| Guardrail / policy | Kiểm tra tự động các quy tắc bắt buộc |
Bạn không cần đủ tất cả ngay từ đầu. Các mục sau minh hoạ ba mảnh nhỏ: một Spring Boot starter cho golden path, một catalog descriptor và một policy check.
5. Golden path bằng Spring Boot starter
Cách rẻ nhất để chuẩn hoá service Java là phát hành một starter nội bộ. Service nào thêm starter này sẽ tự có metadata chuẩn: tên, đội sở hữu, mức độ quan trọng. Metadata được hiển thị qua actuator.
5.1. Dependency
Starter nội bộ cần các dependency sau:
<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 được đánh dấu optional. Service nào không dùng actuator vẫn dùng được starter.
5.2. Properties và 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 is required");
}
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("platform.service.owner is required");
}
if (tier == null || tier.isBlank()) {
tier = "standard";
}
}
}
package dev.nonla.platform;
public record ServiceDescriptor(String name, String owner, String tier) {
}
Compact constructor của record làm hai việc:
- Bắt buộc phải khai báo
namevàowner. Thiếu một trong hai thì application không khởi động được. Đây chính là một guardrail: không có service “mồ côi”. - Gán giá trị mặc định
standardchotiernếu không khai báo.
5.3. Auto-configuration
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()));
}
}
}
Giải thích từng phần:
@ConditionalOnMissingBeancho phép service tự định nghĩaServiceDescriptorriêng. Đây là “lối thoát” khỏi golden path khi thật sự cần.- Nested class
ActuatorConfigurationchỉ được nạp khiInfoContributorcó trên classpath. Nhờ vậy service không dùng actuator sẽ không gặpClassNotFoundException.
Để Spring Boot 3 tìm thấy auto-configuration này, bạn đăng ký nó trong file src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
dev.nonla.platform.PlatformServiceAutoConfiguration
Phía service, ví dụ order-service, chỉ cần thêm starter và vài dòng cấu hình:
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
Gọi GET /actuator/info sẽ nhận được:
{"platform":{"name":"order-service","owner":"team-checkout","tier":"critical"}}
Từ đây, công cụ giám sát hoặc catalog có thể quét endpoint này để biết ai sở hữu service nào.
5.4. Test hành vi của starter
Đội platform phải test starter kỹ. Một lỗi ở đây sẽ ảnh hưởng tới mọi service đang dùng nó. ApplicationContextRunner giúp test auto-configuration nhanh mà không cần khởi động cả 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");
});
}
}
Test thứ nhất xác nhận giá trị mặc định và việc đăng ký InfoContributor. Test thứ hai xác nhận guardrail: thiếu owner thì context không khởi động, và thông báo lỗi chỉ rõ thuộc tính bị thiếu.
6. Service catalog và guardrail
6.1. Catalog descriptor trong repo
Hầu hết IDP lưu metadata của service ngay trong repo, cạnh code. Dưới đây là một descriptor theo kiểu thường thấy ở các portal mã nguồn mở:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: order-service
description: Xử lý đặt hàng và thanh toán
tags:
- java
- spring-boot
spec:
type: service
lifecycle: production
owner: team-checkout
system: checkout
dependsOn:
- resource:orders-db
providesApis:
- order-api
Lưu file này trong Git có hai lợi ích. Metadata được review cùng code, và nó không bị lệch khỏi thực tế như một trang wiki bị bỏ quên. Portal chỉ cần đọc file này để hiển thị owner, phụ thuộc và API của service.
6.2. Policy check đơn giản
Guardrail không nhất thiết phải dùng một policy engine phức tạp. Bước đầu, một đoạn code chạy trong CI là đủ để chặn các cấu hình sai:
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("name must be kebab-case, 3-41 chars");
}
if (!spec.owner().startsWith("team-")) {
errors.add("owner must be a team, not a person");
}
if (!ALLOWED_TIERS.contains(spec.tier())) {
errors.add("unknown tier: " + spec.tier());
}
if ("critical".equals(spec.tier()) && spec.replicas() < 2) {
errors.add("critical services need at least 2 replicas");
}
return errors;
}
}
Với input new ServiceSpec("Order_Service", "alice", "critical", 1), hàm trả về:
[name must be kebab-case, 3-41 chars, owner must be a team, not a person, critical services need at least 2 replicas]
Điểm quan trọng là thông báo lỗi phải nói rõ cần sửa gì. Guardrail tốt hướng dẫn developer sửa lỗi, chứ không chỉ chặn họ lại.
7. Áp dụng thực tế: bắt đầu từ đâu?
- Phỏng vấn developer trước. Hỏi xem họ mất thời gian nhất ở đâu: tạo service mới, chờ cấp môi trường, hay debug pipeline. Ưu tiên vấn đề đau nhất, không phải công cụ thú vị nhất.
- Bắt đầu với “thinnest viable platform”. Đôi khi chỉ cần một template repo, một pipeline dùng chung và một trang tài liệu. Portal có thể đến sau.
- Chọn một golden path duy nhất. Ví dụ “REST service Spring Boot deploy lên Kubernetes”. Làm tốt con đường này rồi mới mở rộng.
- Đo lường. Theo dõi số đội dùng platform, thời gian từ lúc tạo repo đến lần deploy đầu tiên, và phản hồi định kỳ của developer. Nếu không ai tự nguyện dùng, đó là tín hiệu sản phẩm chưa ổn.
- Giữ lối thoát. Golden path nên là con đường dễ nhất, không phải con đường bắt buộc duy nhất. Như ở mục 5.3,
@ConditionalOnMissingBeanlà một ví dụ nhỏ cho nguyên tắc này. - Versioning và deprecation rõ ràng. Starter, template và pipeline dùng chung đều là API. Thay đổi phá vỡ tương thích cần được thông báo và có lộ trình nâng cấp.
8. Lỗi hay gặp và khi nào không nên dùng
Các lỗi phổ biến:
- Xây portal trước khi có nội dung. Một portal đẹp nhưng catalog trống và template lỗi thời sẽ nhanh chóng bị bỏ quên.
- Đổi tên đội ops thành platform team. Nếu developer vẫn phải mở ticket rồi chờ, đó chưa phải self-service.
- Abstraction quá dày. Che giấu hoàn toàn Kubernetes khiến developer không debug được khi có sự cố. Platform nên giảm cognitive load chứ không nên xoá bỏ khả năng nhìn xuống lớp dưới.
- Ép buộc bằng quy định thay vì giá trị. Nếu phải ra lệnh thì người ta mới dùng, nghĩa là platform chưa giải quyết đúng vấn đề.
- Starter “làm mọi thứ”. Một starter kéo theo quá nhiều dependency và cấu hình ngầm sẽ gây xung đột phiên bản và khó nâng cấp.
Lưu ý: Platform engineering có chi phí cố định đáng kể. Bạn cần người xây, người vận hành và người hỗ trợ cho chính platform đó.
Khi nào không nên đầu tư vào một IDP riêng:
- Công ty nhỏ, chỉ có một vài đội và một vài service. Một README tốt cùng pipeline dùng chung thường là đủ.
- Sản phẩm còn đang tìm product-market fit và kiến trúc thay đổi liên tục. Chuẩn hoá quá sớm sẽ khoá cứng những lựa chọn sắp bị thay đổi.
- Các dịch vụ managed của cloud đã đáp ứng đủ nhu cầu. Tự xây lại lớp tương tự chỉ thêm việc phải bảo trì.
- Không có ai sẵn sàng làm product owner cho platform. Thiếu người lắng nghe developer, platform dễ biến thành bộ sưu tập công cụ mà không ai muốn dùng.
9. Kết luận
Platform engineering không phải công cụ mới. Đó là cách tổ chức công việc: một đội coi trải nghiệm của developer nội bộ là sản phẩm, rồi đóng gói kiến thức hạ tầng thành golden path và self-service. Bạn đã thấy ba mảnh ghép nhỏ: Spring Boot starter chuẩn hoá metadata kèm test, catalog descriptor nằm trong repo và policy check chạy trong CI.
Hãy bắt đầu nhỏ, đo lường thật, và chỉ mở rộng khi developer thực sự dùng. Nếu tổ chức của bạn còn nhỏ, một template tốt và một pipeline dùng chung có thể mang lại phần lớn lợi ích mà chưa cần một platform đầy đủ.