Tính năng upload ảnh nào tôi từng xây cũng có chung một mâu thuẫn. Người upload thì muốn ảnh lên thật nhanh. Còn tôi thì muốn server nén ảnh, tạo thumbnail, rút ra một blurhash cùng vài màu chủ đạo, bỏ đi những metadata không nên nằm ở đó, rồi lưu đúng theo cách mà phần còn lại của ứng dụng mong đợi. Cách dễ nhất để tôi có được mọi thứ trong danh sách của mình là cho file đi qua API, và đó lại chính là thứ làm chậm điều duy nhất mà họ muốn.
Cách nhanh là dùng presigned URL (URL ký sẵn). API ký một URL, client PUT file thẳng lên bucket, và dữ liệu file không hề chạm tới API. Đây là câu trả lời chuẩn, và nó thành chuẩn là có lý do. Nhưng ngay khoảnh khắc bạn làm vậy, backend đã bị gạt ra khỏi cuộc chơi. Nó phát ra một URL rồi không nghe thấy gì nữa. File đã tới chưa? Kích thước có đúng như mong đợi không? Nó có thật sự là JPEG không? Phải có thứ gì đó báo cho backend biết, mà các lựa chọn thường thấy thì đều hơi chán: tin rằng client sẽ gọi lại báo “xong rồi” (client thì nói dối, crash, và mất sóng), định kỳ hỏi thăm bucket theo lịch (polling, vừa chậm vừa lãng phí), hoặc xử lý lười (lazy) vào lần đọc đầu tiên (người xem đầu tiên phải trả giá, và bạn vẫn không biết khi nào file đủ an toàn để tham chiếu tới). Chính điều này khiến upload trực tiếp lên S3 trông khó hơn thực tế. Phần upload thì dễ. Thứ còn thiếu là tín hiệu báo về.
Object notification (thông báo object) chính là tín hiệu đó. Tigris, dịch vụ lưu trữ tương thích S3 mà tôi dùng trên Fly.io, sẽ POST một body JSON nhỏ tới ứng dụng của bạn mỗi khi có object được tạo hoặc bị xóa trong bucket. Trên AWS, thứ tương đương là S3 event notification, mà thường nghĩa là phải nối thêm một Lambda, một hàng đợi SQS hoặc EventBridge trước khi bất cứ thứ gì chạm được tới code của bạn (SNS thì có thể POST tới một endpoint HTTPS, nhưng khi đó bạn lại phải xác nhận subscription và xác minh chữ ký trước đã). Tigris giữ mọi thứ ở mức một request HTTP tới một URL do bạn sở hữu, gần như là mức đơn giản nhất mà việc báo tín hiệu từ storage sang backend có thể đạt tới. Tính năng này tồn tại để bucket nói chuyện được với backend của bạn mà bạn không phải tự xây một cây cầu nối cho nó.
Bài này đi trọn cả luồng: phần cài đặt, một endpoint Django nhận sự kiện, một luồng upload bằng presigned URL mà vẫn giữ nguyên toàn bộ phần xử lý phía server, và những cái bẫy hay gặp (CORS, giới hạn kích thước, thời hạn, nhiều file). Cuối bài là cùng tín hiệu ấy làm việc hậu trường (back office): các file log rơi vào bucket được biến thành cảnh báo trên Discord, và nhờ đó tôi xóa được một cron job đã phải chăm bẵm suốt mấy tháng.
Câu trả lời ngắn#
Trong dashboard của Tigris, mở bucket, vào Settings, rồi Object notifications. Đưa cho nó một URL HTTPS trỏ tới ứng dụng của bạn và chọn basic auth (một cặp username và password mà Tigris sẽ gửi trong header Authorization; lựa chọn còn lại là bearer token). Thêm một bộ lọc để chỉ nhận thông báo về những key bạn quan tâm. Bộ lọc là một biểu thức kiểu SQL, nên “chỉ ba prefix mà bài này xử lý” sẽ là:
WHERE `key` REGEXP "^(uploads/raw|logs/api|media)/"
Sau đó, mỗi khi có thay đổi, Tigris gửi một lô (batch) sự kiện như thế này:
{
"events": [
{
"eventName": "OBJECT_CREATED_PUT",
"eventTime": "2026-11-07T09:12:44Z",
"bucket": "example-bucket",
"object": { "key": "uploads/raw/1f3c.jpg", "size": 48213, "eTag": "9b2c…" }
}
]
}Những tên sự kiện bạn sẽ gặp là OBJECT_CREATED_PUT, OBJECT_CREATED_MULTIPART, OBJECT_CREATED_COPY và OBJECT_DELETED, cộng thêm các sự kiện đổi tên và chuyển tầng lưu trữ (storage tier). Endpoint của bạn kiểm tra auth, định tuyến từng sự kiện theo prefix của key, và trả về mã 2xx trong vòng mười giây. Có ba điều cần tính trước ngay từ đầu: việc gửi theo kiểu at least once (ít nhất một lần), nên bạn sẽ gặp sự kiện bị lặp; sự kiện được gom theo lô, nên một request có thể chứa nhiều sự kiện cùng lúc; và chúng có thể đến không theo thứ tự, nên đừng suy ra cái nào “mới nhất” từ thứ tự chúng tới.
Một webhook Django cho sự kiện của bucket#
View này được viết nhàm chán một cách có chủ đích. Nó kiểm tra auth, parse body, rồi giao từng sự kiện cho một bộ định tuyến (router):
import base64, hmac, json, logging
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .bucket_events import handle, parse
logger = logging.getLogger(__name__)
def _authorised(request, secret) -> bool:
header = request.headers.get("Authorization", "")
if not header.startswith("Basic "):
return False
try:
user, _, password = base64.b64decode(header[6:]).decode().partition(":")
except (ValueError, UnicodeDecodeError):
return False
return hmac.compare_digest(user.encode(), b"tigris") and hmac.compare_digest(
password.encode(), secret.encode()
)
@csrf_exempt
@require_POST
def bucket_webhook(request):
secret = settings.BUCKET_WEBHOOK_SECRET
if not secret:
return JsonResponse({"error": "not configured"}, status=503)
if not _authorised(request, secret):
return JsonResponse({"error": "unauthorised"}, status=401)
try:
body = json.loads(request.body or b"{}")
except ValueError:
return JsonResponse({"error": "invalid json"}, status=400)
for event in parse(body):
try:
handle(event)
except Exception:
logger.exception("bucket event failed: %s", event.key)
return JsonResponse({"ok": True})compare_digest ở đó để phép kiểm tra luôn mất cùng một khoảng thời gian, bất kể bạn gửi gì lên (so sánh trên bytes, vì với chuỗi thì nó ném lỗi khi gặp ký tự ngoài ASCII, và một header cố tình chế ra sẽ nhận 500 thay vì 401). Nếu secret chưa được đặt, chẳng hạn trên một máy staging mới dựng, endpoint sẽ từ chối tất cả chứ không phải chấp nhận tất cả. Còn một handler ném ra ngoại lệ thì được ghi log và vẫn được trả lời 200: Tigris gửi lại bất cứ thứ gì không phải 2xx, lần gửi lại sẽ vấp đúng bug đó, và đến lúc ấy sự kiện đã được claim rồi (đánh dấu là đã nhận, xem hai mục bên dưới) nên lần gửi lại cũng chẳng làm được gì. Chính dòng log mới là thứ giúp bug được sửa. Cách làm này giả định rằng lỗi là tất định (deterministic), tức là lần nào chạy cũng hỏng y như nhau. Một lần timeout tạm thời phía storage sẽ tiêu thụ mất sự kiện đó vĩnh viễn: với ảnh thì không sao, vì bước lưu có thể tự xử lý, nhưng cảnh báo từ một file log thì sẽ mất luôn. Nếu điều đó quan trọng với bạn, hãy giải phóng claim trong khối except và trả về 500 để Tigris gửi lại, đồng thời chấp nhận rằng một bug thật khi ấy sẽ bị gửi lại cho tới khi được sửa.
Định tuyến theo prefix#
Mọi thứ trong bucket vốn đã được tổ chức theo prefix, nên định tuyến theo prefix là cách tự nhiên nhất. Mỗi mục đích sử dụng là một hàm và một dòng trong bảng:
HANDLERS = [
("uploads/raw/", on_raw_photo),
("logs/api/", on_api_log_file),
("media/", on_media_change),
]
def handle(event):
for prefix, fn in HANDLERS:
if event.key.startswith(prefix):
if claim(event): # xem bên dưới
fn(event)
returnSau này muốn thêm việc thứ tư thì chỉ cần một hàm mới và một dòng mới. Tôi thích cách này hơn mình tưởng.
“Ít nhất một lần” nghĩa là “có thể hai lần”#
Tigris sẽ gửi lại nếu endpoint của bạn phản hồi chậm hoặc trả lỗi, và giống hầu hết các bên gửi webhook khác, nó không hứa rằng bạn chỉ thấy mỗi sự kiện đúng một lần. Vì vậy, trước khi handler chạy, sự kiện được claim theo bộ ba (key, eTag, eventName). eTag ở đây quan trọng: nếu một file bị ghi đè, đó là phiên bản mới với eTag mới và cần được xử lý lại. Còn nếu chỉ là cùng phiên bản đó được gửi lặp lại thì không cần xử lý nữa.
from django.core.cache import cache
def claim(event, ttl=7 * 24 * 3600) -> bool:
k = f"bucket-event:{event.name}:{event.key}:{event.etag}"
return cache.add(k, 1, ttl) # False nếu đã có bên khác claim trướccache.add chỉ ghi key khi key đó chưa tồn tại, và trên Redis thao tác này là nguyên tử (atomic), nên hai máy cùng nhận một lần gửi lại không thể cùng thắng. Trong hệ thống thật, tôi dùng một cơ chế lease lưu trong database cho việc này, vì muốn nó sống sót qua cả một lần flush Redis, nhưng ý tưởng thì y hệt.
Upload ảnh thẳng lên S3 mà pipeline vẫn nguyên vẹn#
Đây là phần khiến toàn bộ công sức làm đường ống (plumbing) ở trên trở nên xứng đáng.
Trước đây, chọn ảnh cho một tin đăng nghĩa là ứng dụng giữ ảnh lại trên máy, và khi bạn bấm Đăng thì nó gửi tất cả lên API dưới dạng base64. API resize ảnh, tạo thumbnail, tính màu chủ đạo và blurhash, xong xuôi mới phản hồi. Mười tấm ảnh trên một đường mạng chậm là một vòng xoay loading rất dài. Và một request chứa mười tấm ảnh base64 là một body to và chậm mà API server phải giữ trong bộ nhớ, đó cũng là lý do thầm lặng khiến tôi cứ phải giữ ảnh ở kích thước nhỏ.
Giờ luồng này gồm bốn bước, và backend được báo về từng bước một.
1. Client thu nhỏ ảnh và xin một suất upload#
Ứng dụng vẫn resize ngay trên thiết bị, về đúng cạnh dài nhất mà server sẽ giữ (1080px), dưới dạng JPEG với chất lượng 0.7. Lần resize đó chỉ để tiết kiệm băng thông cho người dùng, không vì gì khác. Rồi nó xin API một suất upload (upload slot):
import uuid
from datetime import timedelta
import boto3
from django.utils import timezone
s3 = boto3.client("s3", endpoint_url="https://fly.storage.tigris.dev")
BUCKET = "example-bucket"
def create_upload_slot(user):
public_id = uuid.uuid4()
key = f"uploads/raw/{public_id}.jpg"
upload = PhotoUpload.objects.create(
user=user,
public_id=public_id,
raw_key=key,
expires_at=timezone.now() + timedelta(days=2),
)
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": key, "ContentType": "image/jpeg"},
ExpiresIn=15 * 60,
)
return {
"upload_id": str(upload.public_id),
"url": url,
"method": "PUT",
"headers": {"Content-Type": "image/jpeg"},
"expires_in": 15 * 60,
}Suất upload là một dòng trong database trước khi trở thành một file. Chính điều đó cho server biết ai đã xin nó, khi nào nó hết hạn, và sau này là nó có từng được dùng hay không.
2. Client PUT thẳng lên bucket#
const slot = await api.post("/photo-uploads/");
const res = await fetch(slot.url, {
method: slot.method,
headers: slot.headers,
body: blob,
});
if (!res.ok) throw new Error("upload refused");
// giữ lại slot.upload_id cho bước lưu
API không bao giờ nhìn thấy dữ liệu file. Từ trình duyệt, từ React Native hay từ app native đều y như nhau; khác biệt duy nhất là trình duyệt cần một rule CORS trên bucket, sẽ nói ở phần các cái bẫy bên dưới.
3. Tigris báo cho backend, và pipeline chạy#
Trong vòng vài giây, Tigris POST một sự kiện OBJECT_CREATED_PUT cho uploads/raw/…, và handler chạy đúng pipeline xử lý ảnh mà đường base64 trước giờ vẫn dùng, trong lúc người dùng còn đang gõ tiêu đề:
from io import BytesIO
from PIL import Image, ImageOps
from django.core.files.base import ContentFile
from django.utils import timezone
import blurhash
MAX_BYTES = 15 * 1024 * 1024
def on_raw_photo(event):
if not event.name.startswith("OBJECT_CREATED"):
return
if event.size > MAX_BYTES:
s3.delete_object(Bucket=event.bucket, Key=event.key)
return
upload = PhotoUpload.objects.filter(raw_key=event.key, processed_at=None).first()
if upload is None:
return
data = s3.get_object(Bucket=event.bucket, Key=event.key)["Body"].read()
process(upload, data)
def to_webp(im, longest, quality):
im = im.copy()
im.thumbnail((longest, longest))
buf = BytesIO()
im.save(buf, "WEBP", quality=quality, icc_profile=im.info.get("icc_profile"))
return ContentFile(buf.getvalue())
def process(upload, data):
with Image.open(BytesIO(data)) as im:
im = ImageOps.exif_transpose(im).convert("RGB")
im.thumbnail((1080, 1080))
upload.width, upload.height = im.size
upload.dominant_color = "#%02x%02x%02x" % im.resize((1, 1)).getpixel((0, 0))
upload.image.save(f"{upload.public_id}.webp", to_webp(im, 1080, 80), save=False)
upload.thumbnail.save(f"{upload.public_id}_t.webp", to_webp(im, 320, 70), save=False)
upload.blurhash = blurhash.encode(im.resize((32, 32)), 4, 3)
upload.processed_at = timezone.now()
upload.save()Dòng đầu tiên của on_raw_photo quan trọng hơn vẻ ngoài của nó. Router gửi mọi sự kiện dưới uploads/raw/ tới đây, kể cả các sự kiện xóa do job dọn upload mồ côi và do chính phép kiểm tra kích thước ngay bên dưới tạo ra, mà get_object trên một key đã bị xóa thì chỉ ném lỗi chứ chẳng làm gì khác.
Vậy nên việc client thu nhỏ ảnh chỉ là để nhẹ đường truyền, không hơn. Server vẫn là bên quyết định định dạng (WebP), các kích thước, thumbnail, ảnh placeholder, và chiều rộng chiều cao được lưu lại. Việc encode lại qua Pillow cũng đồng thời bỏ đi khối EXIF, nơi camera điện thoại cất vị trí GPS, nên phần tước bỏ thông tin riêng tư có được miễn phí. Profile ICC lẽ ra cũng sẽ mất theo cùng cách đó, khiến ảnh chụp từ điện thoại có dải màu rộng (wide-gamut) bị lệch màu đôi chút, nên to_webp giữ nó lại và truyền tiếp. Nếu bạn muốn giữ ảnh gốc nguyên từng pixel thì đừng thu nhỏ ở client; luồng vẫn y nguyên, chỉ là các lệnh PUT nặng hơn, và PUT nặng hơn chính xác là thứ mà thiết kế này làm cho rẻ. Một điểm cần lưu ý: ảnh gốc từ iPhone là HEIC, mà Pillow chỉ mở được khi đã cài pillow-heif.
4. Bước lưu gắn ảnh vào và không bao giờ chờ webhook#
Khi người dùng bấm Đăng, request mang theo upload_ids thay vì dữ liệu ảnh. Server tra từng id một, với ba kết quả có thể xảy ra:
def take(user, upload_ids):
uploads = list(PhotoUpload.objects.filter(
user=user, public_id__in=upload_ids, expires_at__gt=timezone.now()
))
if len(uploads) != len(set(upload_ids)): # đã hết hạn, hoặc không phải của người dùng này
raise UploadNotReadyError(upload_ids)
for upload in uploads:
if upload.processed_at is None: # webhook chưa tới
data = read_raw(upload) # None nếu lệnh PUT chưa từng tới nơi
if data is None:
raise UploadNotReadyError(upload.public_id)
process(upload, data)
return uploadsNếu thông báo đã chạy rồi, các file đã xử lý được gắn vào nguyên như vậy. Nếu file đã nằm trong bucket nhưng thông báo chưa tới, bước lưu xử lý ngay tại chỗ, nên một tin đăng không bao giờ phải chờ webhook. Thông báo là một lợi thế xuất phát sớm, không phải một phụ thuộc bắt buộc. Còn nếu file hoàn toàn chưa có, hoặc một id đã hết hạn hay thuộc về người khác, bước lưu trả về lỗi validation và ứng dụng sẽ thử lại sau một chút; ứng dụng vốn chờ các lệnh PUT của chính nó xong rồi mới gửi, nên trên thực tế chuyện này hiếm.
Vài chi tiết giúp cách này đứng vững. Suất upload thuộc về một người dùng, nên server chỉ chấp nhận những upload id do chính người đó tạo; nếu không, bất kỳ ai nhìn thấy một id đều có thể gắn ảnh của bạn vào tin đăng của họ. Mọi thứ đều có đường lui: nếu xin suất upload thất bại, lệnh PUT bị từ chối, hoặc API đang ở phiên bản cũ, ứng dụng gửi base64 y như trước. Nó chỉ đi đường upload khi mọi ảnh mới đều có suất, nên ảnh của một tin đăng hoặc toàn bộ là upload hoặc toàn bộ là base64, và thứ tự của chúng vẫn là một danh sách duy nhất. Và upload mồ côi sẽ tự hết hạn: có người chọn ảnh xong rồi bỏ ngang, nên một upload không bao giờ được gắn vào đâu sẽ hết hạn sau hai ngày, và job dọn media mồ côi sẵn có sẽ xóa các file của nó.
Những cái bẫy khi upload bằng presigned URL: CORS, giới hạn kích thước, thời hạn, nhiều file#
Phần lớn những gì trục trặc với upload bằng presigned URL đều trục trặc trước khi thông báo kịp bắn ra. Đây là những thứ tôi muốn gom lại trên một trang.
CORS, chỉ với trình duyệt#
App native không quan tâm chuyện này. Trình duyệt thì gửi một request preflight OPTIONS tới bucket trước khi PUT, và upload sẽ thất bại với lỗi CORS trừ khi các rule CORS của bucket cho phép PUT từ origin của bạn kèm header Content-Type. Tigris nhận cấu hình CORS chuẩn của S3, nên rule trông giống hệt trên AWS. Đó là thứ đầu tiên cần kiểm tra khi một lệnh PUT bằng presigned URL chạy được từ curl mà không chạy được từ trang web.

