Bài đăng này được viết bởi Uri Segev, Chuyên gia – Kiến trúc sư giải pháp về Serverless
Khi phát triển các ứng dụng mới hoặc hiện đại hóa các ứng dụng hiện tại, bạn có thể sẽ gặp phải những tình huống khó xử: Không biết nên sử dụng công nghệ điện toán nào? Một dịch vụ điện toán không máy chủ (serverless) như AWS Lambda hay là containers? Thông thường, serverless có thể là cách tiếp cận tốt hơn nhờ khả năng tự động mở rộng quy mô, được tích hợp sẵn tính sẵn sàng cao và mô hình chi phí bạn dùng bao nhiêu trả bấy nhiêu. Tuy nhiên, bạn có thể sẽ ngần ngại khi chọn serverless vì một số lý do như:
- Cảm thấy chi phí sẽ cao hơn hoặc khó khăn trong việc ước tính chi phí
- Cần thay đổi mô hình, yêu cầu phải học thêm để bổ sung kiến thức
- Những quan niệm sai lầm về khả năng và trường hợp sử dụng của Lambda
- Lo ngại rằng việc sử dụng Lambda sẽ dẫn đến tình trạng “lock-in” (Phụ thuộc hoàn toàn vào một nhà cung cấp dịch vụ)
- Các khoản đầu tư hiện có vào nền tảng và công cụ trên mô hình máy chủ truyền thống.
Bài đăng này đề xuất các phương pháp tốt nhất để phát triển các hàm Lambda có tính di động cho phép bạn dễ dàng chuyển mã nguồn của mình sang container trong tương lai. Bằng cách đó, bạn có thể tránh trường hợp “lock-in” và thử nghiệm các giải pháp không có máy chủ mà không sợ gặp rủi ro.
Mỗi phần của bài đăng này mô tả những điều bạn cần cân nhắc khi triển khai mã nguồn có tính di động và các bước cần thiết để di chuyển mã nguồn này từ Lambda sang container sau này.
Best practices cho các hàm Lambda có tính di động
Tách biệt phần business logic và Lambda handler
Về bản chất, các hàm Lambda được kích hoạt theo sự kiện. Khi một sự kiện cụ thể xảy ra, nó sẽ gọi hàm Lambda bằng cách gọi handler method của nó. Handler method nhận một đối tượng sự kiện chứa thông tin liên lý do gọi hàm. Khi quá trình thực thi hàm hoàn tất, nó sẽ trả về từ handler method. Bất cứ thứ gì được trả về từ handler method đều là giá trị trả về của hàm.
Để viết những đoạn mã có tính di động, bạn chỉ nên sử dụng handler method làm interface giữa Lambda runtime (đối tượng sự kiện) và logic nghiệp vụ. Khi sử dụng thuật ngữ kiến trúc Lục giác (Hexagonal architecture), handler method phải là một bộ chuyển đổi thực hiện các lệnh đến port, thứ đóng vai trò là một interface do logic nghiệp vụ cung cấp. Handler phải trích xuất tất cả thông tin cần thiết từ đối tượng sự kiện rồi gọi một phương thức riêng biệt đã cài đặt logic nghiệp vụ từ trước.
Khi phương thức đó trả về kết quả, trình xử lý sẽ cấu trúc lại kết quả đó theo định dạng mà handler method mong muốn và trả về kết quả đó. Chúng tôi cũng khuyên bạn nên chia mã xử lý và mã logic nghiệp vụ thành các tệp riêng biệt. Nếu sau này chuyển đổi sang container, bạn chỉ cần di chuyển các tệp mã nguồn logic nghiệp vụ của mình mà không cần thay đổi gì thêm.
Đoạn mã giả sau đây biểu thị cách Lambda handler trích xuất thông tin từ đối tượng sự kiện và gọi logic nghiệp vụ. Khi logic nghiệp vụ được thực hiện, trình xử lý sẽ đặt kết quả vào giá trị trả về của hàm:
| import business_logic # The Lambda handler extracts needed information from the event# object and invokes the business logichandler(event, context) { # Extract needed information from event object payload = event[‘payload’] # Invoke business logic result = do_some_logic(payload) # Construct result for API Gateway return { statusCode: 200, body: result }} |
Đoạn mã giả dưới đây cho thấy logic nghiệp vụ. Nó nằm trong một tệp riêng biệt và không biết rằng nó đang được gọi từ hàm Lambda. Đó là logic thuần túy.
| # This is the business logic. It knows nothing about who invokes it.do_some_logic(data) {result = “This is my result.” return result} |
Cách tiếp cận này cũng giúp việc chạy unit test trên logic nghiệp vụ trở nên dễ dàng hơn mà không cần tạo dựng các đối tượng sự kiện và gọi Lambda handler.
Nếu sau này bạn di chuyển sang container, bạn sẽ bao gồm các tệp logic nghiệp vụ trong container của mình với mã giao diện (interface code) mới sẽ được mô tả trong phần sau.
Tích hợp với nguồn của sự kiện
Một trong những lợi ích của Lambda function là khả năng tích hợp nguồn sự kiện. Ví dụ: nếu bạn tích hợp Lambda với Amazon Simple Queue Service (Amazon SQS), dịch vụ Lambda sẽ đảm nhiệm việc polling từ hàng đợi, gọi các hàm Lambda tương ứng và xóa tin nhắn khỏi hàng đợi khi hoàn tất. Thông qua khả năng tích hợp này, bạn sẽ cần viết ít mã nguyên mẫu (boilerplate code) hơn. Bạn chỉ cần tập trung vào việc triển khai logic nghiệp vụ chứ không cần tích hợp với nguồn sự kiện.
Đoạn mã giả sau đây cho thấy Lambda handler trông sẽ như thế nào khi tích hợp với nguồn sự kiện của SQS:
| import business_logic handler(event, context) { entries = [] # Iterate over all the messages in the event object for message in event[‘Records’] { # Call the business logic to process a single message success = handle_message(message) # Start building the response if Not success { entries.append({ ‘itemIdentifier’: message[‘messageId’] }) } } # Notify Lambda about failed items. if (let(entries) > 0) { return { ‘batchItemFailures’: entries } }} |
Như bạn có thể thấy trong đoạn mã trên, hàm Lambda hầu như không biết rằng nó đang được gọi bởi SQS. Không có lệnh gọi API SQS. Nó chỉ biết cấu trúc của đối tượng sự kiện của riêng SQS.
Khi chuyển sang container, trách nhiệm tích hợp sẽ chuyển từ dịch vụ Lambda sang bạn, người phát triển. Có rất nhiều nguồn sự kiện khác nhau trong AWS và mỗi nguồn sự kiện sẽ yêu cầu một cách tiếp cận khác nhau để lấy sự kiện và gọi logic nghiệp vụ. Ví dụ: nếu nguồn sự kiện là từ Amazon API Gateway thì ứng dụng của bạn sẽ cần tạo một máy chủ HTTP để lắng nghe trên cổng HTTP và chờ các yêu cầu đến để gọi logic nghiệp vụ.
Nếu nguồn sự kiện từ Amazon Kinesis Data Streams, ứng dụng của bạn sẽ cần chạy một poller để đọc các bản ghi từ các phân đoạn (shards), theo dõi các bản ghi đã qua xử lý, xử lý các trường hợp thay đổi số lượng phân đoạn trong luồng, thử lại khi gặp lỗi. Bất kể nguồn sự kiện là gì, nếu bạn làm theo các đề xuất ở phần trước, bạn sẽ không cần thay đổi bất kỳ điều gì trong mã nguồn logic nghiệp vụ của bạn.
Đoan mã giả sau đây cho thấy quá trình tích hợp với SQS sẽ diễn ra như thế nào khi sử dụng container. Lưu ý rằng bạn sẽ mất một số tính năng như tạo batch, lọc và auto scaling.
| import aws_sdkimport business_logic QUEUE_URL = os.environ[‘QUEUE_URL’]BATCH_SIZE = os.environ.get(‘BATCH_SIZE’, 1)sqs_client = aws_sdk.client(‘sqs’) main() { # Infinite loop to poll for messages from SQS while True { # Receive a batch of messages from the queue response = sqs_client.receive_message( QueueUrl = QUEUE_URL, MaxNumberOfMessages = BATCH_SIZE, WaitTimeSeconds = 20 ) # Loop over the messages in the batch entries = [] i = 1 for message in response.get(‘Messages’,[]) { # Process a single message success = handle_message(message) # Append the message handle to an array that is later # used to delete processed messages if success { entries.append( { ‘Id’: f’index{i}’, ‘ReceiptHandle’: message[‘receiptHandle’] } ) i += 1 } } # Delete all the processed messages if (len(entries) > 0) { sqs_client.delete_message_batch( QueueUrl = QUEUE_URL, Entries = entries ) } }} |
Một điểm khác cần xem xét ở đây là các Lambda destination. Nếu hàm của bạn được gọi theo cách bất đồng bộ (asynchronous) và bạn đã cấu hình đích đến cho hàm của mình, bạn sẽ cần đưa đích đó vào interface code. Nhờ đó, hàm đó sẽ phát hiện bất kỳ lỗi logic nghiệp vụ nào và dựa theo đó để gọi đúng đích đến.
Đóng gói các hàm thành container
Lambda hỗ trợ các chức năng đóng gói dưới dạng tệp .zip và container image. Để phát triển mã nguồn có tính di động, chúng tôi khuyên bạn nên sử dụng container image làm phương pháp đóng gói mặc định. Mặc dù bạn đóng gói hàm này dưới dạng container image nhưng bạn không thể chạy những hàm này trên các nền tảng như Amazon Elastic Container Service (Amazon ECS) hoặc Amazon Elastic Kubernetes Service (EKS). Tuy nhiên, khi đóng gói theo phương pháp này, việc di chuyển sang containers sau này sẽ dễ dàng hơn vì bạn đã sử dụng các công cụ tương tự và tạo một Dockerfile và chỉ cần một vài thay đổi nhỏ.
Một ví dụ về Dockerfile cho Lambda sẽ tương tự như sau:
| FROM public.ecr.aws/lambda/python:3.9COPY *.py requirements.txt ./RUN python3.9 -m pip install -r requirements.txt -t .CMD [“app.lambda_handler”] |
Nếu sau này chuyển từ Lambda sang container, bạn sẽ cần thay đổi Dockerfile để sử dụng base image khác và thay đổi dòng CMD để xác định cách khởi động ứng dụng. Đây là điều cần lưu ý bên cạnh những thay đổi về mã nguồn được mô tả trong phần trước.
Dockerfile tương ứng cho container sẽ trông như thế này:
| FROM python:3.9COPY *.py requirements.txt ./RUN python3.9 -m pip install -r requirements.txt -t .CMD [“python”, “./app.py”] |
Pipeline để triển khai cũng cần thay đổi khi chúng ta triển khai đến một mục tiêu, môi trường khác. Tuy nhiên, quá trình build các artifacts vẫn được giữ nguyên.
Một lệnh gọi duy nhất cho mỗi instance
Các hàm Lambda chạy trong môi trường runtime riêng biệt của chúng. Mỗi môi trường xử lý một yêu cầu tại một thời điểm, điều này rất phù hợp với Lambda. Tuy nhiên, nếu bạn di chuyển ứng dụng của mình thành container, bạn có thể sẽ gọi đến logic nghiệp vụ từ nhiều luồng (threads) trong một tiến trình tại một thời điểm.
Phần này thảo luận về các khía cạnh của việc chuyển đổi từ một lệnh gọi đơn sang nhiều lệnh gọi đồng thời trong cùng một tiến trình.
Các biến tĩnh (Static variables)
Biến tĩnh là những biến được khởi tạo một lần và sau đó được sử dụng lại qua nhiều lần gọi. Ví dụ về các biến như vậy là các kết nối đến cơ sở dữ liệu hoặc thông tin cấu hình.
Để tối ưu hóa hàm và đặc biệt là để giảm thời gian khởi động nguội (cold start) và thời lượng của các lệnh gọi hàm (warm function invocations), chúng tôi khuyên bạn nên khởi tạo tất cả các biến tĩnh bên ngoài trình xử lý hàm (function handler) và lưu trữ chúng trong các biến toàn cục để các lệnh gọi tiếp theo sẽ sử dụng lại chúng.
Chúng tôi khuyên bạn nên sử dụng hàm khởi tạo mà bạn viết như một phần của mô-đun logic nghiệp vụ và bạn gọi từ bên ngoài trình xử lý (function handler). Hàm này lưu thông tin trong các biến toàn cục mà mã logic nghiệp vụ (business logic code) tái sử dụng trong các lệnh gọi.
Đoạn mã giả sau đây biểu thị hàm Lambda:
| import business_logic # Call the initialization codeinitialize() handler(event, context) { … # Call the business logic …} |
Và business logic code sẽ giống như sau:
| # Global variables used to store static datavar config initialize() { config = read_Config()} do_some_logic(data) { # Do something with config object …} |
Điều tương tự cũng áp dụng cho container. Bạn thường sẽ khởi tạo các biến tĩnh khi tiến trình bắt đầu chứ không phải cho mọi yêu cầu. Khi chuyển sang container, tất cả những gì bạn cần làm là gọi hàm khởi tạo trước khi bắt đầu vòng của lặp ứng dụng chính.
| import business_logic # Call the initialization codeinitialize() main() { while True { … # Call the business logic … }} |
Chúng ta có thể thấy, gần như không có sự thay đổi nào trong business logic code.
Kết nối đến cơ sở dữ liệu
Vì các hàm Lambda không có sự chia sẻ giữa các môi trường runtime, không giống như các container, chúng không phụ thuộc vào connection pools khi kết nối đến cơ sở dữ liệu quan hệ. Vì lý do này, chúng tôi đã tạo Amazon RDS Proxy, hoạt động như một connection pool tập trung và được nhiều hàm sử dụng.
Để viết các hàm Lambda có tính di động, chúng tôi khuyên bạn nên sử dụng đối tượng connection pool với một kết nối duy nhất. Mã logic nghiệp vụ của bạn sẽ luôn yêu cầu kết nối từ pool khi gửi yêu cầu đến cơ sở dữ liệu. Bạn vẫn sẽ cần sử dụng RDS Proxy.
Nếu sau này bạn chuyển sang container, bạn có thể tăng số lượng kết nối trong pool lên mà không cần thay đổi gì thêm và ứng dụng sẽ mở rộng quy mô mà không làm quá tải cơ sở dữ liệu.
Hệ thống tập tin
Các hàm Lambda đi kèm với một thư mục /tmp có thể ghi với kích thước từ 512 MB đến 10 GB. Vì mỗi phiên bản hàm chạy trong môi trường runtime biệt lập nên các nhà phát triển thường sử dụng các tên tệp cố định để lưu trữ các tệp trong thư mục đó. Nếu bạn chạy cùng một mã logic nghiệp vụ trong một container ở nhiều luồng thì các luồng khác nhau sẽ ghi đè lên các tệp do người khác tạo.
Chúng tôi khuyên bạn nên sử dụng một tên tệp duy nhất trong mỗi lệnh gọi. Nối UUID hoặc một số ngẫu nhiên khác vào tên tệp. Xóa các tập tin sau khi bạn hoàn thành chúng để tránh hết dung lượng.
Nếu sau này bạn di chuyển mã của mình sang container thì bạn sẽ không cần phải làm như trên.
Ứng dụng Web có tính di động
Nếu bạn phát triển một ứng dụng web, có một cách khác để đạt được tính di động. Bạn có thể sử dụng dự án AWS Lambda Web Adapter để lưu trữ ứng dụng web bên trong hàm Lambda. Bằng cách này, bạn có thể phát triển một ứng dụng web sử dụng các framework quen thuộc (ví dụ: Express.js, Next.js, Flask, Spring Boot, Laravel hay bất kỳ framework nào sử dụng HTTP 1.1/1.0) và chạy nó trên Lambda. Nếu bạn đóng gói ứng dụng web của mình dưới dạng container, Docker image tương tự có thể chạy trên Lambda (sử dụng web adapter) và container.
Di chuyển ứng dụng từ container sang Lambda
Bài đăng này trình bày về cách phát triển các hàm Lambda có tính di động mà bạn có thể dễ dàng chuyển sang container sau này. Việc cân nhắc những đề xuất này có thể giúp phát triển những đoạn mã có tính di động nói chung, cho phép bạn chuyển từ container sang các hàm Lambda.
Một số điều cần cân nhắc:
- Tách logic nghiệp vụ khỏi interface code trong container. Interface code phải tương tác với các nguồn tạo sự kiện và gọi logic nghiệp vụ.
- Vì các hàm Lambda chỉ có thư mục có thể ghi /tmp, hãy tạo thư mục tương tự trong container của bạn (tuy nhiên bạn có thể ghi vào các đường dẫn khác).
Kết luận
Bài viết này đề xuất các phương pháp tối ưu nhất để phát triển các hàm Lambda cho phép bạn tận dụng lợi ích của kiến trúc không máy chủ (serverless) mà không gặp rủi ro từ việc lock-in với một nhà cung cấp dịch vụ duy nhất.
Bằng cách thực hiện theo các phương pháp tốt nhất này để tách logic kinh doanh (business logic )khỏi Lambda handler, đóng gói các hàm dưới dạng container, xử lý lệnh gọi đơn lẻ của Lambda cho mỗi phiên bản, v.v., bạn có thể phát triển các hàm Lambda có tính di động. Từ đó, bạn sẽ có thể chuyển mã nguồn của mình từ Lambda sang container mà không tốn nhiều công sức nếu sau này.