Spring Production-Ready/Actuator — bề mặt vận hành, không phải bề mặt công khai
11/26
Bài 11 / 26~12 phútObservability & Production-readyMiễn phí lượt xem

Actuator — bề mặt vận hành, không phải bề mặt công khai

Actuator mở endpoint gì, vì sao mặc định chỉ có health, cách chọn endpoint expose cho production và lý do tách management port khỏi cổng ứng dụng.

TL;DR: Spring Boot Actuator thêm một tập endpoint REST để hỏi ứng dụng đang chạy về sức khoẻ, cấu hình và số đo, nhưng mặc định Boot chỉ lộ đúng một endpoint qua HTTPhealth. Mở thêm phải khai báo qua management.endpoints.web.exposure.include, và tuyệt đối không mở toàn bộ (include: "*") ra production vì env, heapdump có thể lộ secret, loggers cho đổi hành vi ứng dụng lúc chạy. Biện pháp mạnh nhất là tách cổng quản trị riêng qua management.server.port, chỉ mở nội bộ; buộc dùng chung cổng thì một SecurityFilterChain khớp EndpointRequest.toAnyEndpoint() là lớp phòng thủ thứ hai.

1. Kịch bản — 3 giờ sáng, TaskFlow "còn sống" nhưng chết lâm sàng

TaskFlow chạy 3 pod sau một load balancer. Health check của load balancer chỉ là TCP connect vào port 8080 — pod nào còn accept connection thì được coi là healthy. 3 giờ sáng, connection pool Hikari tới PostgreSQL cạn kiệt (10 connection, cả 10 đang treo chờ một query chậm), mọi request /api/tasks trả về lỗi 500, nhưng cổng 8080 vẫn accept TCP connection bình thường. Load balancer vẫn route traffic đều vào cả 3 pod — nó không biết gì về Hikari, về PostgreSQL, về connection pool.

Người trực chỉ có hai lựa chọn: SSH vào container đọc log tay, hoặc restart mù cả 3 pod rồi hy vọng. Không có cách nào hỏi thẳng ứng dụng "connection pool còn bao nhiêu chỗ trống", "bản build đang chạy là commit nào". Actuator sinh ra để trả lời đúng nhóm câu hỏi đó — biến ứng dụng từ hộp đen thành thứ có thể hỏi trực tiếp lúc đang chạy. Bài này giải thích Actuator mở ra bề mặt gì, vì sao mặc định nó đóng gần như hoàn toàn, và cách thiết kế bề mặt đó an toàn cho một hệ thống thật.

Trước khi vào bài

Bài này dùng lại, không dạy lại: autoconfiguration + back-off qua @ConditionalOnMissingBean (@ConditionalOn* family — Actuator tự nó là một khối autoconfiguration), và filter chain Spring Security (SecurityFilterChain DSL — dùng thẳng ở mục 6).

2. Thêm Actuator vào TaskFlow — mỗi endpoint trả lời một câu hỏi vận hành

Thêm một dependency là có cả bộ endpoint:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

Actuator không phải một endpoint, mà là một tập endpoint quản trị (management endpoint), mỗi cái trả lời đúng một câu hỏi vận hành cụ thể — nhớ theo câu hỏi chúng trả lời, không nhớ như danh sách suông:

EndpointCâu hỏi vận hành nó trả lờiMức nhạy cảm
healthTaskFlow còn phục vụ request được không?Thấp (mặc định chỉ trả UP/DOWN)
infoĐang chạy đúng bản build nào, commit nào?Thấp
metricsSố đo cụ thể — bao nhiêu request/giây, pool còn trống bao nhiêu?Trung bình
envGiá trị cấu hình thực tế đang là gì (property, biến môi trường)?Cao
configpropsBean @ConfigurationProperties đang bind giá trị gì?Cao
loggersLog level hiện tại của từng package — và cho đổi ngay lúc chạyCao
threaddumpTừng thread đang làm gì, có deadlock không?Trung bình
heapdumpToàn bộ heap memory tại thời điểm nàyRất cao

Câu hỏi 3 giờ sáng ở trên — "connection pool còn bao nhiêu chỗ trống" — chính là việc metrics trả lời (Hikari expose gauge hikaricp.connections.active). Không endpoint nào trong bảng trên là trang trí.

