Spring Core & Boot/PropertySource ordering & relax binding — thứ tự ưu tiên và K8s env var
35/41
Bài 35 / 41~12 phútConfig, Profiles & LoggingMiễn phí lượt xem

PropertySource ordering & relax binding — thứ tự ưu tiên và K8s env var

Boot xếp chồng nhiều nguồn config theo thứ tự ưu tiên cố định. Bài này bóc cơ chế PropertySource ordering (command line uu tien cao nhat, tại sao), 12-factor externalize config, và relax binding (DB_URL map sang db.url mà không cần config thêm) — nền tảng để deploy app trên K8s/Docker không hardcode.

TL;DR: Spring Boot xếp chồng nhiều PropertySource theo thứ tự ưu tiên cố định — command line args ưu tiên cao nhất, rồi env var, rồi file application-{profile}.yml, cuối cùng là application.yml mặc định. Khi resolve ${db.url}, Boot tra từ nguồn cao đến thấp, lấy giá trị đầu tiên tìm được. Relax binding tự động map SPRING_DATASOURCE_URL (UPPER_SNAKE_CASE của K8s) thành spring.datasource.url — không cần config thêm. Command line được ưu tiên cao nhất vì nó là override cuối cùng của operator, không phải developer. Bài này giải thích cơ chế bên dưới hai tính năng đó để bạn debug cấu hình production chính xác, không đoán mò.

Bài Environment & PropertySource đã giới thiệu Environment là lớp trừu tượng tập trung mọi nguồn config và cơ chế PropertySourcesPropertyResolver. Bài này đi sâu hơn vào ứng dụng thực tế: tại sao thứ tự ưu tiên được thiết kế như vậy, làm sao relax binding hoạt động, và pattern chuẩn cho K8s/Docker deployment.

1. Vấn đề: config hardcode và 12-factor App

Spring 4 era, cấu hình thường nằm trong code:

// Thoi Spring 4 — hardcode moi truong
DataSource ds = new HikariDataSource(
    "jdbc:postgresql://localhost/dev",
    "dev_user",
    "dev_pass"
);

Khi cần deploy lên production: sửa code → rebuild → redeploy. Ba vấn đề rõ ràng:

  • Build per env: dev/staging/prod khác DB URL → phải build 3 jar khác nhau. Không reproducible — jar chạy ở staging không phải jar đã test ở dev.
  • Secret leak: password trong source code → commit git → security incident. Repository scanner như GitGuardian detect và alert.
  • Override khó: muốn thử một setting khác cho một lần debug → phải sửa code, rebuild, redeploy.

12-factor App (methodology từ Heroku, 2011) — factor III ("Config") nói: lưu config trong môi trường, không trong code. Config là thứ thay đổi giữa các deployment (dev/staging/prod); code thì không. Hệ quả: một artifact jar duy nhất chạy mọi môi trường, config inject từ bên ngoài.

# Cung 1 jar, config khac nhau theo moi truong:
java -jar app.jar --spring.profiles.active=dev      # dev
DB_URL=jdbc:postgresql://prod/app java -jar app.jar  # prod
java -jar app.jar --db.url=jdbc:postgresql://test/app  # smoke test

Spring Boot hiện thực 12-factor Config qua PropertySource — nhiều nguồn config xếp chồng theo thứ tự ưu tiên.

2. PropertySource ordering — thứ tự ưu tiên từ cao xuống thấp

Environment của Spring gom nhiều PropertySource lại và tra theo thứ tự cố định. Khi một property xuất hiện ở nhiều nguồn, nguồn ưu tiên cao hơn thắng.

Thứ tự đó, từ cao xuống thấp:

NguồnVí dụKhi dùng
Command line args--db.url=jdbc:...Ad-hoc override, smoke test, debug
Java System Properties-Ddb.url=jdbc:...JVM-level override, legacy tooling
OS env varDB_URL=jdbc:...K8s/Docker — standard injection
Profile-specific fileapplication-prod.ymlConfig khác nhau giữa môi trường
Default fileapplication.ymlBase config + dev defaults
@PropertySource@PropertySource("classpath:extra.properties")Config bổ sung từ file custom
Default propertiesapp.setDefaultProperties(...)Fallback trong code framework

Quy tắc nhớ: càng "ngoài" (do operator quyết định lúc chạy) → càng cao priority. application.yml nằm trong jar (do developer quyết định lúc build) — ưu tiên thấp nhất trong nhóm thực tế.

