Chương 11. DevOps tối thiểu, Logging và xử lý sự cố Production
Ở mức 2–4 năm, không ai kỳ vọng bạn là DevOps engineer, nhưng tôi kỳ vọng bạn tự đóng gói được service, đọc được pipeline, biết vì sao pod bị restart và biết làm gì trong 15 phút đầu khi production báo lỗi. Câu hỏi "hệ thống lỗi lúc 2 giờ sáng thì bạn làm gì" nói về một kỹ sư nhiều hơn bất kỳ câu hỏi lý thuyết nào.
Người phỏng vấn muốn kiểm tra
- Dùng Git thành thạo: merge vs rebase, revert vs reset, xử lý conflict.
- Viết Dockerfile multi-stage hợp lý và hiểu probe, graceful shutdown trên Kubernetes.
- Log có cấu trúc, có traceId, không lộ dữ liệu nhạy cảm; biết metrics và alert cơ bản.
- Dùng được các lệnh Linux để đọc log và kiểm tra tài nguyên.
- Có quy trình xử lý sự cố: giảm thiệt hại trước, tìm nguyên nhân sau.
11.1 Git trong làm việc nhóm
Nhắc lại kiến thức
Luồng Git hằng ngày (switch -c, add, commit, fetch, pull, push) và cách đọc conflict đã có ở N2.13. Nhắc lại ba khái niệm dùng ở mục này: commit là một ảnh chụp toàn bộ code kèm con trỏ tới commit cha; nhánh (branch) chỉ là một cái nhãn trỏ vào một commit; HEAD là nhãn cho biết bạn đang đứng ở nhánh/commit nào.
| Tiêu chí | Merge | Rebase |
|---|---|---|
| Lịch sử | Giữ nguyên, thêm merge commit | Thẳng hàng, viết lại commit |
| An toàn | Không đổi commit đã có | Đổi hash commit, cần force push |
| Dùng khi | Gộp feature vào nhánh chung | Cập nhật nhánh cá nhân theo main trước khi tạo PR |
Quy tắc vàng: không rebase nhánh mà người khác đang dùng chung. Khi force push nhánh của mình, dùng git push --force-with-lease để không ghi đè commit người khác vừa đẩy lên.
Git flow có develop, release/*, hotfix/*, hợp với sản phẩm phát hành theo phiên bản. Trunk-based dùng nhánh ngắn hạn merge về main hằng ngày, kết hợp feature flag, hợp với CI/CD liên tục. Nhiều team hiện nay dùng biến thể đơn giản: main cộng nhánh feature ngắn và PR.
- Conflict: đọc hiểu cả hai phía, sửa, chạy lại build và test trước khi commit; không chọn bừa "accept theirs".
- Cherry-pick: lấy một commit cụ thể sang nhánh khác, thường dùng mang bản vá từ
mainsang nhánh release. - Revert tạo commit mới đảo ngược thay đổi, an toàn với nhánh đã push. Reset di chuyển con trỏ nhánh về commit cũ (
--softgiữ thay đổi đã stage,--mixedgiữ trong working tree,--hardbỏ hết), chỉ dùng cho commit chưa chia sẻ.
Trạng thái nhánh trước và sau merge, rebase
Giả sử bạn tạo nhánh feature từ main khi main đang ở commit B. Sau đó bạn commit D, E trên feature, còn đồng nghiệp đẩy commit C lên main. Hai nhánh đã "rẽ" khỏi nhau tại B.
Before:
A---B---C main
\
D---E feature
After "git switch main && git merge feature":
A---B---C-------M main
\ /
D---E---- feature
After "git switch feature && git rebase main":
A---B---C main
\
D'--E' featureGiải thích:
- Trước: lịch sử có hai nhánh rẽ ra từ B.
maincó C màfeaturechưa có,featurecó D, E màmainchưa có. - Sau merge: Git tạo một commit mới M có hai cha là C và E, gộp nội dung của cả hai phía. Các commit D, E giữ nguyên hash, lịch sử cho thấy rõ "feature đã được gộp vào lúc này". Nhãn
featurevẫn trỏ vào E. - Sau rebase: Git lấy từng thay đổi của D và E, áp lại lên đầu C, tạo ra hai commit mới D', E' (nội dung thay đổi giống, nhưng cha khác nên hash khác). Lịch sử thẳng một đường, như thể bạn bắt đầu làm feature sau khi C đã có. D, E cũ không còn nhánh nào trỏ tới.
- Vì rebase tạo commit mới, nếu D, E đã được push và người khác đã kéo về, lịch sử của họ và của bạn lệch nhau; đó là lý do không rebase nhánh dùng chung và phải
push --force-with-leasesau khi rebase nhánh của riêng mình.
Reset và revert qua hai kịch bản cụ thể
Kịch bản 1 (reset, commit chưa push): bạn vừa commit trên nhánh cá nhân và phát hiện lỡ đưa file .env chứa mật khẩu DB vào commit. Commit chưa push nên chưa ai thấy; có thể viết lại lịch sử cục bộ.
git log --oneline -3
# 9f2c1ab (HEAD -> feature/coupon) Add coupon API <- contains .env by mistake
# 4d8e7f0 Add coupon entity
# 1a2b3c4 (origin/main, main) Release 1.4.1
git reset --soft HEAD~1 # move the branch back one commit, keep all changes staged
git restore --staged .env # unstage the secret file, it stays on disk
echo ".env" >> .gitignore
git add .gitignore && git commit -m "Add coupon API"
git log --oneline -2
# 5e6f7a8 (HEAD -> feature/coupon) Add coupon API
# 4d8e7f0 Add coupon entityGiải thích: HEAD~1 nghĩa là "commit cha của commit hiện tại". reset --soft HEAD~1 kéo nhãn nhánh lùi về 4d8e7f0 nhưng giữ nguyên mọi thay đổi của 9f2c1ab ở trạng thái đã stage, nên bạn chỉ cần bỏ .env ra rồi commit lại. Commit 9f2c1ab biến mất khỏi nhánh, thay bằng 5e6f7a8 sạch. Ba chế độ reset khác nhau ở chỗ thay đổi của commit bị bỏ đi nằm đâu:
| Lệnh | Nhánh lùi về commit cũ | Thay đổi của commit bị bỏ |
|---|---|---|
git reset --soft HEAD~1 | Có | Còn, đã stage, sẵn sàng commit lại |
git reset --mixed HEAD~1 (mặc định) | Có | Còn trong file, chưa stage |
git reset --hard HEAD~1 | Có | Mất khỏi working tree (chỉ cứu được qua git reflog) |
Kịch bản 2 (revert, commit đã lên main): commit 7c3d9e1 đổi cách làm tròn tiền giảm giá đã được merge vào main và deploy, khách bắt đầu bị tính sai 1 đồng. Nhiều người đã kéo main về, nên không được viết lại lịch sử.
git switch main && git pull
git log --oneline -3
# 7c3d9e1 Change discount rounding to HALF_DOWN
# b4a1f02 Add order export
# 1a2b3c4 Release 1.4.1
git revert 7c3d9e1 --no-edit
# [main 0e9d8c7] Revert "Change discount rounding to HALF_DOWN"
# 1 file changed, 1 insertion(+), 1 deletion(-)
git push origin main # a normal push, no force neededGiải thích: revert không xóa 7c3d9e1; nó tạo commit mới 0e9d8c7 có nội dung ngược lại (dòng nào commit kia thêm thì bỏ đi, dòng nào bỏ thì thêm lại). Lịch sử chỉ dài thêm, người khác pull về bình thường, và lịch sử vẫn ghi lại rằng thay đổi từng tồn tại rồi bị gỡ, rất có ích khi viết postmortem. b4a1f02 không bị ảnh hưởng. Nếu dùng reset --hard rồi push --force ở đây, mọi người đang có 7c3d9e1 sẽ bị lệch lịch sử và có thể đẩy lại chính commit lỗi.
11.2 Docker cho Spring Boot
Image là bản đóng gói chỉ đọc gồm nhiều layer; container là một instance đang chạy của image, có thêm một layer ghi. Docker cache theo layer: layer nào không đổi thì dùng lại, nên đặt thứ ít thay đổi (dependency) lên trước, code hay thay đổi xuống sau. Dockerfile dưới đây áp dụng nguyên tắc đó cho một service Spring Boot build bằng Maven.
# Stage 1: build, has the JDK and the Maven wrapper
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
# Dependency download layer is cached while pom.xml is unchanged
RUN ./mvnw -q dependency:go-offline
COPY src src
RUN ./mvnw -q package -DskipTests
RUN java -Djarmode=tools -jar target/*.jar extract --layers --launcher --destination extracted
# Stage 2: runtime, JRE only, no source code and no Maven
FROM eclipse-temurin:21-jre
WORKDIR /app
# Create a regular user, do not run as root
RUN useradd --system --uid 1001 spring
USER spring
# Copy from rarely changed to often changed to reuse layer cache
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "org.springframework.boot.loader.launch.JarLauncher"]Giải thích: lần build đầu, stage 1 tải toàn bộ dependency, build fat jar rồi tách jar thành bốn thư mục theo mức độ thay đổi. Stage 2 bắt đầu lại từ image JRE sạch và chỉ copy bốn thư mục đó sang, nên image cuối không chứa JDK, Maven, mã nguồn hay thư mục ~/.m2. Ở lần build sau, nếu bạn chỉ sửa code trong src, Docker dùng lại cache của bước dependency:go-offline và của các layer dependency, chỉ build lại và đẩy lên registry layer application nhỏ. Khi container chạy, JarLauncher khởi động ứng dụng từ các thư mục đã tách, không cần giải nén jar lúc start. Các điểm cần nói được khi phỏng vấn:
- Multi-stage: stage build có JDK và Maven, stage chạy chỉ có JRE, image nhỏ và ít lỗ hổng hơn.
- Tách layer: đổi code chỉ build lại layer
applicationvài trăm KB thay vì cả fat jar. - Chạy bằng user thường, không phải root. Test đã chạy ở bước CI trước đó nên ở đây bỏ qua.
MaxRAMPercentageđể heap tính theo giới hạn bộ nhớ của container.
Ghi chú
Cú pháp -Djarmode=tools ... extract --layers --launcher áp dụng từ Spring Boot 3.3; bản cũ hơn dùng -Djarmode=layertools -jar app.jar extract. Launcher chuyển sang package org.springframework.boot.loader.launch từ Spring Boot 3.2.
Nhắc lại kiến thức
-XX:MaxRAMPercentage và các tham số Heap đã giải thích ở mục 2.5: JVM trong container đọc giới hạn bộ nhớ của container và lấy tỷ lệ phần trăm đó làm Heap tối đa.
Image, container, layer nói bằng lời thường
- Image giống một bản cài đặt đã đóng băng: chứa hệ điều hành tối thiểu, JRE, file jar và lệnh khởi động. Image chỉ đọc, không chạy; từ một image có thể chạy ra bao nhiêu bản cũng được, và bản nào cũng giống hệt nhau trên máy dev, CI hay production.
- Container là một bản đang chạy của image: một tiến trình được cô lập (có hệ thống file, mạng, giới hạn CPU/RAM riêng). Container ghi file thì ghi vào lớp ghi riêng của nó; xóa container là mất lớp đó, image không đổi. Quan hệ image và container giống class và object trong Java.
- Layer là một lớp thay đổi file. Mỗi lệnh
RUN,COPY,ADDtrong Dockerfile tạo ra một layer chứa đúng những file mà lệnh đó thêm hoặc sửa; image là chồng các layer lên nhau. Docker nhận diện layer bằng hash nội dung, nên layer đã có thì không build lại và không đẩy lên registry lần nữa. Khi một layer thay đổi, mọi layer phía sau nó cũng phải build lại.
Các lệnh dưới đây build image từ Dockerfile ở trên, chạy container và kiểm tra kết quả. Output là mẫu rút gọn.
$ docker build -t order-service:1.4.2 .
=> CACHED [build 4/8] RUN ./mvnw -q dependency:go-offline 0.0s
=> [build 5/8] COPY src src 0.1s
=> [build 6/8] RUN ./mvnw -q package -DskipTests 41.7s
=> CACHED [stage-1 4/7] COPY --from=build /app/extracted/dependencies/ 0.0s
=> [stage-1 7/7] COPY --from=build /app/extracted/application/ ./ 0.1s
$ docker images order-service
REPOSITORY TAG IMAGE ID CREATED SIZE
order-service 1.4.2 3f1c2a9e8b7d 12 seconds ago 298MB
$ docker run -d --name order -p 8080:8080 -m 1g order-service:1.4.2
a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9
$ docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
a1b2c3d4e5f6 order-service:1.4.2 Up 15 seconds 0.0.0.0:8080->8080/tcp order
$ docker logs --tail 1 order
Started OrderApplication in 6.412 seconds (process running for 7.1)Giải thích:
docker build: dòngCACHEDnghĩa là layer được dùng lại; ở đây chỉ sửa code nên bước tải dependency và layerdependenciesđều CACHED, chỉ bước compile và layerapplicationchạy lại.docker images: image cuối khoảng 300MB vì chỉ có JRE; nếu dùng image JDK một stage thường dễ lên 600–800MB.docker run -d -p 8080:8080 -m 1g: chạy nền, nối cổng 8080 của máy vào cổng 8080 của container, giới hạn RAM 1GB. Output là id đầy đủ của container.docker ps:STATUS Up 15 secondsnghĩa là đang chạy. Nếu container chết, dùngdocker ps -asẽ thấyExited (137)(bị kill, thường do vượt giới hạn RAM) hoặcExited (1)(ứng dụng tự thoát vì lỗi, đọcdocker logsđể biết lý do).docker logs: xem stdout của ứng dụng; dòngStarted ... in 6.412 secondsxác nhận Spring Boot đã khởi động xong.
11.3 Kubernetes tối thiểu
- Pod: đơn vị chạy nhỏ nhất, chứa một hoặc vài container, có IP riêng, có thể bị xóa và tạo lại bất cứ lúc nào.
- Deployment: khai báo số replica và image; lo rolling update và rollback (
kubectl rollout undo). - Service: địa chỉ ổn định và cân bằng tải tới các pod khớp label.
- Readiness probe: pod đã sẵn sàng nhận traffic chưa; fail thì bị rút khỏi Service nhưng không bị restart.
- Liveness probe: pod còn sống không; fail thì bị restart. Không đưa kiểm tra DB vào liveness, nếu không DB chậm sẽ làm mọi pod restart dây chuyền.
Đoạn khai báo container dưới đây (một phần của Deployment) gắn hai probe vào endpoint health của Spring Boot Actuator và đặt giới hạn tài nguyên.
containers:
- name: order-service
image: registry.example.com/order-service:1.4.2 # fixed tag, never latest
ports:
- containerPort: 8080
readinessProbe: # fail -> removed from Service, not restarted
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
livenessProbe: # repeated failures -> container restarted
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 30 # wait for the app to start before checking
periodSeconds: 10
resources:
requests: { cpu: "500m", memory: "768Mi" } # used to schedule the pod onto a node
limits: { memory: "1Gi" } # exceeding it -> OOMKilledGiải thích: Kubernetes gọi /actuator/health/readiness mỗi 5 giây; khi endpoint này báo lỗi, pod bị gỡ khỏi danh sách endpoint của Service và không nhận request mới, nhưng vẫn chạy. Liveness được gọi mỗi 10 giây sau 30 giây đầu; fail liên tiếp (mặc định 3 lần) thì kubelet restart container. Spring Boot tự bật hai nhóm health liveness và readiness khi phát hiện đang chạy trên Kubernetes. Về tài nguyên, scheduler chỉ đặt pod lên node còn đủ 0,5 CPU và 768Mi; nếu tiến trình dùng quá 1Gi, container bị kill với exit code 137 (OOMKilled). Kết hợp với -XX:MaxRAMPercentage=75 ở Dockerfile, heap tối đa khoảng 768MB, phần còn lại dành cho Metaspace, thread stack và bộ nhớ ngoài heap.
Graceful shutdown: khi pod bị dừng, Kubernetes gửi SIGTERM, chờ terminationGracePeriodSeconds (mặc định 30 giây) rồi SIGKILL. Spring Boot với server.shutdown=graceful (mặc định từ 3.4) ngừng nhận request mới và chờ request đang chạy xong trong spring.lifecycle.timeout-per-shutdown-phase. Thường thêm preStop sleep vài giây để Service kịp rút pod khỏi danh sách endpoint, tránh lỗi 502 lúc deploy.
Kịch bản: pod khởi động chậm bị liveness giết liên tục
Trước hết, probe là việc Kubernetes định kỳ "gõ cửa" container (gọi một URL HTTP, mở một cổng TCP hoặc chạy một lệnh) để hỏi tình trạng. Mỗi probe có ba tham số chính: initialDelaySeconds (chờ bao lâu mới bắt đầu hỏi), periodSeconds (bao lâu hỏi một lần), failureThreshold (sai liên tiếp bao nhiêu lần thì coi là hỏng, mặc định 3).
Tình huống: một bản deploy mới thêm migration Flyway tạo index trên bảng lớn, ứng dụng cần khoảng 90 giây để khởi động. Cấu hình liveness vẫn như ví dụ trên: chờ 30 giây, hỏi mỗi 10 giây, sai 3 lần thì restart.
$ kubectl get pods -l app=order-service
NAME READY STATUS RESTARTS AGE
order-service-7d9f8b6c5d-x2k4p 0/1 CrashLoopBackOff 4 (25s ago) 6m12s
$ kubectl describe pod order-service-7d9f8b6c5d-x2k4p | tail -3
Warning Unhealthy Liveness probe failed: Get "http://10.1.2.3:8080/actuator/health/liveness": dial tcp 10.1.2.3:8080: connect: connection refused
Normal Killing Container order-service failed liveness probe, will be restarted
Warning BackOff Back-off restarting failed container
$ kubectl logs order-service-7d9f8b6c5d-x2k4p --previous | tail -1
INFO o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "42 - add order index"Giải thích: dòng thời gian của mỗi lần khởi động: giây 0 container bắt đầu; giây 30 liveness hỏi lần đầu, ứng dụng chưa mở cổng nên connection refused; giây 40 và 50 sai tiếp; đủ 3 lần sai, kubelet giết container ở khoảng giây 50, trong khi migration cần tới giây 90. Container khởi động lại từ đầu, lại bị giết, và sau vài vòng Kubernetes chuyển sang CrashLoopBackOff (chờ ngày càng lâu giữa các lần thử). kubectl logs --previous xem log của lần chạy vừa chết, cho thấy nó đang dừng ở bước migration. Cách sửa đúng là thêm startupProbe:
startupProbe: # runs first; liveness and readiness wait until it succeeds
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 18 # allows up to 18 x 10 = 180s to start
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10 # no long initialDelaySeconds needed any more
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5Giải thích: khi có startupProbe, Kubernetes tạm hoãn liveness và readiness cho tới khi startup probe thành công lần đầu. Ứng dụng có tối đa 180 giây để khởi động; lần deploy này xong ở giây 90, startup probe thành công, từ đó liveness mới bắt đầu hỏi mỗi 10 giây và phát hiện treo nhanh như trước. Tăng initialDelaySeconds của liveness lên 120 cũng chạy được, nhưng mỗi khi process treo thật sau này, Kubernetes phải chờ lâu hơn mới phát hiện; và lần deploy sau migration cần 150 giây thì lại hỏng. Về lâu dài, migration nặng nên chạy như một Job riêng trước khi deploy, không chặn việc khởi động ứng dụng.
11.4 CI/CD, config và secret
Pipeline điển hình: checkout → build và unit test → static analysis (SonarQube), quét dependency → integration test (Testcontainers) → build image, gắn tag theo commit SHA → push registry → deploy staging → smoke test → deploy production (thủ công hoặc tự động) theo rolling hoặc canary.
Một image chạy mọi môi trường; khác biệt nằm ở cấu hình. Cấu hình thường đưa qua biến môi trường hoặc ConfigMap (Spring Boot tự ánh xạ SPRING_DATASOURCE_URL sang spring.datasource.url). Secret đặt ở Kubernetes Secret, Vault hoặc secret manager của cloud, không bao giờ commit vào Git; nếu lỡ commit thì phải đổi secret, xóa khỏi lịch sử là chưa đủ.
Ví dụ: cùng một image order-service:1.4.2 chạy ở staging và production, chỉ khác biến môi trường khi khởi động.
# staging
docker run -e SPRING_PROFILES_ACTIVE=staging \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://staging-db:5432/orders \
order-service:1.4.2
# production: same image, different config; the password comes from a secret store
docker run -e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://prod-db:5432/orders \
-e SPRING_DATASOURCE_PASSWORD="$(cat /run/secrets/db_password)" \
order-service:1.4.2Giải thích: Spring Boot đổi tên biến môi trường theo quy tắc chữ hoa, dấu gạch dưới thành dấu chấm, nên SPRING_DATASOURCE_URL ghi đè spring.datasource.url trong application.yml. Vì image giống hệt nhau, thứ đã test ở staging chính là thứ chạy ở production; nếu build lại image riêng cho production, bạn đang deploy một thứ chưa từng được test. Mật khẩu được đọc từ file secret lúc chạy, không nằm trong image hay trong Git. Trên Kubernetes, phần -e này được khai báo bằng env lấy từ ConfigMap và Secret.
11.5 Logging
Nhắc lại kiến thức
MDC dựa trên ThreadLocal: mỗi thread có bản sao biến riêng, và vì thread trong pool được tái sử dụng nên phải dọn sau mỗi lần dùng, nếu không dữ liệu rò sang request sau (xem mục 3.9). Thread pool và @Async ở mục 3.6.
Dùng SLF4J làm API, Logback là implementation mặc định của Spring Boot. Quy ước log level:
| Level | Dùng khi |
|---|---|
ERROR | Lỗi cần người xử lý, request thất bại |
WARN | Bất thường nhưng hệ thống tự xoay xở được (retry, fallback) |
INFO | Sự kiện nghiệp vụ quan trọng: đơn tạo, job bắt đầu/kết thúc |
DEBUG | Chi tiết để debug, tắt trên production |
Khi hàng trăm request chạy song song, log của chúng xen kẽ nhau và không biết dòng nào thuộc request nào. Filter dưới đây gắn cho mỗi request một id và đưa id đó vào mọi dòng log.
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
// reuse the caller's id (gateway, other service) or generate a new one
String id = Optional.ofNullable(req.getHeader("X-Correlation-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("correlationId", id); // stored in the ThreadLocal of the request thread
res.setHeader("X-Correlation-Id", id); // returned to the client so it can report errors with the id
try {
chain.doFilter(req, res);
} finally {
MDC.remove("correlationId"); // threads are reused, must clean up
}
}
}Giải thích: với pattern log có %X{correlationId}, mọi dòng log phát ra trong lúc xử lý request (controller, service, repository) đều mang cùng một id, ví dụ [c1f3...] Order 42 created. Khi khách hàng báo lỗi và gửi kèm id trong response header, bạn lọc log theo id đó là thấy toàn bộ câu chuyện. MDC dựa trên ThreadLocal, và Tomcat dùng thread pool: nếu thiếu khối finally, request tiếp theo chạy trên cùng thread sẽ mang id cũ, log bị gán nhầm request. Cũng vì dựa trên ThreadLocal nên id không tự đi theo sang @Async hay CompletableFuture (xem câu hỏi đào sâu ở mục 11.9).
Nếu đã dùng Micrometer Tracing thì traceId và spanId được đưa vào MDC tự động, không cần tự viết filter. Trên production nên log dạng JSON để Elasticsearch/Loki lọc theo field; Spring Boot 3.4 hỗ trợ sẵn qua logging.structured.format.console=ecs (hoặc logstash).
- Dùng placeholder:
log.info("Order {} created", orderId), không nối chuỗi. - Log exception kèm stacktrace:
log.error("Payment failed for order {}", orderId, ex). - Không log mật khẩu, token, số thẻ, CCCD, OTP; che bớt số điện thoại và email.
- Không log cùng một exception ở mọi tầng rồi ném tiếp, một lỗi thành 5 stacktrace.
Một dòng log JSON có traceId trông như thế nào
Log có cấu trúc (structured log) nghĩa là mỗi dòng log là một object JSON với các field có tên, thay vì một chuỗi văn bản tự do. traceId là mã định danh của cả một yêu cầu đi xuyên qua nhiều service; spanId là mã của một đoạn công việc bên trong yêu cầu đó (một lần gọi HTTP, một query). Dưới đây là một dòng log lỗi do Spring Boot 3.4 in ra với logging.structured.format.console=ecs và Micrometer Tracing, được xuống dòng cho dễ đọc (trong file thật nó nằm trên đúng một dòng):
{
"@timestamp": "2026-09-24T02:13:07.412Z",
"log.level": "ERROR",
"service.name": "order-service",
"process.thread.name": "http-nio-8080-exec-7",
"log.logger": "com.shop.order.PaymentService",
"message": "Payment failed for order 42",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"orderId": "42",
"error.type": "java.net.SocketTimeoutException",
"error.message": "Read timed out",
"error.stack_trace": "java.net.SocketTimeoutException: Read timed out\n\tat ..."
}Giải thích: mỗi field trả lời một câu hỏi khi điều tra: lúc nào (@timestamp, theo UTC nên 02:13Z là 09:13 giờ Việt Nam), nghiêm trọng thế nào (log.level), ở đâu (service.name, log.logger, thread), chuyện gì (message, error.*), thuộc yêu cầu nào (traceId). Vì là JSON, hệ thống log tập trung lọc được chính xác traceId = "4bf92f..." để lấy mọi dòng log của yêu cầu này ở tất cả service (gateway, order, payment), thay vì grep chuỗi mơ hồ. Field orderId là field nghiệp vụ tự thêm (qua MDC hoặc structured argument), cho phép hỏi "mọi log liên quan đơn 42". Trên máy chủ không có hệ thống tập trung, jq đọc được file này: jq 'select(.traceId == "4bf92f3577b34da6a3ce929d0e0e4736") | .message' app.log.
Cặp code sai/sửa dưới đây gom các lỗi log hay gặp nhất khi review:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class PaymentLogging {
private static final Logger log = LoggerFactory.getLogger(PaymentLogging.class);
void bad(long orderId, String cardNumber, Exception ex) {
log.debug("Paying order " + orderId + " with card " + cardNumber); // concat + sensitive data
log.error("Payment failed: " + ex.getMessage()); // stack trace lost
}
void good(long orderId, String cardNumber, Exception ex) {
log.debug("Paying order {} with card ****{}", orderId, cardNumber.substring(cardNumber.length() - 4));
log.error("Payment failed for order {}", orderId, ex); // exception as the last argument
}
}Giải thích: ở bad, dòng đầu nối chuỗi nên chuỗi được dựng kể cả khi DEBUG đang tắt (tốn CPU vô ích ở đường nóng), và in nguyên số thẻ, vi phạm PCI DSS; dòng hai chỉ log message, mất stacktrace nên không biết lỗi ở dòng code nào. Ở good, placeholder {} chỉ ghép chuỗi khi level đang bật, số thẻ chỉ còn 4 số cuối, và exception truyền làm tham số cuối cùng nên SLF4J in đầy đủ stacktrace (không cần {} cho nó).
11.6 Monitoring và alert
- Metrics: Micrometer đo, Actuator lộ ra
/actuator/prometheus, Prometheus thu thập, Grafana vẽ dashboard. Theo dõi tối thiểu: request rate, error rate, latency p95/p99, JVM heap, GC pause, số connection trong HikariCP, consumer lag của Kafka. - Logs: ELK (Elasticsearch, Logstash, Kibana) hoặc EFK (Fluentd/Fluent Bit thay Logstash), hoặc Loki.
- Traces: OpenTelemetry gửi về Jaeger hoặc Tempo.
- Alert theo triệu chứng người dùng cảm nhận (error rate vượt 2% trong 5 phút, p99 vượt 1 giây) thay vì theo mọi chỉ số; alert nào không cần hành động thì bỏ, nếu không cả team sẽ quen tắt thông báo.
p95, p99 giải thích bằng dãy số cụ thể
Percentile (phân vị) p95 là giá trị mà 95% số request nhanh hơn hoặc bằng nó; p99 tương tự với 99%. Nói cách khác, p99 = 800ms nghĩa là cứ 100 request thì có khoảng 1 request chậm hơn 800ms. Đoạn code dưới đây tính các chỉ số này cho 20 request có thời gian phản hồi (mili giây) như sau.
import java.util.Arrays;
public class PercentileDemo {
// Nearest-rank percentile: the value at position ceil(p/100 * n) in the sorted array
static long percentile(long[] sorted, double p) {
int rank = (int) Math.ceil(p / 100.0 * sorted.length);
return sorted[rank - 1];
}
public static void main(String[] args) {
long[] ms = {110, 95, 2400, 80, 120, 100, 150, 90, 125, 900,
85, 105, 115, 130, 95, 140, 90, 110, 120, 100};
Arrays.sort(ms);
System.out.println(Arrays.toString(ms));
// prints: [80, 85, 90, 90, 95, 95, 100, 100, 105, 110, 110, 115, 120, 120, 125, 130, 140, 150, 900, 2400]
System.out.println("avg = " + Arrays.stream(ms).average().getAsDouble()); // prints: avg = 263.0
System.out.println("p50 = " + percentile(ms, 50)); // prints: p50 = 110
System.out.println("p95 = " + percentile(ms, 95)); // prints: p95 = 900
System.out.println("p99 = " + percentile(ms, 99)); // prints: p99 = 2400
}
}Giải thích: sau khi sắp xếp, p50 (trung vị) là phần tử thứ 10 = 110ms: một nửa số request nhanh hơn thế, đó là trải nghiệm "điển hình". p95 là phần tử thứ ceil(0,95 x 20) = 19, tức 900ms; p99 là phần tử thứ 20, tức 2400ms. Trung bình 263ms là con số gây hiểu lầm: không request nào thực sự mất khoảng 263ms, 18 request nhanh dưới 150ms còn 2 request rất chậm kéo trung bình lên. Nếu chỉ nhìn trung bình, bạn vừa thấy "hệ thống ổn" (263ms) vừa bỏ lỡ việc 1 trên 20 người dùng chờ gần 1 giây, 1 trên 20 chờ 2,4 giây. Những request chậm đó thường là của khách có giỏ hàng lớn hoặc dữ liệu nhiều, tức là khách quan trọng. Vì vậy alert và SLO đặt trên p95/p99, không đặt trên trung bình.
Trong thực tế bạn không tự tính như trên mà để Micrometer ghi histogram và Prometheus tính:
# application.yml: publish histogram buckets for HTTP server requests
management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: trueGiải thích: với cấu hình này, /actuator/prometheus xuất các bucket http_server_requests_seconds_bucket{le="0.1"}, {le="0.5"}..., đếm số request nhanh hơn mỗi ngưỡng. Prometheus tính p99 trong 5 phút gần nhất bằng truy vấn histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[5m]))). Vì tính từ bucket, kết quả là giá trị xấp xỉ, nhưng cộng gộp được qua nhiều pod, điều mà percentile tính sẵn ở từng pod không làm được (không thể lấy trung bình của các p99).
11.7 Linux cho backend developer
Nhắc lại kiến thức
Các lệnh Git và Linux cơ bản ở mức Fresher có ở N2.13; công cụ chẩn đoán JVM (jcmd, jstack, jstat) và quy trình khi CPU 100% hoặc OOM ở mục 2.5 và 2.6. Mục này tập trung vào cách đọc output của từng lệnh.
Output mẫu và cách đọc
tail và grep: tìm lỗi trong log. tail -n 3 in 3 dòng cuối file; grep -n in kèm số dòng, -A 3 in thêm 3 dòng sau mỗi dòng khớp, -c chỉ đếm số dòng khớp.
$ tail -n 3 app.log
2026-09-24 09:13:07.101 INFO [4bf92f35] c.s.o.OrderController : POST /orders
2026-09-24 09:13:07.412 ERROR [4bf92f35] c.s.o.PaymentService : Payment failed for order 42
2026-09-24 09:13:07.415 WARN [4bf92f35] c.s.o.OrderService : Order 42 marked PAYMENT_PENDING
$ grep -c "ERROR" app.log
37
$ grep -n -A 3 "SocketTimeoutException" app.log | head -4
18233:java.net.SocketTimeoutException: Read timed out
18234- at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:278)
18235- at java.base/java.net.SocketInputStream.read(SocketInputStream.java:...)
18236- at com.shop.order.VnpayClient.charge(VnpayClient.java:57)Cách đọc: ba dòng cuối cùng một traceId 4bf92f35 kể lại một câu chuyện: nhận request, thanh toán lỗi, đơn chuyển trạng thái chờ. Có 37 dòng ERROR trong file. Ở output grep -n -A, dòng khớp có dấu : sau số dòng (18233:), dòng ngữ cảnh có dấu - (18234-). Đọc stacktrace từ trên xuống, dòng đầu tiên thuộc package của bạn (com.shop.order.VnpayClient.charge, dòng 57) là chỗ cần xem code.
top: CPU và RAM theo tiến trình.
$ top -b -n 1 | head -8
top - 09:20:11 up 41 days, 3:02, 1 user, load average: 7.85, 7.62, 5.10
Tasks: 142 total, 1 running, 141 sleeping, 0 stopped, 0 zombie
%Cpu(s): 96.3 us, 2.1 sy, 0.0 ni, 1.2 id, 0.4 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7976.4 total, 212.5 free, 6920.1 used, 843.8 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 801.3 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
21873 spring 20 0 5812344 4.9g 24576 S 385.0 63.1 912:45.02 javaCách đọc: load average là số tiến trình đang chạy hoặc chờ CPU trung bình trong 1, 5, 15 phút; máy 4 core mà load 7,85 nghĩa là quá tải, và con số 1 phút cao hơn 15 phút nghĩa là tình hình đang xấu đi. %Cpu(s): us 96,3% là CPU dùng cho code ứng dụng, id 1,2% là gần như không còn rảnh, wa thấp nên không phải do chờ đĩa. Dòng tiến trình: %CPU 385 nghĩa là java đang dùng gần hết 4 core (100% cho mỗi core), RES 4.9g là RAM thật đang dùng. Bước tiếp theo là top -H -p 21873 tìm thread nóng (mục 2.6).
ps: tìm tiến trình và tham số khởi động.
$ ps aux | grep [j]ava
spring 21873 385 63.1 5812344 5138224 ? Ssl Aug14 912:45 java -XX:MaxRAMPercentage=75 -jar order-service.jarCách đọc: cột 2 là PID (21873), dùng cho jcmd, kill, top -p. Cột cuối cho biết lệnh khởi động đầy đủ, rất hữu ích để kiểm tra JVM đang chạy với tham số gì. Mẹo [j]ava giúp dòng của chính lệnh grep không lọt vào kết quả.
ss: cổng nào đang lắng nghe.
$ ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 100 0.0.0.0:8080 0.0.0.0:* users:(("java",pid=21873,fd=45))
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=1022,fd=6))Cách đọc: -t TCP, -l chỉ socket đang lắng nghe, -n hiện số cổng thay vì tên, -p hiện tiến trình. Java PID 21873 nghe cổng 8080 trên mọi địa chỉ (0.0.0.0), nên máy khác gọi vào được. PostgreSQL chỉ nghe 127.0.0.1:5432, tức là chỉ nhận kết nối từ chính máy này; nếu service ở máy khác báo Connection refused tới DB thì đây là lý do. Khi ứng dụng báo Address already in use lúc khởi động, lệnh này cho biết tiến trình nào đang giữ cổng.
df và du: đĩa đầy ở đâu.
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p1 50G 50G 0 100% /
/dev/nvme1n1 200G 61G 139G 31% /data
$ sudo du -sh /var/log/* | sort -rh | head -3
38G /var/log/order-service
1.2G /var/log/journal
84M /var/log/nginxCách đọc: df -h cho thấy phân vùng gốc / đã dùng 100%, còn /data còn trống 139G; -h in đơn vị dễ đọc (G, M). du -sh tính dung lượng từng thư mục, sort -rh sắp xếp giảm dần theo đơn vị dễ đọc: thủ phạm là 38G log của order-service, dấu hiệu thiếu cấu hình xoay vòng log (rolling) hoặc log DEBUG trên production.
chmod và ls -l: quyền file.
$ ls -l deploy.sh app.yml
-rw-r--r-- 1 spring spring 812 Sep 24 09:00 deploy.sh
-rw-r--r-- 1 spring spring 2048 Sep 24 09:00 app.yml
$ chmod +x deploy.sh && chmod 640 app.yml && ls -l deploy.sh app.yml
-rwxr-xr-x 1 spring spring 812 Sep 24 09:00 deploy.sh
-rw-r----- 1 spring spring 2048 Sep 24 09:00 app.ymlCách đọc: chuỗi -rw-r--r-- gồm ký tự loại file rồi ba nhóm quyền cho chủ sở hữu, nhóm, người khác; r đọc, w ghi, x thực thi. Sau chmod +x, cả ba nhóm có thêm x nên chạy được ./deploy.sh. 640 là cách viết số: 6 = rw- cho chủ, 4 = r-- cho nhóm, 0 = --- cho người khác, phù hợp với file cấu hình chứa thông tin nhạy cảm.
export và echo: biến môi trường.
$ export APP_ENV=prod
$ echo $APP_ENV
prod
$ bash -c 'echo "child sees: $APP_ENV"'
child sees: prod
$ APP_MODE=debug; bash -c 'echo "child sees: [$APP_MODE]"'
child sees: []Cách đọc: biến được export thì tiến trình con (ở đây là một bash mới) nhìn thấy; biến gán không có export chỉ tồn tại trong shell hiện tại, tiến trình con thấy chuỗi rỗng. Đây là lý do một biến bạn vừa đặt mà ứng dụng Java khởi động từ script vẫn "không thấy": thiếu export.
| Lệnh | Dùng để |
|---|---|
tail -f app.log, tail -n 200 app.log | Theo dõi log trực tiếp, xem 200 dòng cuối |
less app.log | Đọc file lớn, / tìm, Shift+G xuống cuối, F theo dõi như tail -f |
grep -n "ERROR" app.log, grep -A 20 "Exception" | Tìm dòng lỗi, kèm 20 dòng sau để xem stacktrace |
top, htop | CPU, RAM theo process |
ps aux, grep java | Tìm process và PID |
ss -tlnp (thay netstat -tlnp) | Cổng nào đang lắng nghe, process nào giữ |
df -h, du -sh * | Dung lượng đĩa còn trống, thư mục nào chiếm chỗ |
chmod +x deploy.sh, chmod 640 app.yml | Cấp quyền thực thi, giới hạn quyền đọc |
export APP_ENV=prod, echo $APP_ENV | Đặt và đọc biến môi trường trong phiên hiện tại |
Bốn lệnh dưới đây là những thao tác hay bị hỏi nhất: chạy một script deploy, đặt biến môi trường lâu dài và lấy thread dump khi ứng dụng Java có vấn đề.
chmod +x deploy.sh && ./deploy.sh # make executable then run; script starts with #!/usr/bin/env bash
bash deploy.sh # or name the shell explicitly
echo 'export JAVA_HOME=/opt/jdk-21' >> ~/.bashrc && source ~/.bashrc # persistent environment variable
jcmd <pid> Thread.print > threads.txt # thread dump when Java burns CPU or hangsGiải thích:
- Dòng 1:
chmod +xthêm quyền thực thi;&&chỉ chạy lệnh sau khi lệnh trước thành công;./deploy.shchạy script bằng trình thông dịch ghi ở dòng shebang. Thiếu quyền thực thi, bạn sẽ gặpPermission denied. - Dòng 2: gọi thẳng
bashthì không cần quyền thực thi và chắc chắn script chạy bằng bash, kể cả khishtrên máy là dash. - Dòng 3:
exportchỉ có hiệu lực trong phiên shell hiện tại và các tiến trình con; ghi vào~/.bashrcđể mọi phiên mới đều có biến,sourceđể áp dụng ngay mà không cần đăng nhập lại. - Dòng 4:
jcmdin stack của mọi thread ra file. Nên chụp 2–3 lần cách nhau vài giây: thread nào lần nào cũng đứng ở cùng một dòng code là chỗ đang ngốn CPU hoặc đang bị chặn.
Ghi chú
Một số bộ câu hỏi cũ trả lời "chạy script bằng lệnh sh". Trên nhiều hệ thống sh là dash chứ không phải bash, nên script dùng cú pháp bash sẽ lỗi. Lệnh đặt biến là export (chữ thường), và muốn giữ lâu dài phải ghi vào file profile.
11.8 Kịch bản xử lý sự cố
API chậm đột ngột
- Xác định phạm vi: một endpoint hay tất cả, một pod hay mọi pod, bắt đầu từ lúc nào, có trùng với deploy hoặc đợt traffic nào không.
- Nhìn dashboard: latency p99, CPU, GC pause, HikariCP (active, pending), DB slow query, thời gian gọi service ngoài trên trace.
- Các nguyên nhân hay gặp: connection pool cạn vì query chậm hoặc transaction dài; một dependency chậm mà không có timeout; GC liên tục vì heap gần đầy; thiếu index sau khi dữ liệu tăng; cache hết hạn đồng loạt.
- Giảm thiệt hại trước (scale thêm pod, tắt tính năng bằng feature flag, bật circuit breaker), rồi thu thập bằng chứng (thread dump, heap dump, slow query log) trước khi restart làm mất dấu vết.
Đi từng bước: p99 của API đặt hàng tăng từ 300ms lên 8 giây
Tình huống: 20:05 tối, alert báo p99 của POST /orders vượt 1 giây trong 5 phút, error rate 12%. Dưới đây là các bước tôi làm, mỗi bước có lệnh, kết quả và kết luận rút ra.
Bước 1: xác định phạm vi. Mọi pod đều bị hay chỉ một pod? Có deploy gần đây không?
$ kubectl get pods -l app=order-service
NAME READY STATUS RESTARTS AGE
order-service-6c8d7f9b4-2hxkq 1/1 Running 0 3d
order-service-6c8d7f9b4-9zqvw 1/1 Running 0 3d
order-service-6c8d7f9b4-tm5rc 1/1 Running 0 3d
$ kubectl rollout history deployment/order-service | tail -2
3 Deploy 1.4.1
4 Deploy 1.4.2Kết luận: ba pod đều chạy 3 ngày, không restart, bản 1.4.2 đã deploy từ 3 ngày trước. Sự cố không trùng deploy, nên rollback chưa phải lựa chọn đầu tiên. Dashboard cho thấy cả ba pod cùng chậm, tức là vấn đề nằm ở thứ chúng dùng chung: DB, cache, hoặc service bên ngoài.
Bước 2: đọc log lỗi.
$ kubectl logs order-service-6c8d7f9b4-2hxkq --since=10m | grep -c ERROR
214
$ kubectl logs order-service-6c8d7f9b4-2hxkq --since=10m | grep ERROR | tail -1
2026-09-24 20:07:41.018 ERROR [9a1c77e2] c.s.o.OrderService : Create order failed
org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms (total=20, active=20, idle=0, waiting=57)Kết luận: request không lấy được connection DB. total=20, active=20, idle=0, waiting=57: cả 20 connection trong pool đều đang bận, 57 thread đang xếp hàng chờ. Pool cạn không phải nguyên nhân gốc; câu hỏi tiếp theo là 20 connection đó đang bận làm gì.
Bước 3: xem connection đang làm gì ở phía DB.
$ psql -h prod-db -U readonly -d orders -c "SELECT pid, now() - query_start AS running, state, left(query, 60) AS query FROM pg_stat_activity WHERE datname = 'orders' AND state <> 'idle' ORDER BY running DESC LIMIT 3;"
pid | running | state | query
-------+-----------------+--------+--------------------------------------------------------------
48211 | 00:00:28.91 | active | SELECT o.* FROM orders o WHERE o.customer_phone = $1 AND o.s
48219 | 00:00:27.40 | active | SELECT o.* FROM orders o WHERE o.customer_phone = $1 AND o.s
48230 | 00:00:26.02 | active | SELECT o.* FROM orders o WHERE o.customer_phone = $1 AND o.sKết luận: hàng loạt câu query tìm đơn theo customer_phone chạy gần 30 giây mỗi câu. Mỗi request đặt hàng giữ một connection suốt thời gian đó, nên pool 20 connection cạn ngay.
Bước 4: giảm thiệt hại trước. Query này thuộc tính năng "kiểm tra khách đặt trùng đơn" vừa được bật qua feature flag lúc 20:00, đúng lúc chiến dịch khuyến mãi bắt đầu. Tắt flag trước để khách đặt được hàng, và chụp bằng chứng trước khi mọi thứ thay đổi.
# capture evidence first: thread dump of one affected pod
kubectl exec order-service-6c8d7f9b4-2hxkq -- jcmd 1 Thread.print > threads-2005.txt
grep -c "HikariPool.getConnection" threads-2005.txt
# output: 57
# then mitigate: turn the feature off
curl -X POST https://flags.internal/api/flags/duplicate-order-check -d '{"enabled": false}'Kết luận: thread dump xác nhận 57 thread đang đứng chờ ở HikariPool.getConnection, khớp với con số waiting=57 trong log. Sau khi tắt flag, 2 phút sau p99 quay về 320ms, error rate về 0,1%. Người dùng hết bị ảnh hưởng; giờ mới điều tra nguyên nhân gốc mà không bị áp lực thời gian.
Bước 5: tìm nguyên nhân gốc và sửa.
orders=> EXPLAIN SELECT * FROM orders WHERE customer_phone = '0901234567' AND status = 'NEW';
Seq Scan on orders (cost=0.00..412893.00 rows=3 width=312)
Filter: (((customer_phone)::text = '0901234567'::text) AND ((status)::text = 'NEW'::text))Kết luận: Seq Scan nghĩa là PostgreSQL đọc toàn bộ bảng orders (khoảng 12 triệu dòng) cho mỗi câu query vì cột customer_phone không có index. Trên staging bảng chỉ có vài nghìn dòng nên không ai thấy chậm. Sửa: thêm index CREATE INDEX CONCURRENTLY idx_orders_phone_status ON orders (customer_phone, status); qua migration, chạy lại EXPLAIN thấy Index Scan, rồi mới bật lại flag cho 10% người dùng và theo dõi. Action item cho postmortem: kiểm tra EXPLAIN cho query mới trong code review, đặt statement_timeout cho DB user của ứng dụng, alert khi hikaricp_connections_pending lớn hơn 0 trong 1 phút.
500 hàng loạt ngay sau deploy
Rollback trước, điều tra sau. Khi thời điểm lỗi trùng với deploy, cách nhanh nhất để người dùng hết bị ảnh hưởng là quay về phiên bản trước (kubectl rollout undo hoặc deploy lại tag cũ), rồi mới đọc log theo traceId, so sánh diff, tái hiện ở staging. Điều kiện để rollback được: image có tag rõ ràng và migration DB tương thích ngược (thêm cột mới trước, xóa cột cũ ở lần deploy sau).
Các bước rollback cụ thể trên Kubernetes, với output mẫu:
$ kubectl rollout history deployment/order-service
REVISION CHANGE-CAUSE
4 Deploy 1.4.2
5 Deploy 1.5.0
$ kubectl rollout undo deployment/order-service
deployment.apps/order-service rolled back
$ kubectl rollout status deployment/order-service
Waiting for deployment "order-service" rollout to finish: 2 of 3 updated replicas are available...
deployment "order-service" successfully rolled out
$ kubectl get deployment order-service -o jsonpath='{.spec.template.spec.containers[0].image}'
registry.example.com/order-service:1.4.2Giải thích: rollout history cho thấy revision 5 (bản 1.5.0) là bản vừa deploy. rollout undo không có tham số quay về revision liền trước (4); muốn chọn revision cụ thể dùng --to-revision=4. rollout status chờ tới khi mọi pod mới sẵn sàng, dòng successfully rolled out nghĩa là rollback xong. Lệnh cuối xác nhận image đang chạy đúng là 1.4.2. Sau đó mở dashboard kiểm tra error rate đã về mức bình thường chưa, rồi mới bắt đầu điều tra bản 1.5.0.
Postmortem
Sau sự cố viết postmortem không đổ lỗi (blameless): dòng thời gian, ảnh hưởng (bao nhiêu user, bao lâu), nguyên nhân gốc (hỏi "tại sao" nhiều lần), điều gì giúp phát hiện và khắc phục, và các action item có người chịu trách nhiệm và hạn chót. Mục tiêu là sửa hệ thống và quy trình, không phải tìm người để trách.
11.9 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Cơ bản): Merge và rebase khác nhau thế nào? Bạn dùng cái nào?
Merge giữ nguyên lịch sử và tạo merge commit; rebase viết lại commit của tôi lên đầu nhánh đích cho lịch sử thẳng. Tôi rebase nhánh cá nhân theo main trước khi mở PR, còn gộp vào nhánh chung thì dùng merge hoặc squash merge theo quy ước team. Không rebase nhánh đã chia sẻ.
Bẫy thường gặp
Dùng git reset --hard rồi git push --force trên nhánh chung để "xóa commit lỗi". Commit đã lên nhánh chung thì dùng git revert.
Câu hỏi (Hiểu sâu): Readiness và liveness probe khác nhau thế nào?
Readiness quyết định pod có nhận traffic không, fail thì bị rút khỏi Service; liveness quyết định pod có bị restart không. Kiểm tra dependency như DB chỉ nên nằm ở readiness, nếu có. Liveness chỉ nên phản ánh chính process có bị treo không.
Câu hỏi đào sâu
Pod Spring Boot bị restart liên tục lúc khởi động là vì sao? Thường vì liveness bắt đầu kiểm tra trước khi ứng dụng khởi động xong; dùng startupProbe hoặc tăng initialDelaySeconds.
Câu hỏi (Thực chiến): Bạn viết Dockerfile cho Spring Boot như thế nào?
Multi-stage: stage build với JDK, stage chạy với JRE. Tách layer dependency và application để tận dụng cache, chạy bằng user không phải root, đặt MaxRAMPercentage để JVM tôn trọng giới hạn bộ nhớ container, tag image theo commit SHA thay vì latest.
Bẫy thường gặp
COPY . . rồi build trong một stage duy nhất: image nặng, chứa cả mã nguồn và công cụ build, đổi một dòng code là tải lại toàn bộ dependency.
Câu hỏi (Hiểu sâu): Log thế nào cho tốt trên production?
Log có cấu trúc JSON, mỗi dòng có traceId qua MDC, dùng đúng level, log sự kiện nghiệp vụ quan trọng ở INFO, exception log một lần kèm stacktrace ở nơi xử lý. Không log dữ liệu nhạy cảm. Log tập trung về ELK hoặc Loki để tìm theo traceId.
Câu hỏi đào sâu
MDC có đi theo sang @Async hay CompletableFuture không? Không, vì MDC dựa trên ThreadLocal. Cần TaskDecorator sao chép MDC hoặc dùng context propagation của Micrometer.
Câu hỏi (Thực chiến): Làm sao tìm lỗi trong file log 2 GB trên server?
Không mở bằng editor. Dùng grep -n "ERROR" app.log | tail để tìm dòng lỗi gần nhất, grep -A 30 để xem stacktrace, lọc theo traceId nếu có, less để đọc quanh vị trí đó, tail -f để theo dõi khi tái hiện. Tốt nhất là log đã được đẩy về hệ thống tập trung.
Câu hỏi (Thực chiến): Sau khi deploy, error rate tăng vọt. Bạn làm gì?
Báo cho team và kênh sự cố, rollback về phiên bản trước ngay vì thời điểm trùng deploy, xác nhận error rate trở lại bình thường. Sau đó mới điều tra: log theo traceId, diff giữa hai phiên bản, thay đổi cấu hình và migration. Cuối cùng viết postmortem và bổ sung test hoặc alert để lần sau phát hiện sớm hơn.
Bẫy thường gặp
Cố sửa nóng trên production trong khi người dùng vẫn đang lỗi, hoặc restart mọi thứ trước khi lấy thread dump và log.
Câu hỏi (Thực chiến): Ứng dụng Java trên server dùng CPU 100%. Bạn điều tra thế nào?
Dùng top tìm PID, top -H -p <pid> tìm thread ngốn CPU, đổi thread id sang hex rồi đối chiếu trong thread dump (jcmd <pid> Thread.print) để thấy đoạn code đang chạy. Nếu thấy nhiều thread GC thì xem GC log và heap, khả năng là thiếu bộ nhớ hoặc rò rỉ bộ nhớ.
Câu hỏi (Cơ bản): git reset --soft, --mixed, --hard khác nhau thế nào? Cho ví dụ khi nào dùng mỗi loại.
Cả ba đều kéo nhãn nhánh về commit cũ; khác nhau ở chỗ thay đổi của các commit bị bỏ nằm đâu: --soft giữ ở trạng thái đã stage, --mixed giữ trong file nhưng chưa stage, --hard xóa khỏi working tree. Ví dụ: --soft HEAD~3 để gộp 3 commit lộn xộn chưa push thành một commit gọn; --mixed HEAD~1 khi muốn tách một commit lớn thành nhiều commit nhỏ (chọn file để add lại từng phần); --hard origin/main khi muốn bỏ hết thay đổi cục bộ và làm lại từ bản trên remote. Cả ba chỉ dùng cho commit chưa chia sẻ; commit đã lên nhánh chung thì dùng revert.
Câu hỏi (Đọc code): Output git log dưới đây cho biết nhánh đã được gộp bằng cách nào?
$ git log --oneline --graph -6
* 8e1f2a3 (HEAD -> main) Merge branch 'feature/coupon'
|\
| * 5e6f7a8 Add coupon API
| * 4d8e7f0 Add coupon entity
* | c7b6a5d Fix VAT rounding
|/
* 1a2b3c4 Release 1.4.1Đáp án:
Nhánh feature/coupon được gộp vào main bằng merge thường (có merge commit), không phải rebase hay fast-forward. Đọc từ dưới lên: cả hai nhánh rẽ ra từ 1a2b3c4; main có thêm c7b6a5d (cột trái), feature/coupon có 4d8e7f0, 5e6f7a8 (cột phải); 8e1f2a3 là merge commit có hai cha, nối hai cột lại (dấu |\). Nếu dùng rebase rồi merge fast-forward, đồ thị sẽ là một đường thẳng không có cột phụ. Nếu dùng squash merge, sẽ chỉ có một commit mới trên main chứa tổng thay đổi của hai commit feature, và hai commit gốc không xuất hiện trong lịch sử main.
Câu hỏi (Thực chiến): Bạn lỡ chạy git reset --hard HEAD~2 và mất hai commit chưa push. Có cứu được không?
Cứu được bằng git reflog, vì commit chưa bị xóa ngay mà chỉ không còn nhánh nào trỏ tới. reflog ghi lại mọi vị trí HEAD từng đứng trong khoảng 90 ngày gần nhất. Chạy git reflog thấy các dòng như a9b8c7d HEAD@{1}: commit: Add refund API; sau đó git reset --hard a9b8c7d (hoặc git branch rescue a9b8c7d để an toàn hơn) là lấy lại. Thứ không cứu được là thay đổi chưa từng commit hay stash: reset --hard xóa chúng khỏi working tree mà Git không lưu bản nào. Vì vậy trước khi làm gì mạo hiểm, git stash hoặc commit tạm.
Câu hỏi (Hiểu sâu): Vì sao không rebase nhánh dùng chung? --force-with-lease khác --force thế nào?
Rebase tạo commit mới với hash mới; người khác đang giữ commit cũ sẽ có lịch sử lệch với remote, và khi họ pull hoặc push, các commit cũ và mới trùng nội dung trộn vào nhau, gây conflict khó hiểu hoặc đẩy lại code đã bị sửa. Sau khi rebase nhánh của mình, bắt buộc phải force push vì remote đang có commit cũ. --force ghi đè remote bằng bản của bạn bất kể trên remote có gì. --force-with-lease chỉ ghi đè nếu remote vẫn ở đúng vị trí mà lần fetch gần nhất bạn thấy; nếu đồng nghiệp đã đẩy thêm commit vào nhánh đó, lệnh bị từ chối thay vì âm thầm xóa commit của họ.
Câu hỏi (Cơ bản): git cherry-pick dùng khi nào? Cho một ví dụ.
Cherry-pick áp một commit cụ thể từ nhánh này sang nhánh khác, tạo commit mới có cùng thay đổi. Ví dụ hay gặp: bug được sửa ở main bằng commit f3e2d1c, nhưng production đang chạy nhánh release/1.4 và chưa thể đưa mọi tính năng mới của main lên. Bạn git switch release/1.4 && git cherry-pick f3e2d1c, build và deploy bản vá 1.4.3. Lưu ý: cherry-pick nhiều commit phụ thuộc nhau dễ conflict và dễ sót; nếu phải cherry-pick thường xuyên, đó là dấu hiệu chiến lược nhánh cần xem lại.
Câu hỏi (Cơ bản): Image, container và layer khác nhau thế nào?
Image là bản đóng gói chỉ đọc, container là một bản đang chạy của image, layer là từng lớp thay đổi file tạo nên image. So sánh với Java: image như class, container như object; chạy 3 container từ cùng một image giống new 3 object. Mỗi lệnh RUN, COPY trong Dockerfile tạo một layer; các image dùng chung layer (ví dụ cùng base eclipse-temurin:21-jre) chỉ lưu và tải layer đó một lần. Container có thêm một lớp ghi riêng, mất khi container bị xóa, nên dữ liệu cần giữ phải nằm ở volume hoặc dịch vụ bên ngoài.
Câu hỏi (Đọc code): Dockerfile dưới đây có những vấn đề gì?
FROM maven:3-eclipse-temurin-21
WORKDIR /app
COPY . .
ENV DB_PASSWORD=Sup3rS3cret
RUN mvn package
EXPOSE 8080
CMD java -jar target/order-service-1.0.jarĐáp án:
Có ít nhất năm vấn đề: một stage duy nhất, cache layer vô dụng, lộ secret, chạy bằng root, và CMD dạng shell làm hỏng graceful shutdown.
- Một stage với image Maven: image cuối chứa JDK, Maven, mã nguồn và
~/.m2, dễ vượt 800MB và có thêm nhiều lỗ hổng không cần thiết. COPY . .trướcmvn package: sửa một dòng code là layerCOPYđổi, nên bướcmvn packagephải tải lại toàn bộ dependency. Cần copypom.xmlvà tải dependency trước, copysrcsau.COPY . .còn đưa cả.git,.envvào image nếu thiếu.dockerignore.ENV DB_PASSWORD=...: mật khẩu nằm vĩnh viễn trong layer, ai kéo được image là đọc được bằngdocker historyhoặcdocker inspect. Secret phải đưa vào lúc chạy.- Không có
USER: tiến trình chạy bằng root; nếu ứng dụng bị khai thác, kẻ tấn công có quyền root trong container. CMD java -jar ...dạng chuỗi được chạy qua/bin/sh -c, nên PID 1 là shell, không phải Java. Khi Kubernetes gửiSIGTERM, shell thường không chuyển tín hiệu cho Java, ứng dụng không được tắt êm và bịSIGKILLsau 30 giây. Dùng dạng mảngENTRYPOINT ["java", "-jar", "app.jar"]. Ngoài ramvn packagechạy cả test trong lúc build image, làm build chậm và phụ thuộc môi trường.
Bản đúng là Dockerfile multi-stage ở mục 11.2.
Câu hỏi (Hiểu sâu): Vì sao chỉ sửa một dòng code mà docker build tải lại toàn bộ dependency?
Vì Docker cache theo layer và theo thứ tự: một layer thay đổi thì mọi layer phía sau đều mất cache; nếu bước tải dependency nằm sau lệnh copy toàn bộ mã nguồn thì mỗi lần sửa code đều làm bước đó chạy lại. Docker quyết định dùng cache cho COPY bằng cách so hash nội dung các file được copy, và cho RUN bằng cách so chuỗi lệnh cùng layer cha. Cách sửa: copy pom.xml (thứ ít đổi) và chạy dependency:go-offline trước, tạo một layer dependency chỉ bị build lại khi pom.xml đổi; copy src sau. Ở CI, còn phải bảo đảm cache được giữ giữa các lần build (BuildKit cache mount hoặc --cache-from registry), nếu không mỗi runner mới đều build từ đầu.
Câu hỏi (Hiểu sâu): Container bị OOMKilled dù Heap tối đa chỉ 768MB và limit là 1GB. Vì sao?
Vì bộ nhớ của tiến trình Java không chỉ có Heap: Metaspace, stack của mỗi thread, code cache, bộ nhớ của GC và direct buffer đều nằm ngoài Heap, và tổng cộng có thể vượt limit của container. Đây là chỗ kiến thức vùng nhớ JVM (mục 2.2) gặp Kubernetes: ví dụ 200 thread Tomcat và thread pool, mỗi thread stack 1MB là 200MB; Metaspace 120MB; Netty hoặc NIO dùng direct buffer thêm 100MB; cộng 768MB Heap là vượt 1GB. Kernel giết tiến trình với exit code 137 mà JVM không kịp ném OutOfMemoryError, nên không có heap dump. Cách xử lý: để MaxRAMPercentage khoảng 70–75% chứ không phải 90%, giới hạn số thread, dùng Native Memory Tracking (-XX:NativeMemoryTracking=summary rồi jcmd <pid> VM.native_memory summary) để xem phần ngoài Heap dùng bao nhiêu, và tăng limit nếu thực sự cần.
Câu hỏi đào sâu
Làm sao phân biệt OOMKilled với OutOfMemoryError? OOMKilled: kubectl describe pod ghi Last State: Terminated, Reason: OOMKilled, Exit Code: 137, log ứng dụng dừng đột ngột không có stacktrace. OutOfMemoryError: log có dòng java.lang.OutOfMemoryError: Java heap space, và nếu bật HeapDumpOnOutOfMemoryError sẽ có file heap dump.
Câu hỏi (Đọc code): Output dưới đây cho biết container đã gặp chuyện gì?
$ docker ps -a --filter name=order
CONTAINER ID IMAGE STATUS NAMES
a1b2c3d4e5f6 order-service:1.4.2 Exited (137) 2 minutes ago order
$ docker inspect order --format '{{.State.OOMKilled}} {{.HostConfig.Memory}}'
true 536870912Đáp án:
Container bị kernel giết vì dùng vượt giới hạn bộ nhớ 512MB. Exit code 137 = 128 + 9, tức tiến trình nhận tín hiệu 9 (SIGKILL); OOMKilled: true xác nhận lý do là hết bộ nhớ, và 536870912 byte chính là 512MB đặt bằng -m 512m. Nếu OOMKilled là false mà exit code vẫn 137, tiến trình bị kill bởi thứ khác (ví dụ docker kill, hoặc Kubernetes SIGKILL sau khi hết thời gian graceful shutdown). Hướng xử lý giống câu trên: kiểm tra MaxRAMPercentage, bộ nhớ ngoài Heap, và tăng limit nếu cần.
Câu hỏi (Thực chiến): Deploy xong, pod mới ở trạng thái CrashLoopBackOff. Bạn điều tra thế nào?
Đọc log của lần chạy vừa chết và event của pod để phân biệt "ứng dụng tự thoát vì lỗi" với "bị Kubernetes giết". Trình tự: kubectl describe pod <pod> xem phần Last State (exit code, OOMKilled) và Events (có Liveness probe failed không); kubectl logs <pod> --previous xem log trước khi chết. Ba nguyên nhân hay gặp: ứng dụng thoát với exit code 1 vì thiếu cấu hình hoặc không kết nối được DB (log có APPLICATION FAILED TO START); bị liveness giết vì khởi động chậm hơn initialDelaySeconds (event Liveness probe failed ... connection refused, sửa bằng startupProbe như kịch bản ở mục 11.3); bị OOMKilled vì limit quá thấp (exit code 137). Trong lúc điều tra, Deployment với rolling update vẫn giữ các pod bản cũ chạy, nên người dùng chưa bị ảnh hưởng; nếu không, rollback trước.
Câu hỏi (Hiểu sâu): Vì sao không đưa kiểm tra DB vào liveness probe?
Vì liveness fail thì pod bị restart, mà restart không chữa được DB chậm; khi DB có sự cố, mọi pod cùng fail liveness và cùng bị restart, biến sự cố DB thành sự cố toàn bộ service. Hơn nữa, lúc các pod khởi động lại đồng loạt, chúng cùng mở connection tới DB đang yếu, làm DB tệ hơn (hiệu ứng dây chuyền). Liveness chỉ nên trả lời "tiến trình này còn xử lý được không" (không deadlock, không treo). Kiểm tra dependency, nếu cần, đặt ở readiness: DB chết thì pod tạm thời không nhận traffic nhưng vẫn sống, DB hồi phục thì pod tự quay lại. Spring Boot mặc định không đưa DB vào nhóm liveness; muốn đưa vào readiness thì cấu hình management.endpoint.health.group.readiness.include=readinessState,db.
Câu hỏi (Hiểu sâu): Deploy kiểu rolling update nhưng vẫn có vài request lỗi 502 trong lúc deploy. Vì sao?
Vì việc rút pod khỏi Service và việc gửi SIGTERM cho pod diễn ra song song: pod có thể đã bắt đầu tắt trong khi load balancer hoặc kube-proxy vẫn còn gửi request mới tới nó. Nếu ứng dụng đóng cổng ngay khi nhận SIGTERM, những request tới trong vài giây đó nhận 502. Cách xử lý kết hợp: bật server.shutdown=graceful để Spring Boot chờ request đang chạy xong; thêm preStop với sleep 5 để pod chờ vài giây cho danh sách endpoint được cập nhật xong rồi mới nhận SIGTERM; bảo đảm terminationGracePeriodSeconds lớn hơn tổng thời gian sleep và thời gian chờ shutdown; và có readiness probe đúng để pod mới chỉ nhận traffic khi đã sẵn sàng.
Câu hỏi (Cơ bản): requests và limits trong Kubernetes khác nhau thế nào?
requests là lượng tài nguyên scheduler dùng để chọn node và được bảo đảm cho pod; limits là mức trần, vượt thì bị hạn chế hoặc bị giết. Ví dụ requests.memory: 768Mi nghĩa là pod chỉ được đặt lên node còn trống ít nhất 768Mi. Với CPU, vượt limits thì bị bóp (throttle), ứng dụng chậm đi nhưng không chết; với memory, vượt limits thì bị OOMKilled. Nhiều team không đặt CPU limit (chỉ đặt requests) để tránh throttle gây latency cao, nhưng luôn đặt memory limit. Với Java, Heap phải tính theo memory limit (MaxRAMPercentage).
Câu hỏi (Cơ bản): Vì sao dùng placeholder {} thay vì nối chuỗi khi log?
Vì với placeholder, chuỗi chỉ được ghép khi level đó đang bật; nối chuỗi thì luôn ghép, kể cả khi dòng log bị bỏ. log.debug("Order " + order + " created") gọi order.toString() và tạo chuỗi mới ở mọi request dù DEBUG tắt trên production; ở đường nóng hàng nghìn request mỗi giây, đó là CPU và rác cho GC vô ích. log.debug("Order {} created", order) chỉ lưu tham chiếu, kiểm tra level trước rồi mới format. Placeholder cũng giúp hệ thống log gom nhóm các dòng cùng mẫu. Nếu việc tính tham số tự nó tốn kém (serialize JSON), bọc trong if (log.isDebugEnabled()) hoặc dùng API lambda log.atDebug().addArgument(() -> toJson(order)).log("...") của SLF4J 2.
Câu hỏi (Đọc code): Dòng log JSON dưới đây cho bạn biết những gì, và bạn sẽ làm gì tiếp?
{"@timestamp":"2026-09-24T13:02:44.905Z","log.level":"WARN","service.name":"payment-service","process.thread.name":"task-3","log.logger":"com.shop.payment.RefundWorker","message":"Refund 881 retry 3/3 failed, giving up","traceId":"","spanId":"","error.type":"java.net.ConnectException"}Đáp án:
Lúc 20:02 giờ Việt Nam, worker hoàn tiền của payment-service đã thử 3 lần hoàn tiền cho yêu cầu 881 và bỏ cuộc vì không kết nối được đối tác; và dòng log không có traceId, nên không lần ngược được request gốc. Đọc từng field: WARN nhưng nội dung là "giving up", tức là tiền của khách chưa được hoàn mà không ai được báo, lẽ ra phải là ERROR kèm alert hoặc đẩy vào hàng đợi xử lý tay. Thread task-3 là thread của @Async/executor, và traceId rỗng cho thấy context tracing không được truyền từ thread request sang thread nền (MDC dựa trên ThreadLocal). ConnectException nghĩa là không mở được kết nối (đối tác sập hoặc sai địa chỉ, firewall), khác với SocketTimeoutException (kết nối được nhưng chờ phản hồi quá lâu). Việc tiếp theo: kiểm tra trạng thái đối tác và các yêu cầu hoàn tiền khác cùng khoảng thời gian, xử lý tay yêu cầu 881, sửa mức log và cấu hình truyền context (xem câu hỏi về @Async bên dưới).
Câu hỏi (Hiểu sâu): Vì sao theo dõi p99 thay vì latency trung bình? Giải thích bằng ví dụ số.
Vì trung bình bị vài request cực chậm kéo lệch và che giấu trải nghiệm thật; p99 cho biết 1% người dùng tệ nhất đang chờ bao lâu. Ví dụ 20 request ở mục 11.6: 18 request dưới 150ms, một request 900ms, một request 2400ms. Trung bình 263ms trông ổn, nhưng không phản ánh ai cả: người dùng điển hình (p50) chờ 110ms, còn p95 là 900ms và p99 là 2400ms. Một trang gọi 10 API song song thì chỉ cần một API rơi vào đuôi chậm là cả trang chậm, nên xác suất người dùng gặp đuôi chậm cao hơn nhiều so với 1%. Lưu ý kỹ thuật: không được lấy trung bình p99 của các pod để ra p99 toàn hệ thống; phải tính từ histogram gộp.
Câu hỏi (Đọc code): Đoạn code log dưới đây có những vấn đề gì?
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class LoginService {
private static final Logger log = LoggerFactory.getLogger(LoginService.class);
String login(String username, String password) {
log.info("Login attempt: user=" + username + ", password=" + password);
try {
return authenticate(username, password);
} catch (IllegalStateException e) {
log.error("Login failed", e);
throw e; // caller logs it again
}
}
private String authenticate(String u, String p) { throw new IllegalStateException("bad credentials"); }
}Đáp án:
Ba vấn đề: log mật khẩu dạng rõ, nối chuỗi trong log, và vừa log vừa ném lại exception. Mật khẩu xuất hiện trong file log, hệ thống log tập trung, bản backup, và bất kỳ ai có quyền đọc log; đây là sự cố bảo mật, phải bỏ hẳn tham số password khỏi log. Nối chuỗi làm việc ghép chuỗi luôn xảy ra; dùng log.info("Login attempt: user={}", username). Log rồi throw e khiến cùng một exception bị log lại ở tầng trên (controller, @ControllerAdvice), một lỗi thành nhiều stacktrace, khó đếm và dễ gây nhầm là nhiều lỗi. Nguyên tắc: xử lý thì log, không xử lý thì ném tiếp mà không log. Ngoài ra, đăng nhập sai là chuyện bình thường, nên log ở WARN hoặc INFO kèm số lần thử, không phải ERROR với stacktrace.
Câu hỏi (Thực chiến): Log của các tác vụ @Async không có traceId. Vì sao và sửa thế nào?
Vì traceId nằm trong MDC, mà MDC dựa trên ThreadLocal của thread request; tác vụ @Async chạy trên thread khác của executor nên không thấy MDC đó. Đây là chỗ kiến thức ThreadLocal (mục 3.9) và thread pool (mục 3.6) gặp logging. Cách sửa thủ công: cấu hình ThreadPoolTaskExecutor.setTaskDecorator(...) với một TaskDecorator chụp MDC.getCopyOfContextMap() ở thread gọi, đặt lại vào MDC ở đầu tác vụ và MDC.clear() trong finally. Với Micrometer Tracing trong Spring Boot 3, dùng ContextPropagatingTaskDecorator để truyền cả trace context (span con được tạo đúng cha). Với CompletableFuture.supplyAsync(...) phải truyền executor đã được bọc, không dùng ForkJoinPool.commonPool() mặc định.
Câu hỏi đào sâu
Vì sao phải MDC.clear() trong finally ở thread của executor? Vì thread được tái sử dụng: tác vụ sau chạy trên cùng thread sẽ mang traceId của tác vụ trước, log bị gán nhầm request, còn tệ hơn là không có traceId.
Câu hỏi (Đọc code): Output dưới đây nói lên điều gì, và vì sao xóa file log xong mà ổ đĩa vẫn đầy?
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p1 50G 50G 0 100% /
$ rm /var/log/order-service/app.log && df -h / | tail -1
/dev/nvme0n1p1 50G 50G 0 100% /
$ sudo lsof +L1 | grep app.log
java 21873 spring 7w REG 259,1 38654705664 0 1311 /var/log/order-service/app.log (deleted)Đáp án:
Ổ gốc đầy 100%; file log 38GB đã bị xóa khỏi thư mục nhưng tiến trình Java PID 21873 vẫn đang mở nó để ghi, nên dung lượng chưa được giải phóng. Trên Linux, rm chỉ xóa tên file; dữ liệu chỉ thực sự bị thu hồi khi không còn tiến trình nào giữ file mở. lsof +L1 liệt kê các file đã xóa (số liên kết bằng 0) mà vẫn bị mở: 7w là file descriptor số 7 mở để ghi, kích thước 38654705664 byte (khoảng 36GB). Cách xử lý: không rm file log đang được ghi, mà làm rỗng nó bằng truncate -s 0 app.log (hoặc : > app.log); nếu đã lỡ xóa, restart ứng dụng hoặc truncate qua /proc/21873/fd/7. Sửa gốc: cấu hình rolling log theo dung lượng và số ngày (logging.logback.rollingpolicy.max-file-size, max-history, total-size-cap), và alert khi đĩa vượt 80%.
Câu hỏi (Thực chiến): Ổ đĩa server đầy 100% lúc nửa đêm, ứng dụng bắt đầu lỗi. Bạn làm gì theo thứ tự?
Giải phóng dung lượng an toàn trước để hệ thống chạy lại, sau đó tìm nguyên nhân tăng dung lượng và chặn nó. Trình tự: df -h xác định phân vùng nào đầy; du -sh /var/log/* | sort -rh | head (lặp lại vào thư mục con) tìm thủ phạm; làm rỗng file log lớn bằng truncate -s 0 thay vì rm (xem câu trên), xóa log cũ đã nén, heap dump cũ trong thư mục dump; kiểm tra lại df -h và health của ứng dụng. Sau đó tìm nguyên nhân: log DEBUG bị bật trên production, một vòng lặp lỗi log stacktrace liên tục, thiếu rolling policy, hoặc heap dump tự động ghi nhiều lần. Action item: rolling policy có total-size-cap, đẩy log ra hệ thống tập trung, alert đĩa ở 80%, tách thư mục dump sang phân vùng riêng.
Câu hỏi (Thực chiến): Log báo HikariPool-1 - Connection is not available, request timed out after 30000ms. Bạn điều tra thế nào?
Thông báo này nghĩa là mọi connection trong pool đều đang bị giữ và request phải chờ quá 30 giây; nguyên nhân gốc gần như luôn là connection bị giữ quá lâu, không phải pool quá nhỏ. Trình tự như kịch bản ở mục 11.8: đọc phần (total=20, active=20, idle=0, waiting=57) trong log để xác nhận pool cạn; xem pg_stat_activity (hoặc tương đương) để biết các connection đang chạy query gì và bao lâu; lấy thread dump để xem thread đang giữ connection làm gì. Các nguyên nhân hay gặp: query chậm vì thiếu index; transaction dài vì gọi HTTP ra ngoài bên trong @Transactional (connection bị giữ trong lúc chờ mạng); connection leak do tự lấy Connection mà không đóng (bật leakDetectionThreshold để phát hiện); hoặc Open Session In View giữ connection tới hết request. Tăng maximumPoolSize chỉ là giải pháp tạm và có thể làm DB quá tải hơn.
Câu hỏi đào sâu
Vì sao gọi HTTP bên trong @Transactional lại làm cạn pool? Vì transaction mở là đã lấy connection và giữ tới khi commit; nếu đối tác phản hồi mất 10 giây, connection bị giữ 10 giây mà không làm gì. 20 request như vậy cùng lúc là cạn pool 20 connection. Sửa: gọi đối tác bên ngoài transaction, chỉ bọc phần ghi DB.
Câu hỏi (Hiểu sâu): Lỡ commit mật khẩu DB lên Git, xóa file trong commit sau có đủ không?
Không đủ; phải coi mật khẩu đó đã bị lộ và đổi ngay, rồi mới dọn lịch sử. Commit sau chỉ xóa file khỏi phiên bản mới nhất, còn mọi commit cũ vẫn chứa mật khẩu; ai đã clone repo, fork, hoặc công cụ CI đã cache đều có bản sao. Kể cả khi viết lại lịch sử bằng git filter-repo và force push, bạn không thu hồi được các bản sao đó. Trình tự đúng: đổi mật khẩu (rotate) và kiểm tra log truy cập DB xem có đăng nhập lạ không; sau đó mới dọn lịch sử nếu cần; cuối cùng chặn tái diễn bằng .gitignore, secret manager, và công cụ quét secret trong pre-commit hook và CI.
11.10 Tóm tắt trọng tâm
- Merge giữ nguyên lịch sử và thêm merge commit, rebase chép lại commit của bạn lên đầu nhánh đích với hash mới, vì vậy không rebase nhánh người khác đang dùng.
- Commit chưa chia sẻ thì sửa bằng
reset(soft, mixed, hard khác nhau ở chỗ thay đổi còn nằm đâu), commit đã lên nhánh chung thì gỡ bằngrevertvì nó không viết lại lịch sử. - Image là bản đóng gói chỉ đọc, container là bản đang chạy của image, layer là lớp thay đổi file; layer đổi thì mọi layer sau mất cache, nên copy dependency trước, code sau.
- Dockerfile tốt dùng multi-stage, chạy non-root, không chứa secret, dùng
ENTRYPOINTdạng mảng để Java nhận đượcSIGTERM. - Readiness quyết định có nhận traffic, liveness quyết định có bị restart, startup probe bảo vệ ứng dụng khởi động chậm khỏi bị liveness giết.
- Không đưa DB vào liveness, vì restart không chữa được DB và sẽ làm mọi pod chết dây chuyền.
- Heap chỉ là một phần bộ nhớ của JVM, nên container có thể bị OOMKilled (exit code 137) dù
-Xmxnhỏ hơn limit. - Một image chạy mọi môi trường, khác nhau ở cấu hình qua biến môi trường; secret nằm trong secret manager, lỡ lộ thì phải đổi ngay.
- Log JSON có
traceIdcho phép lọc toàn bộ câu chuyện của một request qua nhiều service; MDC dựa trên ThreadLocal nên phải dọn và phải truyền sang thread@Async. - Log dùng placeholder, kèm exception làm tham số cuối, không log dữ liệu nhạy cảm, và chỉ log exception một lần ở nơi xử lý.
- Theo dõi p95/p99 thay vì trung bình, vì vài request cực chậm bị trung bình che mất; p99 toàn hệ thống phải tính từ histogram gộp.
- Đọc được output của
tail,grep,top,ps,ss,df,du,ls -lgiúp khoanh vùng sự cố trong vài phút đầu. - Xử lý sự cố theo thứ tự: xác định phạm vi, thu thập bằng chứng, giảm thiệt hại (rollback, tắt flag), rồi mới tìm nguyên nhân gốc và viết postmortem không đổ lỗi.
- Pool connection cạn là triệu chứng; nguyên nhân gốc thường là query chậm, transaction dài hoặc connection leak.