Bài viết thuộc series “Chinh phục CDK”
Giới thiệu
Ở bài trước chúng ta đã tìm hiểu các thành phần cơ bản của CDK và làm một ví dụ tạo hạ tầng cho ứng dụng Q&A đơn giản. Trong bài này chúng ta sẽ tìm hiểu cách mở rộng hạ tầng cho ứng dụng Q&A.
Thiết kế
Kết thúc bài trước hạ tầng của ta như sau:
Hiện tại để lấy danh sách câu hỏi và câu trả lời người dùng sẽ gọi tới máy chủ (EC2) và EC2 sẽ truy vấn trực tiếp vào Database (RDS). Thông thường khi người dùng tạo câu hỏi hoặc câu trả lời thì họ rất ít khi thay đổi. Nên truy vấn liên tục vào RDS để lấy cùng một kết quả dẫn tới việc hao phí tài nguyên. Bên cạnh đó truy vấn kết quả trực tiếp từ RDS khá chậm.
Nên ta thêm một tầng cache ở giữa EC2 và RDS để tăng tốc độ đọc dữ liệu và giảm tải cho RDS. AWS cung cấp cho ta AWS Elasticache để làm cache.
Ngoài việc làm cache thì Elasticache còn giúp ta tăng độ khả dụng (High Availability) cho ứng dụng. Ví dụ nếu con RDS có chết thì người dùng tuy không thể ghi dữ liệu nhưng vẫn có thể đọc dữ liệu.
Tuy nhiên nếu con EC2 của ta chết thì người dùng không thể truy cập ứng dụng được. Nên để tăng độ khả dụng ta cần tách riêng con EC2 cho việc đọc và ghi dữ liệu.
Hệ thống hiện tại vẻ ổn nhưng nó có một điểm yếu là nếu người dùng ghi liên tục vào RDS ở một thời điểm, và vì dữ liệu đọc của ta ghi ở Elasticache nên ta sẽ thực hiện cập nhật Elasticache liên tục. Tuy nhiên với ứng dụng Q&A việc cập nhật dữ liệu theo thời gian thực như vậy không cần thiết => dẫn tới việc lãng phí tài nguyên của Elasticache.
Ta có thể thực hiện cập nhật dữ liệu đọc của Elasticache trong khoảng thời gian 5 phút một lần, toàn bộ dữ liệu được ghi vào RDS trong vòng 5 phút sẽ được cập nhật một lúc. Điều này giúp ta tránh lãng phí tài nguyên của Elasticache.
Thay vì phải truy vấn RDS và đọc dữ liệu trong vòng 5 phút. Ta có thể thực hiện như sau để đơn giản hóa công việc này:
- Lưu dữ liệu vào RDS
- Lấy ID của dữ liệu đó lưu vào một Database tạm khác
Để cho các bạn biết cách tạo thêm nhiều dịch vụ của AWS bằng CDK thì Database tạm mình dùng DynamoDB.
Để dễ dàng cho việc mở rộng và quản lí về sau, ta chia từng mục liên quan thành một Stack trong CDK.
Chuẩn bị
Tạo thư mục và khởi tạo ứng dụng:
mkdir question-service && cd question-service
cdk init app --language go
go get
Xóa hết code trong tệp tin question-service.go
và dán đoạn code sau vào:
package main
import (
"os"
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/jsii-runtime-go"
)
func main() {
defer jsii.Close()
// App
app := awscdk.NewApp(nil)
app.Synth(nil)
}
func env() *awscdk.Environment {
return &awscdk.Environment{
Account: jsii.String(os.Getenv("CDK_DEFAULT_ACCOUNT")),
Region: jsii.String(os.Getenv("CDK_DEFAULT_REGION")),
}
}
Tạo một thư mục tên là stack
:
mkdir stack
Tạo 3 tệp tin sau trong thư mục stack
:
├── cache-stack.go
├── insert-stack.go
└── worker-stack.go
Cấu trúc thư mục hiện tại:
...
├── go.mod
├── go.sum
├── question-service.go
├── question-service_test.go
└── stack
├── cache-stack.go
├── insert-stack.go
└── worker-stack.go
Từng tệp tin trong thư mục stack
sẽ tương ứng với một Stack trong CDK. Ta dán đoạn code sau vào từng tệp tin.
Tệp tin insert-stack.go
:
package stack
import (
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/constructs-go/constructs/v10"
)
type QuestionInsertStackProps struct {
awscdk.StackProps
}
func NewQuestionInsertStack(scope constructs.Construct, id string, props *QuestionInsertStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
return stack
}
Tệp tin cache-stack.go
:
package stack
import (
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/constructs-go/constructs/v10"
)
type QuestionCacheStackProps struct {
awscdk.StackProps
}
func NewQuestionCacheStack(scope constructs.Construct, id string, props *QuestionCacheStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
return stack
}
Tệp tin worker-stack.go
:
package stack
import (
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/constructs-go/constructs/v10"
)
type QuestionWorkerStackProps struct {
awscdk.StackProps
}
func NewQuestionWorkerStack(scope constructs.Construct, id string, props *QuestionWorkerStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
return stack
}
Tất cả đoạn code đều giống nhau và chỉ khác tên hàm. Quay lại tệp tin question-service.go
ta thêm đoạn code sau:
...
import (
"os"
"question-service/stack"
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/jsii-runtime-go"
)
func main() {
defer jsii.Close()
// App
app := awscdk.NewApp(nil)
// Question Cache Stack
stack.NewQuestionCacheStack(app, "QuestionCacheStack", &stack.QuestionCacheStackProps{
StackProps: awscdk.StackProps{
Env: env(),
},
})
// Question Worker Stack
stack.NewQuestionWorkerStack(app, "QuestionWorkerStack", &stack.QuestionWorkerStackProps{
StackProps: awscdk.StackProps{
Env: env(),
},
})
// Question Insert Stack
stack.NewQuestionInsertStack(app, "QuestionInsertStack", &stack.QuestionInsertStackProps{
StackProps: awscdk.StackProps{
Env: env(),
},
})
app.Synth(nil)
}
...
Khi trong một ứng dụng CDK có nhiều Stack thì để liệt kê toàn bộ Stack bạn chạy câu lệnh sau:
cdk list
QuestionCacheStack
QuestionInsertStack
QuestionWorkerStack
Tiếp theo ta sẽ bắt đầu viết code.
Viết code
QuestionInsertStack
Bắt đầu với QuestionInsertStack
vì đây là Stack ta đã tạo ở bài trước.
Mở tệp tin insert-stack.go
và dán đoạn code sau vào:
package stack
import (
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/aws-cdk-go/awscdk/v2/awsec2"
"github.com/aws/aws-cdk-go/awscdk/v2/awsrds"
"github.com/aws/constructs-go/constructs/v10"
"github.com/aws/jsii-runtime-go"
)
type QuestionInsertStackProps struct {
awscdk.StackProps
}
func NewQuestionInsertStack(scope constructs.Construct, id string, props *QuestionInsertStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
// VPC Construct
vpc := awsec2.Vpc_FromLookup(stack, jsii.String("DefaultVPC"), &awsec2.VpcLookupOptions{IsDefault: jsii.Bool(true)})
// RDS Construct
awsrds.NewDatabaseInstance(stack, jsii.String("Postgres"), &awsrds.DatabaseInstanceProps{
Engine: awsrds.DatabaseInstanceEngine_POSTGRES(),
InstanceType: awsec2.NewInstanceType(jsii.String("t3.micro")),
Credentials: awsrds.Credentials_FromPassword(
jsii.String("question"),
awscdk.NewSecretValue("question", &awscdk.IntrinsicProps{}),
),
PubliclyAccessible: jsii.Bool(true),
VpcSubnets: &awsec2.SubnetSelection{SubnetType: awsec2.SubnetType_PUBLIC},
Vpc: vpc,
})
// EC2 Construct
awsec2.NewInstance(stack, jsii.String("Server"), &awsec2.InstanceProps{
InstanceType: awsec2.NewInstanceType(jsii.String("t3.micro")),
MachineImage: awsec2.NewAmazonLinuxImage(&awsec2.AmazonLinuxImageProps{
Generation: awsec2.AmazonLinuxGeneration_AMAZON_LINUX_2,
}),
Vpc: vpc,
})
return stack
}
Tạo CloudFormation để xem trước các tài nguyên được tạo. Với một ứng dụng có nhiều Stack thì ta thêm tên Stack vào sau câu lệnh synth
:
cdk synth QuestionInsertStack
QuestionCacheStack
Tiếp theo ta làm việc với QuestionCacheStack
.
QuestionCacheStack
bao gồm ba Construct là VPC, EC2, Elasticache. Với VPC, EC2 là Construct ta khá quen thuộc. Mở tệp tin cache-stack.go
và dán đoạn code để tạo VPC và EC2 Construct trước:
package stack
import (
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/aws-cdk-go/awscdk/v2/awsec2"
"github.com/aws/constructs-go/constructs/v10"
"github.com/aws/jsii-runtime-go"
)
type QuestionCacheStackProps struct {
awscdk.StackProps
}
func NewQuestionCacheStack(scope constructs.Construct, id string, props *QuestionCacheStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
// VPC Construct
vpc := awsec2.Vpc_FromLookup(stack, jsii.String("DefaultVPC"), &awsec2.VpcLookupOptions{IsDefault: jsii.Bool(true)})
// EC2 Construct
awsec2.NewInstance(stack, jsii.String("Server"), &awsec2.InstanceProps{
InstanceType: awsec2.NewInstanceType(jsii.String("t3.micro")),
MachineImage: awsec2.NewAmazonLinuxImage(&awsec2.AmazonLinuxImageProps{
Generation: awsec2.AmazonLinuxGeneration_AMAZON_LINUX_2,
}),
Vpc: vpc,
})
return stack
}
Sau đó để tạo Elasticache Construct ta dùng hàm NewCfnCacheCluster
:
func NewQuestionCacheStack(scope constructs.Construct, id string, props *QuestionCacheStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
...
// Elasticache Construct
awselasticache.NewCfnCacheCluster(stack, jsii.String("Cache"), &awselasticache.CfnCacheClusterProps{
Engine: jsii.String("redis"),
CacheNodeType: jsii.String("cache.t2.micro"),
NumCacheNodes: jsii.Number(1),
})
return stack
}
Ta tạo Elasticache với loại là Redis và số lượng Node là 1.
Khác với EC2 Construct (L2 Construct) thì Elasticache Construct là L1 Construct. Khi ta tạo nó sẽ không có sẵn Security Group để cho phép tài nguyên khác truy cập Port của nó (mình sẽ giải thích khái niệm L Construct ở bài sau). Nên ta cần tạo Security Group cho Elasticache:
func NewQuestionCacheStack(scope constructs.Construct, id string, props *QuestionCacheStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
...
// SG Construct
sg := awsec2.NewSecurityGroup(stack, jsii.String("CacheSG"), &awsec2.SecurityGroupProps{
AllowAllOutbound: jsii.Bool(true),
Vpc: vpc,
})
sg.AddIngressRule(
awsec2.Peer_AnyIpv4(),
awsec2.NewPort(&awsec2.PortProps{
FromPort: jsii.Number(6379),
ToPort: jsii.Number(6379),
StringRepresentation: jsii.String("Redis"),
Protocol: awsec2.Protocol_ALL,
}),
jsii.String("Redis Port"),
jsii.Bool(false),
)
// Elasticache Construct
awselasticache.NewCfnCacheCluster(stack, jsii.String("Cache"), &awselasticache.CfnCacheClusterProps{
Engine: jsii.String("redis"),
CacheNodeType: jsii.String("cache.t2.micro"),
NumCacheNodes: jsii.Number(1),
VpcSecurityGroupIds: &[]*string{sg.SecurityGroupId()},
})
return stack
}
Ta tạo Security Group cho phép truy cập Port 6379 và gán nó vào Elasticache. Tạo CloudFormation cho QuestionCacheStack
:
cdk synth QuestionCacheStack
QuestionWorkerStack
Cuối cùng ta làm việc với QuestionWorkerStack
.
QuestionWorkerStack
bao gồm ba Construct là VPC, EC2, DynamoDB. Mở tệp tin worker-stack.go
và dán đoạn code sau vào:
package stack
import (
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/aws-cdk-go/awscdk/v2/awsec2"
"github.com/aws/constructs-go/constructs/v10"
"github.com/aws/jsii-runtime-go"
)
type QuestionWorkerStackProps struct {
awscdk.StackProps
}
func NewQuestionWorkerStack(scope constructs.Construct, id string, props *QuestionWorkerStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
// VPC Construct
vpc := awsec2.Vpc_FromLookup(stack, jsii.String("DefaultVPC"), &awsec2.VpcLookupOptions{IsDefault: jsii.Bool(true)})
// EC2 Construct
awsec2.NewInstance(stack, jsii.String("Worker"), &awsec2.InstanceProps{
InstanceType: awsec2.NewInstanceType(jsii.String("t3.micro")),
MachineImage: awsec2.NewAmazonLinuxImage(&awsec2.AmazonLinuxImageProps{
Generation: awsec2.AmazonLinuxGeneration_AMAZON_LINUX_2,
}),
Vpc: vpc,
})
return stack
}
Ta dùng hàm awsdynamodb.NewTable
để tạo DynamoDB Table:
func NewQuestionWorkerStack(scope constructs.Construct, id string, props *QuestionWorkerStackProps) awscdk.Stack {
var sprops awscdk.StackProps
if props != nil {
sprops = props.StackProps
}
stack := awscdk.NewStack(scope, &id, &sprops)
...
// DynamoDB Construct
awsdynamodb.NewTable(stack, jsii.String("QuestionID"), &awsdynamodb.TableProps{
TableName: jsii.String("QuestionID"),
PartitionKey: &awsdynamodb.Attribute{
Name: jsii.String("id"),
Type: awsdynamodb.AttributeType_STRING,
},
})
return stack
}
Ta tạo Dynamo Table tên là QuestionID
với một cột là id
. Tạo CloudFormation cho QuestionWorkerStack
:
cdk synth QuestionWorkerStack
Triển khai
Để triển khai ứng dụng với nhiều Stack ta cần thêm tên Stack vào câu lệnh deploy
. Ta triển từng Stack theo thứ tự:
QuestionCacheStack:
cdk deploy QuestionCacheStack
[█████████████████████▊····································] (3/8)
10:47:32 AM | CREATE_IN_PROGRESS | AWS::CloudFormation::Stack | QuestionCacheStack
10:47:36 AM | CREATE_IN_PROGRESS | AWS::IAM::Role | Server/InstanceRole
10:47:46 AM | CREATE_IN_PROGRESS | AWS::ElastiCache::CacheCluster | Cache
QuestionWorkerStack:
cdk deploy QuestionWorkerStack
[████████████████▌·········································] (2/7)
10:41:12 AM | CREATE_IN_PROGRESS | AWS::CloudFormation::Stack | QuestionWorkerStack
10:41:16 AM | CREATE_IN_PROGRESS | AWS::IAM::Role | Worker/InstanceRole
10:41:17 AM | CREATE_IN_PROGRESS | AWS::DynamoDB::Table | QuestionID
QuestionInsertStack:
cdk deploy QuestionInsertStack
Kiểm tra AWS Console ta sẽ thấy các tài nguyên của ta. Sau khi làm xong nhớ xóa tài nguyên để tránh mất tiền, chạy câu lệnh destroy
với tên Stack theo thứ tự:
cdk destroy QuestionCacheStack
cdk deploy QuestionWorkerStack
cdk deploy QuestionInsertStack
Kết luận
Vậy là ta đã tìm hiểu xong cách thiết kế và xây dựng hạ tầng với CDK trong ví dụ này, như các bạn thấy thì nó cũng không khó lắm 😁.
Tác giả Quân Huỳnh
Nếu bài viết có gì sai hoặc cần cập nhật thì liên hệ Admin.
Tham gia nhóm chat của DevOps VN tại Telegram.
Kém tiếng Anh và cần nâng cao trình độ giao tiếp: Tại sao bạn học không hiệu quả?