Lịch sử System Design · Phần I · về mục lục · chương trước

Chương 3. Chạy nhiều máy chủ ứng dụng sau cân bằng tải

phiên, health check và tự động mở rộng

Chương này phân tích bước chuyển từ một máy ứng dụng sang nhiều máy đứng sau một bộ cân bằng tải (load balancer). Tình huống nghiên cứu là hai sự cố của Chợ Phố ngay sau khi thêm máy thứ hai: khách mất phiên đăng nhập, và một máy treo vẫn tiếp tục nhận yêu cầu (request). Từ hai sự cố này, chương trình bày cân bằng tải tầng 4 và tầng 7, ứng dụng phi trạng thái (stateless) và ba cách giữ phiên. Chương cũng trình bày health check với liveness và readiness, và tự động mở rộng.

Thời gian đọc khoảng 20 phút Chương sau Chương 4. Bộ nhớ đệm và CDN Cập nhật 27/10/2026

Tình huống: thêm một máy, khách bị đăng xuất

Ba tháng sau khi cơ sở dữ liệu (database) được chuyển sang máy riêng ở chương 2, Chợ Phố phục vụ khoảng 120.000 người dùng mỗi ngày. Hệ thống gồm một máy chủ (server) ứng dụng chạy Tomcat và một máy chủ cơ sở dữ liệu chạy PostgreSQL. Từ đợt khuyến mãi tháng 3, CPU của máy ứng dụng chạm trần ở mỗi đợt cao điểm, trong khi máy cơ sở dữ liệu vẫn còn dư năng lực. Tối thứ Sáu 23 tháng 4, tháng thứ 14 kể từ khi mở cửa, đợt khuyến mãi tiếp theo lặp lại đúng tình trạng đó.

Nhóm kỹ thuật quyết định không tiếp tục nâng cấu hình máy ứng dụng. Thay vào đó, nhóm thêm một máy ứng dụng thứ hai giống hệt máy đầu, và đặt phía trước hai máy một bộ cân bằng tải (load balancer).

27/4 10:00
HAProxy bắt đầu chia yêu cầu (request) cho hai máy app1 và app2.
27/4 10:20
Bộ phận hỗ trợ nhận phản ánh đầu tiên: khách bị đăng xuất giữa lúc thanh toán.
27/4 10:40
Nhiều khách báo giỏ hàng trở thành trống sau khi chuyển sang trang khác. Lỗi không lặp lại theo một thao tác cố định.
27/4 11:05
Nhóm bật sticky session trên HAProxy như một biện pháp tạm thời. Phản ánh dừng lại.
6/5 14:02
app2 ngừng trả lời. HAProxy vẫn ghi nhận app2 ở trạng thái hoạt động và tiếp tục gửi yêu cầu tới máy này.
6/5 14:25
An khởi động lại Tomcat trên app2 sau khi nhận cảnh báo từ bộ phận hỗ trợ.
Chương này trả lời ba câu hỏi. Vì sao thêm một máy làm khách mất phiên đăng nhập và giỏ hàng? Vì sao sticky session làm phản ánh dừng lại nhưng chưa được coi là lời giải? Vì sao bộ cân bằng tải tiếp tục gửi yêu cầu tới một máy đã không còn trả lời?

Cân bằng tải và reverse proxy

Thêm máy cùng loại để chia tải được gọi là mở rộng theo chiều ngang (scale out). Cách này khác với mở rộng theo chiều dọc (scale up) đã phân tích ở chương 1, vốn tăng năng lực của một máy duy nhất. Khi có nhiều máy, hệ thống cần một thành phần quyết định yêu cầu nào đi tới máy nào.

Cân bằng tải là thành phần nhận yêu cầu từ người dùng và phân phối chúng tới một nhóm máy chủ phía sau theo một quy tắc xác định. Reverse proxy là máy chủ trung gian đứng trước các máy chủ ứng dụng, nhận yêu cầu thay cho chúng rồi chuyển tiếp vào trong. Người dùng chỉ thấy địa chỉ của reverse proxy, không thấy địa chỉ của từng máy ứng dụng. Một reverse proxy có nhiều máy phía sau và biết chọn giữa chúng thì cũng là một bộ cân bằng tải. Chợ Phố dùng HAProxy cho cả hai vai trò.

Kéo ngang để xem hết hình

THÁNG THỨ 14: HAI MÁY ỨNG DỤNG, PHIÊN VẪN NẰM TRONG TỪNG MÁY Trình duyệt cookie JSESSIONID HAProxy cân bằng tải tầng 7 app1: Tomcat heap giữ phiên của khách A, B app2: Tomcat heap giữ phiên của khách C phiên chỉ có ở app1 phiên chỉ có ở app2 PostgreSQL máy riêng HTTPS chia vòng tròn
Hình 3.1. Hai máy ứng dụng dùng chung cơ sở dữ liệu nhưng không dùng chung phiên. Khung đỏ trong mỗi máy là phần trạng thái mà máy kia không đọc được.

Cấu hình ban đầu của HAProxy như sau:

haproxy# /etc/haproxy/haproxy.cfg: bản đầu tiên, rút gọn
defaults
    mode http
    option httplog
    timeout connect 5s
    timeout client  30s
    timeout server  30s

frontend chopho_web
    bind :443 ssl crt /etc/haproxy/certs/chopho.pem
    option forwardfor                    # thêm header X-Forwarded-For chứa IP của khách
    default_backend app_servers

backend app_servers
    balance roundrobin                   # chia vòng tròn, đây cũng là mặc định
    server app1 10.0.1.10:8080 check     # "check" không kèm httpchk: chỉ thử mở kết nối tới cổng
    server app2 10.0.1.11:8080 check

Phần frontend khai báo địa chỉ nhận yêu cầu từ Internet, còn phần backend khai báo nhóm máy ứng dụng và cách chia. HAProxy giải mã HTTPS tại chỗ, nên máy ứng dụng chỉ nhận HTTP trong mạng nội bộ. Cơ chế mã hoá TLS được trình bày ở chương 26.

