Profile & cấu hình: một bản build, chạy dev, staging, prod

6 câu hỏi~25 phútTừ khóa: application.yml, spring.profiles.active, @Profile, @Value, @ConfigurationProperties, @Validated, property precedence, environment variables, Vault

Tiếp đi: cấu hình và profile. Shop-app của mình chạy local thì dùng H2, lên server thì dùng PostgreSQL. Hiện mình đang sửa tay file config trước mỗi lần deploy, có cách nào tốt hơn không?

Có. Sửa tay trước mỗi lần deploy là cách dễ gây sự cố nhất. Spring Boot giải quyết bằng externalized configuration và profile: một file JAR duy nhất, môi trường nào thì nạp cấu hình của môi trường đó.

1. Bài toán: shop-app chạy ở 3 môi trường

             dev (laptop)        staging              prod
DB           H2 in-memory        Postgres staging     Postgres cluster
Payment URL  mock localhost      sandbox.pay.vn       api.pay.vn
Log level    DEBUG               INFO                 WARN
Password     không cần           từ env               từ Vault

2. Vấn đề

3. Giải pháp

4. Code từng bước

Bước 1: Dependency. Có sẵn trong spring-boot-starter. Thêm validation và processor để IDE gợi ý key:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

Bước 2: File config

# application.yml  (chung cho mọi môi trường)
spring:
  application:
    name: shop-app
  profiles:
    active: dev            # mặc định khi chạy local
shop:
  payment:
    timeout: 3s
    retry: 2

---
# application-dev.yml
spring:
  datasource:
    url: jdbc:h2:mem:shop
shop:
  payment:
    base-url: http://localhost:9090

---
# application-prod.yml
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST}:5432/shop
    username: ${DB_USER}
    password: ${DB_PASSWORD}      # không bao giờ ghi giá trị thật
shop:
  payment:
    base-url: https://api.pay.vn
    api-key: ${PAYMENT_API_KEY}
logging:
  level:
    root: WARN

Bước 3: Class cấu hình có kiểu

@Validated
@ConfigurationProperties(prefix = "shop.payment")
public record PaymentProperties(
        @NotBlank String baseUrl,
        String apiKey,
        @NotNull Duration timeout,
        @Min(0) @Max(5) int retry) { }

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication { ... }

@Service
public class PaymentClient {
    private final RestClient client;

    public PaymentClient(PaymentProperties props, RestClient.Builder builder) {
        this.client = builder.baseUrl(props.baseUrl())
            .defaultHeader("X-Api-Key", props.apiKey())
            .build();
    }
}

base-url trong yml tự map sang baseUrl (relaxed binding). 3s tự đổi thành Duration.

Bước 4: Chạy và test

# local, profile dev mặc định
./mvnw spring-boot:run

# prod: cùng một JAR, chỉ đổi biến môi trường
export SPRING_PROFILES_ACTIVE=prod
export DB_HOST=db.internal DB_USER=shop DB_PASSWORD=...   # lấy từ secret store
java -jar shop-app.jar

# kiểm tra profile đang chạy (cần actuator, bài 4.1.9)
curl localhost:8080/actuator/env/shop.payment.base-url

Nếu thiếu base-url hoặc retry: 9, app không khởi động, báo rõ key nào sai. Lỗi ở lúc deploy tốt hơn lỗi lúc khách thanh toán.

5. Cách hoạt động

java -jar shop-app.jar  (SPRING_PROFILES_ACTIVE=prod)
        |
        v
Environment ── nạp các PropertySource theo thứ tự
   application.yml           (thấp)
   application-prod.yml      (ghi đè file chung)
   biến môi trường OS        (ghi đè file)
   --tham.so.dong.lenh       (cao nhất)
        |
        v
Binder: shop.payment.* ──→ PaymentProperties (record)
        |                        |
        |                  @Validated → sai thì fail startup
        v
Tạo bean: @Profile("prod") được tạo, @Profile("dev") bị bỏ qua

6. So sánh .properties và .yml

