Tôi đã xây dựng một ứng dụng đơn giản bằng NestJS, k8s, ArgoCD và Terraform như thế nào ? (Phần 1)
Thời gian gần đây thông qua công việc tôi có dịp được đào sâu hơn về k8s do đó trong series bài viến lần này tôi muốn chia sẻ với bạn đọc những kiến thức cũng như kinh nghiệm tôi tự đúc rút được về k8s thông qua việc xây dựng một ứng dụng đơn giản.
Tổng quan về ứng dụng
Đây chỉ là một server application đơn giản với giao diện Swagger như hình bên dưới:
Ứng dụng của tôi gồm có:
- GET user data API.
- UPDATE user data API.
- DELETE user data API.
Tổng quan về kiến trúc của ứng dụng sẽ như sau:
Ở đây tôi sử dụng:
- Github actions và ArgoCD cho GitOps.
- NestJS để xây dựng server application.
- Terraform để tạo các AWS resources.
Đó là về phần tổng quan, giờ chúng ta sẽ cùng nhau đi sâu vào chi tiết hơn.
Về server application
Như đã nói ở trên, tôi sử dụng NestJS để xây dựng server application.
Bạn đọc có thể đọc source code hoàn chỉnh tại đây: https://github.com/tuananhhedspibk/restful-app
Do nội dung chính của series lần này là k8s nên tôi sẽ chỉ nói rất nhanh về phần xây dựng server application. Tôi xây dựng ứng dụng dựa trên nguyên lí DDD (Domain Driven Design), bạn đọc có thể xem hình phía dưới để có thể hiểu tổng quan về nguyên lí này.
Bạn đọc có thể tham khảo bài viết này nếu muốn nắm rõ hơn về DDD
Ứng dụng của tôi gồm 4 tầng (layers):
- Tần Domain - Tầng nghiệp vụ.
- Tầng Application - Tầng ứng dụng.
- Tầng Presentation - Tầng trình diễn.
- Tầng Infrastructure - Tầng cơ sở (xin lỗi bạn đọc vì tôi không thể nghĩ ra một cái tên phù hợp hơn 😅).
Mũi tên màu đỏ ở hình trên cho thấy mối quan hệ phụ thuộc giữa các tầng:
Tầng bên ngoài luôn phụ thuộc vào tầng bên trong (điều ngược lại KHÔNG BAO GIỜ ĐƯỢC PHÉP XẢY RA)
Tại tầng nghiệp vụ, tôi sẽ thiết lập cho User Aggregate như sau:
import { Expose } from 'class-transformer';
import { BaseAggregate } from '../base';
import {
DomainError,
DomainErrorCode,
DomainErrorDetailCode,
} from '@libs/exception/domain';
export class UserAggregate extends BaseAggregate {
@Expose()
private id?: string;
@Expose()
email: string;
@Expose()
private password: string;
@Expose()
private salt: string;
@Expose()
name?: string;
getId(): string | null {
return this.id ? this.id : null;
}
setId(value: string) {
if (this.id) {
throw new DomainError({
code: DomainErrorCode.BAD_REQUEST,
message: 'User already has an id',
info: {
errorCode: DomainErrorDetailCode.USER_AGGREGATE_ALREADY_HAS_ID,
},
});
}
this.id = value;
}
setEmail(value: string) {
this.email = value;
}
setName(value: string) {
this.name = value;
}
getPassword(): string {
return this.password;
}
setPassword(value: string) {
this.password = value;
}
getSalt() {
return this.salt;
}
setSalt(value: string) {
this.salt = value;
}
}
Hiểu một cách đơn giản thì User Aggregate (Kết tập User) sẽ mô phỏng lại đặc điểm chung của các đối tượng User - giống như con người trong thực tế. User Aggregate bao gồm:
- Thuộc tính.
- Hành động (Methods).
Với tầng ứng dụng, tôi chia thành 2 phần:
- Command: cho việc xử lí CUD dữ liệu, ví dụ như:
- Tạo user (Signup).
- Update user.
- Delete user.
- Query: cho việc xử lí lấy về dữ liệu, ví dụ như:
- Lấy về thông tin của user.
Tầng này sẽ xử lí logic chính cho các APIs của ứng dụng
Bạn đọc có thể xem chi tiết hơn:
- Command: https://github.com/tuananhhedspibk/restful-app/tree/main/src/users/application/command
- Query: https://github.com/tuananhhedspibk/restful-app/tree/main/src/users/application/query
Tầng trình diễn là nơi thiết lập các API endpoints:
- /v1/user/signup
- /v1/user/signin
- ...
Bạn đọc có thể xem thêm tại đây
Với tầng dưới cùng là tầng cơ sở.
Đây là tầng xử lí các tương tác với hệ thống ngoài:
- API của hệ thống khác.
- Database.
- ...
Lấy ví dụ: khi tìm user bằng id, chúng ta cần truy cập vào database, repository đảm nhận việc này sẽ thực thiện các hành động:
- Kết nối tới database để thu thập dữ liệu.
- Tạo aggregate và trả về aggregate đó.
async findById(id: string): Promise<UserAggregate | null> {
try {
const repository = this.getRepository(UserEntity);
const user = await this.getBaseQuery(repository)
.addSelect(['user.name', 'user.salt'])
.where({ id })
.getOne();
return user ? this.factory.createAggregate({ ...user }) : null;
} catch (err) {
console.error(err.stack);
throw new InfrastructureError({
code: InfrastructureErrorCode.INTERNAL_SERVER_ERROR,
message: 'Internal Server Error',
});
}
}
OK, đây chính là phần tổng quan về server application, bây giờ chúng ta sẽ chuyển qua phần infrastructure (AWS).
AWS Infrastructure
Tổng quan
Hình phía trên mô tả tổng quan về infrastructure của ứng dụng, tôi sẽ chia nó ra thành 3 phần chính:
- Networking (mạng).
- RDB (cơ sở dữ liệu).
- Server app.
Trong bài viết này, thay vì tập trung vào việc giải thích các khái niệm liên quan đến AWS resources (VPC, Subnets, ...) tôi sẽ chỉ tập trung vào cách tôi sử dụng và thiết lập chúng trong ứng dụng lần này.
Networking
Đây là nơi thiết lập:
- VPC.
- Public/ Private subnets.
- Internet / NAT gateways.
Các resources này sẽ được kết hợp như hình mô tả dưới đây:
VPC là nơi đặt toàn bộ app và resources vào trong đó.
Public subnet - như cái tên của nó, đây là mạng con sẽ được public ra ngoài internet (có thể truy cập từ bên ngoài vào), với mục đích để:
- Triển khai kết nối API từ client.
- Thực hiện kết nối SSH từ dev đến database.
Private Subnet, đây là mạng con KHÔNG ĐƯỢC public ra ngoài (không thể truy cập từ bên ngoài), trong này ta sẽ đặt:
- API instances.
- Database instances.
- ...
nói chung là các resources mang tính "nhạy cảm" cao.
Cuối cùng là NAT Gateway, đay chính là giải pháp giúp
Private subnet có thể kết nối ra bên ngoài nhưng bên ngoài KHÔNG THỂ đi vào Private subnet
RDB
Đây là phần thiết lập cho database. Do tôi sử dụng PostgreSQL cho database ở môi trường local nên tôi sẽ sử dụng aurora-postgresql trên môi trường AWS.
Vì đây là database nên chúng ta PHẢI ĐẶT NÓ VÀO TRONG PRIVATE SUBNET.
Bạn đọc có thể tham khảo code chi tiết cho thiết lập RDB tại đây
Do developers cũng cần phải "đi vào bên trong" database để xem dữ liệu nên chúng ta cần tạo một tunnel cho họ. Cách làm của tôi đó là:
- Tạo một SSH Proxy Instance "đứng chắn phía trước" database.
- Tạo các SSH users tương ứng với các developers muốn truy cập vào database.
- Các developers sẽ sử dụng SSH để kết nối tới database thông qua SSH Proxy.
Cụ thể như sau:
Tôi muốn bạn đọc chú ý vào phần tôi khoanh đỏ trong hình trên.
- Proxy của chúng ta chỉ đơn thuần là một EC2 instance.
- Security Group của RDS phải chấp nhận kết nối SSH từ Proxy instance.
Config cho security group sẽ như sau:
resource "aws_security_group" "proxy" {
name = "${var.app_name}-${var.env_name}-proxy-sg"
description = "Allow ssh connect to db proxy"
vpc_id = var.vpc_id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.app_name}-${var.env_name}-proxy-sg"
}
}
Trong đoạn code trên, tôi thiết lập ingress (đầu vào) lắng nghe ở cổng 22 cho kết nối SSH.
Với RDS
Bạn đọc có thể tham khảo đoạn code dưới đây
resource "aws_security_group_rule" "proxy" {
security_group_id = aws_security_group.this.id
type = "ingress"
from_port = var.port
to_port = var.port
protocol = "tcp"
source_security_group_id = var.proxy_security_group
description = "Access from proxy"
}
Server App
Trong phần này tôi sẽ trình bày về:
- Load balancer.
- EKS.
- ECR.
Về Load balancer
Đây chính là "cổng" để clients có thể kết nối tới server app của chúng ta.
Tôi sẽ sử dụng Application Load Balancer vì tôi muốn load balancer không chỉ nhận request mà còn hoạt động như một reverse proxy để phân tải cho ứng dụng của mình.
Bạn đọc có thể tham khảo bài viết này để hiểu thêm về khái niệm reverse proxy.
EKS
Kubernetes service được cung cấp bởi AWS giúp quản lý các instances dưới dạng pods.
Cụ thể như sau:
EKS quản lí 2 pods, trong mỗi pod sẽ là Server App Docker Container, các container này sẽ kết nối trực tiếp tới database.
Bạn đọc có thể tham khảo thêm về EKS terraform tại đây
ECR
Là một Container Managed Service được cung cấp ởi AWS
Tôi sử dụng nó để quản lí Docker Image của ứng dụng.
Source code
Bạn đọc có thể xem full source code tại đây
Nói sơ qua về source code folders:
- modules: đây là nơi tôi sẽ đặt thiết lập cho các AWS resources (VPC, subnets, RDS, EKS, ...), có thể coi đây như base environment.
- environments: đúng như tên gọi, đây sẽ là folder tôi phân chia các môi trường khác nhau (staging, production).
Bạn đọc có thể xem chi tiết hơn về cách chạy và thực thi code tại file README
k8s & ArgoCD
Đây là phần nội dung chính của series. Trong bài viết lần này, tôi xin phép được trình bày với bạn đọc về:
- Cấu trúc project k8s.
- Cách tôi thiết lập và sử dụng ArgoCD.
Cấu trúc project k8s
Trong bài viết này, tôi sẽ không giải thích các định nghĩa như Pod hoặc Replica, ... mà thay vào đó, tôi sẽ trình bày với bạn đọc về cách tôi sử dụng các resources này cho dự án của mình.
Ở hình phía trên, các bạn có thể thấy 2 flows:
- Server app flow.
- Migration flow.
Server app flow
Với flow này, tôi sử dụng Deployment để quản lí ReplicaSet với 2 Pods (mỗi Pod sẽ có 1 docker container để chạy app).
Vì Deployment không có địa chỉ IP nên để truy cập đến Pod, tôi sử dụng Service.
Lúc này, chúng ta sẽ truy cập tới Pods thông qua Service.
Migration Flow
Với flow này, tôi sử dụng Job - với job, chúng ta có thể thực thi một tác vụ giống như việc migration cho database.
ArgoCD
GitOps
Theo như định nghĩa trong cuốn GitOps Cookbook
GitOps là một pương thức cho phép chúng ta sử dụng Git Repository như một Single Source Of Truth để triển khai infrastructure as code (IaC).
Trong ứng dụng của tôi
ArgoCD sẽ theo dõi trạng thái mới nhất của k8s project và sau đó đồng bộ hoá (tự động hoặc bằng tay) tới ứng dụng để ứng dụng đạt trạng thái mới nhất.
Tôi sẽ trình bày kĩ hơn về vấn đề này trong bài viết tiếp theo.
Tổng kết
Hi vọng sau bài viết này, bạn đọc đã có thể nắm được tổng quan về ứng dụng mà tôi muốn xây dựng. Trong các bài viết tiếp theo tôi sẽ trình bày kĩ hơn với bạn đọc về cách thiết lập k8s và ArgoCD cho ứng dụng của mình.
Xin cảm ơn và hẹn gặp lại bạn đọc.
Happy coding
All rights reserved