Backend

99 Ngày Spring — Ngày 72: Spring Cloud Config

SSite Admin
7 tháng 10, 2026 7 phút đọc 3 lượt xem
99 Ngày Spring — Ngày 72: Spring Cloud Config

Ngày 71 ta chốt khi nào microservices xứng đáng. Bài toán đầu tiên xuất hiện ngay khi có hơn một service: cấu hình. Mỗi service có application.yml riêng, mỗi môi trường một bản, URL và secret rải trong mười repo, đổi một timeout là mười lần deploy. Spring Cloud Config đưa cấu hình về một nơi có version, cho service tự lấy lúc khởi động, và cho phép đổi giá trị khi đang chạy. Bài này dùng Spring Boot 3.5, Spring Cloud 2025.0 và Java 21.

Spring Cloud Config: Git làm nguồn cấu hình, config server, refresh và mã hóa secret

1. Cấu hình là dữ liệu có version, không phải file trong image

Mô hình gồm ba phần. Config repo: một Git repo riêng chứa các file YAML theo tên service và profile. Config Server: ứng dụng Spring Boot đọc repo đó và phục vụ qua HTTP. Client: mỗi service, lúc khởi động, hỏi server bằng tên ứng dụng và profile của mình rồi nhận về các property như thể chúng nằm trong application.yml.

config-repo/
├── application.yml            # chung cho MỌI service
├── order-service/
│   ├── order-service.yml      # mặc định của order-service
│   └── order-service-prod.yml # ghi đè khi profile = prod
└── payment-service/
    └── payment-service.yml

# order-service/order-service-prod.yml
order:
  max-items: 50
  payment-url: https://payment.internal
  api-key: '{cipher}AQB3f9...'   # đã mã hóa bằng POST /encrypt của Config Server
  • Thứ tự ưu tiên: {application}-{profile}.yml ghi đè {application}.yml, và cả hai ghi đè application.yml dùng chung. Đúng thứ tự profile bạn đã quen từ Spring Boot.

  • Tách repo cấu hình khỏi repo code: quyền truy cập khác nhau, nhịp thay đổi khác nhau, và một commit cấu hình không nên kéo theo build lại image.

  • Mọi thay đổi có lịch sử Git: ai đổi timeout, khi nào, và rollback là một git revert.

2. Dựng Config Server

Thêm BOM Spring Cloud vào dependency management, rồi dependency spring-cloud-config-server và spring-boot-starter-security cho server. Không tự chọn phiên bản từng artifact Spring Cloud: BOM bảo đảm chúng khớp nhau và khớp Boot.

<!-- pom.xml của MỌI service trong hệ: BOM Spring Cloud khớp với Spring Boot 3.5.x -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
package vn.motdev.configserver;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;

@SpringBootApplication
@EnableConfigServer   // dependency: spring-cloud-config-server
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}
# config-server/src/main/resources/application.yml
server:
  port: 8888
spring:
  application:
    name: config-server
  cloud:
    config:
      server:
        git:
          uri: https://github.com/motdev/config-repo   # repo riêng, KHÔNG phải repo code
          default-label: main
          clone-on-start: true          # lỗi ngay lúc khởi động nếu không clone được
          search-paths: '{application}' # mỗi service một thư mục
  security:
    user:
      name: config
      password: ${CONFIG_SERVER_PASSWORD}   # thêm spring-boot-starter-security để bật Basic Auth
encrypt:
  key: ${ENCRYPT_KEY}   # khóa đối xứng lấy từ biến môi trường — không bao giờ nằm trong git
  • Kiểm tra bằng GET /order-service/prod với Basic Auth: server trả JSON gồm các property source tìm được, xếp theo thứ tự ưu tiên. Đây là cách nhanh nhất để biết vì sao một giá trị không như bạn nghĩ.

  • clone-on-start: true làm server thất bại ngay khi khởi động nếu không clone được repo, thay vì thất bại lần đầu có client hỏi. Lỗi sớm luôn rẻ hơn.

  • Backend không chỉ có Git: native đọc thư mục cục bộ (tiện cho dev), vault lấy từ HashiCorp Vault, và có thể ghép nhiều backend. Với secret quan trọng, Vault là lựa chọn đúng hơn mã hóa trong Git.

