+5

Serverless Series (Golang) - Bài 4 - Integrate AWS Lambda with S3 for file storage and hosting web app

Giới thiệu

Chào các bạn tới với series về Serverless, ở bài trước chúng ta đã nói về cách sử AWS Lambda với DynamoDB để lưu dữ liệu, nhưng nếu ta muốn lưu trữ file thì ta không thể dùng DynamoDB được. Ở bài này chúng ta sẽ tìm hiểu về thành phần thứ 4 để xây dựng mô hình Serverless trên môi trường AWS Cloud, là S3.

Ở bài này chúng ta sẽ xây dựng hệ thống như minh họa sau đây.

Kết thúc bài trước, chúng ta đã xây được phần API Gateway + Lambda + DynamoDB.

Trong bài này thì mình sẽ dùng Terraform để tạo ra hệ thống ở trên, nên ta không cần phải tạo từ đầu, nếu các bạn muốn biết cách tạo bằng tay theo từng bước thì các bạn đọc ở bài trước. Bước tiếp theo là ta sẽ gắn thêm hai S3 bucket vào nữa, một bucket để ta lưu trữ hình ảnh, ta sẽ viết code cho Lambda function để nó có thể save file được upload từ website vào S3. Và một bucket để ta hosting một trang web dạng single page application, trang web này nó sẽ gọi API tới API Gateway của ta và lấy dữ liệu từ DynamoDB ra để hiển thị.

S3

Amazon S3 (Amazon Simple Storage Service) là một dịch vụ của AWS cho ta phép ta lưu trữ file theo dạng object storage. Thay vì phải lưu dữ liệu ở dưới máy chủ của ta, rồi ta phải làm hệ thống backup cho nó, nếu số lượng lưu trữ lớn thì ta phải tìm phương pháp backup phù hợp nữa, thì ta xài S3 cho tiện. Tỉ lệ mất dữ liệu khi ta lưu trên S3 khá nhỏ, data durability của nó là 99.999999999% / year, nghĩa là nếu bạn lưu trữ 1 tỷ file thì 1 năm bạn có thể chỉ phải mất 1 file.

Ngoài ra S3 còn cung cấp performance cao, và dữ liệu lưu trữ lớn, v...v...

image.png

Ta chỉ nói sơ qua về S3 là gì thôi, tiếp theo ta sẽ bắt tay vào xây dựng hệ thống của ta và code Lambda function. Đầu tiên là ta sẽ dùng Terraform để dựng lên hệ thống ở bài trước.

Provisioning previous system

Các bạn tải source code ở git repo này https://github.com/hoalongnatsu/serverless-series.git, di chuyển tới folder bai-4/terraform-start, mở file policies/lambda_policy.json ra. Cập nhật lại chỗ resource arn:aws:dynamodb:us-west-2:<ACCOUNT_ID>:table/books với ACCOUNT_ID là AWS account id của bạn.

Xong sau đó, bạn chạy câu lệnh:

terraform init
terraform apply -auto-approve

Xong khi tạo xong, bấm vào AWS Lambda và API Gateway, bạn sẽ thấy các function như sau.

image.png

API Gateway.

Bấm vào books-api và bấm qua mục Stages, ta sẽ thấy URL của API ngay chỗ Invoke URL.

DynamoDB và item trong nó.

image.png

image.png

Giờ ta sẽ gửi request tới API get list books để xem hệ thống ta có chạy không.

$ curl https://e7jgw0lk1g.execute-api.us-west-2.amazonaws.com/staging/books ; echo
[{"id":"1","name":"Go in Action","author":"William Kennedy with Brian Ketelsen and Erik St. Martin Foreword by Steve Francia"}]

Oke, vậy là phần API Gateway + Lambda + DynamoDB của ta đã được triển khai thành công, các bạn đọc bài trước để xem về những thành phần này nhé. Tiếp theo, ta sẽ tạo S3 bucket và dùng nó để hosting một trang web Single Page Application (SPA), và trang web này sẽ gọi API lên hệ thống của chúng ta để lấy dữ liệu về hiển thị.