2.1 Cơ chế bên dưới — PropertySourcesPropertyResolver

Giả sử bốn nguồn cùng set db.url, mỗi nơi một giá trị:

Bốn nguồn cùng set db.url; resolver dừng ngay ở command line args và trả về prod2, ba giá trị còn lại ở tầng dưới không được đọc tới

Class PropertySourcesPropertyResolver (trong spring-core) duyệt danh sách MutablePropertySources theo thứ tự, gọi PropertySource.getProperty(name) trên từng cái, trả về giá trị đầu tiên khác null — rồi dừng. Nó không gom mọi nguồn lại rồi chọn, cũng không cảnh báo rằng có ba nguồn khác cũng khai key này. Khi bạn thêm CommandLinePropertySource (tự động khi dùng SpringApplication.run()), nó được thêm vào đầu danh sách — do đó luôn thắng.

Hệ quả: một giá trị "không có tác dụng" hầu như luôn là chuyện vị trí, không phải cú pháp — env var của bạn vẫn đúng, chỉ là có tầng cao hơn đã trả lời trước.

Bạn có thể quan sát trực tiếp tại runtime qua /actuator/env:

{
  "name": "db.url",
  "property": {
    "value": "jdbc:postgresql://prod/app",
    "origin": "System Environment Property \"DB_URL\""
  }
}

Dòng origin cho biết chính xác PropertySource nào win — công cụ debug mạnh nhất khi "tại sao config của tôi không có tác dụng".

3. Tại sao command line được ưu tiên cao nhất

Câu hỏi hợp lý: tại sao không đặt env var ưu tiên cao nhất? Lý do thiết kế:

Command line là override cuối cùng của operator tại thời điểm chạy. Khi bạn chạy:

java -jar app.jar --db.url=jdbc:postgresql://canary/app

Đây là lệnh tường minh, explicit — operator/SRE đang nói rõ "tôi muốn dùng giá trị này cho lần chạy này". Nó không nên bị override bởi bất kỳ thứ gì đã được set sẵn trong môi trường.

Ngược lại, env var thường được set ở cấp deployment (K8s manifest, Docker compose) — không phải cho từng lần chạy cụ thể. Command line cho phép override env var mà không cần sửa manifest. Ứng dụng thực tế:

# CI/CD standard: env var set trong K8s manifest
DB_URL=jdbc:postgresql://prod/app

# SRE can test 1 lan voi DB backup ma khong sua manifest:
kubectl exec pod -- java -jar app.jar --db.url=jdbc:postgresql://backup/app

Nếu env var ưu tiên cao hơn command line, pattern này không hoạt động được.

Java System Properties (-D...) nằm dưới command line cũng có lý: system property set ở cấp JVM process, còn command line args là app-level explicit override. Thứ tự phản ánh "app config cụ thể hơn JVM config".

4. Relax binding — vì sao DB_URL map được sang db.url

K8s và Docker convention dùng UPPER_SNAKE_CASE cho env var (SPRING_DATASOURCE_URL, DB_MAX_POOL_SIZE). YAML dùng kebab-case (spring.datasource.url, db.max-pool-size). Hai format không match trực tiếp.

Spring Boot giải quyết bằng relax binding: Boot chuẩn hoá tên property về dạng canonical trước khi so sánh.

4.1 Quy tắc chuẩn hoá

Boot convert mọi tên property về dạng lowercase + không separator:

spring.datasource.url   →  springdatasourceurl
SPRING_DATASOURCE_URL   →  springdatasourceurl
spring_datasource_url   →  springdatasourceurl
spring.datasource-url   →  springdatasourceurl

Tất cả đều cùng một canonical form → match với nhau. Quy tắc canonical form được implement trong SpringConfigurationPropertySourceRelaxedPropertyResolver.

// Simplified logic trong RelaxedNames:
static String canonicalize(String name) {
    return name.toLowerCase()
               .replace("-", "")
               .replace("_", "")
               .replace(".", "");
}

Canonical form là thước đo duy nhất: khớp thì bind, lệch một segment thì không — và Boot không kêu tiếng nào ở trường hợp thứ hai.

Ba cách viết cùng cho ra canonical springdatasourceurl nên bind vào cùng một property; còn env var thiếu segment HIKARI cho canonical khác nên không bind mà cũng không sinh lỗi

Nửa dưới của hình là ca hay gặp nhất: env var trông rất hợp lý nhưng thiếu một segment so với tên property Boot thật sự dùng — không exception, không log, property chỉ giữ nguyên mặc định.

