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ợ.
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
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 checkPhầ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
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.
- 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.
- 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.
- Í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.
- 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
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êmSpring 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.
- 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.
- 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).
- Số phiên đồng thời. 200 × (8 + 30) = 7.600 phiên.
- 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
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
7412Token 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útClaim 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ên | Trạng thái nằm ở đâu | Hạn chế chính |
|---|---|---|
| Sticky session | Heap của một máy ứng dụng | Mấ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 chung | Redis, ngoài các máy ứng dụng | Thêm một lượt gọi mạng mỗi yêu cầu; Redis phải luôn sẵn sàng |
| JWT | Trì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.916Cổ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=30sTà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 checkTham 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.
- 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.
- 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.
- 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
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=5Tuỳ 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%.
- 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.
- 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.
- Đợ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
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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. - 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.
- 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 ... statecủ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.