Chuyển đến nội dung chính
  1. Bài viết/

Tech stack hiện tại của tôi: vận hành hai sản phẩm trên lượng tài nguyên tối thiểu

· loading · loading ·
Nhân Tài Đức
Tác giả
Nhân Tài Đức
Người dẫn dắt đội ngũ và kỹ sư phần mềm, sống tại Seoul, Hàn Quốc

Hiện tại, với vai trò là một solo developer, tôi đang vận hành đồng thời hai sản phẩm:

  • một ứng dụng field estimation dành cho các kỹ thuật viên/thợ dịch vụ cần ước tính và báo giá công việc trực tiếp tại hiện trường;
  • và một nền tảng community marketplace có các tính năng như job listings, mua bán đồ đã qua sử dụng và real-time messaging.

Hai sản phẩm khác nhau, nhóm người dùng cũng khác nhau, nhưng tech stack gần như giống hệt nhau — và cả hai đều đang chạy hoàn toàn trên các máy shared-cpu-1x, tier rẻ nhất mà Fly.io cung cấp.

Đây là một giới hạn được tôi chủ động lựa chọn.

Thay vì sizing server theo peak load, tôi cố gắng tối ưu để khai thác tối đa hiệu năng của một máy nhỏ, đo chính xác ngưỡng mà hệ thống bắt đầu bottleneck hoặc collapse under load, rồi để autoscaler của Fly.io tự động scale out / scale in bằng cách thêm hoặc loại bớt machine dựa trên ngưỡng đã được đo đó.

Bài viết này sẽ đi vào các optimisation giúp mô hình đó khả thi, các số liệu load testing được dùng để xác định scaling threshold, và cách quá trình scale up / scale down thực tế hoạt động trên Fly.io.

Triết lý: đo một máy, rồi nhân lên
#

Chiến lược scale của cả hai ứng dụng đều gồm ba bước giống nhau:

  1. Tối ưu một máy nhỏ cho nhanh nhất trong khả năng hợp lý.
  2. Load test cho đến khi nó gãy, rồi ghi lại mức concurrency mà tại đó latency bắt đầu sụp.
  3. Cấu hình autoscaler của Fly để bật máy thứ hai ngay trước điểm đó, và dừng các máy đang rảnh khi traffic giảm.

Khi đó, capacity tăng gần như tuyến tính theo số máy, còn chi phí nền chỉ là một VM nhỏ cho mỗi ứng dụng. Mọi thứ trong phần còn lại của bài đều phục vụ một trong ba bước này.

Tối ưu cho một máy nhỏ chạy nhanh
#

Granian: WSGI cho API, ASGI với uvloop cho WebSocket
#

Backend của cả hai sản phẩm đều là Django 6 + Django REST Framework. API chạy trên Granian, một server viết bằng Rust, nhưng dùng WSGI với blocking thread pool chứ không phải ASGI:

granian --interface wsgi project.wsgi:application \
    --workers 1 --blocking-threads 16 \
    --workers-max-rss 1400 \
    --workers-kill-timeout 15 --respawn-failed-workers

Lúc đầu tôi dùng --interface asgi --loop uvloop, và đó là một quyết định sai. Mọi view HTTP trong hai ứng dụng này đều là sync, mà ASGI handler của Django lại chạy view sync ở chế độ thread_sensitive, tức mỗi worker chỉ xử lý được một view tại một thời điểm. Kết quả là với ASGI, mỗi worker chỉ xử lý được khoảng một request đồng thời, lại còn phải nạp nguyên stack asyncio/uvloop/Channels vào RAM mà chẳng được lợi gì. Còn WSGI với --blocking-threads thì chạy chính những view sync đó trong một thread pool thực thụ: một worker × 16 thread = 16 request I/O-bound đồng thời (đọc cơ sở dữ liệu, tạo presigned URL S3), mà bộ nhớ thường trú lại còn thấp hơn bản async.

Tại sao chỉ dùng một worker? Vì VM là shared-cpu-1x, tức chỉ có một core. Thêm worker thứ hai cũng không chạy Python song song được trên một core (GIL cộng với chỉ một CPU), nhưng lại tốn thêm nguyên một process Django thường trú trong bộ nhớ; chính Granian cũng cảnh báo đúng chuyện này. Với các tác vụ I/O-bound, mười sáu thread trên một worker là mức trần thực tế của một máy 1 vCPU. Tham số --workers-max-rss 1400 sẽ khởi động lại worker nếu nó phình quá giới hạn bộ nhớ, nên nếu một job tạo PDF hay xử lý ảnh chạy mất kiểm soát thì worker sẽ được thay mới, thay vì kéo cả máy đến chỗ OOM.

