Cho tới gần đây, mọi thay đổi trên ứng dụng web của Maru đều được đưa lên theo đúng cách của một thay đổi trên API Django: build Docker image, đẩy lên, rồi triển khai lần lượt (rolling) lên từng máy trên Fly.io. Giao diện web (bản xuất web của Expo, cộng với các trang giới thiệu do Hugo sinh ra) được sao chép vào image ngay cạnh API, nên không có cách nào khác để đưa nó ra. Sửa một dòng CSS mất khoảng bảy phút và khởi động lại 11 máy. Có một ngày trong tháng Tám tôi làm việc đó sáu lần.
Bài này đi lần lượt qua thứ tôi đã thay vào đó: một script triển khai build cả hai cây giao diện, tải chúng lên object storage dưới một tiền tố có phiên bản, đổi một đối tượng con trỏ, đợi đến khi mọi máy đều đang phục vụ phiên bản mới, rồi xóa bộ nhớ đệm của Cloudflare qua API. Ở phía bên kia, một module Django nhỏ đọc con trỏ ở mỗi yêu cầu và phục vụ đúng phiên bản được ghi trong đó. API không khởi động lại chút nào.
Vì sao cách cũ có vấn đề#
Chi phí lớn nhất là việc khởi động lại. Mười một máy triển khai lần lượt nghĩa là mười một lần khởi động nguội (cold start), và trên CPU dùng chung thì Django mất khoảng mười ba giây để lên, trong đó bảy giây chỉ để import Django. Tôi có thêm một bước làm nóng ở cuối script triển khai, gọi vào từng máy một để người dùng thật không phải là người trả cái giá đó, và hệ quả là mỗi lần phát hành web đều kết thúc bằng việc ngồi đợi máy chậm nhất. Những lần khởi động nguội đó tốn kém thế nào trên Fly.io, tôi đã viết trong bài 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. Nói ngắn gọn, mỗi lần khởi động lại là một khoảng thời gian người dùng có thể nhận lỗi 504, nên một đường triển khai không khởi động lại gì cả sẽ đóng hẳn khoảng đó cho các lần phát hành web.
Vấn đề thứ hai là đầu ra của Hugo bị tách đôi. HTML thì vào image, nhưng RSS feed và chỉ mục tìm kiếm thì lên thẳng bucket. Chúng được build từ cùng một nguồn, chỉ là không cùng thời điểm, nên có thể lệch nhau mà chẳng ai nhận ra cho đến khi lệch thật.
Vấn đề thứ ba thiên về thói quen. Khi một lần phát hành tốn bảy phút cộng một lần khởi động lại, bạn sẽ thôi làm những lần phát hành nhỏ. Tôi để ba bốn bản sửa chất đống thay vì đưa lên từng cái, và đó chính xác là cách một thay đổi một dòng bị đưa lên cùng với thứ gì đó đang hỏng.
Bố cục trong bucket#
Mọi thứ nằm trong bucket tĩnh trên Tigris, dưới tiền tố web/:
web/current ← một tệp văn bản chứa đúng một chuỗi phiên bản
web/a1b2c3d-20260914021500/manifest.json ← danh sách mọi tệp của phiên bản này
web/a1b2c3d-20260914021500/dist/... ← bản xuất web của Expo
web/a1b2c3d-20260914021500/hugo/... ← đầu ra của Hugo
web/9f8e7d6-20260912093012/... ← phiên bản trước, vẫn còn đóPhiên bản là mã git SHA ngắn cộng với dấu thời gian UTC, nên nhìn vào là biết commit nào và đưa lên lúc nào. web/current là thứ duy nhất thay đổi khi một bản phát hành lên sóng.
Script triển khai làm gì#
Toàn bộ là một script, scripts/deploy_web.py, chạy từ thư mục gốc của repo. Theo thứ tự:
1. Từ chối cây git chưa sạch. Việc đầu tiên nó làm là git status --porcelain, và nếu có bất kỳ dòng nào trả về thì thoát. Cái gì nằm trên đĩa là cái được tải lên, và script triển khai Hugo cũ từng đưa lên nội dung chưa commit đúng theo cách đó. Có cờ --allow-dirty cho lúc bạn biết rõ mình đang làm gì, nhưng tôi chưa dùng. Sau đó nó tính chuỗi phiên bản từ git rev-parse --short HEAD và thời điểm hiện tại.
2. Build cả hai cây. Với ứng dụng, nó chạy npm run build:web:prod, tức là expo export --platform web với địa chỉ API và WebSocket của production được nhúng sẵn, tiếp theo là một lượt nén trước bằng Brotli và bundle của trình soạn thảo khối cho trang quản trị. Việc nén trước đáng để giải thích: Cloudflare có nén Brotli tại chỗ, nhưng ở mức chất lượng thấp, và trên bundle này đo ra còn tệ hơn gzip (2,04MB so với 1,66MB). Nén một lần lúc build với chất lượng 11 đưa nó xuống 1,28MB, nên script ghi một tệp .br song sinh cạnh mỗi tệp JS, CSS và JSON trên 1KB. Với trang giới thiệu, nó chạy hugo --gc --minify trong hugo-site/, xuất ra public/.
3. Tải lên. Nó duyệt mobile-app/dist và public/, rồi tải mọi tệp lên web/<version>/dist/... và web/<version>/hugo/... bằng mười sáu luồng. Mỗi đối tượng được gán Content-Type đoán từ tên tệp (bỏ .br trước), và các tệp Brotli song sinh có thêm Content-Encoding: br. Chỉ khi mọi tệp đã lên hết, nó mới ghi manifest.json, một đối tượng JSON với hai danh sách đường dẫn tương đối. Thứ tự này quan trọng: phía Django coi một phiên bản không có tệp kê khai là không tồn tại, nên một phiên bản tải dở dang không bao giờ có thể được đặt làm hiện tại. Hiện giờ là 314 tệp và tải lên mất khoảng sáu giây.
4. Đổi con trỏ. Một lệnh put_object ghi chuỗi phiên bản vào web/current. Đó là phát hành.
5. Đợi các máy. Mỗi máy API đệm con trỏ năm giây, nên trong chốc lát sau khi đổi, vài máy vẫn còn ở phiên bản cũ. Script thăm dò /api/web-version/ mỗi nửa giây và đếm số câu trả lời liên tiếp mang phiên bản mới. Fly cân bằng tải qua tất cả các máy, nên một chuỗi mười sáu câu trả lời khớp liên tiếp (nhiều hơn số máy) là cách nó biết mọi máy đều đã đọc lại con trỏ. Một câu trả lời cũ là đếm lại từ đầu. Sau 90 giây nó bỏ cuộc và bảo bạn kiểm tra bằng tay; lần đổi thật đầu tiên mất hơn 30 giây một chút, nên giới hạn không đặt thấp hơn.
6. Xóa bộ nhớ đệm Cloudflare. Một lời gọi API, nói ở dưới. Nếu thất bại, script in cảnh báo rồi đi tiếp.
7. Đăng lên Discord. Cùng webhook mà cảnh báo lỗi lúc chạy dùng nhận một dòng với phiên bản và người chạy, để một loạt lỗi có thể được đọc đối chiếu với thứ vừa thay đổi. Quay lui đăng bằng màu hổ phách.
Từ đầu đến cuối, một lần phát hành mất hơn một phút một chút, phần lớn là thời gian xuất của Expo.
Phía Django#
Django vốn đã là thứ phục vụ giao diện. Mọi URL không phải API đều được một view trả lời bằng trang đã dựng sẵn (prerender) trong bản xuất của Expo cho route đó, còn các trang giới thiệu, chính sách bảo mật và hỗ trợ lấy từ đầu ra của Hugo. Trước đây những view này mở tệp trên đĩa. Giờ chúng đi qua một module nhỏ, web_bundle.py, có lớp WebFiles mà view nào cũng gọi thay vì đọc tệp:
body = web_files.read("dist", "tabs/home.html")Với mỗi yêu cầu, nó xác định ba thứ: phiên bản hiện tại (đọc từ web/current, đệm năm giây), tệp kê khai của phiên bản đó (đệm chừng nào phiên bản đó còn là hiện tại), và bản thân tệp (một LRU chứa nội dung, khoảng 48MB mỗi tiến trình, nên HTML của trang và bundle chính luôn nằm sẵn trong bộ nhớ). Tệp kê khai là thứ khiến 404 rẻ: đường dẫn không có trong danh sách thì trả lời không luôn, không cần hỏi bucket. Nếu client chấp nhận Brotli, nó đọc tệp .br song sinh thay thế và đặt header mã hóa tương ứng. Tài nguyên tĩnh đi ra với header cache immutable một năm, vì tên tệp đã gắn hash theo nội dung.
Cùng module đó cũng dựng bảng route của Expo, để /tabs/home ánh xạ tới tabs/home.html, và bảng này được đệm theo phiên bản, nên một bản triển khai mới sẽ dựng lại nó ở yêu cầu đầu tiên chứ không sớm hơn.
Rồi đến endpoint mà script triển khai thăm dò:
def web_version(request):
response = JsonResponse({"source": web_files.source(), "version": web_files.version()})
response["Cache-Control"] = "no-store"
return response/api/web-version/ trả về bucket:<version> trên production, hoặc filesystem khi đang phục vụ từ image. Mỗi máy Fly chỉ trả lời cho chính nó, nên gọi chừng chục lần là biết cả cụm đã đồng bộ chưa. Đây cũng là cách nhanh nhất để xem một máy đang nghĩ cái gì là bản chạy khi có gì đó trông không ổn.
Xóa bộ nhớ đệm Cloudflare#
HTML trên marucommunity.com được đệm ở biên của Cloudflare trong năm phút. Nếu không xóa, bản triển khai đã chạy trên máy nhưng hầu hết người dùng vẫn nhận trang cũ tới tận năm phút, thế thì mất hết ý nghĩa.
Việc xóa chỉ là một lời gọi API:
urllib.request.Request(
f"https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
data=json.dumps({"hosts": ["marucommunity.com", "www.marucommunity.com"]}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)Tôi xóa theo host thay vì theo URL để khỏi phải liệt kê từng trang. Các tài nguyên tĩnh (asset) thì không cần xóa: tên tệp đã gắn hash theo nội dung, nên bản build mới trỏ tới tên tệp mới và các tệp cũ đơn giản là không còn được tham chiếu. Chỉ phần HTML tham chiếu tới chúng là cần làm mới.
Script đợi các máy trước khi xóa đệm là có chủ ý. Nếu xóa trước, Cloudflare có thể nạp lại bộ nhớ đệm từ một máy vẫn còn ở phiên bản cũ, và trang đó sẽ nằm ở biên thêm năm phút nữa.
Nếu xóa đệm thất bại, script in một cảnh báo rồi vẫn chạy cho xong. Bộ nhớ đệm tự hết hạn trong vòng năm phút, nên trường hợp xấu nhất là một lần phát hành chậm chứ không phải một lần phát hành hỏng. Tôi thà như vậy còn hơn để việc triển khai bị hủy ở bước cuối.
Kiểm tra một bản build trước khi lên sóng#
--no-flip làm đến hết bước 3 rồi dừng. Phiên bản đã nằm trong bucket nhưng chưa có gì phục vụ nó. Để xem, đặt WEB_BUNDLE_VERSION=<version> trên một máy; máy đó sẽ ghim vào bản build ấy bất kể web/current nói gì. Khi thấy ổn, --rollback <version> đặt nó làm hiện tại. Cờ tên là rollback nhưng thực chất là “trỏ current vào phiên bản này”, và tiến lên hay lùi lại đều là cùng một thao tác.
Quay lui#
Các phiên bản cũ vẫn nằm trong bucket, nên quay lui (rollback) là chạy --rollback <version> với một phiên bản trước đó, và --list cho thấy có những gì, mới nhất trước. Nó cũng đợi máy và xóa đệm y như một lần triển khai tiến lên. Tôi chưa cần dùng tới. Hiện chưa có gì dọn các phiên bản cũ; chúng nhỏ và tôi thà giữ còn hơn không.
Những gì vẫn như cũ#
API vẫn triển khai theo cách cũ qua deploy-all.sh, và nên như vậy: nó có trạng thái và là nơi migration chạy. Dockerfile cũng vẫn sao chép mobile-app/dist và public/ vào image. Đó là chủ ý. Khi không cấu hình bucket (phát triển cục bộ, bộ kiểm thử) hoặc không đọc được bucket, WebFiles lùi về những thư mục đó trên đĩa, nên sự cố lưu trữ nghĩa là người dùng nhận được giao diện đi kèm image gần nhất thay vì một trang lỗi. WEB_BUNDLE_FROM_BUCKET=0 ép hành vi đó trên production nếu có lúc cần tạm gạt bucket ra khỏi luồng.
Giao diện thay đổi thường xuyên hơn API nhiều, và giờ mỗi lần phát hành mất khoảng một phút thay vì bảy. Tôi đã thôi dồn lại mà chẳng cần cố.