Đặt một reverse proxy phía trước làm ứng dụng mất thông tin về người dùng. Mọi yêu cầu tới Tomcat đều đến từ địa chỉ IP của HAProxy, nên nhật ký truy cập và các chức năng dựa trên địa chỉ của khách ghi nhận sai. Dòng option forwardfor khắc phục điều này bằng cách thêm header X-Forwarded-For chứa địa chỉ thật của khách. Ứng dụng phải được cấu hình để tin header này, nhưng chỉ khi yêu cầu đến từ HAProxy.

Tên miền chopho.example trỏ tới địa chỉ của HAProxy bằng một bản ghi DNS, cơ chế được trình bày ở chương 26.

HAProxy cũng là một điểm lỗi đơn (single point of failure) theo nghĩa đã nêu ở chương 1. Cách phổ biến là chạy hai máy HAProxy với keepalived, phần mềm dùng giao thức VRRP để chuyển một địa chỉ IP ảo sang máy dự phòng khi máy chính ngừng hoạt động. Câu hỏi tiếp theo là bộ cân bằng tải nhìn thấy những gì trong một yêu cầu để ra quyết định.

Cân bằng tải tầng 4 và tầng 7

Hai tên gọi này lấy theo các tầng của mô hình mạng OSI. Cân bằng tải tầng 4 ra quyết định chỉ dựa trên thông tin của tầng mạng và tầng giao vận, tức địa chỉ IP và cổng của kết nối (connection). Nó chuyển tiếp các byte mà không hiểu nội dung, nên máy được chọn một lần và phục vụ mọi yêu cầu đi trên kết nối đó. Cân bằng tải tầng 7 đọc nội dung HTTP của từng yêu cầu, gồm phương thức, đường dẫn, header và cookie, rồi mới chọn máy.

Kéo ngang để xem hết hình

TẦNG 4: QUYẾT ĐỊNH MỘT LẦN CHO CẢ KẾT NỐI Trình duyệt Cân bằng tải tầng 4 chỉ thấy địa chỉ IP và cổng app1 hoặc app2 kết nối 1 kết nối 2 byte của cả kết nối TẦNG 7: QUYẾT ĐỊNH CHO TỪNG YÊU CẦU HTTP Trình duyệt Cân bằng tải tầng 7 đọc phương thức, đường dẫn, header, cookie app1 hoặc app2 kết nối 1 kết nối 2 từng yêu cầu HTTP
Hình 3.2. Cả hai cách đều có thể mở kết nối riêng tới máy ứng dụng. Điểm khác nằm ở phần dữ liệu mà bộ cân bằng tải hiểu được, thể hiện ở dòng chữ dưới mỗi khung giữa.

Trong HAProxy, hai chế độ này tương ứng với mode tcp và mode http. Chế độ mặc định khi không khai báo là mode tcp. Tầng 4 tốn ít CPU hơn và dùng được cho mọi giao thức, kể cả kết nối tới cơ sở dữ liệu. Tầng 7 tốn nhiều CPU hơn vì phải giải mã và phân tích từng yêu cầu, nhưng mở ra những khả năng mà Chợ Phố cần trong chương này.

Khả năng thứ nhất là đọc và chèn cookie, cơ sở của sticky session. Bộ cân bằng tải tầng 4 chỉ có thể gắn khách với máy theo địa chỉ IP nguồn (balance source). Cách này hỏng khi nhiều khách dùng chung một địa chỉ IP sau thiết bị NAT của nhà mạng, hoặc khi điện thoại chuyển từ Wi-Fi sang mạng di động. Khả năng thứ hai là health check bằng một yêu cầu HTTP thật, được phân tích ở phần sau. Khả năng thứ ba là định tuyến theo đường dẫn, ví dụ gửi /api/ tới một nhóm máy riêng.

Sau khi chọn được tầng, bộ cân bằng tải còn cần một quy tắc chọn máy cụ thể cho mỗi yêu cầu.

Thuật toán chia tải

Chia vòng tròn (round robin) gửi yêu cầu lần lượt cho từng máy theo thứ tự cố định. Biến thể có trọng số cho phép máy mạnh hơn nhận phần lớn hơn, khai báo bằng tham số weight trên dòng khai báo từng máy. Đây là thuật toán HAProxy dùng khi không khai báo gì khác. Ít kết nối nhất (least connections) gửi yêu cầu tới máy đang có ít kết nối chưa xong nhất, trong HAProxy là balance leastconn.

Hai thuật toán cho kết quả giống nhau khi các máy phản hồi nhanh như nhau. Chúng khác nhau khi một máy chậm đi. Ví dụ sau dùng định luật Little ở chương 0: số yêu cầu đang xử lý bằng tốc độ đến nhân với thời gian mỗi yêu cầu ở lại. Giả sử giờ cao điểm thường có 84 yêu cầu mỗi giây, app1 phản hồi trong 40 ms, còn app2 bị chậm do thiếu bộ nhớ và cần 400 ms.

  1. Cao điểm thường sau khi người dùng tăng. 120.000 ÷ 50.000 = 2,4 lần so với chương 0, nên 35 × 2,4 = 84 yêu cầu/giây.
  2. Chia vòng tròn. Mỗi máy nhận 84 ÷ 2 = 42 yêu cầu/giây. Số yêu cầu đang xử lý ở app1 là 42 × 0,04 = 1,68, ở app2 là 42 × 0,4 = 16,8. Độ trễ (latency) trung bình là 0,5 × 40 + 0,5 × 400 = 220 ms.
  3. Ít kết nối nhất. Thuật toán đẩy hai số đang xử lý về gần nhau, tức λA × 0,04 ≈ λB × 0,4. Giải ra λB = 84 × 0,04 ÷ (0,04 + 0,4) ≈ 7,6 và λA = 84 − 7,6 = 76,4 yêu cầu/giây.
  4. Kết quả. Độ trễ trung bình còn (76,4 × 40 + 7,6 × 400) ÷ 84 ≈ 73 ms. Máy chậm vẫn nhận một phần nhỏ yêu cầu và những yêu cầu đó vẫn chậm.