3. Client: một dòng spring.config.import

# order-service/src/main/resources/application.yml
spring:
  application:
    name: order-service          # = tên file trong config-repo
  profiles:
    active: prod
  config:
    import: "optional:configserver:http://config:${CONFIG_SERVER_PASSWORD}@localhost:8888"
  cloud:
    config:
      fail-fast: true            # không có cấu hình thì đừng khởi động "nửa vời"
      retry:
        max-attempts: 6          # cần spring-retry + spring-aspects trên classpath
        initial-interval: 1000
management:
  endpoints:
    web:
      exposure:
        include: health,refresh  # mở /actuator/refresh — và CHỈ những gì cần
package vn.motdev.order;

import org.springframework.boot.context.properties.ConfigurationProperties;

// Bean @ConfigurationProperties được Spring Cloud rebind sau /actuator/refresh —
// không cần @RefreshScope. Record: bất biến, mỗi lần refresh là một instance mới.
@ConfigurationProperties(prefix = "order")
public record OrderProperties(int maxItems, String paymentUrl, String apiKey) {}
  • Từ Spring Boot 2.4, client khai báo nguồn bằng spring.config.import thay cho bootstrap.yml cũ. Tiền tố optional: cho phép khởi động khi không có server; với môi trường thật hãy bỏ optional: và bật fail-fast để service không chạy với cấu hình thiếu.

  • spring.application.name phải trùng tên file trong repo. Đây là nguồn của phần lớn lỗi “server trả rỗng”.

  • Property từ server có thể bị ghi đè bởi biến môi trường và tham số dòng lệnh như thường lệ, nên cơ chế ENV của Kubernetes vẫn dùng được cho những gì đặc thù theo pod.

  • Retry cần spring-retry và spring-boot-starter-aop trên classpath; nếu thiếu, cấu hình retry.* bị bỏ qua trong im lặng.

4. Đổi cấu hình khi đang chạy: refresh

package vn.motdev.order;

import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RefreshScope   // bean được TẠO LẠI lười biếng sau refresh — chỉ dùng cho bean rẻ, không stateful
public class LimitController {
    private final OrderProperties props;
    LimitController(OrderProperties props) { this.props = props; }

    @GetMapping("/limits")
    public String limits() {
        return "max-items=" + props.maxItems();
    }
}

// Sau khi sửa order-service-prod.yml và push lên config-repo:
//   curl -X POST http://localhost:8080/actuator/refresh
//   → ["order.max-items"]   (danh sách khóa vừa đổi)
  • POST /actuator/refresh làm client tải lại cấu hình từ server, so sánh, và trả về danh sách khóa đã đổi. Bean @ConfigurationProperties được rebind tự động; bean khác chỉ nhận giá trị mới nếu có @RefreshScope.

  • @RefreshScope tạo lại bean lười biếng ở lần dùng kế tiếp. Không đặt lên bean giữ trạng thái hoặc tài nguyên đắt (connection pool, cache): bạn sẽ mất trạng thái hoặc tạo pool mới mà không đóng pool cũ.

  • Với 30 instance, gọi refresh từng cái là không thực tế. Spring Cloud Bus (RabbitMQ hoặc Kafka) phát tán một POST /actuator/busrefresh tới mọi instance; webhook từ Git có thể gọi thẳng endpoint này trên server.

  • Không phải property nào cũng refresh được: server.port, cấu hình DataSource và những gì được đọc một lần lúc khởi động vẫn cần deploy lại. Refresh giải quyết feature flag, giới hạn, URL đối tác, không giải quyết tất cả.

