← All posts

Chúng tôi chuyển hình ảnh sang CDN và máy chủ gốc vẫn tiếp tục phục vụ chúng

Authagonal·July 30, 2026

Tài liệu của chúng tôi dày đặc ảnh chụp màn hình. Mọi tính năng của cổng thông tin, chụp ở chế độ sáng và tối, ở hai độ rộng, trên mười ngôn ngữ, cộng lại thành hơn một trăm megabyte hình ảnh. Tất cả nằm trong chính gói tĩnh của ứng dụng, được phục vụ từ máy chủ gốc, vốn là nơi sai lầm để chứa một trăm megabyte hình ảnh không bao giờ thay đổi. Vì vậy chúng tôi chuyển chúng sang Cloudflare R2 và đặt một CDN ở phía trước, mỗi môi trường một cái: cdn.authagonal.io cho môi trường production và một host CDN tương ứng cho trang dev của chúng tôi.

Ứng dụng chọn đúng host CDN khi chạy bằng cách nhìn vào tên máy chủ mà nó đang được phục vụ từ đó. Nếu trang nằm trên authagonal.io, hình ảnh đến từ cdn.authagonal.io. Đơn giản, không cần cấu hình lúc build, một tham chiếu hình ảnh duy nhất phân giải đúng ở cả hai môi trường. Chúng tôi phát hành nó, xem trình duyệt kéo từng hình ảnh gọn gàng từ CDN, rồi nhìn vào lưu lượng của máy chủ gốc. Máy chủ gốc vẫn đang phục vụ hình ảnh. Từng cái một, ở lần tải đầu tiên, y hệt như trước.

Trang mà trình thu thập dữ liệu nhìn thấy không phải là trang mà trình duyệt dựng lên

Để giải thích tại sao, tôi phải giải thích các trang marketing của chúng tôi thực sự là gì. Chúng là một ứng dụng một trang, nhưng một ứng dụng một trang chỉ là một vỏ rỗng cho đến khi JavaScript chạy, và một vỏ rỗng thì tệ cho các công cụ tìm kiếm và tệ cho khoảng thời gian mà thứ có ý nghĩa đầu tiên cần để xuất hiện. Vì vậy lúc build chúng tôi kết xuất trước: chúng tôi khởi động trang, mở từng trang trong một trình duyệt không giao diện, để nó dựng đầy đủ, và lưu lại HTML thu được. Chính HTML đã kết xuất trước đó là thứ chúng tôi phục vụ đầu tiên. Nó đã có sẵn nội dung bên trong, trình thu thập dữ liệu thấy một trang thật, người dùng thấy chữ và hình ảnh ngay lập tức, rồi JavaScript tiếp quản.

Đây là vấn đề ẩn trong mô tả đó. Quá trình kết xuất trước chạy trang trên 127.0.0.1, một địa chỉ loopback trên máy build. Vậy nên khi ứng dụng hỏi «tôi đang ở trên tên máy chủ nào, để có thể chọn một CDN», câu trả lời trong lúc kết xuất trước là «một địa chỉ IP cục bộ», không khớp với authagonal.io cũng không khớp với host dev. Việc suy ra host khi chạy, phần khéo léo, lặng lẽ rơi về lựa chọn duy nhất còn lại: đường dẫn tương đối theo máy chủ gốc. Và chính đường dẫn gốc đó bị đóng băng vào HTML đã kết xuất trước.

Vậy nên mỗi khách truy cập nhận được HTML với các URL hình ảnh của máy chủ gốc được nướng sẵn vào, và trình duyệt làm điều hiển nhiên: nó tải chúng, từ máy chủ gốc, ở lần vẽ đầu tiên. Một lát sau, JavaScript dựng lại trang, suy ra lại tên máy chủ, lần này lấy được cái thật, và đổi hình ảnh sang CDN. Hai lượt tải cho mỗi hình ảnh, lượt đầu tiên đánh trúng đúng cái máy chủ mà chúng tôi đang cố tha cho, và hoàn toàn vô hình trừ khi bạn đang theo dõi log của máy chủ gốc, bởi vì trang trông và hoạt động hoàn hảo.

Bản sửa lỗi khiến mọi thứ tệ hơn trong mười một phút