WebSocket được tách ra chạy trong một process Granian riêng: một ứng dụng ASGI dùng uvloop và --ws, với một worker chuyên giữ các kết nối sống lâu. Phần API sync hoàn toàn không cần tới ASGI: khi một view cần đẩy dữ liệu xuống socket, nó gọi async_to_sync(channel_layer.group_send) qua Redis, và message vẫn đến được process WebSocket. Hai server, mỗi cái chạy đúng interface mà nó làm tốt nhất. Docker ở local cũng chạy cùng thread pool WSGI đó, nên khi kiểm thử, laptop của tôi mô phỏng khá sát một máy production.

Dùng Rust ở các hot path
#

Ở hot path nào của Python mà có sẵn một thư viện viết bằng Rust thay thế được ngay, tôi đều dùng:

  • orjson qua drf-orjson-renderer: mọi response của API đều được serialise bằng Rust thay vì JSON encoder của thư viện chuẩn.
  • blurhash-rs: tạo placeholder cho ảnh ngay lúc tải lên.
  • uuid-utils: sinh UUID bằng Rust.
  • psycopg 3 (binary): driver Postgres được tăng tốc bằng C.

Không thư viện nào trong số này làm thay đổi kiến trúc; chúng chỉ nâng mức trần của một vCPU lên, để bạn chưa phải thêm máy thứ hai sớm.

Loại bỏ những khoản latency ẩn
#

Cải thiện lớn nhất trong cả hai codebase không đến từ thuật toán nào cả, mà từ việc tái sử dụng kết nối. Trong thread pool của Granian, mỗi thread mới lại tạo một S3 client boto3 mới toanh, và endpoint nào trả về ảnh hay PDF cũng phải gánh chi phí đó. Tôi viết một class riêng, SharedConnectionS3Storage, để cache client theo từng process:

Thời gian tạo signed URL trên các thread mới giảm từ ~150ms xuống ~1ms.

Với cơ sở dữ liệu cũng vậy: Postgres trên Fly nằm sau PgBouncer, nên ứng dụng chạy với conn_max_age=0 và để pooler lo phần tái sử dụng kết nối, kèm connect_timeout 5 giây để khi gặp kết nối SSL đã chết thì báo lỗi ngay, chứ không làm treo cả worker.

Cache đúng chỗ đang thực sự nghẽn
#

Load test phía marketplace cho tôi một bài học đáng nhớ: mức trần nằm ở số kết nối Postgres, không phải CPU. Phần lớn traffic duyệt tin đến từ người dùng ẩn danh, và nội dung họ xem giống hệt nhau. Khi tải lên cao, những truy vấn danh sách giống hệt nhau đó làm đầy connection pool, trong khi CPU vẫn ngồi chơi.

Cách sửa là thêm một lớp cache Redis cho các trang danh sách mà người dùng ẩn danh xem, và invalidate cache bằng bộ đếm phiên bản theo từng namespace: mỗi thao tác ghi chỉ cần tăng một bộ đếm (O(1), không phải theo dõi từng key), thế là mọi trang đã cache của resource đó lập tức hết hiệu lực. Một TTL ngắn làm lưới an toàn cho những trường hợp bị sót. Trả trang duyệt tin thẳng từ Redis gỡ được đúng chỗ nghẽn thật, và để cái máy nhỏ dành kết nối cơ sở dữ liệu cho người dùng đã đăng nhập.

Chạy tác vụ nền mà không cần broker
#

Cả hai ứng dụng đều dùng django-tasks-db, framework task của Django lưu hàng đợi trong cơ sở dữ liệu, thay cho Celery. Một process db_worker riêng liên tục poll cơ sở dữ liệu để lấy job: xử lý ảnh, tạo PDF, dịch máy, gửi nhắc nhở. Không cần broker, không phải chọn cấu hình và giám sát thêm service nào, và process API không bao giờ bị chặn bởi những việc nặng. Worker còn kết nối thẳng tới Postgres, bỏ qua PgBouncer, vì các job chạy lâu không hợp với transaction pooling.

Bộ nhớ: jemalloc và swap để dự phòng
#

Xử lý ảnh và PDF làm bộ nhớ tăng vọt theo những cách mà khi tính toán cấu hình cho trạng thái ổn định bạn không lường trước được. Tôi dùng hai cách giảm thiểu rẻ tiền: preload jemalloc trong Docker image để giảm phân mảnh bộ nhớ, và bật swap trên mọi máy (2GB trên máy API, 1GB trên máy WebSocket), để khi bộ nhớ tăng vọt thì dữ liệu được đẩy xuống đĩa chứ process không bị OOM kill. Swap không phải tính năng giúp tăng hiệu năng; nó là một khoản bảo hiểm, cho phép tôi giữ mức cấp phát bộ nhớ ở mức nhỏ.