3. Vì sao mặc định Boot chỉ lộ health ra ngoài?

Bật dependency xong, gọi thử /actuator/metrics hay /actuator/env đều trả về 404. Property điều khiển việc này là management.endpoints.web.exposure.include, giá trị mặc định chỉ có một entry: health — không phải bug.

Lý do Boot chọn kín: framework không biết trước ứng dụng của bạn nhạy cảm tới đâu — env an toàn với app demo nhưng có thể lộ DATABASE_URL chứa mật khẩu nếu app thật đang chạy production. Boot chọn mặc định an toàn (secure by default): bạn chủ động bật từng endpoint, nghĩa là chủ động nghĩ qua rủi ro trước khi mở.

Mở thêm endpoint cho TaskFlow:

management:
  endpoints:
    web:
      exposure:
        include: "health,info,metrics,mappings"
Đừng bao giờ include: "*" trên production

include: "*" mở toàn bộ endpoint kể cả env, heapdump, loggers — mục 4 giải thích vì sao nguy hiểm. Pattern này thường lọt production vì copy nguyên application.yml của dev, nơi mở hết cho tiện debug local. Cần mở hết ở dev thì đặt trong profile dev riêng, không phải file mặc định mà production cũng đọc.

4. Vì sao endpoint quản trị là bề mặt tấn công thật

Ba endpoint sau đáng dừng lại lâu hơn, mỗi cái mở một lớp rủi ro khác nhau:

env/configprops lộ cấu hình thật — property đang chạy, biến môi trường, mọi property source Spring nạp. Boot sanitize sẵn giá trị của key trông giống secret (password, secret, token), nhưng đó là suy đoán theo tên, không phải đảm bảo: property đặt tên khác quy ước như internal.apiKeyForVendor lộ nguyên văn.

heapdump lộ nguyên bộ nhớ tại một thời điểm — file dump toàn bộ heap, gồm cả chuỗi ký tự đang nằm trong biến cục bộ của mọi request đang xử lý. Đúng lúc dump mà một request đang giữ JWT token hay mật khẩu trong bộ nhớ, chuỗi đó nằm nguyên trong file trả về. Đây là endpoint nhạy cảm nhất Actuator có.

loggers cho đổi hành vi ứng dụng lúc đang chạy — nhận POST để đổi log level bất kỳ package nào ngay lập tức, không cần redeploy. Kẻ tấn công có quyền gọi endpoint này có thể hạ log xuống ERROR để xoá dấu vết, hoặc bật TRACE để làm đầy đĩa (denial of service qua log).

Cả ba đúng một khuôn: endpoint quản trị là kênh đọc/ghi trạng thái nội bộ, cần thiết kế cẩn trọng như một API nghiệp vụ, không phải trang thống kê vô hại.

5. Tách cổng quản trị — biện pháp mạnh nhất

Biện pháp mạnh nhất không phải lọc từng request, mà để Actuator không bao giờ đứng chung địa chỉ mạng với API công khai. management.server.port chuyển toàn bộ endpoint Actuator sang cổng khác:

management:
  server:
    port: 9090
  endpoints:
    web:
      exposure:
        include: "health,info,metrics,loggers,threaddump"