4.2 Mapping thực tế K8s/Docker

# K8s deployment.yaml — env var UPPER_SNAKE_CASE
spec:
  containers:
    - name: app
      env:
        - name: SPRING_DATASOURCE_URL        # → spring.datasource.url
          value: jdbc:postgresql://prod/app
        - name: SPRING_DATASOURCE_USERNAME   # → spring.datasource.username
          value: app_prod
        - name: DB_PASSWORD                  # → db.password
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: password
        - name: SPRING_PROFILES_ACTIVE       # → spring.profiles.active
          value: prod
# application.yml — kebab-case canonical
spring:
  datasource:
    url: jdbc:postgresql://localhost/dev     # default dev
    username: dev_user
    password: ${DB_PASSWORD}                # required, khong co default
  profiles:
    active: dev

Boot resolve spring.datasource.url:

  1. Check command line — không có.
  2. Check env var SPRING_DATASOURCE_URL (canonical: springdatasourceurl) — match spring.datasource.url (cùng canonical form), nên Boot trả về giá trị prod.

Không cần config thêm gì. Relax binding là tính năng "zero-config" cho K8s.

4.3 Tại sao relax binding tồn tại — lý do thực tiễn

Bash/Zsh không cho phép . hoặc - trong tên env var (chỉ accept [A-Za-z_][A-Za-z0-9_]*). Nếu Boot yêu cầu env var match chính xác spring.datasource.url, bạn không thể set env var đó trong shell hoặc K8s YAML một cách thông thường.

UPPER_SNAKE_CASE (SPRING_DATASOURCE_URL) là format hợp lệ trong mọi shell, Docker, K8s, CI/CD. Relax binding là cầu nối giữa YAML convention (kebab, hierarchical) và shell/container convention (UPPER_SNAKE) — không cần user config gì, Boot tự lo.

Verify relax binding tại runtime

Bật management.endpoints.web.exposure.include=env và check /actuator/env. Response chỉ ra từng property đến từ nguồn nào, ví dụ origin: "System Environment Property SPRING_DATASOURCE_URL" — xác nhận relax binding đã map đúng.

5. Pattern production chuẩn 2026

Kết hợp PropertySource ordering và relax binding, pattern deployment chuẩn:

application.yml             ← base defaults + dev config (commit git)
application-prod.yml        ← prod-specific non-secret (commit git)
K8s env var                 ← secrets + runtime override (không commit)
Command line                ← ad-hoc override (debug, canary)
# application.yml — commit git, base defaults
spring:
  datasource:
    url: jdbc:postgresql://localhost/dev
    username: dev_user
    password: ${DB_PASSWORD:dev_pass}     # fallback cho local dev

app:
  max-orders: 100
  allowed-origins:
    - http://localhost:3000
# application-prod.yml — commit git, prod non-secret
spring:
  datasource:
    url: jdbc:postgresql://prod-db.internal:5432/app
    username: app_prod
    password: ${DB_PASSWORD}              # required, khong co default
  jpa:
    show-sql: false

app:
  max-orders: 5000
  allowed-origins:
    - https://olhub.org
    - https://www.olhub.org
# K8s manifest — secrets inject qua env var
env:
  - name: SPRING_PROFILES_ACTIVE
    value: prod
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: db-secret
        key: password

Boot resolve db.password cho request production:

  1. Command line — không.
  2. Env var DB_PASSWORD — có (K8s Secret inject) → win.

application-prod.yml được load (vì SPRING_PROFILES_ACTIVE=prod) nhưng db.password ở đó chỉ là ${DB_PASSWORD} — Boot tiếp tục resolve placeholder này từ env var. Đây là placeholder resolution trong YAML, không phải PropertySource priority — cả hai cơ chế phối hợp nhau.

6. Pitfall phổ biến

Pitfall 1 — "env var không có tác dụng" khi debug:

# Dat env var nhu nay, app van dung application.yml:
export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar --spring.profiles.active=dev   # command line override env var

Command line ưu tiên cao hơn env var. Nếu CI/CD script truyền --spring.profiles.active=dev hardcoded, env var SPRING_PROFILES_ACTIVE bị override. Debug bằng /actuator/env để xem nguồn nào win.

Pitfall 2 — thiếu một segment trong tên env var:

# SAI — thieu segment HIKARI:
export SPRING_DATASOURCE_MAXIMUMPOOLSIZE=20

# DUNG — du segment, moi . hoac - thanh mot _:
export SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=20

