Bối cảnh
Viblo là một trang web về công nghệ thông tin và lập trình, nơi cộng đồng lập trình viên có thể chia sẻ kiến thức, kinh nghiệm và học hỏi từ nhau. Trang web chứa nhiều hình ảnh, bài viết, bài học và tài liệu liên quan đến lập trình và công nghệ, do đó việc quản lý và hiển thị hình ảnh một cách hiệu quả trên trang web là rất quan trọng.
Khi xây dựng trang web như Viblo, việc tự xây dựng một server ảnh và thực hiện resize ảnh cũng như tối ưu kích thước ảnh có thể coi là một phần quan trọng của công việc phát triển. Dưới đây là một số lý do mà chúng tôi nhận thấy:
Tối ưu hóa tốc độ tải trang:
- Việc tải ảnh có kích thước lớn có thể làm giảm tốc độ tải trang và trải nghiệm người dùng.
- Tối ưu hóa kích thước ảnh giúp giảm băng thông và thời gian tải trang.
Tiết kiệm băng thông:
- Việc sử dụng ảnh có kích thước lớn có thể tăng chi phí băng thông và lưu trữ.
- Tối ưu kích thước ảnh giúp tiết kiệm băng thông và tài nguyên máy chủ.
Tăng tốc độ tải trang trên di động:
- Đối với người dùng truy cập từ thiết bị di động, việc tải các ảnh có kích thước lớn có thể làm giảm trải nghiệm.
- Resize ảnh giúp tăng tốc độ tải trang và cải thiện trải nghiệm người dùng trên di động.
Kiểm soát chất lượng ảnh:
- Bằng cách tự xây dựng server ảnh, Viblo có thể kiểm soát chất lượng và định dạng của ảnh được tải lên trang web.
- Việc tối ưu hóa kích thước ảnh cũng giúp đảm bảo rằng hình ảnh được hiển thị trên trang web đều có chất lượng tốt và tương thích với các thiết bị khác nhau.
Chúng tôi cũng đã cân nhắc việc sử dụng Lambda@Edge và CloudFront. Tuy nhiên Viblo có hàng triệu request ảnh mỗi ngày, vì vậy sẽ phát sinh thêm vấn đề về chi phí.
Với những lý do trên, việc tự xây dựng một server ảnh và thực hiện resize ảnh cũng như tối ưu kích thước ảnh là một phần quan trọng của việc phát triển và quản lý trang web như Viblo.asia. Điều này giúp cải thiện hiệu suất và trải nghiệm người dùng, đồng thời tiết kiệm tài nguyên, băng thông máy chủ, cũng như chi phí vận hành.
Sơ đồ hoạt động
Sơ đồ luồng hoạt động xử lý ảnh.
(1): Người dùng yêu cầu xem một ảnh của Viblo, ví dụ ảnh này có đường dẫn là: https://images.viblo.asia/98eef5e0-42cd-491d-9547-8dd3673ef843.png
(2): Yêu cầu này sẽ đi qua Nginx Reverse proxy và đi đến server ảnh (Image Server)
(3): Tùy vào lượng yêu cầu (Request), sẽ cần 1 hay nhiều replicas. Tại server ảnh sẽ thực hiện tiếp nhận yêu cầu bao gồm thông tin của ảnh, sau đó sẽ tiếp tục gửi yêu cầu đến Object Storage Bucket.
(4): Lấy ra được nội dung (Content) của ảnh từ Object Storage Bucket. Xử lý hình ảnh (resize, crop,...).
(5): Trả ra định dạng WEBP sau khi đã xử lý xong hình ảnh, thực hiện cache hình ảnh.
(6): Trả ra hình ảnh sau khi đã xử lý cho người dùng, thực hiện cache ảnh cho những lần yêu cầu (Request) sau. Người dùng xem hình ảnh trên trình duyệt hoặc tải về.
Lưu ý: Có một số trường hợp như chỉ xem ảnh gốc và ảnh này ở trạng thái công khai, thì tại bước (2) Nginx sẽ gửi yêu cầu luôn lên Object Storage Bucket có thể proxy_pass
đến presigned URLs, vì thực tế khi chúng ta sử dụng cloud thì mỗi ảnh cũng đã có một presigned URLs rồi (trường hợp presigned URLs có lifetime chúng ta cần cấu hình Nginx cached có thời gian phù hợp). Nginx trả ra ảnh cho client luôn, thay vì phải thêm 1 bước xử lý ở server ảnh.
Xây dựng server ảnh
Imaginary
Imaginary là một dự án mã nguồn mở được viết bằng ngôn ngữ Go, được sử dụng để xử lý và thao tác hình ảnh trên web.
Xử lý hình ảnh: Imaginary hỗ trợ hỗ trợ xử lý ảnh với định dạng JPEG, PNG và output với các định dạng JPEG, PNG và WEBP. Nhiều thao tác xử lý hình ảnh như resize, crop, rotate, flip, blur, hoặc kết hợp nhiều thao tác, hay thậm chí cả convert giữa các định dạng, hay lấy thông tin về ảnh (kích thước, định dạng, chiều xoay)...
Tối ưu hóa: Cung cấp khả năng tối ưu hóa hình ảnh để giảm kích thước file mà không ảnh hưởng đến chất lượng hình ảnh.
HTTP API: Cung cấp một HTTP API đơn giản để tương tác với các dịch vụ hoặc ứng dụng khác thông qua các yêu cầu HTTP.
Imaginary thực sự là một lựa chọn tốt để làm backend cho server ảnh.
Cài đặt
Do được phát triển bằng ngôn ngữ Go nên bạn có thể download sourcecode của Imaginary và chạy lên. Tuy nhiên do vấn đề về việc đồng nhất môi trường giữa máy dev, môi trường CI hay staging và production, chúng ta nên chạy Imaginary với Docker.
Cài đặt với docker, tiến hành pull image:
docker pull h2non/imaginary
Tiếp theo chúng ta chạy lệnh bên dưới: (Nếu bạn muốn cho phép xử lý ảnh từ remote URL thì có thể thêm -enable-url-source
).
docker run -p 9000:9000 -v <folder chứa ảnh của bạn>:/mnt/data h2non/imaginary -mount /mnt/data -enable-url-source
Giờ chúng ta thử resize 1 ảnh:
http://localhost:9000/resize?file=test.jpg&width=400&height=400
Ảnh test.jpg
phải là 1 ảnh trong folder ảnh bạn vừa mount ở trên. Ngoài ra bạn có thể resize một đường dẫn ảnh.
http://localhost:9000/resize?url=https://i.2kvn.com/img/vib-2024-98eef5e0-42cd-491d-9547-8dd3673ef843.png&width=500&height=500
Chúng tôi đang sử dụng helm charts để triển khai ứng dụng Kubernetes. Với Imaginary chúng tôi đang deploy service này sử dụng helm charts: https://github.com/sun-asterisk-research/helm-charts/tree/master/charts/imaginary
Config với Nginx
Viblo sử dụng nginx làm reverse proxy cho dịch vụ của mình. Chúng tôi muốn cải thiện dạng URL của hình ảnh để dễ đọc hơn. Và hạn chế quyền truy cập vào API chỉ cho phép sử dụng các loại xử lý mà chúng tôi đã xác định. Tuy nhiên, điều quan trọng nhất là chúng tôi có thể điều chỉnh dịch vụ của mình để tối ưu hiệu suất, bao gồm load-bancing và caching... Về phần cấu hình, dưới đây là một tùy chỉnh cơ bản để thực hiện điều này.
ở config này lấy ví dụ s3 url là https://myBucketName.s3.amazonaws.com/
. Và ở các ví dụ bên dưới service Imaginary có host là http://imaginary
server { listen 81; server_tokens off; root /var/www/images; proxy_intercept_errors on; error_page 400 = @error; #__resolver__# location /full/ { expires 1y; proxy_pass https://myBucketName.s3.amazonaws.com/; } location ~* "^/[0-9a-f\-]{36}\.gif$" { expires 1y; proxy_pass https://myBucketName.s3.amazonaws.com/; }
Chúng ta chỉ cần chuyển tiếp mọi yêu cầu đến cho Imaginary xử lý là đủ. Bạn cũng nên thêm tham số root để chỉ đường dẫn đến thư mục chứa các hình ảnh gốc, giúp nginx load được các hình ảnh này. Ngoài ra, để bảo mật thông tin và tránh tiết lộ chi tiết lỗi của backend, bạn có thể chuyển hướng các yêu cầu lỗi đến trang lỗi mặc định của nginx. Việc load-bancing cho service chúng tôi cũng sử dụng thêm ingress-nginx
trong Kubernetes.
Như có chia sẻ ở phần cơ chế hoạt động, nếu chúng ta muốn xem ảnh gốc thì phía nginx sẽ yêu cầu thẳng đến Object Storage mà không cần yêu cầu đến server ảnh nữa. Đó là tất cả đường dẫn ảnh có định dạng: https://i.2kvn.com/img/vib-2024-98eef5e0-42cd-491d-9547-8dd3673ef843.png
Mặt khác cần lưu ý là imaginary có sử dụng thư viện libvips
nên không resize được ảnh GIF. Vì thế nên ta có 2 lựa chọn
- Không resize: https://images.viblo.asia/2928a904-257f-422d-99be-9ecbc3adaddb.gif
- Resize nhưng output dạng khác (Không phải là ảnh động nữa): https://images.viblo.asia/avatar/2928a904-257f-422d-99be-9ecbc3adaddb.gif
Nếu bạn muốn thay đổi URL đơn giản hơn, ví dụ: https://images.viblo.asia/avatar/98eef5e0-42cd-491d-9547-8dd3673ef843.png chẳng hạn, thì thêm config như thế này:
server { # ... if ($uri ~* "/(.+\.(jpg|jpeg|png|gif)$)") { set $filename $1; } location ~ ^/avatar/(\d+)x(\d+)/ { expires 1y; proxy_pass http://imaginary/avatar?file=$filename; }
}
Caching
Luồng request và cache tài nguyên với Nginx.
Như có đề cập ở trên, lượng request tới server ảnh của Viblo là tương đối lớn, có thể lên đến vài nghìn, thậm chí là vài triệu request. Tất cả các yêu cầu xử lý (resize, crop,...) cũng tiêu tốn đáng kể nguồn tài nguyên của server. Caching là một giải pháp hữu ích để tối ưu hiệu suất và giảm tải cho máy chủ, bằng cách lưu trữ các phiên bản đã xử lý của tài nguyên, như các hình ảnh đã thay đổi kích thước, vào bộ nhớ tạm. Điều này cũng giúp giảm tải cho máy chủ bằng cách giảm số lượng yêu cầu cần được xử lý trực tiếp.
Đơn giản nhất thì bạn có thể dùng Cloudflare. Cloudflare có thể giúp bạn cache tất cả ảnh và tăng đáng kể tốc độ tải. Nếu không dùng cloudflare thì ta có thể dễ dàng xử lý với nginx.
server { # ... proxy_cache_path /var/cache/nginx keys_zone=image-cache:10m; proxy_cache image-cache; proxy_cache_key $uri; proxy_cache_lock on;
}
Trong đó:
-
proxy_cache_path
: Xác định đường dẫn tới thư mục lưu trữ cache và cấu hình các tùy chọn liên quan đến cache như levels (số lượng thư mục con để tạo). -
proxy_cache_key
: Xác định cách tạo khóa cache cho mỗi tài nguyên, bao gồm các thông tin như scheme (http hoặc https), request_method (GET hoặc POST), host và request_uri. -
proxy_cache
: Cho phép lưu trữ các tài nguyên được yêu cầu vào bộ nhớ cache (image-cache, static-content-cache, dynamic-page-cache,...) -
proxy_cache_lock
: Đảm bảo rằng chỉ có một yêu cầu đến máy chủ gốc để tải một tài nguyên vào cache trong cùng một thời điểm.
Webp
Một số trình duyệt hỗ trợ định dạng Webp.
WEBP là một định dạng hình ảnh được khuyến khích sử dụng trên web vì kích thước nhỏ giúp tải trang nhanh hơn. Nếu bạn sử dụng Cloudflare, hình ảnh của bạn sẽ tự động được chuyển đổi sang định dạng WEBP mỗi khi có thể. Tuy nhiên, nếu bạn không sử dụng Cloudflare, bạn vẫn có thể sử dụng Imaginary để chuyển đổi hình ảnh sang định dạng này. Với tất cả các API, bạn có thể thêm tham số &type=webp
để xuất ra hình ảnh với định dạng WEBP. Dưới đây là một số dòng cấu hình bổ sung cho nginx để thực hiện điều này:
server { if ($http_accept ~* webp) { set $serve_webp "&type=webp"; } proxy_pass http://imaginary/$uri$is_args$args$serve_webp&stripmeta=true; proxy_cache image-cache; proxy_cache_key $uri$http_accept; proxy_cache_lock on; proxy_intercept_errors on;
}
Vì có một số trình duyệt vẫn chưa hỗ trợ định dạng WEBP (như iOS Safari), vì vậy chúng ta cần kiểm tra xem khi nào cần xuất ra định dạng WEBP. Điều này có thể được thực hiện bằng cách kiểm tra trong header Accept
trong yêu cầu gửi lên để xem liệu có chứa "image/webp" hay không. Chỉ khi điều này xảy ra, chúng ta mới xuất ra định dạng WEBP. Đồng thời, chúng ta cũng cần điều chỉnh khóa cache proxy_cache_key
một chút bằng cách thêm $http_accept
vào để tạo ra các bản cache khác nhau cho các yêu cầu hỗ trợ và không hỗ trợ định dạng WEBP.
Triển khai môi trường production
- Domain: images.viblo.asia
- Server:
-
Nền tảng: Hiện tại các service của Viblo đang được vận hành bởi Kubernetes
-
Cấu hình:
- Resource Request: 0.2 virtual core CPU, 500MiB Memory
- Resource Limit: 1 virtual core CPU, 3GiB Memory
Để hiểu thêm về các đơn vị, bạn có thể đọc thêm tại: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
-
Cấu hình trên phù hợp với lượng traffic hiện tại của Viblo. Tuy nhiên, hệ thống Viblo của chúng tôi có cơ chế autoscale (scale up, scale out) nên việc tiêu thụ resources rất linh hoạt và tương đối tối ưu. Ở các bài tiếp theo, chúng tôi cũng sẽ chia sẻ về cách vận hành đó.
Demo
Demo resize ảnh trên Viblo.
Kết luận
Việc tự cấu hình và xây dựng máy chủ ảnh mang lại sự linh hoạt và kiểm soát đối với dịch vụ của bạn, đồng thời giúp tối ưu hóa hiệu suất và bảo mật. Tuy nhiên, điều này cũng đòi hỏi sự đầu tư thời gian và kiến thức kỹ thuật để triển khai và duy trì máy chủ một cách hiệu quả.
© Tác giả: Serverside Engineer Quy Nguyen