Load test: tìm điểm gãy
#

Một ngưỡng chỉ đáng tin khi nó đã được đo thực tế. Mỗi dự án có bộ công cụ kiểm thử riêng:

  • Ứng dụng field estimation có một stress test harness tự viết bằng Python (scripts/stress-test.py), chạy lần lượt qua các mức concurrency và báo cáo RPS, p50/p95/p99 cùng tỉ lệ lỗi. Đi kèm là mấy management command sinh ra hàng trăm job trông như thật, để các endpoint danh sách được truy vấn trên khối lượng dữ liệu sát thực tế.
  • Marketplace dùng Locust với kiểu tải bậc thang, tăng từ 50 → 500 người dùng theo từng bậc 30 giây để tìm điểm bão hòa, với trọng số các endpoint khớp với traffic thực tế (phần lớn là duyệt tin). Ngoài ra còn một bộ k6 riêng, tăng từ 5 → 50 người dùng ảo, chạy thẳng vào production với ngưỡng p95 < 2s.

Vài con số tiêu biểu từ lượt đo của ứng dụng field estimation (một worker Granian × 16 blocking thread, chạy Docker trên máy dev của tôi):

EndpointConcurrencyRPSp95Lỗi
health2560252ms0%
danh sách job25309260ms0%
danh sách khách hàng2535761ms0%
danh sách job50309347ms0%

Còn con số quyết định mọi thứ khác đến từ đợt load test marketplace trên một máy shared-cpu-1x duy nhất. Với bản ASGI cũ, đây là một bức tường cứng: các view sync bị xếp hàng tuần tự, chỉ còn khoảng ba slot dùng được, nên p95 giữ dưới 300ms cho đến khoảng ~5 request đồng thời, rồi sụp hẳn khi vượt ~8–10, vì mọi request đều phải xếp hàng sau cái lock thread-sensitive. Chính việc chuyển API sang thread pool WSGI đã đẩy điểm gãy đó ra xa: 16 slot đồng thời thực sự trên mỗi máy thay vì ~3, và mức trần mới này là nền tảng cho toàn bộ cấu hình autoscaling.

Scale trên Fly.io hoạt động thế nào
#

Mô hình của Fly khá đơn giản: ứng dụng của bạn là một nhóm máy giống hệt nhau đứng sau một proxy, và proxy đếm tải trên từng máy. Bạn chỉ cần khai báo hai con số:

[http_service.concurrency]
  type = 'requests'
  soft_limit = 12   # past this, start another machine
  hard_limit = 24   # past this, shed load instead of queueing

auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1

Scale up: khi số request đang xử lý trên một máy vượt soft limit, proxy sẽ bật một máy đang dừng và chuyển traffic mới sang đó. Thread pool WSGI cho mỗi máy 16 slot đồng thời thực sự, nên tôi đặt soft limit ở 12, tức khoảng 75% pool, để máy số 2 khởi động khi các thread vẫn còn dư sức, thay vì đợi đến lúc người dùng phải chờ tới vài giây. Hard limit 24 (gấp 1.5 lần pool) đóng vai trò cầu dao: những đợt tăng vọt ngắn thì xếp hàng chờ thread, còn vượt quá mức đó thì proxy sẽ từ chối bớt tải, thay vì để một máy bị quá tải tới mức sập. (Bản ASGI cũ chạy 8/20, tính theo ~3 slot thực của nó; chính việc nới rộng pool mới cho phép nâng cả hai con số lên.)

Scale down: khi traffic giảm, Fly tự động dừng các máy đang rảnh, xuống tới min_machines_running = 1. Máy đã dừng không tốn đồng tiền compute nào, chỉ tốn phần lưu trữ rootfs, nên chi phí nền của mỗi ứng dụng đúng nghĩa đen là một VM nhỏ, mà lúc nào cũng có sẵn capacity dự phòng cho những đợt tăng tải.

Ban đầu, ở đoạn này tôi có viết rằng một máy đã dừng “cold start mất chưa tới một giây”. Câu đó sai, và cái sai ấy khá đắt. Đến khi chịu đo thực tế thay vì đoán, tôi mới thấy một lần cold start mất 25 đến 33 giây, và bất cứ request nào proxy chuyển tới máy trong khoảng thời gian đó đều bị timeout ở edge: 22% số request trong một ngày, rơi đúng vào những endpoint mà ứng dụng di động poll định kỳ, trong khi mọi health check vẫn báo xanh.