TaskFlow phục vụ nghiệp vụ ở cổng 8080 như cũ, còn toàn bộ /actuator/* chỉ nghe ở cổng 9090 — cùng một JVM, hai socket khác nhau. Ingress chỉ trỏ vào 8080; cổng 9090 không route từ internet, chỉ hệ thống giám sát nội bộ (Prometheus, dashboard, đội vận hành) chạm được, thường qua security group hoặc network policy giới hạn theo dải IP nội bộ.

flowchart LR
    USR[Nguoi dung / Internet] --> LB[Ingress - Load Balancer]
    LB -- "chi route 8080" --> APP["TaskFlow API<br/>port 8080 - public"]
    APP --- ADM["Actuator<br/>port 9090"]
    OPS[Prometheus / Doi Ops] -- "9090 - noi bo" --> ADM
    subgraph VPC[Mang noi bo - khong qua ingress]
        ADM
        OPS
    end

Đây là lý do tách cổng mạnh hơn mọi bộ lọc ở tầng ứng dụng: một request không tới được cổng 9090 thì không có cơ hội chạm vào bất kỳ dòng code Java nào của Actuator, kể cả code có bug — chặn ngay ở tầng hạ tầng, trước khi request chạm tới tiến trình xử lý.

6. Bảo vệ khi buộc phải dùng chung cổng

Không phải hạ tầng nào cũng cho tách cổng dễ dàng — một số PaaS chỉ expose đúng một cổng ra ngoài. Khi buộc dùng chung 8080, lớp phòng thủ còn lại là filter chain: một SecurityFilterChain riêng khớp mọi request Actuator qua EndpointRequest.toAnyEndpoint(), cho health đi qua công khai, còn lại đòi vai trò quản trị.

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration(proxyBeanMethods = false)
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint());
        http.authorizeHttpRequests(requests -> requests
            .requestMatchers(EndpointRequest.to("health")).permitAll()
            .anyRequest().hasRole("ADMIN"));
        http.httpBasic(withDefaults());
        return http.build();
    }
}

EndpointRequest.toAnyEndpoint() tự biết base path thật của endpoint quản trị, không cần hardcode "/actuator/**". @Order(1) để chain này chạy trước chain nghiệp vụ — recall pattern nhiều SecurityFilterChain song song đã học ở multiple chains & stateless.

flowchart TB
    REQ["Request toi /actuator/*"] --> SC{"SecurityFilterChain<br/>EndpointRequest.toAnyEndpoint()"}
    SC -- "path la health" --> PUB["Cho qua - public"]
    SC -- "path khac" --> AUTH{"Co role ADMIN?"}
    AUTH -- "co" --> OK["200 - tra du lieu endpoint"]
    AUTH -- "khong" --> DENY["403 Forbidden"]

Hai biện pháp không loại trừ nhau — hệ thống cẩn trọng dùng cả hai: tách cổng chặn phần lớn traffic ngay ở hạ tầng, filter chain là lớp dự phòng khi network policy cấu hình sai. Bỏ cả hai thì /actuator/loggers công khai y như một endpoint nghiệp vụ bình thường.

7. Chi tiết của health — mặc định chỉ trả UP

health là endpoint duy nhất công khai mặc định, nhưng ngay cả nó cũng không lộ chi tiết. Gọi /actuator/health mặc định chỉ trả về:

{"status":"UP"}

Không có thông tin database còn kết nối được không, disk còn trống bao nhiêu, hay thành phần nào đang DOWN. Property management.endpoint.health.show-details (mặc định never) mới lộ breakdown theo từng health indicator — đặt always sẽ trả về thứ tương tự:

{
  "status": "UP",
  "components": {
    "db": { "status": "UP", "details": { "database": "PostgreSQL" } },
    "diskSpace": { "status": "UP", "details": { "free": 10737418240 } }
  }
}

Chi tiết này hữu ích cho dashboard nội bộ, nhưng cũng là dữ liệu vận hành: nó lộ TaskFlow dùng PostgreSQL, còn bao nhiêu dung lượng đĩa. always trên endpoint công khai duy nhất phá vỡ nguyên tắc "mặc định kín" của mục 3. when-authorized là lựa chọn cân bằng — chỉ user đã xác thực mới thấy chi tiết, client vô danh (load balancer) vẫn chỉ thấy UP/DOWN. Cách map từng thành phần vào readiness/liveness probe thuộc bài tiếp theo.

Pitfall thường gặp

Nhầm 1 — copy include: "*" từ môi trường dev sang production:

# Tien loi luc dev, nguy hiem khi len production
management:
  endpoints:
    web:
      exposure:
        include: "*"

✅ Whitelist tường minh từng endpoint cần cho production, và đặt include: "*" (nếu thật sự cần) trong application-dev.yml, tách khỏi file mặc định mà production cũng đọc.

Nhầm 2 — tưởng management.server.port một mình là đủ, bỏ luôn filter chain:

Cổng riêng chỉ an toàn khi security group/network policy thật sự chặn cổng đó từ ngoài. Nếu ai đó mở tạm port 9090 ra internet để test, mọi endpoint quản trị công khai ngay lập tức — không còn lớp nào chặn.

✅ Giữ cả hai lớp cùng lúc (mục 6): tách cổng để giảm diện tấn công, filter chain để không phụ thuộc hoàn toàn vào cấu hình network đúng 100%.

Đào sâu

📚 Deep Dive Spring Reference

Spec / reference chính thức:

Ghi chú: trang endpoints liệt kê đầy đủ hơn 20 endpoint có sẵn (bảng ở mục 2 chỉ trích những cái hay dùng nhất) — tra khi cần một endpoint không nằm trong bảng đó.

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

Tóm tắt

  • TaskFlow 3 giờ sáng: load balancer chỉ biết TCP còn sống, không biết Hikari cạn pool — đúng lỗ hổng quan sát mà metrics được sinh ra để lấp.
  • Sanitize của env/configprops theo tên key (password, secret, token) là suy đoán, không phải đảm bảo — property đặt tên khác quy ước vẫn lộ nguyên văn.
  • Tách cổng chặn ở tầng hạ tầng trước khi request chạm code; filter chain chỉ chặn được sau khi request đã vào tới servlet container — hai tầng phòng thủ khác bản chất, không thay thế nhau.
  • EndpointRequest.toAnyEndpoint() tự biết base path thật của Actuator, không cần hardcode "/actuator/**" — an toàn hơn khi base path đổi.
  • show-details: always là quyết định bảo mật, không chỉ UX: nó nới độ chi tiết của endpoint công khai duy nhất, phá nguyên tắc "mặc định kín".

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Spring Boot chọn mặc định chỉ expose health qua HTTP thay vì mở hết rồi để bạn tắt bớt?

Framework không biết trước ứng dụng của bạn nhạy cảm tới đâu — một property trong env vô hại với app demo có thể là connection string chứa mật khẩu trong app thật. Boot chọn mặc định an toàn: bạn chủ động liệt kê từng endpoint muốn mở qua management.endpoints.web.exposure.include, nghĩa là phải nghĩ qua rủi ro của từng cái trước khi bật.

Q2
TaskFlow đặt include: "*" để tiện debug lúc dev rồi quên đổi khi lên production. Vì sao heapdump nguy hiểm hơn hẳn env trong tình huống đó?

env lộ property theo tên key, sanitize được phần lớn secret quen thuộc. heapdump dump toàn bộ heap tại một thời điểm — không phân biệt tên biến, chỉ cần một request đang giữ JWT token hay mật khẩu trong bộ nhớ đúng lúc dump là chuỗi đó nằm nguyên trong file trả về. Sanitize theo tên không cứu được thứ không có tên.

Q3
Vì sao tách management.server.port sang cổng riêng mạnh hơn lọc bằng SecurityFilterChain, dù cả hai đều chặn được request?

Filter chain vẫn phải nhận request vào tiến trình Java, chạy qua servlet container rồi mới từ chối — request đã chạm code trước khi bị chặn. Tách cổng chặn sớm hơn một tầng: nếu ingress chỉ route vào 8080 và network policy không mở route tới 9090, request từ internet không có đường vật lý tới Actuator, chưa nói tới tầng application code.

Q4
Health endpoint mặc định chỉ trả {"status":"UP"}. Bật show-details: always thì trade-off gì, và when-authorized giải quyết ra sao?

always lộ breakdown từng thành phần (loại database, dung lượng đĩa) cho bất kỳ ai gọi được endpoint, kể cả client vô danh — dữ liệu trinh sát tốt cho kẻ tấn công. when-authorized tách hai đối tượng: client chưa xác thực vẫn chỉ thấy UP/DOWN đủ để routing, còn user có vai trò khai báo mới thấy chi tiết.

Q5
TaskFlow deploy trên PaaS chỉ cho expose đúng một cổng — không tách được management.server.port. Bạn thiết kế bề mặt Actuator thế nào để vẫn an toàn?

Lớp phòng thủ chính dồn vào SecurityFilterChain khớp EndpointRequest.toAnyEndpoint(): health đi qua public với show-details: never hoặc when-authorized, còn lại đòi vai trò ADMIN. Whitelist exposure.include vẫn phải tối thiểu — filter chain là lớp phòng thủ thứ hai, không phải lý do bỏ lớp thứ nhất; heapdump nên loại hẳn khỏi whitelist trừ khi có quy trình rõ ràng.

Bài tiếp theo: Health, readiness và liveness — thiết kế probe đúng nghĩa

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

Health indicator và hai loại probe