S3 hosting static website

Trang SPA của ta sẽ như sau.

image.png

Đầu tiên ta sẽ tạo S3 bucket, truy cập vào AWS Web Console, kiếm S3 ở ô tìm kiếm, sau đó bấm vào nút Create bucket, bạn sẽ thấy UI như sau.

image.png

Ở chỗ bucket name thì bạn điền gì cũng được, sau đó kéo xuống phần Block Public Access settings for this bucket, uncheck mục Block all public access, các mục còn lại ta để mặc định, sau đó bạn bấm tạo bucket.

image.png

Sau khi tạo xong, ta sẽ thấy bucket của ta, ta bấm vào nó để vào cấu hình bên trong, ta sẽ cấu hình để nó có thể hosting một static web.

image.png

Bấm qua mục properties, kéo xuống cuối cùng ta sẽ thấy mục Static website hosting

image.png

image.png

Bấm vào nút edit, ta sẽ thấy trang như bên dưới, chuyển Static website hosting sang chế độ Enable.

image.png

Mục Hosting type ta chọn Host a static website, bạn điền vào ô index document là index.html, và ô ở dưới điền vào error.html, sau đó ta bấm lưu. Di chuyển tới mục ban nãy, bạn sẽ thấy URL static web của ta, URL sẽ có định dạng như sau http://<bucket-name>.s3-website-<region>.amazonaws.com.

image.png

Bucket của ta sẽ có url như sau http://serverless-series-spa.s3-website-us-west-2.amazonaws.com.

Oke, ta đã chuẩn bị xong chỗ để hosting SPA web, giờ ta sẽ upload SPA web của ta lên bucket ở trên là được. Trang SPA thì được code bằng React, code của trang SPA nằm ở repo github ở trên, bên trong folder bai-4/front-end. Nếu các bạn có hứng thú với FE thì code này được code từ react-starter-kit sau đây https://github.com/hoalongnatsu/react-starter-kit.git. Ở thư mục front-end, ta mở file .env-cmdrc lên, update nó lại như sau.

{
  "dev": {
    "PORT": "3000",
    "REACT_APP_STAGE": "dev",
    "REACT_APP_API_URL": "http://localhost:3001"
  },
  "staging": {
    "PORT": "3000",
    "REACT_APP_STAGE": "staging",
    "REACT_APP_API_URL": "https://e7jgw0lk1g.execute-api.us-west-2.amazonaws.com/staging"
  }
}

Ta sửa ở chỗ staging.REACT_APP_API_URL bằng đường dẫn mà ta lấy ở phần Invoke URL của API Gateway ở trên. Sau đó chạy:

yarn build:staging

Sau khi build xong, bạn sẽ thấy có thư mục build được tạo ra, ta sẽ upload folder này lên S3 serverless-series-spa bucket.

aws s3 cp build s3://serverless-series-spa --recursive

Giờ ta truy cập vào trang web của ta với url bucket ở trên.

image.png

Ta sẽ thấy nó báo lỗi là 403, vì ta chưa cập nhật bucket policy để cho phép bất kì ai cũng có thể truy cập được bucket này hết. Ta cập nhật bucket policy cho serverless-series-spa bucket. Mở mục permissions, ở phần bucket policy, bấm edit.

image.png