Property thật là spring.datasource.hikari.maximum-pool-size (canonical springdatasourcehikarimaximumpoolsize); env var thiếu HIKARI cho canonical khác nên không bind — đúng nửa dưới của hình ở mục 4.1. Riêng dấu _ ở ranh giới từ chỉ là convention cho dễ đọc: canonical bỏ hết separator nên khi đã đủ segment thì ..._MAXIMUM_POOL_SIZE..._MAXIMUMPOOLSIZE là một.

Pitfall 3 — hardcode secret trong YAML:

# SAI — commit git = security incident
spring:
  datasource:
    password: prod-secret-2026

Luôn dùng placeholder ${DB_PASSWORD} và inject qua env var hoặc K8s Secret. Repository scanner (GitHub Secret Scanning) detect password pattern trong git commit.

Pitfall: @PropertySource không load YAML

@PropertySource("classpath:custom.yml") không hoạt động cho file YAML — annotation này chỉ hỗ trợ .properties format mặc định. Để load YAML custom, cần implement PropertySourceFactory (viết riêng) hoặc dùng spring.config.import thay thế.

Liên hệ các bài khác

Bài này là một mảnh của bức tranh config rộng hơn trong course:

  • Environment & PropertySource: cơ chế Environment interface, PropertySourcesPropertyResolver, và cách @Value("${...}") resolve — bài này build on top của foundation đó.
  • @ConfigurationProperties: bước tiếp theo từ bài này — thay vì inject từng property bằng @Value, bind cả nhóm property vào một Java record type-safe với validation. Hiểu PropertySource ordering ở đây giúp debug khi @ConfigurationProperties không bind đúng giá trị.
  • Profiles — activation và bean-level: application-prod.yml trong bài này activate qua profile — bài đó đào sâu cơ chế @Profile, profile groups, và bean conditional dựa trên profile.

Tóm tắt

  • 12-factor Config — config sống ngoài artifact, inject tại runtime. Một jar chạy mọi môi trường.
  • PropertySource ordering — Boot xếp chồng nguồn config theo thứ tự ưu tiên cố định: command line ưu tiên cao nhất, rồi env var, rồi profile-specific file, rồi application.yml, rồi default.
  • Cơ chế bên dướiPropertySourcesPropertyResolver duyệt danh sách MutablePropertySources theo thứ tự, trả giá trị đầu tiên khác null.
  • Command line ưu tiên cao nhất vì nó là override explicit của operator tại thời điểm chạy — không bị override bởi env var đã set sẵn trong deployment manifest.
  • Relax binding — Boot chuẩn hoá tên property về lowercase + no separator trước khi so sánh. SPRING_DATASOURCE_URL map sang spring.datasource.url tự động, không cần config.
  • Relax binding tồn tại vì shell/container convention dùng UPPER_SNAKE, còn YAML convention dùng kebab. Không cần user config, Boot tự lo cầu nối.
  • Production pattern — non-secret trong git (application.yml, application-prod.yml); secret qua K8s env var hoặc Vault. Không bao giờ commit password.
  • Debug tool/actuator/env cho thấy chính xác PropertySource nào win cho từng property.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    App có 4 nguồn cùng set property db.url: (1) application.yml có giá trị localhost, (2) env var DB_URL=staging, (3) JVM arg -Ddb.url=prod1, (4) command line --db.url=prod2. Giá trị nào win? Giải thích theo thứ tự PropertySource.
  2. Q2
    Tại sao command line args được thiết kế có ưu tiên cao hơn env var, thay vì ngược lại? Cho ví dụ thực tế trong K8s deployment.
  3. Q3
    K8s deployment set env var SPRING_DATASOURCE_URL=jdbc:postgresql://prod/app. App dùng @Value("${spring.datasource.url}"). Cơ chế nào cho phép env var match property name? Viết lại canonical form của cả hai.
  4. Q4
    Bạn set env var SPRING_DATASOURCE_MAXIMUMPOOLSIZE=20 nhưng spring.datasource.hikari.maximum-pool-size không binding đúng. Vấn đề ở đâu? Env var đúng nên đặt là gì?
  5. Q5
    Mô tả production deployment pattern chuẩn cho một Spring Boot app: file nào commit git, secret inject thế nào, command line dùng cho mục đích gì? Vì sao không commit password vào application-prod.yml?

Bài tiếp theo: @ConfigurationProperties vs @Value

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

@ConfigurationProperties vs @Value — bind config type-safe