↓ Chuyển đến nội dung chính

Fly.io Django DevOps

Thu về 0 (scale to zero), và mỗi lần khởi động lại là một lần sập 30 giây

22% yêu cầu gửi tới ứng dụng của tôi bị lỗi trong khi kiểm tra tình trạng (health check) vẫn báo xanh. Lần theo nguyên nhân, tôi rút từ 33 giây khởi động nguội (cold start) xuống còn 3 giây khi đánh thức máy từ trạng thái tạm ngưng.

Tôi mở bảng điều khiển (dashboard) của Cloudflare để tìm hiểu một đợt lưu lượng truy cập (traffic) tăng đột biến, và phát hiện bên dưới nó là một chuyện tệ hơn nhiều: trong một ngày, 22% tổng số yêu cầu (request) gửi tới ứng dụng của tôi đã bị lỗi. Không có cảnh báo nào bắn ra. Lượt kiểm tra tình trạng (health check) nào cũng báo xanh. Nhật ký (log) trông hoàn toàn bình thường, vì những yêu cầu bị lỗi chưa bao giờ tới được ứng dụng.

Hóa ra thủ phạm lại là chính script khởi động container của tôi: nó làm những việc hoàn toàn hợp lý, chỉ có điều là vào đúng thời điểm tệ nhất.

Đợt lưu lượng tăng vọt chỉ là cú đánh lạc hướng
#

Ban đầu tôi chỉ định tìm hiểu một khung giờ nhận khoảng 4,900 yêu cầu, trong khi trung vị chỉ là 121. Có bốn mươi hai khách truy cập khác nhau, tức là 110 yêu cầu cho mỗi khách. Người thật thì không lướt web kiểu đó.

Đó là một công cụ quét (scanner): một con bot duy nhất lần lượt dò qua các tên miền phụ (subdomain) do nó tự bịa ra, và hỏi từng cái xem có để lộ thông tin bí mật (secrets) nào không:

api-stage-asia.marucommunity.com/.env.local
api-us-east-1-demo.marucommunity.com/.env
api-eu-uat.marucommunity.com/wp-config.php~
app-test-us-east-1.marucommunity.com/actuator/configprops
prod-api-use2.marucommunity.com/.env.local
relay.marucommunity.com/.env.production

Khoảng ba mươi tên máy chủ (hostname), đủ mọi biến thể .env bạn nghĩ ra được, thêm cả các endpoint actuator của Spring Boot trên một ứng dụng Django. Tất cả đều nhận mã 301 chuyển hướng về tên miền chính, và không có gì bị lộ ra ngoài. Nhàm chán, và thật sự vô hại.

Nhưng nhân tiện đang mở trang phân tích (analytics), tôi thử nhóm số liệu của cùng ngày đó theo mã trạng thái (status code). Vấn đề thật nằm ở đây.

Mã trạng tháiSố yêu cầu trong 24h
Tổng11,350
504 Gateway Timeout2,474

Hai mươi hai phần trăm. Và số lỗi này không rải đều khắp trang, mà dồn vào đúng những chỗ đau nhất:

EndpointSố lỗi 504
số thông báo chưa đọc621
danh sách thông báo614
số tin nhắn chưa đọc469
feed cursor210

Đây đều là những endpoint mà ứng dụng di động của tôi gọi định kỳ (poll) mỗi 30 giây. Nên đây không phải một con số tỉ lệ lỗi trừu tượng. Đó là điện thoại của từng người dùng, âm thầm không tải được số tin chưa đọc hiện trên biểu tượng, suốt cả ngày, và nhiều khả năng là đã như vậy mấy tuần liền.

Vì sao log vẫn trông bình thường
#

Phản xạ đầu tiên của tôi là ứng dụng đã bị chậm đi. Nhưng không phải. Tôi lấy từ kho nhật ký ra các dòng nhật ký yêu cầu của chính ứng dụng trong một khung giờ tệ nhất:

[2026-09-08 01:00:29] "GET /api/v1/notifications/unread_count/" 304 61.615
[2026-09-08 01:00:29] "GET /api/v1/notifications/"             304 63.568
[2026-09-08 01:00:59] "GET /api/v1/notifications/unread_count/" 304 58.742
[2026-09-08 01:00:59] "GET /api/v1/notifications/"             304 105.438

Từ bốn mươi bốn đến một trăm mili giây, toàn bộ đều là 304. Máy chủ gốc (origin) chạy rất nhanh.

Đó là manh mối quan trọng, và đáng để đúc kết thành một quy tắc: nếu tầng biên (edge) báo lỗi mà máy chủ gốc chưa từng nghe thấy, thì yêu cầu đã chết trước khi tới nơi. Lúc đó, hãy thôi đọc nhật ký ứng dụng và bắt đầu nhìn vào những gì đứng trước nó.

Trong trường hợp của tôi, thứ đứng trước là proxy của Fly.io, và câu trả lời nằm ở cách các máy (machine) của tôi được lên lịch chạy.

Thu về 0 (scale to zero) là lời hứa về tiền, không phải về độ trễ
#

Cấu hình của tôi trông như thế này, và thoạt nhìn thì khá hợp lý:

[http_service]
  auto_stop_machines = 'stop'
  auto_start_machines = true
  min_machines_running = 1

Máy nào rảnh thì tự dừng. Có lưu lượng thì tự bật lại. Dùng bao nhiêu trả bấy nhiêu. Với một ứng dụng nhỏ có lưu lượng lúc lên lúc xuống thất thường, đây đúng là thứ bạn cần, và đó cũng là lý do tôi khai báo mười máy nhưng gần như lúc nào cũng chỉ có một cái đang chạy.

Cái giá bị giấu đi ở đây là độ trễ (latency). Khi một yêu cầu tới một máy đang dừng, sẽ có người phải ngồi chờ máy đó khởi động. Nếu thời gian khởi động lâu hơn sức chờ của proxy, người đó sẽ nhận về lỗi 504.

Vậy máy của tôi mất bao lâu để khởi động? Tôi chưa từng đo. Nhưng kho nhật ký thì biết, vì script khởi động có in ra từng giai đoạn, và dòng nhật ký nào cũng kèm ID của máy:

WITH s AS (
  SELECT fly.app.instance AS inst, timestamp,
         CASE WHEN message ILIKE '%Starting Granian%'    THEN 'begin'
              WHEN message ILIKE '%Migrations complete%' THEN 'migrated'
              WHEN message LIKE  '%"GET /api/health/%'   THEN 'serving' END AS phase
  FROM logs('koreapost', '2026-09-08')
)
SELECT inst,
       min(timestamp) FILTER (WHERE phase = 'begin')    AS started,
       min(timestamp) FILTER (WHERE phase = 'migrated') AS migrated,
       min(timestamp) FILTER (WHERE phase = 'serving')  AS first_serve
FROM s GROUP BY inst

Kết quả rất nhất quán trên mọi máy:

Giai đoạnThời gian
Từ lúc khởi động đến khi chạy xong chuyển đổi lược đồ (migration)10 đến 13 giây
Từ lúc xong chuyển đổi lược đồ đến khi phục vụ yêu cầu đầu tiên8 đến 19 giây
Tổng25 đến 33 giây

Sau đó tôi đếm xem chuyện này xảy ra bao nhiêu lần:

SELECT count(*) AS starts, count(DISTINCT fly.app.instance) AS machines
FROM logs('koreapost', '2026-09-08')
WHERE message ILIKE '%Starting Granian%'
-- starts: 92, machines: 10

Chín mươi hai lần khởi động nguội (cold start) chỉ trong một ngày. Mỗi lần là một khoảng nửa phút, trong đó yêu cầu nào được định tuyến tới máy đó cũng đều hết thời gian chờ (timeout). Đến đây thì con số hai mươi hai phần trăm hết còn bí ẩn.

Thủ phạm nằm ngay trong script khởi chạy (entrypoint) của tôi
#

Đây là những gì mọi máy đều phải chạy trước khi chịu trả lời yêu cầu:

if [ "$1" = "granian-api" ]; then
    echo "Running migrations..."
    python manage.py migrate --noinput

    echo "Running collectstatic in background..."
    python manage.py collectstatic --noinput &

    exec granian --interface wsgi koreapost_project.wsgi:application ...
fi

Hãy đọc lại đoạn này, lần này nhớ trong đầu con số “92 lần mỗi ngày”.

migrate là một lần khởi động Django trọn vẹn: nạp mọi mô hình, kết nối tới Postgres, truy vấn bảng migrations, rồi kết luận là chẳng có gì phải làm. Trên CPU dùng chung (shared CPU), mất mười giây chỉ để không làm gì cả. collectstatic là lần khởi động Django trọn vẹn thứ hai, sau đó còn tải các tệp tĩnh (static file) lên kho lưu trữ đối tượng (object storage), tranh giành đúng một nhân CPU duy nhất mà máy chủ web đang cố khởi động trên đó. Granian là lần khởi động thứ ba.

Ba trình thông dịch Python, hai trong số đó làm lại những việc đã xong từ trước, và chuyện này lặp lại mỗi khi một máy thức dậy.

Tôi viết như vậy là có lý do chính đáng, và có lẽ script khởi chạy của bạn trông na ná cũng vì lý do đó: chuyển đổi lược đồ thì phải chạy ở đâu đó, release_command của Fly từng bị treo với tôi, và script khởi chạy là nơi duy nhất chắc chắn được chạy mỗi lần triển khai. Đặt việc của khâu triển khai vào đó là đúng. Có điều, đó không phải chỗ đúng để chạy việc đó mỗi lần máy khởi động, và một khi máy tự dừng rồi tự bật lại, hai sự kiện này không còn là một nữa.

Toàn bộ lỗi chỉ có vậy, và tôi nghĩ nó khá phổ biến:

Những việc thuộc về một lần phát hành lại bị chạy lại mỗi lần máy khởi động. Cơ chế thu về 0 biến một sự kiện thành chín mươi hai sự kiện.

Cách sửa: mỗi bản phát hành chỉ chuẩn bị đúng một lần
#

Bước chuyển đổi lược đồ không cần chạy lại trên chiếc máy thứ tám khởi động từ cùng một image. Nó chỉ cần chạy một lần cho image đó, và mọi máy khác cần biết là việc này đã xong.

Nói cách khác, ta cần một khóa (lock) có tên thay đổi theo từng lần triển khai. Fly cung cấp sẵn cái tên đó trong FLY_IMAGE_REF, còn Redis thì tôi đã có sẵn. Tất cả chỉ là một script nhỏ chạy trước khi Django được nạp, nên trong trường hợp thường gặp, nó chỉ tốn vài mili giây thay vì một lần khởi động cả framework:

def release_id() -> str:
    """Something that changes exactly when the deployed image does."""
    return os.getenv("FLY_IMAGE_REF") or os.getenv("FLY_MACHINE_VERSION") or ""


def claim() -> int:
    release = release_id()
    if not release:
        return PREPARE

    client = _client()
    if client is None:          # no Redis: behave exactly as before
        return PREPARE

    done_key, lock_key = _keys(release)

    if client.get(done_key):    # somebody already did it for this image
        return SKIP

    owner = os.getenv("FLY_MACHINE_ID", "unknown")
    if client.set(lock_key, owner, nx=True, ex=LOCK_TTL_SECONDS):
        return PREPARE          # we won the race; we do the work

    # Someone else is preparing. Wait for them, because serving requests
    # against a half-migrated schema is worse than starting slowly.
    deadline = _monotonic() + WAIT_SECONDS
    while _monotonic() < deadline:
        _sleep(POLL_SECONDS)
        if client.get(done_key):
            return SKIP
        if not client.get(lock_key) and client.set(lock_key, owner, nx=True, ex=LOCK_TTL_SECONDS):
            return PREPARE      # the owner died mid-migration; take over
    return PREPARE              # waited long enough; do it ourselves

Script khởi chạy giờ chỉ còn một câu if:

if python3 koreapost_project/release_gate.py claim; then
    python manage.py migrate --noinput
    python manage.py collectstatic --noinput &
    python3 koreapost_project/release_gate.py done
fi

exec granian --interface wsgi koreapost_project.wsgi:application ...

Mọi nhánh lỗi đều phải dẫn tới “cứ chuẩn bị đi”
#

Phần đáng học theo nằm ở đây, hơn là ở đoạn mã.

Một cổng kiểm soát (gate) như thế này đứng giữa người dùng và một lần chuyển đổi lược đồ. Nếu nó sai theo hướng bỏ qua, bạn sẽ phục vụ yêu cầu trên một cơ sở dữ liệu chưa được chuyển đổi, và đó là một sự cố thật sự nghiêm trọng. Còn nếu nó sai theo hướng chuẩn bị, bạn chỉ chạy lại một bước chuyển đổi lũy đẳng (idempotent) vốn chẳng làm gì, và mất thêm mười giây.

Hai kết cục này không ngang nhau, nên mã nguồn không được xử lý chúng như nhau. Mọi nhánh không chắc chắn đều trả về PREPARE:

  • Không có Redis, hoặc không kết nối được tới Redis. Chuẩn bị. Đây chính là hành vi cũ, nên nếu bộ nhớ đệm (cache) có sập thì hệ thống chỉ “chậm” chứ không “hỏng”.
  • Biến môi trường không có tham chiếu tới image. Chuẩn bị. Không biết đây là bản phát hành nào thì cũng không thể biết nó đã sẵn sàng hay chưa.
  • Máy đang giữ khóa chết giữa chừng lúc chạy chuyển đổi lược đồ. Khóa của nó sẽ hết hạn, và máy tiếp theo đang chờ sẽ giành lấy khóa rồi chuẩn bị.
  • Chờ quá thời gian cho phép. Cứ chuẩn bị. Một máy không bao giờ phục vụ được còn tệ hơn một lần chuyển đổi lược đồ bị chạy trùng.

Có một điểm bất đối xứng tôi cố ý đặt theo chiều ngược lại: máy nào thấy một máy khác đang giữ khóa thì sẽ chờ chứ không phục vụ yêu cầu. Khởi động chậm một chút trong lúc triển khai thì không sao. Nhưng trả lời truy vấn trên một lược đồ mới áp dụng được một nửa thì có sao đấy.

Nếu bạn dựng cơ chế này trên một lớp bọc (wrapper) bộ nhớ đệm kiểu “chịu lỗi” như tôi, hãy cẩn thận với một cái bẫy. Khi không kết nối được Redis, lớp bọc của tôi trả về False từ add(), trông y hệt kết quả “đã có máy khác giữ khóa”, và nếu không để ý thì đã khiến mọi máy bỏ qua bước chuyển đổi lược đồ. Đó chính xác là hướng nguy hiểm. Hãy phân biệt hai trường hợp bằng cách đọc lại khóa: nếu không ai giữ khóa, nghĩa là bộ nhớ đệm đang hỏng, chứ không phải bạn thua cuộc đua.

Kiểm thử mà không cần Redis
#

Logic này đáng để viết kiểm thử đơn vị (unit test), vì những nhánh đáng chú ý nhất lại chính là những nhánh bạn không thể tái hiện bằng tay. Chỉ cần một bản giả lập (fake) có hai phương thức là đủ:

class FakeRedis:
    """Enough of redis-py for the gate: get, and set with nx/ex."""

    def __init__(self) -> None:
        self.store: dict[str, str] = {}

    def get(self, key): return self.store.get(key)

    def set(self, key, value, nx=False, ex=None):
        if nx and key in self.store:
            return None
        self.store[key] = value
        return True

Sau đó, dựng các trường hợp khó bằng cách tự điều khiển thời gian trôi. Lưu ý là cổng kiểm soát gọi các bí danh _sleep và _monotonic ở cấp mô-đun chứ không gọi thẳng time.sleep, nên bài kiểm thử có thể thay thế chúng mà không phải vá (patch) mô-đun time cho mọi luồng (thread) khác trong tiến trình:

def test_a_waiter_takes_over_when_the_owner_disappears(self):
    self.assertEqual(release_gate.claim(), release_gate.PREPARE)   # owner takes the lock
    _done, lock_key = release_gate._keys(release_gate.release_id())

    def owner_dies(_seconds):
        self.redis.store.pop(lock_key, None)                       # its lock expired

    with patch.object(release_gate, "_sleep", owner_dies):
        self.assertEqual(release_gate.claim(), release_gate.PREPARE)

Có hiệu quả không? Được một phần
#

Triển khai, dừng một máy, bật lại, rồi canh đồng hồ. Giờ đây lúc khởi động, script báo ra quyết định của nó rồi nhường đường luôn:

15:23:34  Starting Granian (API server — HTTP/1.1 + HTTP/2)...
15:23:36  release gate: this release is already prepared, starting straight away
15:23:44  "GET /api/health/ HTTP/1.1" 200

Mười giây, và một máy thứ hai đo được mười một giây. So với 25 đến 33 giây trước đó, hai phần ba khoảng thời gian chết đã biến mất, và bản thân cổng kiểm soát chỉ tốn hai giây vì nó không bao giờ nạp Django.

Nhưng mười giây vẫn là mười giây. Yêu cầu nào tới trong những giây đầu tiên đó vẫn bị lỗi. Nên tôi đi tìm tiếp phần còn lại.

Bytecode chẳng ai lưu đệm
#

Image có một dòng mà gần như Dockerfile Python nào cũng có:

ENV PYTHONDONTWRITEBYTECODE=1

Đây là lời khuyên đúng. Container không nên tự ghi các tệp .pyc vào lớp (layer) của image lúc đang chạy. Nhưng tôi chưa bao giờ nghĩ kỹ về nửa còn lại của vấn đề: nếu lúc chạy không ghi bytecode, lúc dựng image cũng không ghi, thì chẳng có gì được lưu đệm cả, và mỗi lần tiến trình khởi động là biên dịch lại toàn bộ cây thư viện phụ thuộc từ mã nguồn.

Tôi thử đếm:

site-packages .py  files : 5,707
site-packages .pyc files : 0

Django, DRF, mọi thư viện đều bị biên dịch lại trong từng lần khởi động, 92 lần mỗi ngày. Đo trên một máy trong môi trường production:

django.setup()Thời gian
Bản đang chạy hiện tại5.15s
Sau khi chạy compileall3.17s

Cách sửa chỉ có một dòng, và vì compileall luôn ghi bytecode ra tệp, nên nó vẫn hoạt động bất kể biến môi trường kia:

RUN python -m compileall -q /app/.venv/lib /app/koreapost_project /app/marketplace /app/utils || true

Tốn thêm mười sáu giây lúc dựng image, chỉ một lần, trong một lớp đã được lưu đệm.

Nhân tiện, tôi còn phát hiện thêm một vấn đề âm thầm hơn. Tệp .dockerignore ghi:

__pycache__/
*.pyc