Ta dán đoạn json sau đây vào và bấm lưu.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::serverless-series-spa/*"
        }
    ]
}

image.png

Ok, giờ ta truy cập vào trang web thì sẽ thấy trang web của ta có thể truy cập được rồi.

image.png

Nhưng ta đợi một lúc vẫn không thấy trang web hiển thị gì cả, theo như lúc ta gọi API ở trên, thì sẽ có một kết quả được trả về chứ.

$ curl https://e7jgw0lk1g.execute-api.us-west-2.amazonaws.com/staging/books ; echo
[{"id":"1","name":"Go in Action","author":"William Kennedy with Brian Ketelsen and Erik St. Martin Foreword by Steve Francia"}]

Vậy UI sẽ hiển thị một book item chứ sao lại không thấy gì hết? Để biết bị gì thì bạn mở phần web develop lên bằng cách bấm chuột phải => Inspect. Mở qua mục console. Ta sẽ thấy lỗi CORS .

image.png

Mặc định thì web sẽ block tất cả request khi ta gọi từ domain này tới domain khác. Nên vì bucket url và API Gateway url của ta khác nhau, nên nó sẽ bị block, để fix lỗi này, ta sẽ cập nhật lại lambda function list như sau, di chuyển vào folder bai-4/code, mở file list/main.go lên (code của từng function mình đã giải thích ở bài trước nên mình sẽ không nói lại nữa), ở phần return response ở cuối function list, ta thêm vào "Access-Control-Allow-Origin": "*".

...

type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
}

func list(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	...

	res, _ := json.Marshal(books)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type":                "application/json",
			"Access-Control-Allow-Origin": "*", // add here
		},
		Body: string(res),
	}, nil
}

func main() {
	lambda.Start(list)
}

Oke, giờ ta sẽ init package và upload function lên AWS Lambda lại.

go get
sh build.sh
aws lambda update-function-code --function-name books_list --zip-file fileb://list.zip --region us-west-2

Sau khi upload xong, ta tải lại trang và sẽ thấy trang SPA của ta đã gọi API thành công.

image.png

Nhưng ta sẽ không thấy nó có hình ảnh, vì ở bài trước Book struct của ta không có trường image, ở file list/main.go ta thêm trường image vào Book struct.

...
type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
	Image  string `json:"image"`
}
...

Build code và upload lại function.

sh build.sh
aws lambda update-function-code --function-name books_list --zip-file fileb://list.zip --region us-west-2

Tải lại trang và ta sẽ thấy image đã được hiển thị.

Oke, vậy là ta đã xong phần SPA, tiếp theo ta sẽ sang phần upload image lên S3.

S3 store file

Ta tạo một bucket khác để lưu trữ hình ảnh của book, tạo một S3 bucket mới tên là serverless-series-upload, nhớ uncheck chỗ Block all public access ở mục Block Public Access settings for this bucket khi tạo nhé, xong các bạn cập nhật lại phần bucket policy của nó giống với bucket ở trên nha.

image.png

Vì bucket mà ta dùng để lưu hình ảnh sẽ có url khác với bucket ta hosting SPA, nên khi trang SPA của ta request hình tới serverless-series-upload bucket thì nó sẽ bị lỗi CORS. Nên ta cần enable CORS cho serverless-series-upload bucket. Ở phần permissions, các bạn kéo xuống dưới mục Cross-origin resource sharing (CORS), bấm Edit và thêm đoạn json này vô và bấm lưu.

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT",
            "POST",
            "DELETE"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": []
    }
]

image.png

Lambda upload file to S3

Sau khi chuẩn bị xong bucket để lưu file, bây giờ ta sẽ viết code lại chỗ lambda book create, thêm vào cho nó phần upload image lên trên S3 nữa. Ở trang SPA của ta, bạn nhấn nút Create new book để nó qua trang create.

image.png

image.png

Ở folder bai-4/code, cập nhật lại file create/main.go như sau:

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/grokify/go-awslambda"
)

type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
	Image  string `json:"iamge"`
}

func Upload(request events.APIGatewayProxyRequest, cfg aws.Config) (image string, err error) {
	client := s3.NewFromConfig(cfg)
	r, err := awslambda.NewReaderMultipart(request)
	if err != nil {
		return
	}

	part, err := r.NextPart()
	if err != nil {
		return
	}

	content, err := ioutil.ReadAll(part)
	if err != nil {
		return
	}

	bucket := "test-bucket-kala"
	filename := part.FileName()

	data := &s3.PutObjectInput{
		Bucket: &bucket,
		Key:    &filename,
		Body:   bytes.NewReader(content),
	}

	_, err = client.PutObject(context.TODO(), data)
	if err != nil {
		return
	}

	image = fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, "us-west-2", filename)

	return
}

func create(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Headers: map[string]string{
				"Content-Type":                "application/json",
				"Access-Control-Allow-Origin": "*",
			},
			Body: "Error while retrieving AWS credentials",
		}, nil
	}

	image, err := Upload(req, cfg)
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Headers: map[string]string{
				"Content-Type":                "application/json",
				"Access-Control-Allow-Origin": "*",
			},
			Body: err.Error(),
		}, nil
	}

	r, _ := awslambda.NewReaderMultipart(req)
	form, _ := r.ReadForm(1024)
	svc := dynamodb.NewFromConfig(cfg)
	data, err := svc.PutItem(context.TODO(), &dynamodb.PutItemInput{
		TableName: aws.String("books"),
		Item: map[string]types.AttributeValue{
			"id":     &types.AttributeValueMemberS{Value: form.Value["id"][0]},
			"name":   &types.AttributeValueMemberS{Value: form.Value["name"][0]},
			"author": &types.AttributeValueMemberS{Value: form.Value["author"][0]},
			"image":  &types.AttributeValueMemberS{Value: image},
		},
		ReturnValues: types.ReturnValueAllOld,
	})
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Headers: map[string]string{
				"Content-Type":                "application/json",
				"Access-Control-Allow-Origin": "*",
			},
			Body: err.Error(),
		}, nil
	}

	res, _ := json.Marshal(data)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type":                "application/json",
			"Access-Control-Allow-Origin": "*",
		},
		Body: string(res),
	}, nil
}

func main() {
	lambda.Start(create)
}

Ta thêm vào hàm Upload, đây là function để Lambda thực hiện upload file lên trên S3.

func Upload(request events.APIGatewayProxyRequest, cfg aws.Config) (image string, err error) {
	client := s3.NewFromConfig(cfg)
	r, err := awslambda.NewReaderMultipart(request)
	if err != nil {
		return
	}

	part, err := r.NextPart()
	if err != nil {
		return
	}

	content, err := ioutil.ReadAll(part)
	if err != nil {
		return
	}

	bucket := "serverless-series-upload"
	filename := part.FileName()

	data := &s3.PutObjectInput{
		Bucket: &bucket,
		Key:    &filename,
		Body:   bytes.NewReader(content),
	}

	_, err = client.PutObject(context.TODO(), data)
	if err != nil {
		return
	}

	image = fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, "us-west-2", filename)

	return
}

Vì ta cần upload hình từ trang web, nên Content-Type của request sẽ là multipart/form-data, nên ta sẽ dùng hàm NewReaderMultipart để lấy thông tin form-data từ request ra, bằng hàm.

r, err := awslambda.NewReaderMultipart(request)

Sau đó, để đọc nội dung file, ta dùng hàm r.NextPart() và ioutil.ReadAll(part).

part, err := r.NextPart()
if err != nil {
    return
}

content, err := ioutil.ReadAll(part)
if err != nil {
    return
}

Đoạn code tiếp theo ta dùng để upload file lên S3.

bucket := "serverless-series-upload"
filename := part.FileName()

data := &s3.PutObjectInput{
    Bucket: &bucket,
    Key:    &filename,
    Body:   bytes.NewReader(content),
}

_, err = client.PutObject(context.TODO(), data)
if err != nil {
    return
}

image = fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, "us-west-2", filename)

Vì chỗ Body của PutObjectInput struct nó đòi là phải là io.Reader, nên ta dùng hàm bytes.NewReader() để chuyển biến content thành dạng io.Reader, sau đó ta return url của file ta vừa upload về.

Chỗ thay đổi thứ hai so với bài trước là phần ta lấy dữ liệu book.

r, _ := awslambda.NewReaderMultipart(req)
form, _ := r.ReadForm(1024)
svc := dynamodb.NewFromConfig(cfg)
data, err := svc.PutItem(context.TODO(), &dynamodb.PutItemInput{
    TableName: aws.String("books"),
    Item: map[string]types.AttributeValue{
        "id":     &types.AttributeValueMemberS{Value: form.Value["id"][0]},
        "name":   &types.AttributeValueMemberS{Value: form.Value["name"][0]},
        "author": &types.AttributeValueMemberS{Value: form.Value["author"][0]},
        "image":  &types.AttributeValueMemberS{Value: image},
    },
    ReturnValues: types.ReturnValueAllOld,
})

Trước đó vì ta gọi request với Content-Type là application/json, nên ta mới có thể lấy dữ liệu từ body ra, như sau.

var book Book
err := json.Unmarshal([]byte(req.Body), &book)

Vì vậy, khi ta chuyển Content-Type sang multipart/form-data thì ta sẽ xử lý kiểu khác, như sau.

r, _ := awslambda.NewReaderMultipart(req)
form, _ := r.ReadForm(1024)

Biến form sẽ chứa dữ liệu của form-data, để truy cập giá trị thì ta dùng như sau.

form.Value["id"][0]

Sau khi viết code xong, ta init package và upload lại lambda function.

go get
sh build.sh
aws lambda update-function-code --function-name books_create --zip-file fileb://create.zip --region us-west-2

Oke, giờ thì ta nhập thông tin lên trang SPA của ta, và bấm tạo.

image.png

Khi thành công thì nó sẽ dẫn ta về trang home, ta sẽ thấy book ta vừa mới tạo ra.

image.png

Nhưng ta đợi một thời gian thì cũng chả thấy image của ta mới upload nó hiển thị được. Bạn mở web develop lên kiểm tra responese của API get list, ta thấy nó vẫn trả về có trường image đàng hoàng mà sao hình nó không hiển thị?

image.png

Bạn thử truy cập link hình, thì bạn sẽ thấy hình nó được tải về chứ nó không hiển thị lên web. Vì sao vậy? Ở chỗ S3 bucket, bạn bấm vào nó, chọn image vừa upload lên, bấm nút Action ở trên, chọn Edit metadata.

image.png

Nó sẽ dẫn ta qua trang khác, và bạn sẽ thấy ở mục Metadata, phần Content-Type của hình không phải là image/png (do mình up hình png), mà là một dạng khác.

image.png

Lý do là vì mặc định API Gateway chỉ support ta media type dạng text, các dạng media type khác nó sẽ không hỗ trợ. Ta sẽ config lại API Gateway để nó support các dạng khác.

API Gateway support multi media type

Bấm qua API Gateway, bấm vào books-api của ta, sau đó ta chọn mục Setting.

image.png

Kéo xuống cuối cùng, ở mục Binary Media Types, ta thêm vào hai thuộc tính là */*multipart/form-data.