Cách sửa không nằm ở mấy con số scale phía trên; chúng vẫn ổn. Vấn đề nằm ở những gì máy làm trong lúc khởi động, và ở việc yêu cầu Fly suspend máy thay vì stop, để mỗi lần đánh thức máy chỉ còn là một lần khôi phục bộ nhớ mất khoảng ~3 giây. Tôi đã viết riêng một bài về chuyện này: Scale về 0, và mỗi lần khởi động lại là 30 giây sập, nói về cách tôi chẩn đoán và những gì cần thay đổi. Nếu bạn đang chạy bất cứ thứ gì trên Fly với auto_stop_machines, hãy đọc bài đó trước khi tin vào đoạn này.

Có một điểm tinh tế: kiểu concurrency là requests, không phải connections. Client hiện đại giữ kết nối keep-alive luôn mở, nên nếu đếm theo kết nối thì con số gần như đứng yên dù tải thực tế tăng, và autoscaler sẽ không bao giờ được kích hoạt. Đếm số request đang xử lý mới phản ánh đúng khối lượng công việc thực.

WebSocket scale theo một trục khác
#

Marketplace tách server WebSocket ra thành một ứng dụng Fly thứ hai, dùng chung Docker image. Ứng dụng API chạy một worker × 16 blocking thread dưới WSGI và đếm theo request (soft limit 12). Ứng dụng WebSocket thì chạy 1 worker dưới ASGI với uvloop, trên một máy 512MB, và đếm theo kết nối:

[http_service.concurrency]
  type = 'connections'
  soft_limit = 1500
  hard_limit = 2000

Giữ một socket sống lâu gần như không tốn gì, nhưng nó chiếm một slot hàng giờ liền, trong khi một request HTTP chỉ chiếm slot vài mili giây, nên không thể dùng chung một tín hiệu autoscaling cho cả hai. Tách riêng ra thì mỗi nhóm máy scale theo đúng thước đo phản ánh tải thật của nó, và capacity WebSocket lúc rảnh chỉ tốn 512MB chứ không phải 2GB. Về phía Channels, lớp Redis chạy với capacity: 1500, message hết hạn sau 60 giây, cộng thêm socket keepalive mỗi 30 giây, vì proxy Redis của Fly sẽ tự đóng các socket BRPOP sống lâu mà Channels cần tới khi chúng không hoạt động.

Đẩy việc tính toán ra edge và sang phía client
#

Việc rẻ nhất cho server là việc server không bao giờ phải làm:

  • Tệp tĩnh và media nằm trên Tigris, dịch vụ lưu trữ tương thích S3 của Fly, được phân phối qua CDN của Tigris, với signed URL cho media riêng tư. Không một byte ảnh nào phải đi qua các máy Django.
  • AI inference chạy trên điện thoại của người dùng. Cả hai ứng dụng đều nhúng một LLM chạy local qua llama.rn (marketplace stream một mô hình 4B đã quantize, ~2.7GB; ứng dụng field estimation thì nối tool-calling vào lớp dữ liệu để trợ lý tạo và cập nhật được job). Phía server không tốn đồng inference nào, và tính năng này chạy được cả khi offline.
  • Ứng dụng di động offline-first (Expo SDK 57, SQLite trên thiết bị, Dexie trên web, một hàng đợi sync có retry) giúp ứng dụng gom các thao tác ghi lại thành từng đợt và chịu được những vùng mất sóng, thay vì dội vào API hàng loạt request lắt nhắt theo từng lần gõ phím.

Phần còn lại của stack, điểm nhanh
#

Cả hai ứng dụng dùng Firebase Auth cộng với passkey WebAuthn, kèm một middleware tự viết để xác thực kết nối WebSocket trước khi upgrade. Postgres cho production, SQLite khi phát triển. django-unfold cho trang admin, django-simple-history để lưu audit trail. uv và ruff làm công cụ phát triển. Phía di động là React Native 0.86 / React 19 / TypeScript với Expo Router, Gluestack UI v3 và Tailwind 4, cập nhật OTA qua Expo Updates tự host, còn subscription thì qua RevenueCat. Khâu kiểm soát chất lượng: pytest + mypy + bandit ở backend, Jest + Detox + Playwright ở client.

Những gì bạn nên “học lỏm”
#

Điều đáng mang về không nằm ở một thư viện cụ thể nào, mà ở cả vòng lặp. Tối ưu cho một máy rẻ tiền chạy thật nhanh (dùng Rust ở hot path, tái sử dụng kết nối, đặt cache chắn trước chỗ nghẽn thật, đẩy được gì sang CDN và client thì đẩy). Load test cho đến khi tìm ra chính xác mức concurrency mà nó gãy. Đặt soft limit của autoscaler ngay tại điểm gãy đó, để các máy rảnh tự dừng, và giữ tối thiểu một máy luôn chạy. Hai ứng dụng production, có tính năng real-time, có trợ lý AI, mà hóa đơn hằng tháng lúc bình thường chỉ là vài VM rẻ nhất trên nền tảng.