Tài liệu HAProxy khuyến nghị leastconn cho các kết nối kéo dài như SQL hoặc LDAP, và xem nó kém phù hợp với giao thức có phiên ngắn như HTTP. Chợ Phố giữ chia vòng tròn vì các yêu cầu có thời gian xử lý tương đương nhau. Ví dụ trên cũng cho thấy thuật toán chỉ giảm thiệt hại do máy chậm. Việc loại một máy hỏng khỏi nhóm thuộc về health check, được phân tích sau phần về phiên.

Phân tích nguyên nhân: phiên nằm trong bộ nhớ từng máy

Phiên (session) là dữ liệu máy chủ giữ cho một người dùng giữa các yêu cầu, như danh tính đã đăng nhập và giỏ hàng. Tomcat lưu phiên trong heap của chính tiến trình (process) Java. Trình duyệt chỉ giữ mã phiên trong cookie JSESSIONID và gửi lại mã này ở mỗi yêu cầu. Khi một yêu cầu mang mã phiên mà Tomcat không có, ứng dụng cần phiên sẽ nhận một phiên mới, rỗng, và phản hồi gửi về một cookie mới.

Để kiểm chứng, An bật ghi cookie vào nhật ký của HAProxy và lọc theo địa chỉ của một khách đã phản ánh:

log# frontend chopho_web có thêm: capture cookie JSESSIONID len 40
# Log của một khách, chỉ giữ IP, frontend, backend/máy, mã trạng thái, cookie gửi lên, cookie trả về, yêu cầu
203.0.113.25 chopho_web~ app_servers/app1 200 -               JSESSIONID=4F2A "POST /dang-nhap"
203.0.113.25 chopho_web~ app_servers/app2 200 JSESSIONID=4F2A JSESSIONID=B71D "GET /gio-hang"
203.0.113.25 chopho_web~ app_servers/app1 302 JSESSIONID=B71D JSESSIONID=E0C2 "POST /gio-hang/them"
# mã phiên rút gọn còn 4 ký tự

Trường app_servers/app1 cho biết máy đã phục vụ yêu cầu. Hai trường cookie lần lượt là cookie khách gửi lên và cookie máy trả về. Yêu cầu thứ hai đến app2 với mã phiên của app1, và app2 trả về một mã mới. Yêu cầu thứ ba quay về app1 với mã của app2, và app1 lại tạo thêm một phiên rỗng.

Kéo ngang để xem hết hình

Trình duyệt app1 app2 POST /dang-nhap Set-Cookie: JSESSIONID=4F2A tạo phiên 4F2A, đã đăng nhập GET /gio-hang, gửi cookie 4F2A Set-Cookie: JSESSIONID=B71D không có 4F2A: tạo phiên rỗng B71D POST /gio-hang/them, gửi cookie B71D Set-Cookie: JSESSIONID=E0C2 không có B71D: tạo phiên rỗng E0C2
Hình 3.3. Mỗi lần yêu cầu rơi vào máy không giữ phiên, máy đó tạo phiên mới và trả về cookie mới. Cookie cũ bị ghi đè, nên phiên đã đăng nhập ở app1 không còn được tham chiếu, kể cả khi khách quay lại app1.

Với chia vòng tròn, các yêu cầu liên tiếp của cùng một khách đan xen với yêu cầu của hàng nghìn khách khác. Vì vậy, khi nhìn từ phía một khách, máy nhận yêu cầu tiếp theo gần như ngẫu nhiên. Xét một mô hình đơn giản, trong đó mỗi yêu cầu rơi vào một trong hai máy với xác suất ngang nhau. Khi đó một lượt thanh toán gồm sáu yêu cầu dùng phiên chỉ hoàn tất trọn vẹn khi cả sáu cùng đến một máy. Xác suất đó là (1/2)⁵ = 1/32 ≈ 3,1%. Đây là lý do lỗi xuất hiện ở những bước khác nhau với mỗi khách, trong khi tỉ lệ khách gặp lỗi rất cao.

Nguyên nhân gốc là ứng dụng giữ trạng thái trong máy. Ứng dụng phi trạng thái (stateless) là ứng dụng không giữ dữ liệu nào của người dùng trong bộ nhớ hay trên ổ đĩa (disk) của máy giữa hai yêu cầu. Mọi trạng thái nằm ở phía trình duyệt hoặc ở một kho dùng chung. Khi đó mọi máy đều phục vụ được mọi yêu cầu. Phiên không phải dạng trạng thái duy nhất cần rà soát. Tệp khách tải lên phải nằm ở kho dùng chung, việc Chợ Phố đã làm khi chuyển ảnh sang object storage ở chương 2.

Có ba hướng sửa. Hướng thứ nhất giữ trạng thái trong máy nhưng bảo đảm mỗi khách luôn quay về đúng máy của mình. Hướng thứ hai đưa trạng thái ra khỏi máy ứng dụng, vào một kho dùng chung. Hướng thứ ba đưa trạng thái về phía trình duyệt dưới dạng một token có chữ ký. Nhóm áp dụng hướng thứ nhất trước vì chỉ cần sửa cấu hình HAProxy.

Sticky session và giới hạn của nó

Sticky session là cơ chế trong đó bộ cân bằng tải gửi mọi yêu cầu của một khách tới cùng một máy trong suốt phiên làm việc. Với HAProxy, cơ chế này dựa trên một cookie riêng do chính HAProxy chèn vào:

haproxybackend app_servers
    balance roundrobin
    cookie SRV insert indirect nocache   # HAProxy chèn cookie SRV=app1 hoặc SRV=app2 vào phản hồi đầu tiên
    option redispatch                    # máy trong cookie đã DOWN thì gửi sang máy khác
    server app1 10.0.1.10:8080 check cookie app1
    server app2 10.0.1.11:8080 check cookie app2