Bản sửa đầu tiên là bản hấp dẫn. Nếu vấn đề là một URL sai bị đóng băng vào HTML đã kết xuất trước, thì đừng phát ra URL nào trong lúc kết xuất trước cả. Để trống nguồn hình ảnh trong ảnh chụp, và để JavaScript điền vào URL CDN đúng một khi nó chạy và biết được host thật. Không có URL sai, không có lượt tải về máy chủ gốc.

Nó được phát hành, và nó sai, và chúng tôi thay nó khoảng mười một phút sau. Để trống nguồn hình ảnh trong HTML đã kết xuất trước phá hỏng toàn bộ lý do khiến chúng tôi kết xuất trước. Trình thu thập dữ liệu đọc trang tĩnh giờ không thấy hình ảnh nào, và trình duyệt không có gì để hiển thị cho đến khi JavaScript đã chạy, mà đó chính là con đường chậm mà chúng tôi dựng HTML kết xuất trước để tránh. Chúng tôi đã loại bỏ lượt tải kép bằng cách loại bỏ hình ảnh khỏi phiên bản duy nhất của trang tồn tại trước JavaScript. Điều đó đánh đổi một vấn đề lưu lượng lấy một vấn đề tìm kiếm và vẽ lần đầu, đó là một cuộc đánh đổi tệ hơn.

Bài học từ mười một phút đó: HTML đã kết xuất trước phải mang một tham chiếu hình ảnh thật, chạy được. Lỗi chưa bao giờ là việc có một URL hiện diện. Lỗi là việc URL sai hiện diện, và việc để trống nó chỉ đánh đổi một khiếm khuyết này lấy một khiếm khuyết khác. Thứ chúng tôi thực sự cần là URL đúng có thể biết được vào lúc kết xuất trước, và nó thì không, bởi vì vào lúc kết xuất trước không ai biết môi trường nào sẽ phục vụ trang.

Quyết định ở thời điểm muộn nhất có thể

URL đúng phụ thuộc vào host của yêu cầu, và host của yêu cầu thì chưa biết được cho đến khi có một yêu cầu. Quá trình kết xuất trước xảy ra một lần, lúc build, và tạo ra một tệp được phục vụ ở cả hai môi trường. Vậy nên phần của URL đặc thù theo môi trường không thể được quyết định lúc build. Nó phải được quyết định theo từng yêu cầu, và thứ duy nhất trong ngăn xếp nhìn thấy mọi yêu cầu và host của nó là máy chủ web ở edge.

Vậy nên quá trình kết xuất trước không còn ghi ra một URL hay một chỗ trống nữa. Nó ghi ra một chuỗi giữ chỗ, một chuỗi sentinel theo nghĩa đen, https://cdn.authagonal.io, ở vị trí của host CDN. HTML đã kết xuất trước rời khỏi bản build ghi https://cdn.authagonal.io/screenshots/whatever.webp, thứ không phải là URL của máy chủ gốc cũng không phải là một nguồn rỗng. Đó là một URL có một lỗ hổng trong đó.

nginx lấp lỗ hổng đó trên đường ra. Nó ánh xạ host của yêu cầu tới đúng cơ sở CDN, host không phải production tới CDN dev của riêng nó, authagonal.io tới https://cdn.authagonal.io, mọi thứ khác tới rỗng để URL sụp về tương đối theo máy chủ gốc như một phương án dự phòng cục bộ an toàn. Rồi một chỉ thị duy nhất viết lại chuỗi sentinel thành giá trị đó khi HTML truyền tới client:

map $host $asset_cdn_base {
    default              "";
    "~*example\.dev$"    "https://cdn.example.dev";
    "~*authagonal\.io$"  "https://cdn.authagonal.io";
}

sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;

HTML tĩnh giờ rời khỏi máy chủ gốc đã mang sẵn các URL CDN thật, đúng cho bất kỳ host nào đã hỏi. Trình duyệt tải thẳng từ CDN ở lần vẽ đầu tiên. Không có lượt tải đầu tiên sai nào cần hoàn tác, bởi vì không có URL sai nào, và cũng chưa bao giờ có một cái rỗng. Trình thu thập dữ liệu thấy một trang hoàn chỉnh với các liên kết hình ảnh thật. Một tệp duy nhất phục vụ cả hai môi trường, và không môi trường nào cần một bản build riêng.

