spring profiles và externalized config
Tung Bui Thanh (TungDaDev)11 mins read

Một lỗi bảo mật nặng mà team nào cũng từng dính: nhét mật khẩu database, API key hay chứng chỉ thẳng vào file
application-prod.ymlrồi commit lên Git. Theo 'Factor III: Config' của 12-Factor App, code và cấu hình môi trường phải tách riêng hẳn. Một artifact đã đóng gói (Docker Container Image) phải chạy được từ Local, Dev, Staging tới Production mà không cần build lại dòng nào.
Trong Spring Boot, Spring Profiles là cơ chế cơ bản để bật Bean có chọn lọc và nạp bộ thuộc tính phù hợp với từng môi trường triển khai. Có điều từ Spring Boot 2.4+, cách nạp cấu hình đã được làm lại gần như toàn bộ với spring.config.import, nên cách tổ chức nhiều môi trường cũng đổi theo.
Bài này đi vào thứ tự ưu tiên nạp cấu hình (Property Source Precedence) của Spring Boot, rồi đưa ra một cách tổ chức cấu hình kiểu Cloud-Native cho production, kết hợp Kubernetes và HashiCorp Vault.
# nguyên tắc 12-factor: tách hẳn code & cấu hình
# hai phép thử của 12-factor config
- Thử public code: giả sử mai repo của bạn bị mở thành Open Source trên GitHub, bạn có sợ lộ thông tin đăng nhập nào không? Nếu CÓ, cách tổ chức cấu hình của bạn đang có vấn đề.
- Không tách artifact theo môi trường: đừng bao giờ build ra
app-dev.jarhayapp-prod.jar. Phải là cùng một file.jar, lúc khởi động mới nhận cấu hình từ môi trường bên ngoài.
# thứ tự ưu tiên cấu hình trong Spring Boot (property source hierarchy)
Một thuộc tính (ví dụ database.url) được khai ở nhiều chỗ: file YAML, biến môi trường của hệ điều hành, tham số dòng lệnh. Vậy Spring Boot lấy giá trị nào?
Spring Boot có một thứ tự ưu tiên rất rõ ràng (tầng trên đè hẳn giá trị tầng dưới). Docs chính thức liệt kê khoảng 15 nguồn (kể cả mấy nguồn chỉ dùng khi test như @TestPropertySource, @DynamicPropertySource), sơ đồ dưới chỉ giữ những tầng hay gặp nhất. Nguồn: Spring Boot: Externalized Configuration.
Tầng 7 thật ra không phải PropertySource, chỉ là giá trị dự phòng khi không nguồn nào khai báo. Riêng tầng 4-6 thì đầy đủ là: file trong Jar < file profile trong Jar < file ngoài Jar < file profile ngoài Jar. Nghĩa là một application.yml đặt ngoài Jar vẫn đè được application-prod.yml đóng gói bên trong.
Quy tắc nên theo: tận dụng thứ tự này để làm cấu hình cho an toàn:
application.ymlđóng gói trong Jar: chỉ chứa giá trị mặc định an toàn cho Local / Test.- Production: đè bằng biến môi trường (Environment Variables) hoặc nạp từ hệ thống quản lý secret bên ngoài, tuyệt đối không sửa file đóng gói trong Jar!
# Spring Boot 2.4+ đổi cách chơi: cú pháp spring.config.import
Trước 2.4, mỗi khối YAML ngăn bởi --- dùng key spring.profiles để gắn với profile, và thứ tự đè giá trị giữa các khối, các file khá khó đoán. Từ 2.4, dấu --- vẫn dùng bình thường, nhưng key spring.profiles bị deprecate, thay bằng spring.config.activate.on-profile; quy tắc đè cũng đơn giản lại: khối nào nằm sau thì thắng. Cùng đợt đó Spring Boot thêm spring.config.import để nạp thêm nguồn cấu hình (file, thư mục mount, Vault, Config Server...) ngay trong file cấu hình:
# application.yml chuẩn hiện đại
spring:
application:
name: payment-core-service
# Profile mặc định khi không ai chỉ định (SPRING_PROFILES_ACTIVE / --spring.profiles.active).
# Dùng default chứ không dùng active: lỡ quên set biến môi trường thì vẫn là local,
# còn đã chỉ định profile thì local không bị trộn vào.
profiles:
default: local
# Cấu hình dùng chung cho mọi môi trường
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
---
# Khối cấu hình cho Profile LOCAL
spring:
config:
activate:
on-profile: local
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
---
# Khối cấu hình cho Profile PRODUCTION
spring:
config:
activate:
on-profile: prod
# Nạp cấu hình từ file mount ngoài Kubernetes và HashiCorp Vault
# (vault:// cần spring-cloud-starter-vault-config; thêm tiền tố optional: nếu nguồn có thể vắng mặt)
import:
- 'configtree:/etc/config/app/'
- 'vault://secret/payment-service'# configtree làm được gì
Khai báo configtree:/etc/config/app/ là Spring Boot tự quét hết thư mục mà Kubernetes ConfigMap mount vào:
- Đường dẫn file thành tên key cấu hình (ví dụ file
/etc/config/app/database/urlthànhdatabase.url; file têndatabase.urlnằm ngay trong thư mục cũng ra cùng key). - Nội dung file thành giá trị của thuộc tính đó!
# bật tắt Bean: @Profile vs @ConditionalOnProperty
Để bật hay tắt Bean theo môi trường, Spring có hai cơ chế với cách nghĩ khác hẳn nhau:
# so sánh
| Tiêu chí | @Profile | @ConditionalOnProperty |
|---|---|---|
| Dùng để | Bật/tắt component theo môi trường lớn (Local, Test, Prod) | Bật/tắt component theo từng tính năng (Feature Toggles) |
| Biểu thức | Có logic cơ bản: @Profile("dev | test"), @Profile("prod & !dr") | Kiểm tra giá trị: havingValue = "true", matchIfMissing = false |
| Dễ test không | Khó hơn: phải bật đúng tổ hợp profile bằng @ActiveProfiles | Dễ: override thuộc tính (@TestPropertySource, ApplicationContextRunner) |
| Nên dùng cho | Hạ tầng nền (Datasource, Cloud Storage Provider) | Khoảng 90% logic nghiệp vụ và tích hợp 3rd party |
# mock service với feature flag
public interface PaymentGatewayClient {
PaymentResult charge(BigDecimal amount);
}
// Bean Mock chỉ chạy khi bật cờ feature flag.
// matchIfMissing = false: quên khai báo cờ thì app không có bean nào và fail ngay lúc khởi động,
// thay vì lặng lẽ chạy Mock trên production.
@Service
@ConditionalOnProperty(prefix = "features.payment", name = "use-sandbox", havingValue = "true", matchIfMissing = false)
public class MockSandboxPaymentClient implements PaymentGatewayClient {
@Override
public PaymentResult charge(BigDecimal amount) {
return new PaymentResult("SANDBOX_SUCCESS_TX_123");
}
}
// Bean Production thật chỉ kích hoạt khi tắt sandbox
@Service
@ConditionalOnProperty(prefix = "features.payment", name = "use-sandbox", havingValue = "false")
public class LiveVisaPaymentClient implements PaymentGatewayClient {
@Override
public PaymentResult charge(BigDecimal amount) {
// Gọi API Visa thật qua mTLS
return callVisaApi(amount);
}
}# bảo mật: quản lý secret với hashicorp Vault & Kubernetes secrets
Ở ngân hàng hay công ty tài chính, không ai (kể cả Lead Developer) được biết mật khẩu Database Production.
# được lợi gì về bảo mật
- Dynamic Secrets: HashiCorp Vault (database secrets engine, đọc ở path
database/creds/<role>, khác với KVsecret/data/...dùng cho secret tĩnh) tự sinh một cặp tài khoản Database tạm cho mỗi Pod, sống (TTL) 1 giờ. Lease hết hạn hoặc bị revoke (Pod chết thì không còn ai gia hạn) là Vault xoá tài khoản đó khỏi PostgreSQL. Lease gia hạn được tớimax_ttlthôi, quá mốc đó phải lấy tài khoản mới và connection pool phải đổi theo, chỗ này cần test kỹ. - Zero-Trust & Audit Trail: lần lấy mật khẩu nào cũng được ghi lại (Audit Log), rõ tới từng tên Pod và IP.
# đổi cấu hình không cần restart: @RefreshScope
Khi cần đổi mấy tham số nhạy cảm (ngưỡng Rate Limit, Timeout khi gọi đối tác) trên Production, restart cả trăm container là chuyện rủi ro.
Dùng Spring Cloud Bus kết hợp @RefreshScope:
@Component
@RefreshScope // Tự động reload giá trị khi nhận được sự kiện RefreshEvent
@ConfigurationProperties(prefix = "business.thresholds")
public class DynamicThresholdProperties {
private BigDecimal maxTransferLimit = new BigDecimal("50000000");
// Getter & Setter
public BigDecimal getMaxTransferLimit() { return maxTransferLimit; }
public void setMaxTransferLimit(BigDecimal limit) { this.maxTransferLimit = limit; }
}Khi cấu hình trên Git hoặc Vault thay đổi, người vận hành chỉ cần bắn một webhook tới Actuator Endpoint:
curl -X POST https://api.company.com/actuator/busrefreshSpring Cloud Bus sẽ phát sự kiện qua Kafka/RabbitMQ tới tất cả các Pod đang chạy (endpoint busrefresh phải được expose qua management.endpoints.web.exposure.include). Mỗi Pod nạp lại Environment; bean nào có @RefreshScope bị bỏ instance cũ, lần gọi kế tiếp mới tạo instance mới với cấu hình mới, mà không cần restart container.
Một chi tiết nhỏ: bean @ConfigurationProperties thì Spring Cloud tự rebind khi Environment thay đổi, kể cả không có @RefreshScope. @RefreshScope thực sự cần cho bean đọc cấu hình bằng @Value hoặc bean khởi tạo tài nguyên từ cấu hình lúc tạo (client, connection...).
# checklist trước khi lên production (architect's checklist)
- 1. Quét secret bằng Git Hooks: đã gắn công cụ quét secret (như
gitleakshaytrufflehog) vào pre-commit hook để chặn commit credentials lên Git chưa? - 2. Không để application-prod.yml trong Jar: Production phải nhận cấu hình qua biến môi trường OS hoặc Secret Store bên ngoài.
- 3. Validate chặt lúc khởi động: đã dùng
@ConfigurationPropertieskèm Jakarta Bean Validation (@Validated,@NotNull) để app dừng ngay (Fail-Fast) khi thiếu cấu hình quan trọng chưa? - 4. Khoá quyền Actuator Refresh: endpoint
/actuator/refresh,/actuator/busrefreshvà/actuator/envphải được Spring Security bảo vệ, chỉ mở cho mạng nội bộ hoặc tài khoản admin. - 5. Thống nhất tên profile: chốt quy ước đặt tên profile cho cả công ty (ví dụ
local,dev,staging,prod), tránh cảnh chỗ nàyproduction, chỗ kialive.
# lời kết
Quản lý cấu hình không chỉ là tạo vài file .yml rồi đổi profile qua lại. Nó là một kỷ luật ảnh hưởng thẳng tới độ an toàn và độ linh hoạt của cả hệ thống.
Nắm được thứ tự nạp cấu hình của Spring Boot, theo đúng 12-Factor, rồi kết hợp với hạ tầng quản lý secret như Kubernetes / Vault, bạn sẽ yên tâm vận hành cả trăm microservice trên cloud mà không phải thấp thỏm chuyện lộ dữ liệu hay cấu hình đá nhau.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.