So sánh các phương pháp thiết kế để xây dựng serverless microservices

by James Beswick | on 04 MAR 2024 | in AWS Lambda, Serverless | Permalink |  Share

Bài viết này được viết bởi Luca Mezzalira, Principal SA, and Matt Diamond, Principal, SA.

Thiết kế một khối công việc với AWS Lambda đặt ra những câu hỏi cho các nhà phát triển do tính mô-đun có thể được thể hiện ở cấp độ mã hoặc cấp độ cơ sở hạ tầng. Sử dụng serverless để chạy mã yêu cầu kế hoạch bổ sung để trích xuất logic kinh doanh từ các thành phần chức năng cơ bản. Sự tách biệt cố ý này đảm bảo tính mô-đun mạnh mẽ, mở đường cho kiến trúc tiến hóa.

Bài viết này tập trung vào khối công việc đồng bộ, nhưng cách xem xét tương tự cũng có thể áp dụng trong các loại khối công việc khác. Sau khi xác định ngữ cảnh ràng buộc của API của bạn và đồng ý với các hợp đồng API với người tiêu dùng, đến lúc cấu trúc kiến trúc của ngữ cảnh ràng buộc của bạn và cơ sở hạ tầng liên quan.

Hai cách phổ biến nhất để cấu trúc một API bằng cách sử dụng các hàm Lambda là single responsibility và Lambda-lith. Tuy nhiên, bài đăng trên blog này khám phá một phương pháp thay thế cho những cách tiếp cận này, có thể cung cấp sự kết hợp tốt nhất của cả hai.

Single responsibility Lambda functions

Các Single responsibility Lambda functions được thiết kế để chạy một nhiệm vụ cụ thể hoặc xử lý một hoạt động event-triggered cụ thể trong một kiến trúc serverless:

Phương pháp này cung cấp một sự tách biệt mạnh mẽ giữa business logic và capabilities. Bạn có thể kiểm tra các khả năng cụ thể một cách độc lập, triển khai một hàm Lambda một cách độc lập, giảm bớt diện tích để giới thiệu lỗi và kích hoạt việc gỡ lỗi dễ dàng hơn cho các vấn đề trong Amazon CloudWatch.

Ngoài ra, các hàm đơn nhiệm vụ cho phép phân bổ tài nguyên hiệu quả vì Lambda tự động mở rộng dựa trên nhu cầu, tối ưu hóa tiêu thụ tài nguyên và giảm thiểu chi phí. Điều này có nghĩa là bạn có thể điều chỉnh kích thước bộ nhớ, kiến ​​trúc và bất kỳ cấu hình khác nào có sẵn cho mỗi hàm. Hơn nữa, việc yêu cầu cập nhật thực thi hàm đồng thời thông qua một phiếu hỗ trợ trở nên dễ dàng hơn vì bạn không tổng hợp lưu lượng vào một hàm Lambda duy nhất xử lý mọi yêu cầu nhưng bạn có thể yêu cầu tăng cụ thể dựa trên lưu lượng của một nhiệm vụ cụ thể.

Một ưu điểm khác là thời gian thực thi nhanh chóng. Xem xét logic kinh doanh cho một single-purpose Lambda function  được thiết kế cho một nhiệm vụ duy nhất, bạn có thể tối ưu hóa kích thước của một hàm một cách dễ dàng hơn, mà không cần các thư viện bổ sung được yêu cầu trong các phương pháp khác. Điều này giúp giảm thời gian khởi động lạnh do kích thước bản gói nhỏ hơn.

Mặc dù có những lợi ích này, một số vấn đề tồn tại khi chỉ dựa vào các single-purpose Lambda functions. Mặc dù thời gian khởi động lạnh được giảm thiểu, bạn có thể gặp phải một số lượng lớn thời gian khởi đầu lạnh, đặc biệt là đối với các hàm với các lời gọi không đều hoặc ít xảy ra. Ví dụ, một hàm thực hiện việc xóa người dùng trong bảng Amazon DynamoDB có thể không được kích hoạt nhiều lần như một hàm đọc dữ liệu người dùng. Hơn nữa, phụ thuộc mạnh mẽ vào các hàm Lambda đơn nhiệm vụ có thể dẫn đến sự phức tạp của hệ thống tăng lên, đặc biệt là khi số lượng các hàm tăng lên.

Một sự tách biệt tốt giúp duy trì cơ sở mã của bạn, nhưng với chi phí của sự thiếu kết nối. Trong các hàm có nhiệm vụ tương tự, như các hoạt động ghi của một API (POST, PUT, DELETE), bạn có thể sao chép mã và hành vi quá nhiều hàm. Hơn nữa, việc cập nhật các thư viện chung được chia sẻ qua Lambda Layers, hoặc các hệ thống quản lý phụ thuộc khác, yêu cầu nhiều thay đổi trên mỗi hàm thay vì một thay đổi nguyên tử trên một tập tin duy nhất. Điều này cũng đúng cho bất kỳ thay đổi nào trên nhiều hàm, ví dụ, cập nhật phiên bản runtime.