Ở yêu cầu đầu tiên, HAProxy chọn máy theo chia vòng tròn và chèn cookie SRV=app1 vào phản hồi. Từ đó, yêu cầu mang cookie này luôn được gửi về app1. Từ khoá indirect làm HAProxy xoá cookie này trước khi chuyển yêu cầu vào máy ứng dụng, nên ứng dụng không cần biết đến nó. Từ khoá nocache báo cho các bộ nhớ đệm (cache) trung gian không lưu phản hồi có chứa cookie này. Phản ánh của khách dừng lại ngay sau khi cấu hình được nạp.

Sticky session che nguyên nhân thay vì loại bỏ nó, vì phiên vẫn chỉ tồn tại trong một máy. Hệ quả thứ nhất xuất hiện khi một máy ngừng hoạt động. Lúc cao điểm, Chợ Phố có khoảng 7.600 phiên còn hiệu lực, cách tính được trình bày ở mục sau. Nếu một trong hai máy hỏng, khoảng 7.600 ÷ 2 = 3.800 phiên mất theo máy đó. Dòng option redispatch chỉ đưa những khách này sang máy còn lại, nơi họ phải đăng nhập lại.

Hệ quả thứ hai liên quan đến việc rút máy để triển khai (deploy) phiên bản mới. Muốn rút một máy mà không làm khách mất phiên, nhóm phải ngừng gửi khách mới tới máy đó rồi chờ các phiên cũ hết hạn, có thể mất tới nửa giờ. Hệ quả thứ ba là tải phân bố không đều. Máy vừa thêm vào chỉ nhận khách mới, còn khách cũ vẫn ở máy cũ, nên thêm máy không giảm tải ngay. Hệ quả này làm việc tự động thêm máy kém hiệu quả.

Kho phiên dùng chung

Kho phiên dùng chung là một hệ lưu trữ nằm ngoài các máy ứng dụng, nơi mọi máy đọc và ghi phiên theo mã phiên. Chợ Phố chọn Redis làm kho phiên và dùng thư viện Spring Session. Thư viện này thay cơ chế lưu phiên trong heap của Tomcat bằng việc đọc ghi Redis, nên mã nguồn của ứng dụng gần như không thay đổi.

xml<!-- pom.xml: Spring Boot 3.x tự cấu hình Spring Session khi thấy hai thư viện này -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>
properties# application.properties
spring.data.redis.host=10.0.1.20
spring.data.redis.port=6379
spring.session.timeout=30m                 # mặc định lấy theo server.servlet.session.timeout (30m)
spring.session.redis.namespace=chopho:session
server.servlet.session.cookie.name=JSESSIONID   # giữ tên cookie cũ; mặc định của Spring Session là SESSION
server.forward-headers-strategy=native     # đọc header X-Forwarded-For do HAProxy thêm

Spring Session mặc định dùng cookie tên SESSION. Nhóm giữ tên cũ để các công cụ theo dõi hiện có tiếp tục hoạt động. Dù vậy, mọi phiên đang nằm trong heap của Tomcat vẫn mất một lần vào thời điểm triển khai, vì Redis chưa có dữ liệu của chúng. Nhóm triển khai kho phiên vào sáng sớm thứ Ba 18 tháng 5, khung giờ vắng khách nhất.

Dung lượng cần cho kho phiên có thể ước lượng bằng định luật Little. Số phiên còn sống bằng số lượt truy cập mới mỗi phút nhân với thời gian sống của một phiên. Thời gian sống gồm thời gian khách thao tác và khoảng chờ trước khi phiên hết hạn.

  1. Lượt truy cập trong giờ cao điểm. Giả định giờ cao điểm chiếm một phần mười số người dùng trong ngày: 120.000 × 10% = 12.000 lượt, tức 12.000 ÷ 60 = 200 lượt/phút.
  2. Thời gian sống của phiên. Một lượt thao tác trung bình 8 phút, cộng 30 phút chờ trước khi hết hạn (mặc định của Spring Boot).
  3. Số phiên đồng thời. 200 × (8 + 30) = 7.600 phiên.
  4. Dung lượng. Mỗi phiên khoảng 3 KB (danh tính và giỏ hàng; 1 KB = 1.000 byte), nên 7.600 × 3 KB = 22,8 MB.

Dung lượng này nhỏ so với bộ nhớ của một máy Redis. Chi phí thực sự nằm ở chỗ khác. Mỗi yêu cầu cần phiên phải thêm một lượt đi về tới Redis qua mạng nội bộ, loại chi phí đã phân tích ở chương 2. Redis cũng trở thành một thành phần mà mọi yêu cầu đã đăng nhập đều phụ thuộc vào, nên nó cần được giám sát như cơ sở dữ liệu.

Kéo ngang để xem hết hình

STICKY SESSION KHO PHIÊN DÙNG CHUNG HAProxy cookie SRV gắn khách với máy app1 hỏng mất phiên của khách gắn vào app2 phiên của nhóm khách còn lại khách của app1 phải đăng nhập lại rút máy phải chờ phiên hết hạn HAProxy chia đều, không gắn khách app1 hỏng không giữ phiên app2 không giữ phiên Redis phiên của mọi khách
Hình 3.4. Cùng một sự cố ở app1. Bên trái, trạng thái nằm trong máy hỏng nên mất theo máy. Bên phải, trạng thái nằm ở Redis, và đường nét đứt cho thấy app1 không còn đóng vai trò gì với phiên.

Sau một tuần kho phiên hoạt động ổn định, nhóm gỡ cấu hình sticky session khỏi HAProxy. Các máy ứng dụng giờ đây phi trạng thái, và bất kỳ máy nào cũng phục vụ được bất kỳ khách nào.

Đi sâu: phiên được lưu trong Redis như thế nào

