个人发展(基础设施)
首先
这次,我使用以下技术栈进行个人开发,所以我想将其留作备忘录。
-
- Go(API)
-
- Next.js・TypeScript(フロント)
-
- AWS・Terraform(インフラ)
- github actions(CI/CD)
本文将简要提及使用AWS进行基础设施建设的相关工作。有关后端和前端的内容,请参阅以下部分。
后端方面
前台方面 (Front desk area)
Github代码库
应用程序侧
基础设施方面
系统概要图
-
- s3
-
- dynamodb
-
- ECS
-
- ECR
-
- Fargate
-
- VPC
-
- Systems Manager
-
- Lambda
-
- API Gateway
-
- RDS
- Load Balancer
其他使用的技术 de
Terraform是一种基础设施即代码(IaC)工具。
Terraform 是 HashiCorp 开发的一种开源的 “基础设施即代码” 工具之一。通过使用 Terraform,可以使用代码定义云资源和本地资源,并根据这些定义创建、更新和删除资源。
现在,我们正在用 Terraform(HCL)来描述和管理在 AWS 上构建的所有资源。
目前在市场上流行的 IaC 工具除了 Terraform 外,还有 “AWS CDK”、”Cloud Formation” 和 “Pulumi”。
它们各自具有以下特点:
-
- マルチクラウド
Terraform
Pulumi
多言語対応
AWS CDK
Pulumi
有多种选择,每种选择都有各自的优缺点,但考虑到当前的普及率和多云适应能力,我们选择采用Terraform。
Github Actions – GitHub行动
GitHub Actions是一种工具,用于自动化GitHub库中的持续集成/持续部署(CI/CD)工作流程。它可以通过触发GitHub上特定的事件,如代码提交或拉取请求的创建,自动执行构建、测试和部署等任务。
Github操作和AWS集成
为了让GitHub Actions可以访问AWS资源,需要进行身份验证。因此,本次将使用OIDC进行AWS认证。
OIDC(OpenID Connect)是建立在OAuth 2.0协议之上的认证协议,它提供了ID令牌的形式,用于安全共享用户的认证信息。
OIDC认证的好处。
在解释OIDC的优点之前,如果想要在Github Actions中添加AWS身份验证而不使用OIDC,可以考虑以下方法。
-
- 创建IAM用户
-
- 生成认证信息(Access Key ID、Secret Access Key)
- 将认证信息设置到GitHub Actions的Secrets中
只需按照上述的方式,两个服务很容易实现协作合作。
然而,基于安全风险和管理成本增加的考虑,这种方法并不被推荐。
另一方面,使用OIDC可以在不存放永久认证信息的情况下,仅通过发放符合OIDC规范的临时令牌来操作AWS资源。因此,不再需要在github上直接管理和运营AWS认证信息。
基于这些原因,我们决定采用OIDC认证。
以下是這次使用的OIDC認證步驟:
-
- 在AWS的管理控制台中创建ID提供者
-
- 创建用于Github Actions的IAM角色
- 编辑策略,仅对特定存储库的特定分支进行认证。
tfstate文件的动态管理
在使用Terraform时,tfstate文件是表示Terraform基础设施当前状态的重要文件。如果有多个开发人员或CI/CD工具同时执行Terraform,则需要适当的管理来避免tfstate的一致性和竞争问题。
作为tfstate文件的管理方法,有一些选项,例如在本地保留或使用git进行管理,但由于这些方法都很难保证一致性并且存在较高的安全风险,因此不被推荐使用。
因此,本次将使用 AWS 的服务(如 s3 和 DynamoDB)来管理 tfstate 文件的方法。
使用S3的原因是为了管理tfstate文件,但是为了保持文件的一致性,需要使用DynamoDB。通过在S3中管理tfstate文件,可以让多个人处理tfstate文件。然而,这也带来了状态冲突的风险。
因此,Terraform提供了使用DynamoDB(对于S3)来保持tfstate文件一致性的功能。
这种方法在官方处也被推荐使用。
terraform {
backend "s3" {
bucket = "recruit-info-service-tfstate"
key = "terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "recruit-info-service-tfstate-locking"
encrypt = true
}
}
通过使用后端”s3″ ,Terraform可以在s3中管理tfstate文件。
使用Terraform构建AWS资源
我们将从这里开始介绍使用Terraform创建基础设施环境的实际资源。
网络建设
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
name = "aws-ecs-terraform"
cidr = "10.0.0.0/16"
azs = ["${local.region}a", "${local.region}c"]
public_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnet_names = ["Public Subnet 1a", "Public Subnet 1c"]
private_subnet_names = ["Private Subnet 1a", "Private Subnet 1c"]
enable_dns_hostnames = true
enable_dns_support = true
enable_nat_gateway = true
single_nat_gateway = false
}
# RDSをaのAZに配置
resource "aws_subnet" "rds_subnet_a" {
vpc_id = module.vpc.vpc_id
cidr_block = "10.0.5.0/24"
availability_zone = "${local.region}a"
map_public_ip_on_launch = false
tags = {
Name = "${local.app} RDS Private Subnet 1a"
"MapPublicIpOnLaunch" = "false"
"Type" = "rds"
}
}
# RDSをcのAZに配置
resource "aws_subnet" "rds_subnet_c" {
vpc_id = module.vpc.vpc_id
cidr_block = "10.0.6.0/24"
availability_zone = "${local.region}c"
map_public_ip_on_launch = false
tags = {
Name = "${local.app} RDS Private Subnet 1c"
"MapPublicIpOnLaunch" = "false"
"Type" = "rds"
}
}
resource "aws_db_subnet_group" "my_db_subnet_group" {
name = "${local.app}-db-subnet-group"
subnet_ids = [aws_subnet.rds_subnet_a.id, aws_subnet.rds_subnet_c.id]
tags = {
Name = "${local.app}-db-subnet-group"
}
}
我們接下來將詳細解釋下面所列出的設定。
source = "terraform-aws-modules/vpc/aws"
通过使用Terraform的VPC模块来实现。
enable_dns_hostnames = true
在上述描述中,AWS的DNS服务器启用了名称解析。
enable_dns_support = true
根据上述描述,VPC 内的资源将自动分配公共 DNS 主机名。
enable_nat_gateway = true
single_nat_gateway = false
根据以上描述,将在指定的每个可用区创建NAT网关。
与互联网网关不同,使用NAT网关时需要明确说明。
resource "aws_subnet" "rds_subnet_a" {
vpc_id = module.vpc.vpc_id
cidr_block = "10.0.5.0/24"
availability_zone = "${local.region}a"
map_public_ip_on_launch = false
tags = {
Name = "${local.app} RDS Private Subnet 1a"
"MapPublicIpOnLaunch" = "false"
"Type" = "rds"
}
}
通过上述方式,明确指定了设置RDS子网信息的操作。
将map_public_ip_on_launch设置为false时,默认情况下不会分配公有IP地址。
由于RDS不会直接公开在互联网上,所以将其设置为false。
resource "aws_db_subnet_group" "my_db_subnet_group" {
name = "${local.app}-db-subnet-group"
subnet_ids = [aws_subnet.rds_subnet_a.id, aws_subnet.rds_subnet_c.id]
tags = {
Name = "${local.app}-db-subnet-group"
}
}
上述是用于定义Terraform资源的RDS子网组。
为了将RDS实例放置在VPC的特定子网中,需要使用子网组来指定数据库将被放置在哪个子网中。在VPC中创建RDS实例时,必须使用子网组,否则无法实现。
以上,完成了为部署Fargate、ALB、RDS和Lambda(用于MySQL的AutoMigrate)所需的网络设置。
安全组
安全组是指控制在AWS虚拟私有云(VPC)内资源的网络流量的虚拟防火墙。
resource "aws_security_group" "alb" {
name = "${local.app}-alb"
description = "For ALB."
vpc_id = module.vpc.vpc_id
ingress {
description = "Allow HTTP from ALL."
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all to outbound."
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.app}-alb"
}
}
resource "aws_security_group" "ecs" {
name = "${local.app}-ecs"
description = "For ECS."
vpc_id = module.vpc.vpc_id
egress {
description = "Allow all to outbound."
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.app}-ecs"
}
}
resource "aws_security_group_rule" "ecs_from_alb" {
description = "Allow 8080 from Security Group for ALB."
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
source_security_group_id = aws_security_group.alb.id
security_group_id = aws_security_group.ecs.id
}
resource "aws_security_group" "rds" {
name = "${local.app}-rds"
description = "For RDS."
vpc_id = module.vpc.vpc_id
ingress {
description = "Allow MySQL from ECS security group"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.app}-rds"
}
}
# For Lambda
resource "aws_security_group" "lambda_sg" {
name = "${local.app}-lambda"
description = "For ${local.app} Lambda"
vpc_id = module.vpc.vpc_id
egress {
description = "Allow all to outbound."
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.app}-lambda"
}
}
本次设定的安全组大致分为四个。
-
- ALB使用
-
- ECS使用
-
- RDS使用
- Lambda使用
aws_security_group "alb"
目标: 定义用于ALB的安全组
入口规则: 允许来自所有IP地址(0.0.0.0/0)的HTTP流量(端口80)
出口规则: 允许所有对外流量
aws_security_group "ecs"
目标:定义ECS使用的安全组
出口:允许所有对外流量
aws_security_group_rule "ecs_from_alb"
目的:为了允许从ALB到ECS的流量,定义一个安全组规则。
类型:用于指示流量方向。在这种情况下,是”ingress”,表示接收流量。
from_port和to_port:允许端口8080的流量。
协议:允许TCP协议的流量。
source_security_group_id:指定ALB安全组作为此规则的源。
security_group_id:指定适用于ECS安全组的此规则。
aws_security_group "rds"
目的:定义用于RDS的安全组。
入站:允许来自ECS安全组的MySQL(端口3306)流量。
出站:允许所有对外流量。
aws_security_group "lambda_sg"
目的:定义用于AWS Lambda的安全组
出站:允许所有对外流量。
负载均衡器
负载均衡器是一种网络设备,可将输入流量分散给多个目标(如EC2服务器和容器),从而提高系统的可用性和冗余性。
resource "aws_lb" "alb" {
name = "${local.app}-alb"
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = module.vpc.public_subnets
}
resource "aws_lb_listener" "alb_listener" {
load_balancer_arn = aws_lb.alb.arn
port = "80"
protocol = "HTTP"
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Fixed response content"
status_code = "200"
}
}
}
resource "aws_lb_listener_rule" "alb_listener_rule" {
listener_arn = aws_lb_listener.alb_listener.arn
action {
type = "forward"
target_group_arn = aws_lb_target_group.target_group.arn
}
condition {
path_pattern {
values = ["*"]
}
}
}
resource "aws_lb_target_group" "target_group" {
name = "${local.app}-tg"
port = 8080
protocol = "HTTP"
target_type = "ip"
vpc_id = module.vpc.vpc_id
health_check {
healthy_threshold = 3
interval = 30
path = "/health_checks"
protocol = "HTTP"
timeout = 5
}
}
从现在开始,我们将详细解释上述设置的内容。
resource "aws_lb" "alb"
-
- ここではALBのリソースを作成する
-
- load_balancer_typeはロードバランサーのタイプを指定し、この場合は “application”とする
-
- security_groupsはALBに関連付けるセキュリティグループを指定する
- subnetsはALBを配置するパブリックサブネットを指定する
aws_lb_listener "alb_listener"
ALB(应用程序负载均衡器)的监听器是一个具有特定IP地址和端口的组件,用于接收来自客户端的连接请求,并定义了当接收到客户端连接请求时的操作。
aws_lb_listener_rule "alb_listener_rule"
这里定义了ALB的侦听规则。
侦听规则是一组条件和操作,用于定义接收流量的路由方式。使用侦听规则可以根据特定条件将流量路由到不同的目标组。
aws_lb_target_group "target_group"
在这里,我们正在定义将与ALB关联的目标组。目标组是负载均衡器用于路由流量的一组资源,它们会监听特定的端口和协议,并负责执行健康检查的配置。
在目标群体的设定中,也包括了health_check的描述。
health_check {
healthy_threshold = 3
interval = 30
path = "/health_checks"
protocol = "HTTP"
timeout = 5
}
定期性地检查已注册目标的健康状况。每30秒向路径发送一次请求,如果连续3次在5秒内收到响应,则判断容器已成功启动。如果健康检查失败,ALB将停止向目标组发送流量。
ECR (全欧共同宏观金融信息) 的意思是欧洲共同市场宏观金融信息。
为了部署,我们会使用Docker将API端的Go语言和前端端的Next.js应用打包成容器。为了进行部署,我们需要将在本地创建的容器镜像推送到一个用于存储和管理的注册表中。
这次我们使用Amazon ECR作为容器注册表。
以下是ECR的特点说明。
-
- 安全性:提供了安全的存储私有容器镜像的位置,并使用IAM来控制对资源的访问。
可扩展性:具有高度的可扩展性,可以存储大量的容器镜像,用户可以轻松地存储和获取数百万个镜像。
集成:无缝集成了AWS的容器管理服务,如Amazon ECS和AWS Fargate,使镜像部署变得更加容易。
镜像扫描:具有基于公开的脆弱性信息自动扫描Docker镜像的功能。
生命周期策略:可以设置自动删除旧镜像和不再需要的镜像的生命周期策略,以减少不必要的存储成本。
创建ECR时,使用Terraform将如下所示。
# GoのECRリポジトリ
resource "aws_ecr_repository" "go_ecr_repository" {
name = "${local.app}-go"
image_tag_mutability = "IMMUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "go_ecr_lifecycle_policy" {
repository = aws_ecr_repository.go_ecr_repository.name
policy = <<EOF
{
"rules": [
{
"rulePriority": 1,
"description": "Keep last 30 images for Go",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 30
},
"action": {
"type": "expire"
}
}
]
}
EOF
}
# Next.jsのECRリポジトリ
resource "aws_ecr_repository" "nextjs_ecr_repository" {
name = "${local.app}-nextjs"
image_tag_mutability = "IMMUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "nextjs_ecr_lifecycle_policy" {
repository = aws_ecr_repository.nextjs_ecr_repository.name
policy = <<EOF
{
"rules": [
{
"rulePriority": 1,
"description": "Keep last 30 images for Next.js",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 30
},
"action": {
"type": "expire"
}
}
]
}
EOF
}
我正在创建一个注册表来存储Go(API端)和Next.js的Docker镜像。
关于此次注册表的创建要点,列举以下两点。
-
- 禁止覆盖图像标签
- 像push时进行弱点扫描的图像
关于问题1,涉及的部分如下所示。
image_tag_mutability = "IMMUTABLE"
image_tag_mutability 的设置控制着存储库中 Docker 镜像标签的可变性,有两个选项:MUTABLE 和 IMUUTABLE。
可更改:您可以将具有相同标签的新图像推送到代码库,并且可以重用相同标签来覆盖现有的图像
如果选择了这个设置,一旦给镜像打了标签,就无法覆盖它,如果试图使用相同的标签推送新的镜像,就会出现错误。
本次我们使用IMMUTABLE,并简要介绍其优点。
使用IMMUTABLE設定,可以保证与特定标签相关联的图像始终保持相同,避免在部署或回滚时产生混乱和误解。
安全性:能够防止意外覆写图像和推送恶意图像,保护仓库内容免受意外修改的影响。
监管和可追踪性:每个标签都是唯一的,并且无法更改,因此使用特定标签可以轻松准确地追踪部署的图像的版本和内容。
在部署过程中排除将相同的标签关联到不同的镜像上的可能性,可以提高部署的可靠性。
我读了下面的文章。
关于第二点,以下是。
image_scanning_configuration {
scan_on_push = true
}
每当将图像推送到 ECR 时,都会自动触发脆弱性扫描的设置
通过设置此项,每当将新的容器映像推送到存储库时,该映像将自动进行脆弱性扫描,以防止部署可能包含安全风险的新映像。
Fargate上的ECS
首先,ECS 是一种全托管的容器编排服务,可以轻松部署、管理和扩展容器化的应用程序。
两种启动类型和特点
在使用ECS来操作应用程序时,有两种主要的执行环境。
我们将在下面解释这两种环境的特点,并对其优缺点以及本次选择进行说明。
在EC2起動类型中,在集群内的EC2实例上运行容器需要进行实例选择、扩展、补丁应用等管理任务。
【优势】
灵活性:可选择实例类型并进行操作系统级定制
成本:可通过预订实例或使用竞价实例来优化成本
缺点:
管理工作繁琐:需要管理EC2实例的生命周期和安全性。
扩展性:需要手动进行扩展或设置自动扩展。
Fargate启动类型是一种无服务器容器执行环境,无需管理实例。只需指定任务或服务的规格,基础设施将由AWS自动处理。
<优点>
管理成本: 不需要管理服务器、集群或应用补丁。
简单的可扩展性: 只需指定任务数量或服务部署,即可自动扩展。
安全性: 每个任务都有独立的隔离边界,增强了安全性。
【缺点】
成本:根据使用情况,可能比EC2实例类型更昂贵
灵活性限制:操作系统级别的定制受到限制
可以将上述内容归纳如下。
在这个使用案例中,
-
- 只有一名应用程序工程师进行开发
- 没有操作系统级别的细节配置知识和技术能力
以此思考之后,我暂时决定在Fargate上使用ECS运行。
ECS架构组成元素
集群(Cluster)
集群是ECS资源的一个逻辑组,通过在集群内执行任务和服务,可以有效地管理和隔离资源。
服务(Service)
服务是维护和执行指定数量任务实例的管理实体。
例如,作为Web应用程序的后端,如果始终需要执行3个任务,可以使用服务来实现。
服务可以在任务失败或部署新版本时自动替换任务。
任务(Task)
任务是在ECS上运行的单位,由一个或多个Docker容器组成,并根据任务定义执行任务。
任务定义
任务定义是执行任务的“设计蓝图”
指定运行任务的Docker镜像,分配的CPU和内存大小,卷,环境变量,网络设置等,关于任务执行的详细信息。
如果使用Terraform来管理ECS,将会得到以下的结果。
resource "aws_ecs_task_definition" "ecs_task_definition" {
family = local.app
network_mode = "awsvpc"
cpu = 256
memory = 512
requires_compatibilities = ["FARGATE"]
execution_role_arn = aws_iam_role.ecs.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = <<CONTAINERS
[
{
"name": "${local.app}",
"image": "medpeer/health_check:latest",
"portMappings": [
{
"containerPort": 8080
}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8080/health_checks || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 10
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${aws_cloudwatch_log_group.cloudwatch_log_group.name}",
"awslogs-region": "${local.region}",
"awslogs-stream-prefix": "${local.app}"
}
},
"environment": [
{
"name": "NGINX_PORT",
"value": "8080"
},
{
"name": "HEALTH_CHECK_PATH",
"value": "/health_checks"
}
]
}
]
CONTAINERS
}
resource "aws_ecs_service" "ecs_service" {
name = local.app
launch_type = "FARGATE"
cluster = aws_ecs_cluster.ecs_cluster.id
task_definition = aws_ecs_task_definition.ecs_task_definition.arn
desired_count = 2
network_configuration {
subnets = module.vpc.private_subnets
security_groups = [aws_security_group.ecs.id]
}
}
resource "aws_ecs_cluster" "ecs_cluster" {
name = local.app
}
resource "aws_ecs_task_definition" "nextjs_ecs_task_definition" {
family = "${local.app}-nextjs"
network_mode = "awsvpc"
cpu = 256
memory = 512
requires_compatibilities = ["FARGATE"]
execution_role_arn = aws_iam_role.ecs.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = <<CONTAINERS
[
{
"name": "${local.app}-nextjs",
"image": "medpeer/nextjs_app:latest",
"portMappings": [
{
"containerPort": 8080
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${aws_cloudwatch_log_group.cloudwatch_log_group.name}",
"awslogs-region": "${local.region}",
"awslogs-stream-prefix": "${local.app}-nextjs"
}
},
"environment": [
{
"name": "NGINX_PORT",
"value": "8080"
},
{
"name": "NEXTJS_ENV_VAR",
"value": "Your value here"
}
]
}
]
CONTAINERS
}
resource "aws_ecs_service" "nextjs_ecs_service" {
name = "${local.app}-nextjs"
launch_type = "FARGATE"
cluster = aws_ecs_cluster.nextjs_ecs_cluster.id
task_definition = aws_ecs_task_definition.nextjs_ecs_task_definition.arn
desired_count = 2
network_configuration {
subnets = module.vpc.private_subnets
security_groups = [aws_security_group.ecs.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.nextjs_target_group.arn
container_name = "${local.app}-nextjs"
container_port = 8080
}
depends_on = [aws_lb_listener_rule.alb_listener_rule]
}
resource "aws_ecs_cluster" "nextjs_ecs_cluster" {
name = "${local.app}-nextjs"
}
简要解释这次的两种容器构成情况。
-
- ALBに紐付けるコンテナはNext.jsを動かすコンテナのみ
-
- コンテナ間でAPIのやり取りをする
- APIコンテナに関しては/health_checkに対して、ヘルスチェックを行う
在下面,我们展示了用于两个容器之间的HTTP通信的security_group。
resource "aws_security_group_rule" "ecs_from_nextjs" {
description = "Allow 8080 from next.js to API."
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
source_security_group_id = aws_security_group.ecs.id
security_group_id = aws_security_group.ecs.id
}
根据以上描述,也会进行IAM的设置。
如果在Fargate上运行ECS,将需要分配以下两个角色。下面对它们分别进行简单解释。
任务执行角色用于ECS任务与AWS服务进行交互。
其中包括从ECR拉取Docker镜像的认证和将日志发送到CloudWatch Logs的权限。
任务卷轴
任务中的应用程序用于与AWS服务进行交互,并在任务执行期间由应用程序使用。
这次,没有将权限授予任务滚动条本身。
对于任务执行滚动条来说,需要赋予以下权限。
-
- 登录到 ECR 存储库并获取镜像的权限
- 将日志输出到 CloudWatch 的日志组的权限
考虑到异常情况,如果使用Terraform来编写IAM配置,将会如下所示。
data "aws_iam_policy_document" "ecs_task_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_task" {
name = "${local.app}-ecs-task"
assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}
data "aws_iam_policy_document" "ecs_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs" {
name = "${local.app}-ecs"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
resource "aws_iam_role_policy_attachment" "ecs_basic" {
role = aws_iam_role.ecs.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
接下来,我们将使用Terraform创建输出容器运行日志到CloudWatch日志组的功能。
resource "aws_cloudwatch_log_group" "cloudwatch_log_group" {
name = "/aws/ecs/${local.app}"
retention_in_days = 3
}
假设
retention_in_days = 3
这里指定了日志的保存期限(指定的天数是由官方规定的)。
我也在ecs.tf中指定了这个日志组。
RDS(MySQL)
RDS是用于在云上轻松、高效和可扩展地运营关系型数据库的服务。
如果总结一下这次的流程,就是指运行在Fargate上的ECS的Go应用程序发出的SQL查询被发送到RDS并由RDS处理并返回结果。
RDS和ECS之间的通信通过快速安全的VPC网络进行。
在启动RDS实例时,使用Terraform将其描述如下:
data "aws_ssm_parameter" "rds_password" {
name = "/${local.app}/rds/password"
}
resource "aws_db_instance" "my_db_instance" {
db_subnet_group_name = aws_db_subnet_group.my_db_subnet_group.name
allocated_storage = 20
storage_type = "gp2"
engine = "mysql"
engine_version = "5.7"
instance_class = "db.t2.micro"
db_name = "mydb"
username = "admin"
password = data.aws_ssm_parameter.rds_password.value
parameter_group_name = "default.mysql5.7"
skip_final_snapshot = true
vpc_security_group_ids = [aws_security_group.rds.id]
tags = {
Name = "${local.app} RDS Instance"
}
}
从现在开始,我将详细地进行解释
allocated_storage = 20
storage_type = "gp2"
engine = "mysql"
engine_version = "5.7"
instance_class = "db.t2.micro"
这部分指定了RDS实例的容量等。
data "aws_ssm_parameter" "rds_password" {
name = "/${local.app}/rds/password"
}
这里正在获取存储在 SSM 参数存储中的 MySQL 密码信息。
SSM参数存储是一个安全的存储服务,用于安全存储敏感信息。数据会被加密后保存,并可以使用KMS密钥进行加密和解密。
通过处理这个问题,可以安全地处理作为机密信息的MySQL连接信息。
另外,要让 Terraform 访问 SSM,需要 IAM 权限。下面是本次设置的 IAM。
data "aws_iam_policy_document" "ssm_get_parameter" {
statement {
actions = ["ssm:GetParameter"]
resources = ["arn:aws:ssm:ap-northeast-1:113713103169:parameter/recruit-service-board/rds/*"]
}
}
resource "aws_iam_policy" "ssm_get_parameter" {
name = "${local.app}-ssm-get-parameter"
description = "Allows access to SSM GetParameter"
policy = data.aws_iam_policy_document.ssm_get_parameter.json
}
最后,我将解释以下内容。
skip_final_snapshot = true
这个设置用于控制在删除RDS实例时是否获取最终的数据库快照。如果设置为true,则在删除实例时也不会获取最终的数据库快照。由于这会额外产生费用,所以我们明确地将其设置为true。
使用Github Actions进行CI/CD构建
这次我们在应用端和基础架构端分别建立了两个流水线。
基础设施部分
我們會使用Terraform將編寫的基礎架構資源同步到AWS上。然後,我們將使用GitHub Actions根據以下事件來觸發並啟動工作流程。
-
- mainブランチに向けてPRが作成された時
- mainブランチにmerge or pushされた時
name: "Terraform"
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
OIDC_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/recruit-info-service-github-actions
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Assume Role
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: ${{ env.OIDC_ARN }}
aws-region: ap-northeast-1
- name: Terraform Format
id: fmt
run: terraform fmt -check
- name: Terraform Init
id: init
run: terraform init
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color -input=false
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false
我将详细解释本次工作流程的关键点。
- name: Terraform Format
id: fmt
run: terraform fmt -check
检查HCL文件中的代码是否已经被格式化。
如果格式不正确,工作流将失败。
- name: Terraform Init
id: init
run: terraform init
在这里进行了Terraform的初始化,并为后续的terraform命令的正确执行做准备。
- name: Terraform Validate
id: validate
run: terraform validate -no-color
进行语法检查以验证HCL描述是否正确,并对目录中的所有HCL文件执行语法检查,如果存在语法错误,则工作流失败。
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color -input=false
在这里,我们会在创建或更新pull request时,根据terraform的配置执行资源变更的模拟,并展示其结果。
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false
在这里,当对主分支进行推送时,将使用Terraform编写的基础设施资源应用到AWS上。
应用程序方面
在应用程序中,关于CI/CD有两种主要的方式:
1. 当合并到主分支时,访问AWS并更新各个资源的工作流程。
name: "APP Build and Deploy"
on:
push:
branches:
- main
env:
OIDC_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/recruit-info-service-github-actions
ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com
ECR_REPOSITORY: recruit-service-board
APP: recruit-service-board
NEXT_ECR_REPOSITORY: next-service-board
NEXT_APP: next-service-board
NEXT_DOCKERFILE: ./front/front.dockerfile
permissions:
id-token: write
contents: read
jobs:
build-and-deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Assume Role
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: ${{ env.OIDC_ARN }}
aws-region: ap-northeast-1
- name: Login to ECR
uses: docker/login-action@v1
with:
registry: ${{ env.ECR_REGISTRY }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./app.dockerfile
push: true
tags: |
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Build and push Next.js
uses: docker/build-push-action@v5
with:
context: .
file: ${{ env.NEXT_DOCKERFILE }}
push: true
tags: |
${{ env.ECR_REGISTRY }}/${{ env.NEXT_ECR_REPOSITORY }}:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-next,mode=max
- name: Move Next.js cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-next /tmp/.buildx-cache
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ./aws/task-definition.json
container-name: ${{ env.APP }}
image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
- name: Fill in the new image ID in the Next.js Amazon ECS task definition
id: task-def-next
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ./aws/task-definition-next.json
container-name: ${{ env.NEXT_APP }}
image: ${{ env.ECR_REGISTRY }}/${{ env.NEXT_ECR_REPOSITORY }}:${{ github.sha }}
- name: Trigger Lambda through API Gateway
run: |
response_body=$(curl -s ${{ secrets.API_GATEWAY_ENDPOINT }})
response_code=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.API_GATEWAY_ENDPOINT }})
echo "Response Body: $response_body"
echo "Response Code: $response_code"
if [ "$response_code" -ne 200 ]; then
echo "Failed to trigger Lambda through API Gateway. HTTP Response code: $response_code"
exit 1
fi
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.APP }}
cluster: ${{ env.APP }}
wait-for-service-stability: true
timeout-minutes: 5
- name: Deploy Next.js Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def-next.outputs.task-definition }}
service: ${{ env.NEXT_APP }}
cluster: ${{ env.NEXT_APP }}
wait-for-service-stability: true
timeout-minutes: 5
关于上述的工作流程内容,简略提及。
- 今回のワークフローが発火する条件として、直接mainにコードがpushされた時とbranchがmainmergeされた時となっている
on:
push:
branches:
- main
- ここでは各種権限を設定している
permissions:
id-token: write => (OIDC認証用)
contents: read => (イメージビルドの際、リポジトリコンテンツ参照用)
- dockerコマンドの機能を拡張することができるBuildxプラグインを導入
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- ここでOIDC認証により、Github ActionsがAWSへアクセスできるようになる
- name: Assume Role
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: ${{ env.OIDC_ARN }}
aws-region: ap-northeast-1
- ECRへのログインを行う
- name: Login to ECR
uses: docker/login-action@v1
with:
registry: ${{ env.ECR_REGISTRY }}
- Docker イメージのビルド・プッシュ・キャッシュの生成を行う
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./app.dockerfile
push: true
tags: |
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- 各デプロイでimageタグに置き換え、最新に保つ
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ./aws/task-definition.json
container-name: ${{ env.APP }}
image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
- deployのタイミングでRDSに対してmigrationを回す
- name: Trigger Lambda through API Gateway
run: |
response_body=$(curl -s ${{ secrets.API_GATEWAY_ENDPOINT }})
response_code=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.API_GATEWAY_ENDPOINT }})
echo "Response Body: $response_body"
echo "Response Code: $response_code"
if [ "$response_code" -ne 200 ]; then
echo "Failed to trigger Lambda through API Gateway. HTTP Response code: $response_code"
exit 1
fi
关于这一点,我会进行解释。
这个工作流的角色是“一旦部署,就对RDS进行迁移”
所以在这次的实现中,我们不得不采用以下的架构。
- CIでLambdaを起動するTriggerとなるAPI Gatewayにアクセスする
response_body=$(curl -s ${{ secrets.API_GATEWAY_ENDPOINT }})
response_code=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.API_GATEWAY_ENDPOINT }})
(API Gateway与Lambda函数之间的通信是通过跨越VPC边界进行的,但实际流量并不经过VPC的外部网络,而是通过AWS的内部网络进行。因此,即使API Gateway是公开的,也可以直接触发VPC内的Lambda函数。)
- Lambdaには以下のようなGoのスクリプトのbinaryファイルとして実行しています
package main
import (
"context"
"log"
"recruit-info-service/db"
"recruit-info-service/model"
"github.com/aws/aws-lambda-go/lambda"
)
func HandleRequest(ctx context.Context) (string, error) {
log.Println("Starting the migration for the recruit info service DB...")
dbConn := db.NewDB()
defer log.Println("Successfully Migrated")
defer db.CloseDB(dbConn)
dbConn.AutoMigrate(
&model.User{},
&model.Company{},
&model.Technology{},
&model.CompanyTechnology{},
&model.TechnologyTag{},
&model.TechnologyTechnologyTag{},
&model.Like{},
&model.Comment{},
)
return "Migration completed successfully!", nil
}
func main() {
lambda.Start(HandleRequest)
}
dbConn := db.NewDB()の段階で必要なDB情報をSSM parameter Storeに取りにいく
Lambda関数がRDSに対してmigrate処理をする
在此过程中,我们会额外定义以下两个安全组。
-
- Lambdaのセキュリティグループの設定
RDSへの接続を許可するためのingressルール(RDSが使用しているポートへの接続を許可するルール)を追加する必要がある
ingress {
description = "Allow MySQL connection to RDS"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.rds.id]
}
-
- RDSのセキュリティグループの設定
Lambdaからの接続を許可するためのingressルールを追加する必要がある
ingress {
description = "Allow MySQL from Lambda security group"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.lambda_sg.id]
}
可以通过Github Actions调用API Gateway端点,启动VPC内的Lambda,并执行对RDS的迁移操作。
- 最新のイメージが指定されたタスク定義をデプロイ
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.APP }}
cluster: ${{ env.APP }}
wait-for-service-stability: true
设置 “wait-for-service-stability” 为 true,将等待直到服务稳定运行完成。
2. 对Go应用程序代码进行自动测试和Linter执行。
name: Go Lint and Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.x
- name: Install dependencies
run: go mod download
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
- name: Run Go Test
run: go test ./tests/...
- golangcli-lintを使用してGoのコードにLinterを通す
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
- テストコードの自動実行
- name: Run Go Test
run: go test ./tests/...
最终
这次我首次尝试使用“AWS ✖︎ Terraform ✖︎ Github Actions”这个技术堆栈来进行基础架构搭建。首先我想列举一些反省的事项。
尽管面临着诸多挑战,这个个人开发项目仍然有很多未解决的问题。但由于接触到了许多初次接触的技术,并且非常兴奋地投身其中,我认为这是一次非常宝贵的经历。