Docker so khớp các mẫu (pattern) này với toàn bộ đường dẫn tương đối, chứ không phải với từng thành phần của đường dẫn, nên một mẫu không có neo sẽ chỉ khớp ở thư mục gốc của ngữ cảnh dựng (build context). Kết quả là mọi thư mục __pycache__ lồng bên trong đều bị đóng gói theo. Image chứa 218 tệp .pyc do Python 3.15 biên dịch trên laptop, đi nhờ trong một image có trình thông dịch 3.14, và tất cả đều bị lặng lẽ bỏ qua. Mẫu đúng phải là **/__pycache__/ và **/*.pyc.

Nói thật thì kết quả không như kỳ vọng: tính từ đầu đến cuối, cách này chỉ bớt được khoảng một giây chứ không phải hai. Mười giây còn chín. Phép đo hiệu năng riêng lẻ (benchmark) đã phóng đại, như các phép đo riêng lẻ vẫn thường vậy.

Tạm ngưng (suspend), và dòng cấu hình đang chặn nó
#

Đến mức chín giây thì tôi hết thứ để cắt bỏ. Phần còn lại là một lần khởi động không thể tránh: Python khởi động, Django nạp các mô-đun, rồi ứng dụng WSGI chạy lên trên một nhân CPU dùng chung.

Vậy thì đừng khởi động lại từ đầu nữa. Thay vì tắt hẳn máy, Fly có thể chụp lại toàn bộ bộ nhớ (snapshot) của nó:

[http_service]
  auto_stop_machines = 'suspend'   # was 'stop'

Tôi thử bật lên thì Fly từ chối, kèm theo thông báo lỗi hữu ích nhất trong cả quá trình này:

failed to suspend VM: failed_precondition:
Machines with swap cannot be suspended

Gần đầu tệp cấu hình của tôi có đoạn này, kèm một dòng chú thích (comment) do chính tôi viết và tin sái cổ:

# Swap cushion so a memory spike swaps to disk instead of getting OOM-killed.
swap_size_mb = 2048

Một biện pháp phòng ngừa hợp lý. Nhưng nó có thực sự làm gì không? Máy nào cũng báo:

MemTotal:   985220 kB
SwapTotal: 2097148 kB
SwapFree:  2097148 kB

Chưa từng có một trang bộ nhớ nào bị đẩy ra vùng hoán đổi (swap). Nhật ký trong ngày cũng không có vụ tiến trình nào bị diệt vì hết bộ nhớ (OOM kill) — mười một dòng khớp với chữ “oom” hóa ra là yêu cầu tới một gói JavaScript mà mã băm nội dung tình cờ chứa ba chữ cái đó. Trong khi đó, cơ chế bảo vệ bộ nhớ thật sự lại nằm ở một chỗ hoàn toàn khác, ngay trong lệnh chạy máy chủ: --workers-max-rss 800 sẽ khởi động lại tiến trình worker từ lâu trước khi nó chạm tới trần 1GB.

Nói cách khác, vùng hoán đổi dự phòng là bảo hiểm cho một sự cố chưa từng xảy ra, vốn đã có cơ chế khác lo, và phí bảo hiểm phải trả là mất khả năng tạm ngưng. Tôi bỏ nó đi.

Cách đánh thứcThời gian đến khi phục vụ được
Khởi động nguội, sáng nay25 đến 33s
Khởi động nguội, sau khi thêm cổng kiểm soát và lưu đệm bytecode9 đến 10s
Đánh thức từ trạng thái tạm ngưng2.5 đến 3.1s

Ba lần thử, các kết quả chênh nhau không quá nửa giây. Ngay sau khi thức dậy, endpoint nào cũng trả về 200 trong 250 đến 435 mili giây.

Socket sẽ ra sao khi máy đang ngủ
#

Chẳng có gì tốt đẹp cả, mà cũng không có hook nào để can thiệp. Fly đóng băng máy ảo (VM); tiến trình không nhận được tín hiệu (signal) nào và không kịp đóng thứ gì trước khi ngủ. Những kết nối nó đang giữ vẫn nằm nguyên trong bộ nhớ lúc thức dậy, trỏ tới những socket mà đầu bên kia đã bỏ đi từ mấy phút trước.

Bạn không xử lý chuyện này lúc máy đi ngủ, mà lúc nó thức dậy, và phần lớn là nhờ những quyết định đúng đã có từ trước:

  • Kết nối cơ sở dữ liệu được mở theo từng yêu cầu (conn_max_age = 0), nên không có kết nối sống lâu nào để bị cũ.
  • Bộ nhớ đệm Redis chỉ xuống cấp chứ không ném ngoại lệ (exception). Một lớp bọc bắt lỗi kết nối và coi như không trúng bộ nhớ đệm (cache miss).

Tôi đã được tận mắt thấy cái thứ hai phát huy tác dụng. Một lần đánh thức ghi ra đúng cái lỗi bạn đoán được:

redis.exceptions.ConnectionError: Error while reading from fly-...

và yêu cầu gặp lỗi đó vẫn trả về 200 sau 3.6 giây, vì bộ nhớ đệm chết thì chỉ khiến trang chậm đi chứ không thành trang lỗi. Những lần đánh thức sau đó thì nhật ký sạch trơn.

Nếu thư viện kết nối bộ nhớ đệm của bạn ném ngoại lệ khi gặp kết nối chết, chế độ tạm ngưng sẽ biến mỗi lần thức dậy thành một tràng lỗi 500. Hãy kiểm tra chuyện đó trước khi bật cấu hình này, đừng đợi đến sau.

Lần kiểm thử đã đánh lừa tôi
#

Lần đo chế độ tạm ngưng đầu tiên cho thấy tính năng này là một thảm họa: 502 sau 30 giây, hai lần liên tiếp.

Lúc đó tôi đã ghim yêu cầu vào một máy cụ thể bằng header fly-force-instance-id. Header này rất tiện khi kiểm thử tải (load test) một máy, nhưng lại cực kỳ tệ cho trường hợp này. Ép vào một máy cụ thể sẽ bỏ qua phần logic của proxy vốn có nhiệm vụ đánh thức máy. Thật ra Fly đã nói rõ điều đó, nếu tôi chịu đọc nó như một câu trả lời thay vì một thông báo lỗi:

machine was recently stopped and is unavailable to service request

Con số đáng quan tâm phải đến từ đường đi mà lưu lượng thật đi qua. Tôi bắn bốn mươi yêu cầu đồng thời qua tầng biên, vượt xa giới hạn mềm (soft limit) là 12, để proxy buộc phải mở rộng theo chiều ngang (scale out) sang các máy đang tạm ngưng:

status codes:  40 × 200
slowest:       2.1s

Có hai bài học, và bài thứ hai là bài đắt giá hơn. Hãy đo trên đường đi mà người dùng thật sự đi, chứ không phải đường đi tiện cho việc gắn công cụ đo. Và khi công cụ từ chối làm gì đó, hãy đọc kỹ lời từ chối: chính câu “Machines with swap cannot be suspended” đã gỡ nút thắt cho cả buổi chiều hôm đó.

Nếu là ứng dụng của bạn, tôi sẽ kiểm tra những gì
#

Nếu bạn đang chạy bất cứ thứ gì trên một nền tảng tự dừng máy khi rảnh, thì những câu hỏi dưới đây chỉ tốn của tôi một buổi chiều, nhưng lẽ ra đã giúp tôi tránh được mấy tuần lỗi âm thầm:

  1. Một lần khởi động nguội mất bao lâu? Không phải container mất bao lâu để chạy, mà là mất bao lâu đến khi nó trả lời được một yêu cầu thật. Nếu bạn không trả lời được câu này bằng một con số tính theo giây, hãy đo ngay, trước khi thật sự cần tới nó.
  2. Script khởi chạy của bạn đang làm những việc gì mỗi lần khởi động mà lẽ ra thuộc về khâu triển khai? Chuyển đổi lược đồ, gom tệp tĩnh, làm nóng bộ nhớ đệm, dựng chỉ mục. Làm một lần thì không sao. Làm chín mươi hai lần thì rất tốn kém.
  3. Có thứ gì đang lưu đệm bytecode cho bạn không? Nếu có PYTHONDONTWRITEBYTECODE mà lúc dựng image không chạy compileall, thì câu trả lời là không.
  4. Bạn có thể cho máy tạm ngưng thay vì tắt hẳn không? Nếu không, thì cái gì đang cản bạn? Với tôi, đó là một tệp hoán đổi chưa từng được dùng tới.
  5. Tầng biên có thấy những lỗi mà máy chủ gốc không thấy không? Hãy so sánh hai bên. Khoảng chênh giữa chúng không phải do báo cáo bị lệch, mà là yêu cầu đang chết ở khoảng giữa, và nhật ký ứng dụng sẽ không bao giờ cho bạn thấy điều đó.

À, nhân tiện, các lượt kiểm tra tình trạng vẫn xanh suốt từ đầu đến cuối. Lúc nào chúng cũng xanh cả: kiểm tra tình trạng chỉ chạy trên một máy đã khởi động xong, nên không thể báo cáo gì về ba mươi giây trước khi máy đó tồn tại.