Với cấu hình trên, mỗi phiên là một hash trong Redis có khoá dạng chopho:session:sessions:<mã phiên>, gồm thời điểm tạo, thời điểm truy cập cuối và từng thuộc tính của phiên. Khoá được đặt thời điểm hết hạn, nên Redis tự xoá phiên quá hạn. Theo tài liệu hiện hành của Spring Session, Spring Boot dùng RedisSessionRepository làm mặc định. Biến thể RedisIndexedSessionRepository có thêm chỉ mục (index) để tìm phiên theo tên người dùng, đổi lại tốn thêm khoá và thao tác ghi.

Tham số cần kiểm đầu tiên là chính sách khi Redis đầy bộ nhớ. Mặc định noeviction từ chối lệnh ghi mới, còn lệnh đọc vẫn chạy. Nếu Redis được cấu hình allkeys-lru để dùng chung làm bộ nhớ đệm, Redis có thể xoá bất kỳ khoá nào ít được dùng gần đây, kể cả phiên của khách đang đặt hàng. Khi đó khách bị đăng xuất mà không có lỗi nào được ghi lại.

shell$ redis-cli -h 10.0.1.20 CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
$ redis-cli -h 10.0.1.20 --scan --pattern 'chopho:session:sessions:*' | wc -l
7412

Token tự chứa: JWT

Hướng thứ ba đưa trạng thái về phía trình duyệt. JSON Web Token (JWT, RFC 7519) là một chuỗi chứa các thông tin về người dùng, gọi là claim, kèm chữ ký số của máy chủ. Máy nào có khoá để kiểm chữ ký đều xác thực được token mà không cần tra cứu kho nào. Ở dạng có chữ ký, token gồm ba phần mã hoá base64url nối bằng dấu chấm, theo định dạng của RFC 7515.

json# Một JWT có ba phần base64url nối bằng dấu chấm: header.payload.signature
# Hai phần đầu sau khi giải mã:
{"alg":"RS256","typ":"JWT"}
{"sub":"user:48213","iat":1808794800,"exp":1808795700,"jti":"9b1e4c0a-52f7-4d1e-a3a8-6c0f2b7d91e5"}
# exp - iat = 900 giây: token hết hạn sau 15 phút

Claim sub là định danh người dùng, exp là thời điểm hết hạn, jti là mã riêng của token. Phần payload chỉ được mã hoá base64url, không được mã hoá bí mật, nên không chứa dữ liệu nhạy cảm.

Nhược điểm chính của JWT là thu hồi. Token đã phát hành vẫn hợp lệ cho đến thời điểm exp, kể cả khi người dùng đổi mật khẩu hoặc tài khoản bị khoá. RFC 7519 không định nghĩa cơ chế thu hồi. Thời hạn ngắn giới hạn khoảng thời gian một token bị lộ còn dùng được. Để thu hồi ngay, hướng dẫn của OWASP đề xuất duy trì danh sách chặn theo jti. Danh sách chặn lại là một kho dùng chung mà mọi máy phải tra cứu, tức là trạng thái quay trở lại hệ thống. Token cũng được gửi kèm mọi yêu cầu, nên kích thước của nó cộng vào mỗi lượt truyền.

Cách giữ phiênTrạng thái nằm ở đâuHạn chế chính
Sticky sessionHeap của một máy ứng dụngMất phiên khi máy hỏng; rút máy và thêm máy đều chậm
Kho phiên dùng chungRedis, ngoài các máy ứng dụngThêm một lượt gọi mạng mỗi yêu cầu; Redis phải luôn sẵn sàng
JWTTrình duyệt, có chữ ký của máy chủKhó thu hồi trước hạn; token lớn hơn mã phiên

Chợ Phố chọn kho phiên dùng chung. Trang web (site) và ứng dụng cùng một tên miền nên cookie phiên đủ dùng, và nhóm cần đăng xuất được một tài khoản ngay lập tức khi phát hiện bị lộ mật khẩu. Trước khi kho phiên được triển khai, sự cố ngày 6 tháng 5 đã cho thấy một lỗ hổng khác trong cách HAProxy theo dõi các máy.

Health check: máy treo vẫn nhận yêu cầu

Health check là phép kiểm tra định kỳ do bộ cân bằng tải thực hiện để quyết định một máy có còn được nhận yêu cầu hay không. Lúc 14:02 ngày 6 tháng 5, app2 vẫn ở trạng thái hoạt động trên trang thống kê của HAProxy. An kiểm tra trực tiếp từ máy HAProxy:

shell$ nc -z -w 2 10.0.1.11 8080 && echo "cổng 8080 mở"
cổng 8080 mở
$ curl -sS -m 5 http://10.0.1.11:8080/actuator/health
curl: (28) Operation timed out after 5001 milliseconds with 0 bytes received
$ jstat -gcutil 21877 1000 2        # trên app2: PID của Tomcat, mỗi giây một dòng
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT      CGC    CGCT     GCT
  0.00   0.00 100.00  99.97  97.10  93.42   4120   61.204   212  1480.115   388   52.310  1593.629
  0.00   0.00 100.00  99.97  97.10  93.42   4120   61.204   213  1487.402   388   52.310  1600.916

Cổng 8080 vẫn nhận kết nối, nhưng ứng dụng không trả lời trong năm giây. Kết quả của jstat cho thấy nguyên nhân. Cột O là phần trăm vùng nhớ old của heap đã dùng, gần như đầy. Cột FGC là số lần thu gom toàn bộ heap, tăng thêm một lần trong một giây, và mỗi lần kéo dài nhiều giây. JVM dành gần như toàn bộ thời gian để thu hồi bộ nhớ mà không giải phóng được bao nhiêu, nên các luồng (thread) xử lý yêu cầu gần như không chạy.

Cấu hình ban đầu dùng check mà không kèm option httpchk. Theo tài liệu HAProxy, khi đó health check chỉ thử thiết lập một kết nối TCP, giao thức được trình bày ở chương 26. Việc bắt tay kết nối do nhân hệ điều hành hoàn tất thay cho ứng dụng, rồi kết nối được đưa vào hàng đợi (queue) chờ tiến trình nhận. Vì vậy, một tiến trình Java còn sống nhưng không xử lý được gì vẫn vượt qua phép kiểm này.