application.propertiesapplication.yml
Cú phápshop.payment.retry=2, phẳngLồng nhau theo thụt lề
List, mapa[0]=x, dàiGọn, dễ đọc
Nhiều profile một fileCó (#---, Boot 2.4+)Có (---)
Rủi roÍtSai thụt lề là sai cấu trúc
Cùng tồn tạiĐược; cùng key thì .properties thắng. Nên chọn một kiểu cho cả team.

7. Khi nào nên dùng / không nên dùng

Code cũ của mình toàn @Value("${shop.payment.timeout}"), vẫn chạy bình thường. Vậy @ConfigurationProperties hơn ở chỗ nào?

Bạn hiểu đúng là @Value vẫn chạy. Khác biệt nằm ở chỗ khi config nhiều lên và khi nó sai.

// @Value: rải rác, key là String
@Service
public class PaymentClient {
    @Value("${shop.payment.base-url}") String baseUrl;
    @Value("${shop.payment.timout:3s}") Duration timeout;   // gõ sai key → lặng lẽ dùng default
    @Value("${shop.payment.retry}") int retry;
}

@Service
public class RefundService {
    @Value("${shop.payment.base-url}") String baseUrl;     // lặp lại key
}
@Value@ConfigurationProperties
Phạm viMột giá trịCả nhóm theo prefix
Type-safeChuyển kiểu đơn giảnCó, kể cả object lồng, List, Map, Duration, DataSize
ValidateKhông@Validated + Bean Validation
Relaxed bindingHạn chếCó: base-url, baseUrl, SHOP_PAYMENT_BASEURL
SpELCó (#{...})Không
TestPhải set field qua reflectionnew PaymentProperties(...)

Quy tắc thực tế: một giá trị lẻ, dùng ở một chỗ thì @Value vẫn ổn. Nhóm config của một tính năng thì gom vào @ConfigurationProperties. Không cần đổi hết một lần, sửa dần khi đụng vào.

Trên server mình set biến môi trường SHOP_PAYMENT_RETRY=0 mà trong application-prod.yml ghi retry: 3. Vậy cái nào thắng?

Biến môi trường thắng. Spring Boot có thứ tự ưu tiên cố định; nguồn "bên ngoài JAR" luôn đè nguồn "bên trong JAR". Rút gọn những nguồn hay gặp (cao xuống thấp):

1. Thuộc tính trong test (@SpringBootTest(properties=...), @DynamicPropertySource)
2. Tham số dòng lệnh            --shop.payment.retry=1
3. SPRING_APPLICATION_JSON
4. Biến môi trường OS           SHOP_PAYMENT_RETRY=0      ← thắng trong câu hỏi
5. application-{profile}.yml    bên ngoài JAR (./config/)
6. application.yml              bên ngoài JAR
7. application-{profile}.yml    trong JAR                 retry: 3
8. application.yml              trong JAR
9. @PropertySource, giá trị mặc định

Cách đổi tên sang biến môi trường: chữ hoa, . thành _, bỏ dấu -. shop.payment.base-url → SHOP_PAYMENT_BASEURL.

Đây chính là lý do Docker/Kubernetes hợp với Spring Boot: image giống nhau, chỉ khác biến môi trường trong Deployment. Khi bị "sao config không ăn", kiểm tra /actuator/env: nó cho biết giá trị đang dùng đến từ nguồn nào.

Vậy password DB, API key của cổng thanh toán nên để ở đâu? Để trong biến môi trường đã đủ an toàn chưa?

Gần đúng. Biến môi trường tốt hơn hẳn việc commit vào Git, nhưng chưa phải đích cuối. Có ba mức:

MứcCáchGhi chú
TệGhi thẳng trong application-prod.ymlLộ trong Git history mãi mãi
Ổn${DB_PASSWORD} + biến môi trường từ Kubernetes Secret / CI secretAi đọc được env của pod là thấy; rotate phải restart
TốtSecret manager: HashiCorp Vault, AWS Secrets ManagerCó audit, phân quyền, rotate, thậm chí credential động

Với Vault, Spring Boot 3 nạp secret như một property source bằng spring.config.import:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency>
# application-prod.yml
spring:
  config:
    import: vault://
  cloud:
    vault:
      uri: https://vault.internal:8200
      authentication: KUBERNETES      # pod tự xác thực bằng service account, không cần token cứng
      kv:
        backend: secret
        default-context: shop-app
# Vault chứa secret/shop-app: spring.datasource.password, shop.payment.api-key
Pod shop-app ──(service account)──→ Vault ── kiểm quyền ──→ trả secret
     |
     v
Environment: spring.datasource.password = ****   (code không đổi gì)

Thêm vài thói quen: không log object config chứa secret (toString() của record in hết field), actuator /env phải che giá trị nhạy cảm, và quét Git bằng công cụ như gitleaks trong CI.

Còn @Profile trên bean thì dùng lúc nào? Ví dụ local mình không muốn gọi cổng thanh toán thật.

Đúng ví dụ kinh điển. Khi khác biệt không chỉ là giá trị mà là cả implementation, dùng @Profile để chọn bean:

public interface PaymentGateway {
    PaymentResult charge(Long orderId, BigDecimal amount);
}

@Component
@Profile("dev")
public class FakePaymentGateway implements PaymentGateway {
    public PaymentResult charge(Long orderId, BigDecimal amount) {
        return PaymentResult.success("FAKE-" + orderId);     // luôn thành công
    }
}

@Component
@Profile("!dev")                     // staging và prod
public class VnPayGateway implements PaymentGateway { ... }

OrderService chỉ inject PaymentGateway, không biết đang chạy bản nào. Mỗi profile đúng một bean, nên không bị lỗi "2 bean cùng kiểu".

Nhưng cẩn thận:

@Bean
@ConditionalOnProperty(name = "shop.payment.mode", havingValue = "fake")
PaymentGateway fakeGateway() { return new FakePaymentGateway(); }

Trong test, dùng @ActiveProfiles("test") để nạp application-test.yml.

Phỏng vấn hỏi profile và cấu hình Spring Boot thì trả lời sao?

Q1. Quản lý cấu hình nhiều môi trường trong Spring Boot thế nào?

🇻🇳 Tiếng Việt:

Em build một artifact duy nhất. Cấu hình chung để trong application.yml, khác biệt theo môi trường để trong application-{profile}.yml, bật bằng SPRING_PROFILES_ACTIVE. Giá trị nhạy cảm không nằm trong file mà lấy từ biến môi trường hoặc secret manager như Vault. Nhờ thứ tự ưu tiên của property source, biến môi trường đè được file trong JAR.

🇬🇧 English:

I build a single artifact. Shared settings go in application.yml, environment-specific overrides in application-{profile}.yml, activated with SPRING_PROFILES_ACTIVE. Secrets never live in files; they come from environment variables or a secret manager like Vault. Because of property source precedence, environment variables override the files packaged in the JAR.

Q2. @Value và @ConfigurationProperties khác nhau thế nào?

🇻🇳 Tiếng Việt:

@Value inject từng giá trị, hỗ trợ SpEL, phù hợp giá trị lẻ. @ConfigurationProperties bind cả nhóm theo prefix vào một class có kiểu, hỗ trợ relaxed binding, object lồng, Duration, và validate bằng @Validated để fail ngay lúc startup. Với nhóm config của một tính năng, em ưu tiên @ConfigurationProperties.

🇬🇧 English:

@Value injects single values and supports SpEL, fine for one-off settings. @ConfigurationProperties binds a whole prefix into a typed class with relaxed binding, nested objects and Duration support, and can be validated with @Validated so misconfiguration fails at startup. For a feature's group of settings I prefer @ConfigurationProperties.

Q3. Biến môi trường và file yml cùng định nghĩa một key thì cái nào thắng?

🇻🇳 Tiếng Việt:

Biến môi trường thắng. Thứ tự từ cao xuống: tham số dòng lệnh, biến môi trường, file profile bên ngoài JAR, file bên ngoài, file profile trong JAR, file trong JAR. Muốn biết giá trị đến từ đâu thì xem /actuator/env.

🇬🇧 English:

The environment variable wins. From highest to lowest: command-line arguments, environment variables, profile files outside the JAR, plain files outside, profile files inside the JAR, then plain files inside. To see where a value comes from, check /actuator/env.

Cách nhớ ngắn gọn


Tự kiểm tra

SHOP_PAYMENT_RETRY=0 và retry: 3 trong application-prod.yml, app dùng giá trị nào?

0. Biến môi trường có độ ưu tiên cao hơn file trong JAR.

@Validated trên @ConfigurationProperties mang lại gì?

Config sai (thiếu key, ngoài khoảng) làm app fail lúc startup thay vì lỗi lúc runtime.

shop.payment.base-url viết thành biến môi trường thế nào?

SHOP_PAYMENT_BASEURL.

Vì sao không nên build mỗi môi trường một JAR?

Thứ đã test ở staging không chắc là thứ chạy ở prod. Một artifact, khác nhau chỉ ở cấu hình bên ngoài.

Khi nào dùng @Profile thay vì chỉ đổi giá trị config?

Khi cần implementation khác hẳn, ví dụ FakePaymentGateway ở dev và gateway thật ở prod.