Lambda-lith: Sử dụng một hàm Lambda duy nhất

Khi nhiều khối công việc sử dụng các hàm Lambda đơn nhiệm vụ, các nhà phát triển sẽ đối mặt với sự gia tăng của các hàm Lambda trên tài khoản AWS. Một trong những thách thức chính mà các nhà phát triển đối diện là cập nhật các phụ thuộc chung hoặc cấu hình hàm. Trừ khi có một chiến lược quản trị rõ ràng được triển khai để giải quyết vấn đề này (như sử dụng Dependabot để bắt buộc cập nhật các phụ thuộc, hoặc các tham số có thể tham số hóa được lấy ra vào thời gian cung cấp), các nhà phát triển có thể chọn một chiến lược khác.

Kết quả là, nhiều nhóm phát triển di chuyển theo hướng ngược lại, tổng hợp tất cả mã liên quan đến một API vào cùng một hàm Lambda.

Phương pháp này thường được gọi là Lambda-lith, bởi vì nó thu thập tất cả các động từ HTTP tạo nên một API và đôi khi nhiều API trong cùng một hàm.

Điều này cho phép bạn có một độ liên kết mã và đặt chỗ cao hơn giữa các phần khác nhau của ứng dụng. Tính mô-đun trong trường hợp này được thể hiện ở cấp độ mã, nơi các mẫu như single responsibility, dependency injection, and façade được áp dụng để cấu trúc mã của bạn. Kỷ luật và các quy tắc tốt nhất về mã được áp dụng bởi các nhóm phát triển là rất quan trọng để duy trì các cơ sở mã lớn.

Tuy nhiên, khi xem xét số lượng hàm Lambda giảm đi, việc cập nhật cấu hình hoặc triển khai một tiêu chuẩn mới trên nhiều API có thể được thực hiện dễ dàng hơn so với phương pháp single responsibility.

Hơn nữa, vì mỗi yêu cầu gọi cùng một hàm Lambda cho mỗi động từ HTTP, nên có khả năng rằng các phần ít được sử dụng của mã của bạn sẽ có thời gian phản hồi tốt hơn vì một môi trường thực thi có thể sẵn có để thực hiện yêu cầu.

Một yếu tố khác cần xem xét là kích thước của hàm. Điều này tăng lên khi đặt các động từ trong cùng một hàm với tất cả các phụ thuộc và logic kinh doanh của một API. Điều này có thể ảnh hưởng đến việc khởi động lạnh của các hàm Lambda trong các khối công việc có độ biến động cao. Khách hàng nên đánh giá các lợi ích của phương pháp này, đặc biệt là khi các ứng dụng có SLA hạn chế, sẽ bị ảnh hưởng bởi các khởi đầu lạnh. Các nhà phát triển có thể giảm thiểu vấn đề này bằng cách chú ý đến các phụ thuộc được sử dụng và triển khai các kỹ thuật như tree-shaking, minification và loại bỏ mã chết, trong trường hợp ngôn ngữ lập trình cho phép.

Phương pháp thô này sẽ không cho phép bạn điều chỉnh cấu hình hàm của mình một cách riêng lẻ. Nhưng bạn phải tìm một cấu hình phù hợp với tất cả các khả năng mã với một kích thước bộ nhớ có thể cao hơn và quyền hạn bảo mật linh hoạt hơn có thể xung đột với các yêu cầu được xác định bởi nhóm bảo mật.

Read and write functions

Hai phương pháp này đều có những điểm đối lập, nhưng có một lựa chọn thứ ba có thể kết hợp các lợi ích của chúng.

Thường, lưu lượng API nghiêng về đọc nhiều hơn hoặc viết nhiều hơn và điều đó buộc các nhà phát triển phải tối ưu hóa mã và cấu hình hơn về một phía so với phía còn lại.

Ví dụ, hãy xem xét việc xây dựng một API người dùng cho phép người tiêu dùng tạo, cập nhật và xóa một người dùng nhưng cũng để tìm kiếm một người dùng hoặc một danh sách người dùng. Trong tình huống này, bạn có thể thay đổi một người dùng một lần mà không có thao tác hàng loạt có sẵn, nhưng bạn có thể lấy một hoặc nhiều người dùng trong mỗi yêu cầu API. Chia thiết kế của API thành các hoạt động đọc và viết dẫn đến kiến trúc sau:

Sự liên kết của mã cho các hoạt động ghi (Create, Update và Delete) mang lại nhiều lợi ích. Ví dụ, bạn có thể cần xác thực cơ thể yêu cầu, đảm bảo nó chứa tất cả các tham số bắt buộc. Nếu khối công việc nặng về việc ghi, các hoạt động ít được sử dụng hơn (ví dụ, Delete) sẽ được hưởng lợi từ các môi trường thực thi ấm. Việc đặt mã cùng một chỗ cho phép tái sử dụng mã trên các hoạt động tương tự, giảm bớt gánh nặng nhận thức để cấu trúc các dự án của bạn với các thư viện chia sẻ hoặc lớp Lambda, ví dụ.

Khi xem xét phía hoạt động đọc, bạn có thể giảm mã được gói với hàm này, có một thời gian khởi đầu lạnh nhanh hơn và tối ưu hóa hiệu suất so với một hoạt động ghi. Bạn cũng có thể lưu trữ kết quả truy vấn một phần hoặc toàn bộ trong bộ nhớ của một môi trường thực thi để cải thiện thời gian thực thi của một hàm Lambda.

Phương pháp này giúp bạn tiến xa hơn với tính chất tiến hóa của nó. Hãy tưởng tượng nếu nền tảng này trở nên phổ biến hơn nhiều. Bây giờ, bạn phải tối ưu hóa API thậm chí còn hơn bằng cách cải thiện việc đọc và thêm một cache aside pattern với ElastiCache and Redis. Hơn nữa, bạn đã quyết định tối ưu hóa các truy vấn đọc bằng một cơ sở dữ liệu thứ hai được tối ưu hóa cho khả năng đọc khi bộ nhớ cache không được sử dụng.

Trên phía ghi, bạn đã đồng ý với người tiêu dùng API rằng việc nhận và xác nhận việc tạo hoặc xóa người dùng là đủ, xem xét họ hoàn toàn chấp nhận tính chất nhất quán cuối cùng của hệ thống phân tán.

Bây giờ, bạn có thể cải thiện thời gian phản hồi của các hoạt động ghi bằng cách thêm một hàng đợi SQS trước hàm Lambda. Bạn có thể cập nhật cơ sở dữ liệu ghi theo các nhóm để giảm số lần gọi cần thiết để xử lý các hoạt động ghi, thay vì xử lý từng yêu cầu một.

Command query responsibility segregation (CQRS) là một mẫu được thiết lập tốt phân tách sự biến đổi dữ liệu, hoặc phần lệnh của hệ thống, khỏi phần truy vấn. Bạn có thể sử dụng mẫu CQRS để phân tách các cập nhật và truy vấn nếu chúng có yêu cầu khác nhau về lưu lượng, độ trễ hoặc tính nhất quán.

Mặc dù không bắt buộc phải bắt đầu với một mẫu CQRS đầy đủ, bạn có thể tiến triển từ cơ sở hạ tầng được chọn một cách dễ dàng hơn trong việc triển khai ban đầu của đọc và ghi, mà không cần phải refactor toàn bộ API của bạn.

So sánh ba phương pháp

Dưới đây là một so sánh của ba phương pháp:

Single responsibilityLambda-lithRead and write
BenefitsTách biệt mạnh mẽ giữa các vấn đềCấu hình cụ thểGỡ lỗi tốt hơnThời gian thực thi nhanh chóngÍt lời gọi khởi đầu lạnh hơnSự liên kết mã cao hơnBảo trì đơn giản hơnLiên kết mã khi cần thiếtKiến trúc tiến hóaTối ưu hóa các hoạt động đọc và ghi
IssuesSao chép mãBảo trì phức tạpSố lượng lời gọi khởi đầu lạnh cao hơnCấu hình thôThời gian khởi đầu lạnh cao hơnSử dụng CQRS với hai mô hình dữ liệuCQRS thêm tính nhất quán cuối cùng vào hệ thống của bạn

Kết luận

Các nhà phát triển thường chuyển từ các single responsibility functions sang Lambda-lith khi kiến ​​trúc của họ tiến triển, nhưng cả hai phương pháp đều có các sự đánh đổi tương đối. Bài viết này cho thấy cách có thể có được lợi ích tốt nhất từ cả hai phương pháp bằng cách chia khối công việc của bạn thành các hoạt động đọc và ghi.

Tất cả ba phương pháp đều khả thi để thiết kế các serverless APIs, và hiểu rõ điều bạn đang tối ưu hóa là chìa khóa để đưa ra quyết định tốt nhất. Hãy nhớ rằng, hiểu ngữ cảnh và yêu cầu kinh doanh của bạn để thể hiện trong ứng dụng của bạn sẽ dẫn bạn đến các sự đánh đổi chấp nhận được để xác định bên trong một khối công việc cụ thể. Hãy mở lòng và tìm ra giải pháp giải quyết vấn đề và cân bằng giữa bảo mật, trải nghiệm phát triển, chi phí và khả năng bảo trì.

Để biết thêm tài nguyên học tập về serverless, hãy truy cập Serverless Land.

TAGS: contributed, serverless