Mỗi yêu cầu gửi tới app2 chờ hết thời gian chờ phía máy ứng dụng đã khai báo trong cấu hình, rồi nhận mã 504 từ HAProxy. Lưu lượng lúc đó khoảng 60 yêu cầu mỗi giây, và sticky session đang gắn khoảng một nửa số khách vào app2. Trong 23 phút từ 14:02 đến 14:25, số yêu cầu thất bại tối đa khoảng 60 × 0,5 × 23 × 60 = 41.400 yêu cầu. Con số này là cận trên, vì khách đang phải chờ sẽ gửi ít yêu cầu hơn bình thường. Mỗi yêu cầu thất bại bắt khách chờ nửa phút. Bài học là health check phải kiểm đúng điều cần biết: máy có còn phục vụ được yêu cầu hay không.

Liveness và readiness

Câu hỏi "máy có ổn không" thực ra gồm hai câu hỏi với hai hành động khác nhau. Liveness trả lời câu hỏi tiến trình có còn ở trạng thái làm việc được hay không. Nếu không, hành động đúng là khởi động lại tiến trình. Readiness trả lời câu hỏi tiến trình có sẵn sàng nhận yêu cầu mới ngay lúc này hay không. Nếu không, hành động đúng là tạm ngừng gửi yêu cầu tới máy đó, không khởi động lại. Một máy đang tắt theo graceful shutdown vẫn có liveness tốt, nhưng readiness từ chối yêu cầu mới. Trong lúc ứng dụng đang khởi động, cả liveness lẫn readiness đều chưa đạt.

Spring Boot Actuator cung cấp sẵn hai endpoint /actuator/health/liveness và /actuator/health/readiness. Chúng tự bật khi ứng dụng chạy trên Kubernetes, nền tảng được trình bày ở chương 9. Trên máy thường, cần bật bằng cấu hình:

properties# application.properties: bật hai endpoint liveness và readiness ngoài Kubernetes
management.endpoint.health.probes.enabled=true
# Từ Spring Boot 3.4, graceful shutdown là mặc định; ghi rõ để không phụ thuộc phiên bản
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

Tài liệu Spring Boot khuyến nghị liveness không phụ thuộc vào hệ thống bên ngoài như cơ sở dữ liệu. Nếu cơ sở dữ liệu gặp sự cố mà liveness của mọi máy cùng báo lỗi, mọi máy sẽ bị khởi động lại cùng lúc trong khi lỗi không nằm ở chúng. Nhóm cũng không đưa cơ sở dữ liệu vào readiness, vì khi cơ sở dữ liệu ngừng hoạt động, mọi máy cùng bị loại và HAProxy không còn máy nào để gửi yêu cầu.

Phía HAProxy, health check được đổi thành một yêu cầu HTTP tới endpoint readiness:

haproxybackend app_servers
    balance roundrobin
    option httpchk
    http-check send meth GET uri /actuator/health/readiness
    http-check expect status 200
    default-server inter 2s fall 3 rise 2   # 3 lần hỏng thì DOWN, 2 lần đạt thì UP
    server app1 10.0.1.10:8080 check
    server app2 10.0.1.11:8080 check

Tham số inter, fall và rise trùng với giá trị mặc định của HAProxy, được ghi rõ để người đọc cấu hình không phải tra tài liệu. Khi không khai báo timeout check, HAProxy dùng chính inter làm thời gian chờ tối đa của một lần kiểm. Với máy treo như app2, mỗi lần kiểm hết thời gian chờ và tính là một lần thất bại.

  1. Thời gian phát hiện tối thiểu. Ba lần thất bại liên tiếp, mỗi lần cách nhau hai giây: 2 giây × 3 = 6 giây. Mỗi lần kiểm máy treo phải chờ hết thời gian chờ, nên thời gian thực tế có thể dài hơn vài giây.
  2. Số yêu cầu bị ảnh hưởng. Với lưu lượng và tỉ lệ như sự cố vừa qua, ở mức tối thiểu: 60 × 0,5 × 6 = 180 yêu cầu.
  3. Quay lại nhóm. Sau khi máy phục hồi, cần hai lần kiểm đạt liên tiếp, tức khoảng bốn giây, trước khi máy nhận yêu cầu trở lại.

Kéo ngang để xem hết hình

APP2 TREO TẠI GIÂY 0; MỖI Ô LÀ MỘT LẦN KIỂM, CÁCH NHAU 2 GIÂY 0 s 2 s 4 s 6 s 8 s 10 s 12 s 14 s 16 s 18 s 20 s heap đầy, ứng dụng không trả lời chỉ mở cổng (cấu hình ban đầu) gọi /actuator/health /readiness DOWN: không nhận yêu cầu mới mọi lần kiểm đều đạt: máy vẫn ở trạng thái UP lần kiểm thứ ba hết thời gian chờ
Hình 3.5. Ô xanh là lần kiểm đạt, ô đỏ là lần kiểm thất bại. Hàng trên đạt ở mọi lần kiểm dù ứng dụng không trả lời, còn hàng dưới chuyển sang DOWN ngay sau ô đỏ thứ ba.

Readiness chỉ loại máy treo ra khỏi nhóm. Máy vẫn cần được khởi động lại, và đó là việc của liveness. Chợ Phố chạy Tomcat như một dịch vụ (service) của systemd:

ini# /etc/systemd/system/chopho.service (trích)
[Service]
ExecStart=/usr/bin/java -Xmx8g -XX:+ExitOnOutOfMemoryError -jar /opt/chopho/chopho.jar
Restart=on-failure        # tiến trình thoát với mã khác 0 thì systemd khởi động lại
RestartSec=5