Có một sự thanh lịch nhỏ trong việc phần nào của phản hồi được viết lại. Việc viết lại chỉ giới hạn ở HTML, vốn là mặc định của nginx. Điều đó quan trọng vì cùng chuỗi sentinel đó cũng xuất hiện trong gói JavaScript, như nhánh dự phòng của đoạn mã suy ra host. Trên client nhánh đó là mã chết, vì trình duyệt có một tên máy chủ thật và không bao giờ phát ra chuỗi sentinel, nên chúng tôi đặc biệt không muốn nginx đụng vào gói đó. Để việc viết lại ở mặc định chỉ-HTML của nó cho ta điều đó miễn phí. Nhân tiện, chúng tôi học được rằng viết ra giá trị mặc định một cách tường minh còn tệ hơn là bỏ nó đi, vì một khai báo dư thừa khiến nginx cảnh báo về một bản trùng lặp.

Tại sao không chỉ viết lại tệp một lần lúc khởi động

Phản đối hiển nhiên là đây là quá nhiều việc cho mỗi yêu cầu đối với một giá trị chỉ có hai khả năng. Tại sao không viết lại HTML một lần khi container khởi động, nướng sẵn đúng host vào, và phục vụ một tệp tĩnh không có bộ lọc nào?

Bởi vì container không thể ghi vào chính nó. Trang tĩnh chạy trong nginx với tư cách một người dùng không phải root, với hệ thống tệp gốc chỉ đọc và mọi khả năng Linux bị tước bỏ, chỉ với một thư mục nháp nhỏ có thể ghi được cho các tệp tạm của riêng nginx. Đó là một tư thế gia cố có chủ đích: một máy chủ web phục vụ lưu lượng không đáng tin thì không nên có khả năng sửa đổi các tệp mà nó phục vụ, để một kẻ tấn công tìm được một chỗ đứng sẽ chẳng tìm thấy gì để viết lại. Một lần viết lại HTML lúc khởi động sẽ cần đúng cái quyền ghi mà chúng tôi đã cố ý bỏ đi. Hệ thống tệp chỉ đọc không phải là một trở ngại cần né tránh, nó là một thuộc tính mà chúng tôi muốn, và nó loại trừ gọn gàng việc viết lại lúc khởi động. Việc viết lại theo từng yêu cầu hoàn toàn không đụng vào tệp nào, và đó là lý do nó tương thích với một máy chủ không sở hữu thứ gì mà nó có thể thay đổi.

Chúng tôi cũng không muốn nướng sẵn host CDN vào lúc build và tạo ra hai gói hình ảnh khác nhau, mỗi môi trường một gói, bởi vì điều đó tái xuất hiện bản build đặc thù theo môi trường mà chúng tôi vừa mới thoát khỏi. Mục đích của việc suy ra host là một tạo phẩm duy nhất cho cả hai môi trường. Chuỗi sentinel giữ đúng lời hứa đó: một bản build, một tệp, môi trường được phân giải ở edge vào thời điểm muộn nhất có thể.

Kết xuất trước đóng băng các giá trị môi trường, vậy nên hãy phân giải chúng ở edge

Kết xuất trước đóng băng một khoảnh khắc. Bất cứ điều gì ứng dụng của bạn quyết định bằng cách nhìn vào môi trường của nó đều được quyết định, vào lúc kết xuất trước, dựa trên môi trường của bản build, vốn là một địa chỉ loopback và không phải host thật của ai cả. Vậy nên một giá trị đúng khi chạy trở thành sai ngay khoảnh khắc nó bị chụp lại, và nó thất bại một cách âm thầm, bởi vì trang đã kết xuất trước vẫn dựng ra được, chỉ là trỏ tới sai chỗ.

Cách sửa tổng quát là hoàn toàn không phân giải các giá trị đặc thù theo môi trường trong lúc kết xuất trước. Hãy phát ra một chuỗi giữ chỗ, mang nó nguyên vẹn qua tạo phẩm tĩnh, và phân giải nó ở lớp thực sự nhìn thấy môi trường, mà với một giá trị theo từng yêu cầu thì đó là edge. Đó là ràng buộc muộn, áp dụng cho một chuỗi trong một tệp HTML: để cái lỗ mở suốt qua mọi giai đoạn không biết câu trả lời, và lấp nó ở đúng cái giai đoạn biết câu trả lời.

Nếu bạn muốn tài liệu và tài nguyên của chính nhà cung cấp danh tính của bạn đã được phục vụ đúng cách từ edge, thì đó là một trong nhiều điều nhỏ mà Authagonal đã tranh luận xong sẵn cho bạn, để bạn có thể dành cuộc tranh luận đó cho chính sản phẩm của mình.