Content type là một phần của chữ ký#
Ký kèm ContentType nghĩa là client phải gửi đúng header đó, nếu không bucket trả về 403 SignatureDoesNotMatch. Đó là lý do suất upload trả về cả headers cùng với URL. Hãy ký như vậy một cách có chủ đích, nhưng cần biết rõ nó đem lại gì và không đem lại gì: header thì bị ép buộc, còn nội dung thì không, nên một file PDF gắn nhãn image/jpeg vẫn lọt qua. Pipeline mới là phép kiểm tra thật; Pillow từ chối mở thứ gì không phải ảnh, và dòng đó không bao giờ được đánh dấu là đã xử lý.
Giới hạn kích thước#
Một presigned PUT URL không mang theo giới hạn kích thước nào. Nếu bạn cần một chốt chặn cứng trước khi dữ liệu kịp tới nơi, presigned POST với điều kiện policy content-length-range làm được việc đó. Tôi không bận tâm, vì thông báo đã mang theo size của object: handler xóa bất cứ thứ gì vượt giới hạn trước khi đọc nó, còn đường xử lý lúc lưu thì chỉ đọc tối đa bằng giới hạn cộng thêm một byte, đủ để nhận ra file quá lớn. Ứng dụng gửi vài trăm kilobyte; thứ gì tiến gần tới mười lăm megabyte thì chắc chắn không phải do ứng dụng gửi lên.
Thời hạn hiệu lực#
Có hai mốc hết hạn khác nhau. Presigned URL có hiệu lực mười lăm phút, thừa đủ cho một lệnh PUT và đủ ngắn để một URL bị lộ sớm trở nên vô dụng. Dòng suất upload có hiệu lực hai ngày, đủ cho trường hợp ai đó chọn ảnh, đặt điện thoại xuống, rồi hôm sau mới hoàn thành tin đăng. Client coi một upload đang dang dở là cũ sau một ngày và bắt đầu một upload mới thay vì ngồi hy vọng.
Nhiều file, và file lớn hơn#
Mỗi file một suất upload. Mười tấm ảnh là mười suất và mười lệnh PUT song song, mỗi lệnh có thanh tiến trình (progress) riêng (XMLHttpRequest cho bạn upload.onprogress; fetch thì không), điều bạn không thể có với một lệnh POST base64 khổng lồ. Quá vài megabyte thì dùng multipart (mỗi phần tối thiểu 5 MiB, nên cách này dành cho những file lớn hơn mức đó khá nhiều): tạo multipart upload trên server, ký sẵn từng upload_part, rồi hoàn tất khi các phần đã tới đủ. Tigris gửi OBJECT_CREATED_MULTIPART khi upload được hoàn tất (complete), nên cùng handler đó được kích hoạt và không có gì khác phải thay đổi.
Không theo thứ tự#
Sự kiện có thể đến không theo thứ tự, vì object có thể đã được ghi ở nhiều hơn một region. Với upload thì không sao: một key, một sự kiện. Với một key bị ghi đè, hãy tin vào eTag và Last-Modified của object, chứ không phải thứ tự các webhook tới chỗ bạn.
Cùng tín hiệu đó cho log: cảnh báo lên Discord#
Một khi bucket đã nói chuyện được với backend, bất cứ thứ gì rơi vào bucket đều có thể được phản ứng theo cùng một cách, và thứ thứ hai tôi trỏ nó vào là log.
Log của ứng dụng được đẩy vào bucket dưới dạng các file gzip nhỏ, mỗi ứng dụng khoảng năm phút một file. Suốt gần cả năm, một GitHub Action chạy cron 15 phút một lần dùng DuckDB đọc 20 phút gần nhất trong kho lưu trữ đó, lọc ra các dòng request chậm, rồi gửi bản tổng hợp lên Discord. Trên giấy thì ổn. Thực tế, các workflow chạy theo lịch của GitHub không chạy đúng giờ khi nền tảng đang bận, nên “15 phút một lần” thành ra “vài tiếng một lần”, và đến khi chạy được thì khoảng thời gian cần xử lý đã dồn lại lớn hơn nhiều, thỉnh thoảng còn hết bộ nhớ. Phần cửa sổ chồng lấn mà tôi thêm vào để không bỏ sót gì giữa hai lần chạy lại khiến cùng một request chậm đôi khi hiện lên hai lần. Cả một bộ máy cồng kềnh chỉ để làm một việc: “báo tôi biết khi có request chậm”.
Giờ mỗi file log được đọc đúng một lần, ngay khi nó vừa tới. Mỗi file chỉ vài kilobyte sau khi gzip. Handler lọc ra các dòng request chậm, và nếu có đủ nhiều, hoặc có một request chậm hẳn, nó gửi bản tổng hợp vào một kênh Discord thông qua webhook. Tương tự với một loạt phản hồi 5xx dồn dập trong cùng một file. Gọi Discord webhook từ Python chỉ là một lệnh:
import requests
from django.conf import settings
def discord_alert(text: str) -> None:
requests.post(
settings.DISCORD_WEBHOOK_URL,
json={"content": text[:1900]}, # Discord giới hạn một tin nhắn ở 2000 ký tự
timeout=5,
)Không còn cron, không còn cửa sổ chồng lấn, không còn báo trùng, và cảnh báo đến trong vòng vài phút sau request thay vì đợi đến khi GitHub rảnh tay. Cái Action đó đã bị xóa.
Nhân tiện đã mở từng file ra, tôi đếm luôn những gì có trong đó (lỗi, cảnh báo, health check) rồi cộng vào một bảng thống kê theo giờ. Trước đây, bản tóm tắt hằng ngày phải quét cả một ngày log trong kho lưu trữ. Giờ nó chỉ đọc vài trăm dòng, và chỉ quay lại quét kho lưu trữ khi bảng thống kê bị hở ở đâu đó.
Một chốt chặn cho lệnh xóa#
Cái này tôi không hề lên kế hoạch. Nó tự nảy ra khi đã có sự kiện OBJECT_DELETED. Khi một file media bị xóa, handler kiểm tra xem còn dòng nào trong database trỏ tới file đó không. Nếu còn, nghĩa là có bug ở đâu đó (hoặc có ai đó lỡ tay trong console của bucket), nên handler xóa giá trị ở cột đó (ứng dụng sẽ hiển thị ảnh placeholder thay vì ảnh lỗi) rồi báo cáo lại.
Nó cũng đếm số lượt xóa theo giờ. Dọn dẹp bình thường chỉ xóa vài file. Nếu có lúc nó thấy vài trăm lượt xóa trong một giờ, tôi muốn được biết ngay khi chuyện đang xảy ra, chứ không phải đợi đến lúc người dùng bắt đầu báo ảnh trắng trơn.
Vài điều nên biết#
Phản hồi thật nhanh. Tigris cho mỗi lần gửi mười giây. Đọc một file log 5 KB hay xử lý một tấm ảnh điện thoại thì vừa; thứ gì có thể lâu hơn thì đẩy vào hàng đợi (queue) và để view trả 200 ngay, nếu không Tigris sẽ timeout rồi gửi lại, và bạn lại quay về chuyện khử trùng lặp.
Lọc ngay tại bucket. Bộ đẩy log của tôi ghi một file vài phút một lần cho mỗi ứng dụng, ngày này qua ngày khác. Nếu bạn chỉ quan tâm một prefix, hãy nói rõ trong bộ lọc của thông báo để ứng dụng của bạn không bị đánh thức vì mọi thứ khác.
Kiểm thử bằng body giả. Payload nhỏ và ổn định, nên các bài kiểm thử chỉ cần POST JSON vào view kèm header Authorization đúng, rồi assert xem handler đã làm gì. Không cần đụng đến storage thật.
Đừng để nó gánh trọng trách ngay từ ngày đầu. Mỗi cách dùng ở đây đều có đường lui: bước lưu có thể tự xử lý ảnh, bản tóm tắt hằng ngày vẫn có thể quét kho lưu trữ, ứng dụng vẫn có thể gửi base64. Thông báo giúp từng việc nhanh hơn và rẻ hơn, nhưng không việc nào sụp đổ nếu lỡ mất một webhook.
Khoảnh khắc đã nhất trong tuần là lúc xóa file workflow đó. Bốn mươi dòng YAML, một script Python, hai repository secret và câu hỏi lặp đi lặp lại “sao cảnh báo không bắn ra” đều biến mất, thay vào đó là một handler đọc một file nhỏ ngay khi nó vừa tới. Nhưng chuyện upload mới là mục đích chính. Ảnh đi lên nhanh hết mức đường mạng cho phép, server vẫn là bên quyết định cuối cùng với từng tấm một, và không ai phải xây cầu nối giữa bucket và backend, vì giờ bucket tự kể lại chuyện gì vừa xảy ra.