Tuỳ chọn -XX:+ExitOnOutOfMemoryError làm JVM thoát ngay ở lỗi hết bộ nhớ đầu tiên, thay vì tiếp tục chạy trong trạng thái hỏng. Khi tiến trình thoát với mã khác 0, systemd khởi động lại nó sau năm giây. Trường hợp heap gần đầy nhưng chưa phát sinh lỗi hết bộ nhớ, như sự cố ngày 6 tháng 5, cần một bên ngoài gọi endpoint liveness và khởi động lại tiến trình. Khi JVM kẹt trong việc thu hồi bộ nhớ, endpoint liveness thường không trả lời kịp, nên bên gọi phải coi việc hết thời gian chờ là một lần thất bại. Trên Kubernetes, việc này do livenessProbe đảm nhiệm. Chợ Phố chưa tự động khởi động lại trong trường hợp này. Hệ thống giám sát chỉ cảnh báo khi một máy ở trạng thái DOWN quá hai phút, và người trực xử lý bằng tay.

Đi sâu: cùng bài toán trên nginx

Nhiều hệ thống dùng nginx làm reverse proxy. Bản mã nguồn mở của nginx không có health check chủ động. Chỉ thị health_check thuộc bản thương mại NGINX Plus. Bản mã nguồn mở chỉ kiểm tra bị động, nghĩa là đếm các yêu cầu thật bị lỗi.

Tham số max_fails, mặc định 1, là số lần thất bại trong khoảng fail_timeout, mặc định 10 giây, để máy bị coi là không dùng được. Máy đó bị bỏ qua trong chính khoảng fail_timeout rồi được thử lại. Với máy treo, mỗi lần thất bại chỉ được ghi nhận sau khi hết proxy_read_timeout, mặc định 60 giây. Vì vậy cần đặt thời gian chờ này phù hợp với độ trễ thật của ứng dụng. Chỉ thị proxy_next_upstream mặc định chuyển yêu cầu lỗi sang máy khác, trừ các phương thức như POST đã được gửi đi, để tránh tạo đơn hàng hai lần.

nginxupstream app_servers {
    least_conn;
    server 10.0.1.10:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
}
location / {
    proxy_pass http://app_servers;
    proxy_read_timeout 5s;                 # mặc định 60s: một máy treo giữ yêu cầu tới một phút
    proxy_next_upstream error timeout;     # mặc định; POST đã gửi đi thì không gửi lại sang máy khác
}

Tự động thêm bớt máy

Khi các máy ứng dụng đã phi trạng thái, số máy có thể thay đổi theo tải mà không ảnh hưởng tới khách. Tự động mở rộng (auto-scaling) là cơ chế thêm máy khi một chỉ số vượt ngưỡng trên và bớt máy khi chỉ số xuống dưới ngưỡng dưới. Máy mới được tạo từ một ảnh đĩa dựng sẵn chứa hệ điều hành và ứng dụng. Cách dựng ảnh và quản lý hạ tầng bằng mã được trình bày ở chương 7.

Chỉ số phổ biến nhất là CPU trung bình của nhóm máy, phù hợp khi ứng dụng bị giới hạn bởi CPU như Chợ Phố. Một lựa chọn trực tiếp hơn là số yêu cầu mỗi giây trên mỗi máy. Độ trễ không nên là chỉ số duy nhất. Độ trễ tăng có thể do cơ sở dữ liệu chậm, và khi đó thêm máy ứng dụng chỉ làm cơ sở dữ liệu chịu thêm tải.

Đêm khuyến mãi tháng 4 cho phép ước lượng năng lực của một máy. Nhân đỉnh khuyến mãi ở chương 0 với tỉ lệ tăng người dùng cho đỉnh tải mới: 200 × 2,4 = 480 yêu cầu/giây. Mỗi yêu cầu cần 16 ms CPU, nên tổng nhu cầu là 480 × 16 ms = 7,68 vCPU. Máy 8 vCPU vì vậy bận 7,68 ÷ 8 = 96%, và với hai máy, mỗi máy còn 96% ÷ 2 = 48%.

  1. Năng lực tối đa của một máy. 8 ÷ 0,016 = 500 yêu cầu/giây khi CPU bận hoàn toàn.
  2. Ngưỡng mục tiêu. Nhóm chọn giữ mức sử dụng (utilization) CPU quanh 60%. Theo mô hình hàng đợi ở chương 0, thời gian chờ tăng nhanh khi tài nguyên tiến gần mức bận hoàn toàn. Năng lực mục tiêu của mỗi máy là 500 × 0,6 = 300 yêu cầu/giây.
  3. Đợt giờ vàng. Bộ phận kinh doanh dự kiến tải gấp 2,5 lần đợt khuyến mãi thường: 480 × 2,5 = 1.200 yêu cầu/giây, cần 1.200 ÷ 300 = 4 máy.

Thêm máy không có hiệu lực tức thời. An đo thời gian của từng bước khi tạo một máy mới. Nhà cung cấp cấp máy trong khoảng 90 giây, hệ điều hành và JVM khởi động trong 70 giây. Ứng dụng nạp dữ liệu ban đầu và vượt qua readiness trong 80 giây. Tổng cộng 90 + 70 + 80 = 240 giây. Cơ chế tự động mở rộng còn cần thời gian quan sát để không phản ứng với một đỉnh ngắn. Với cửa sổ quan sát ba phút, máy mới bắt đầu nhận yêu cầu sau 180 + 240 = 420 giây kể từ khi tải tăng.

Kéo ngang để xem hết hình

YÊU CẦU MỖI GIÂY VÀ NĂNG LỰC TỐI ĐA CỦA NHÓM MÁY 0 500 1.000 1.500 2.000 -2 phút 20:00 +2 phút +4 phút +6 phút +8 phút +10 phút nhu cầu nhu cầu giờ vàng năng lực hai máy bốn máy sẵn sàng thiếu năng lực trong 7 phút
Hình 3.6. Vùng đỏ là khoảng thời gian nhu cầu vượt năng lực. Nếu máy được thêm theo lịch trước 20:00, đường năng lực đã ở mức cao trước khi nhu cầu tăng và vùng đỏ không xuất hiện.