5. Secret: mã hóa trong repo, khóa ngoài repo

# Mã hóa một giá trị bằng khóa của server (không cần biết khóa ở máy dev):
curl -u config:$CONFIG_SERVER_PASSWORD -X POST http://localhost:8888/encrypt -d 'sk-live-123'
# → AQB3f9...  → ghi vào yml dưới dạng  '{cipher}AQB3f9...'

# Server tự giải mã trước khi trả cho client (spring.cloud.config.server.encrypt.enabled=true, mặc định)
curl -u config:$CONFIG_SERVER_PASSWORD http://localhost:8888/order-service/prod | jq '.propertySources[0].source'
  • Giá trị {cipher}... nằm trong Git an toàn miễn khóa encrypt.key chỉ tồn tại ở biến môi trường của server. Ai đọc được repo vẫn không đọc được secret; ai đọc được biến môi trường của server thì đọc được tất cả, nên hãy bảo vệ server như bảo vệ Vault.

  • Khóa đối xứng đủ cho đội nhỏ; có thể dùng keystore RSA (encrypt.key-store.*) để tách quyền mã hóa và giải mã.

  • Có thể tắt giải mã ở server và để client tự giải mã bằng khóa riêng; khi đó server không bao giờ thấy plaintext, đổi lại mỗi client phải giữ khóa.

  • Endpoint /encrypt, /decrypt và cả /actuator/env của client là những nơi lộ secret tiềm tàng: giới hạn theo mạng nội bộ và xác thực, không mở ra ngoài.

6. Bài tập thực hành

  • Tạo config-repo cục bộ, chạy server với backend native, rồi gọi GET /order-service/prod và giải thích thứ tự các property source trong JSON.

  • Đổi order.max-items trong repo, gọi /actuator/refresh và xác nhận /limits đổi theo mà không khởi động lại.

  • Bỏ optional: và tắt server: service phải thất bại lúc khởi động với thông điệp rõ ràng. Bật retry và quan sát số lần thử.

  • Mã hóa một giá trị, commit dạng {cipher}, rồi thử đổi ENCRYPT_KEY trên server: thông điệp lỗi trông thế nào và client nhận được gì?

Tóm lại: cấu hình sống trong Git với lịch sử, Config Server phục vụ theo tên ứng dụng và profile, client nhận qua spring.config.import và fail-fast khi thiếu, /actuator/refresh đổi giá trị đang chạy cho những gì thật sự refresh được, và secret đi vào repo ở dạng {cipher} với khóa nằm ngoài. Có cấu hình tập trung rồi, câu hỏi kế tiếp là các service tìm thấy nhau bằng cách nào khi địa chỉ thay đổi liên tục: Ngày 73 là service discovery với Eureka.

Tài liệu đối chiếu

Spring Cloud Config reference: https://docs.spring.io/spring-cloud-config/reference/ ; Spring Boot config data import: https://docs.spring.io/spring-boot/reference/features/external-config.html ; Spring Cloud Bus: https://docs.spring.io/spring-cloud-bus/reference/

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 72: Garbage Collection

Reachability từ GC root, minor và full GC, G1 mặc định và ZGC thế hệ trong Java 21, cách đọc log GC bằng -Xlog và những thói quen nên bỏ.

7 thg 10, 20269 phút4
99 Ngày Spring — Ngày 71: Microservices tổng quan

Monolith và microservices khác nhau ở đâu, được gì và trả giá gì, khi nào không nên tách, và vì sao nên bắt đầu bằng modular monolith với Spring Modulith.

6 thg 10, 20269 phút8
99 Ngày Java — Ngày 71: Bộ nhớ JVM — stack, heap, metaspace

Bản đồ bộ nhớ JVM trên Java 21: stack và frame của từng luồng, heap chia young/old, metaspace ngoài heap, và cách đọc đúng từng loại OutOfMemoryError.

6 thg 10, 202611 phút8