image.png

Bấm save, tiếp theo chọn mục Resources, chọn method POST, bấm vào Method Request.

image.png

Mở mục HTTP Request Headers, thêm vào 2 thuộc tính là Accept với Content-Type vào và bấm lưu.

image.png

Oke, giờ bạn quay lại trang tạo book trên web của ta, upload image lại và bấm tạo. Ta sẽ thấy lúc này hình của ta đã hiển thị ra được 😁.

image.png

Tạo thêm thằng nữa.

image.png

image.png

Yep, ta đã làm thành công.

Kết luận

Vậy là ta đã tìm hiểu xong cách dùng S3 để hosting một trang SPA, cách dùng S3 để lưu trữ ảnh, và cách integrate nó với lambda như thế nào. S3 để hosting các trang SPA thì khá tiện, thay vì ta phải có server rồi deploy code của trang SPA lên đó rồi cấu hình nginx lằng nhằng này nọ. Ta có thể kết hợp S3 với CloudFront (CDN service) để trang web ta chạy tải nhanh hơn và hình cũng được tải nhanh hơn, và dùng Router53 để mapping domain, mình sẽ hướng dẫn ở các bài tới. Nếu có thắc mắc hoặc cần giải thích rõ thêm chỗ nào thì các bạn có thể hỏi dưới phần comment. Hẹn gặp mọi người ở bài tiếp theo.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.