Trong khoảng thời gian đó, hai máy phục vụ tối đa 2 × 500 yêu cầu/giây. Phần thiếu so với nhu cầu là 1.200 − 2 × 500 = 200 yêu cầu/giây. Phần dư này tích lại trong hàng đợi: 200 × 420 = 84.000 yêu cầu. Khi tốc độ đến vượt tốc độ phục vụ, hàng đợi tăng không giới hạn, đúng như mô hình ở chương 0. Thực tế, các yêu cầu này hết thời gian chờ và khách nhận lỗi. Vì vậy, với những đợt tải đã biết trước như giờ vàng, Chợ Phố thêm máy theo lịch trước giờ bắt đầu. Cơ chế phản ứng theo chỉ số chỉ dùng cho mức tăng chậm và không báo trước.

Bớt máy cũng cần quy trình. Trước tiên, máy được đặt ở trạng thái drain để HAProxy ngừng gửi yêu cầu mới. Sau đó ứng dụng được tắt theo kiểu graceful shutdown: readiness chuyển sang từ chối yêu cầu mới, còn các yêu cầu đang xử lý được phép hoàn tất trong tối đa 30 giây.

Đi sâu: rút một máy khỏi nhóm bằng tay

HAProxy cho phép đổi trạng thái của một máy qua runtime API mà không cần nạp lại cấu hình. Trạng thái drain loại máy khỏi việc chia tải nhưng vẫn tiếp tục kiểm health check, nên có thể đưa máy trở lại ngay khi cần.

shell# global: stats socket /run/haproxy/admin.sock mode 660 level admin
$ echo "set server app_servers/app1 state drain" | socat stdio /run/haproxy/admin.sock
# chờ số kết nối tới app1 về 0, rồi dừng Tomcat trên app1 (graceful shutdown tối đa 30 giây)

Hướng tiếp theo

Cuối tháng 5, Chợ Phố chạy hai máy ứng dụng phi trạng thái sau HAProxy, phiên nằm trong Redis, health check gọi endpoint readiness, và máy được thêm theo lịch trước mỗi đợt khuyến mãi. CPU của các máy ứng dụng không còn là giới hạn.

Giới hạn chuyển sang nơi khác. Trong đợt khuyến mãi tiếp theo, CPU của máy cơ sở dữ liệu lên khoảng 85%. Thống kê truy vấn (query) cho thấy phần lớn thời gian dành cho các câu lệnh đọc lặp lại cùng dữ liệu sản phẩm. Đó là tên, giá và tồn kho của vài nghìn mặt hàng đang khuyến mãi. Mỗi máy ứng dụng thêm vào lại gửi thêm đúng những truy vấn ấy.

Nếu phần lớn truy vấn chỉ đọc lại dữ liệu vừa đọc cách đó vài giây, hệ thống có cần hỏi cơ sở dữ liệu mỗi lần không? Nếu lưu tạm dữ liệu đó trong bộ nhớ đệm gần ứng dụng, làm sao biết khi nào dữ liệu lưu tạm đã cũ, chẳng hạn khi giá thay đổi lúc nửa đêm? Chương 4 phân tích các câu hỏi này.

Tóm tắt chương

  1. Mở rộng theo chiều ngang cần một bộ cân bằng tải. Reverse proxy đứng trước các máy ứng dụng. Khi có nhiều máy phía sau, nó chọn máy cho từng yêu cầu.
  2. Tầng 4 chỉ thấy địa chỉ và cổng, còn tầng 7 đọc được HTTP. Cookie, định tuyến theo đường dẫn và health check bằng HTTP đều cần tầng 7.
  3. Chia vòng tròn phù hợp khi các máy như nhau. Ít kết nối nhất giảm thiệt hại khi một máy chậm, nhưng không loại máy hỏng ra khỏi nhóm.
  4. Phiên trong heap của từng máy làm khách mất đăng nhập khi có hai máy trở lên. Ứng dụng phải phi trạng thái.
  5. Sticky session che nguyên nhân. Máy hỏng thì mất phiên, rút máy và thêm máy đều chậm.
  6. Kho phiên dùng chung là lời giải của Chợ Phố. Spring Session với Redis, chính sách bộ nhớ noeviction. JWT là lựa chọn khác, khó thu hồi trước hạn.
  7. Health check chỉ mở cổng không phát hiện được máy treo. Readiness quyết định có gửi yêu cầu hay không. Liveness quyết định có khởi động lại hay không.
  8. Tự động mở rộng có độ trễ vài phút. Tải đã biết trước thì thêm máy theo lịch. Bớt máy thì drain rồi graceful shutdown.

Tài liệu tham khảo

  • HAProxy Configuration Manual 3.0: balance, mode, cookie, option httpchk, http-check, tham số inter, fall, rise, capture cookie, option forwardfor.
  • HAProxy Management Guide: lệnh set server ... state của runtime API.
  • nginx documentation: module ngx_http_upstream_module (max_fails, fail_timeout, least_conn) và ngx_http_proxy_module (proxy_next_upstream, proxy_read_timeout).
  • Spring Boot Reference 3.5: Actuator, mục "Kubernetes Probes" và "Application Availability"; Graceful Shutdown; bảng thuộc tính ứng dụng.
  • Spring Session Reference: Redis, RedisSessionRepository, DefaultCookieSerializer.
  • RFC 7519 (JSON Web Token, tháng 5 năm 2015), RFC 7515 (JSON Web Signature); OWASP JSON Web Token Cheat Sheet.
  • Redis documentation: "Key eviction"; keepalived documentation; trang man listen(2), systemd.service(5); Oracle JDK: jstat, release notes 8u92 (ExitOnOutOfMemoryError).

Chợ Phố là tình huống giả định. Các phép tính được kiểm lại bằng _build/c03_calc.py; giá trị mặc định, hành vi phần mềm và cú pháp cấu hình được đối chiếu tài liệu chính thức trong _build/c03-verify.md.