Dựng Môi Trường: AWS SAM và Hàm Lambda Đầu Tiên
Bài trước dừng ở kiến trúc trên giấy. Trước khi dựng từng mảnh của nó, ta cần một vòng lặp làm việc chạy được: viết code, đẩy lên AWS, gọi thử, rồi xóa đi mà không để lại rác. Bài này dựng đúng vòng lặp đó bằng AWS SAM, và kết thúc bằng một hàm Lambda thật chạy trên AWS rồi biến mất sạch.
Mục tiêu
Cài SAM CLI, hiểu cấu trúc một project SAM tối thiểu, deploy một hàm trả về JSON qua Function URL, gọi nó bằng curl, chạy thử ngay trên máy bằng Docker, rồi sam delete để dọn. Toàn bộ tài nguyên ở bài này nằm trong free tier, chi phí thực tế bằng không.
SAM là gì và vì sao dùng nó
AWS SAM (Serverless Application Model) là một phần mở rộng của CloudFormation dành riêng cho serverless. Bạn khai báo tài nguyên trong một file template.yaml, nhưng thay vì viết hàng trăm dòng CloudFormation thuần cho một hàm Lambda kèm role, log group và quyền, bạn dùng các kiểu tài nguyên rút gọn như AWS::Serverless::Function. Dòng Transform: AWS::Serverless-2016-10-31 ở đầu template báo cho CloudFormation biết cần "bung" các kiểu rút gọn đó ra thành tài nguyên đầy đủ trước khi tạo.
SAM CLI là công cụ dòng lệnh đi kèm: sam build đóng gói code, sam deploy đẩy lên, sam local invoke chạy thử hàm trong một container Docker giống môi trường Lambda thật, sam delete xóa cả stack. Ta sẽ dùng cả bốn ngay trong bài này.
Cài SAM CLI — và một bài học về phiên bản
Trên macOS, cách cài nhanh nhất là qua Homebrew:
brew install aws-sam-cli
Phiên bản là chuyện cần để ý. Lần đầu dựng project này, một bản SAM CLI cũ (1.119.0) báo lỗi ngay ở bước build:
Build Failed
Error: 'nodejs22.x' runtime is not supported
Lý do đơn giản: bản SAM CLI đó ra đời trước khi runtime nodejs22.x được thêm vào luồng build esbuild. Runtime trên AWS đổi nhanh hơn công cụ trên máy bạn, nên trước khi đổ lỗi cho template, hãy kiểm phiên bản công cụ. Nâng lên bản mới (1.161.0) là build chạy ngay:
$ sam --version
SAM CLI, version 1.161.0
Đây là một dạng lỗi sẽ gặp lại nhiều lần với serverless: thông báo lỗi nói về runtime, nhưng gốc rễ là công cụ chưa biết tới runtime đó. Bài 02 sẽ tra danh sách runtime hiện hành trực tiếp từ tài liệu để không đoán mò.
Khung project
Project tối thiểu cho URL shortener gồm bốn file. Đây là cấu trúc ta khởi đầu, và sẽ lớn dần qua các bài:
serverless-url-shortener-aws/
├── template.yaml # khai báo hạ tầng (SAM)
├── package.json # khai báo công cụ build (esbuild) + types
├── tsconfig.json # cấu hình TypeScript
└── src/
└── handlers/
└── hello.ts # code hàm Lambda
package.json chỉ cần các gói build và type, không cần dependency runtime nào ở bài này (Lambda runtime đã có sẵn AWS SDK):
{
"name": "serverless-url-shortener-aws",
"devDependencies": {
"@types/aws-lambda": "^8.10.145",
"@types/node": "^22.10.0",
"esbuild": "^0.24.0",
"typescript": "^5.7.2"
}
}
Hàm Lambda là một file TypeScript export một hàm handler. AWS gọi hàm này mỗi khi có sự kiện, truyền vào event (ở đây là một HTTP request từ Function URL) và trả về response:
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
export const handler = async (
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
const now = new Date().toISOString();
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
message: "Hello from a serverless URL shortener",
path: event.rawPath,
time: now,
}),
};
};
Phần khai báo hạ tầng nằm trong template.yaml. Khối Globals đặt mặc định cho mọi hàm (runtime, kiến trúc CPU, bộ nhớ, timeout), khỏi lặp lại ở từng hàm:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: nodejs22.x
Architectures:
- arm64
MemorySize: 128
Timeout: 5
Resources:
HelloFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2022
EntryPoints:
- hello.ts
Properties:
CodeUri: src/handlers
Handler: hello.handler
FunctionUrlConfig:
AuthType: NONE
Outputs:
HelloFunctionUrl:
Value: !GetAtt HelloFunctionUrl.FunctionUrl
Vài điểm đáng dừng lại. Architectures: [arm64] chọn CPU Graviton, rẻ hơn x86 và thường nhanh hơn cho code Node; bài 02 sẽ nói kỹ. Khối Metadata.BuildMethod: esbuild báo SAM dùng esbuild để gom TypeScript thành một file JavaScript khi build. Handler: hello.handler nghĩa là "trong file hello, gọi export tên handler". FunctionUrlConfig.AuthType: NONE tạo một endpoint HTTPS công khai gọi thẳng vào hàm, không cần API Gateway; nó là cách đơn giản nhất để gọi một hàm qua HTTP, đủ cho bài đầu (các bài sau ta chuyển sang API Gateway để có route, auth, CORS đàng hoàng).
Build
sam build đọc template, chạy esbuild gom code lại, và đặt kết quả vào thư mục .aws-sam/build:
$ sam build
Building codeuri: .../src/handlers runtime: nodejs22.x architecture: arm64 functions: HelloFunction
Running NodejsNpmEsbuildBuilder:EsbuildBundle
Build Succeeded
Built Artifacts : .aws-sam/build
Built Template : .aws-sam/build/template.yaml
Từ giờ mọi lệnh deploy hay invoke đều làm việc trên artifact đã build trong .aws-sam/build, không phải code gốc.
Deploy
sam deploy \
--stack-name url-shortener \
--resolve-s3 \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--region ap-southeast-1
Mỗi cờ ở đây làm một việc cụ thể. --stack-name đặt tên CloudFormation stack, là cái túi chứa mọi tài nguyên ta tạo (xóa stack là xóa sạch). --resolve-s3 bảo SAM tự tạo và dùng một S3 bucket được quản lý để chứa code đã đóng gói, vì CloudFormation lấy code từ S3 chứ không nhận trực tiếp. --capabilities CAPABILITY_IAM là bạn xác nhận cho phép stack tạo IAM role (Lambda cần một execution role để chạy). --no-confirm-changeset bỏ qua bước hỏi xác nhận để chạy không tương tác.
SAM tính ra một changeset (danh sách thay đổi), rồi áp dụng. Output cuối in ra giá trị trong Outputs:
CloudFormation outputs from deployed stack
-------------------------------------------------------------
Key HelloFunctionUrl
Value https://vmriafelvovzbhuzwnqmggyflu0innum.lambda-url.ap-southeast-1.on.aws/
Successfully created/updated stack - url-shortener in ap-southeast-1
Một lệnh, và đã có một hàm Lambda kèm IAM role, log group, và một endpoint HTTPS công khai chạy trên AWS.
máy của bạn AWS
┌──────────────┐ sam build ┌──────────────────┐
│ template.yaml │─────────────▶│ .aws-sam/build │
│ src/hello.ts │ (esbuild) │ (code đã gom) │
└──────────────┘ └────────┬─────────┘
│ sam deploy --resolve-s3
▼
┌──────────────┐ CloudFormation
│ S3 (artifact)│ đọc code rồi tạo:
└──────┬───────┘
▼
┌──────────────────────────────┐
│ Lambda HelloFunction │
│ + IAM execution role │
│ + Function URL (HTTPS công khai)│
│ + CloudWatch log group │
└──────────────────────────────┘
Gọi hàm
Function URL là một endpoint HTTPS thật, gọi bằng curl như mọi API khác:
$ curl https://vmriafelvovzbhuzwnqmggyflu0innum.lambda-url.ap-southeast-1.on.aws/
{"message":"Hello from a serverless URL shortener","path":"/","time":"2026-05-25T16:05:15.130Z"}
$ curl https://vmriafelvovzbhuzwnqmggyflu0innum.lambda-url.ap-southeast-1.on.aws/abc
{"message":"Hello from a serverless URL shortener","path":"/abc","time":"2026-05-25T16:05:15.304Z"}
Trường path đổi theo URL ta gọi vì handler đọc event.rawPath. Đây là chứng cứ rằng AWS đã đóng gói HTTP request thành event rồi truyền vào hàm.
Chạy thử ngay trên máy
Không phải lúc nào cũng muốn deploy lên AWS chỉ để thử một thay đổi nhỏ. sam local invoke chạy hàm trong một container Docker dùng đúng image runtime của Lambda, nên hành vi sát với thật:
$ sam local invoke HelloFunction --event <(echo '{"rawPath":"/local-test"}')
Using local image: public.ecr.aws/lambda/nodejs:22-rapid-arm64.
START RequestId: 4ddde017-185f-479f-84a7-bb891a7986b5 Version: $LATEST
END RequestId: 4ddde017-185f-479f-84a7-bb891a7986b5
REPORT RequestId: 4ddde017-185f-479f-84a7-bb891a7986b5 Init Duration: 0.04 ms Duration: 51.37 ms Billed Duration: 52 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"statusCode": 200, "headers": {"content-type": "application/json"}, "body": "{\"message\":\"Hello from a serverless URL shortener\",\"path\":\"/local-test\",\"time\":\"2026-05-25T16:05:41.803Z\"}"}
Dòng REPORT đáng để ý vì nó là cách Lambda báo cáo mỗi lần chạy, và ta sẽ gặp lại nó suốt series. Duration là thời gian code chạy, Billed Duration là thời gian bị tính tiền (làm tròn lên mili-giây), Max Memory Used cho biết hàm thực sự dùng bao nhiêu trong số bộ nhớ đã cấp. Init Duration là thời gian khởi tạo môi trường, phần liên quan tới cold start mà bài 02 và bài 15 sẽ mổ kỹ.
🧹 Dọn dẹp
Vì mọi tài nguyên nằm trong một CloudFormation stack, xóa hết chỉ là một lệnh:
$ sam delete --stack-name url-shortener --no-prompts --region ap-southeast-1
- Deleting S3 object with key 8164374de92f3fab02f3c529558b49a7
- Deleting Cloudformation stack url-shortener
Deleted successfully
Kiểm lại để chắc chắn stack đã biến mất:
$ aws cloudformation describe-stacks --stack-name url-shortener --region ap-southeast-1
An error occurred (ValidationError) ... Stack with id url-shortener does not exist
Lỗi "does not exist" ở đây là tin tốt: không còn tài nguyên nào, không còn gì phát sinh tiền. Thói quen xóa stack sau mỗi buổi thực hành là cách chắc chắn nhất để không bao giờ nhận hóa đơn bất ngờ.
Tổng kết
Ta đã có vòng lặp làm việc đầy đủ: viết code TypeScript, sam build để gom, sam deploy để đẩy lên thành một stack, gọi qua Function URL hoặc chạy local bằng sam local invoke, rồi sam delete để dọn sạch. Cũng rút ra một bài học sẽ còn dùng: khi runtime báo "not supported", nghi ngờ phiên bản công cụ trước.
Bài sau đi xuống một tầng: Lambda thực sự chạy code của bạn ra sao bên trong. Ta sẽ xem vòng đời môi trường thực thi (init, invoke, shutdown), vì sao có cold start, quan hệ giữa bộ nhớ và CPU, và vì sao chọn arm64. Hiểu phần này rồi thì những quyết định về hiệu năng và chi phí ở các bài sau mới có cơ sở.