프로젝트 개요
이번 프로젝트의 목표는 AWS CodePipeline을 활용하여 CI/CD 파이프라인을 구축하고, 이를 통해 CI/CD 개념을 실제로 이해하고 적용하는 것이다. 또한, ECS 클러스터 기반의 컨테이너 운영 방식을 사용함으로써 AWS 전체 인프라 아키텍처의 흐름을 이해하는 것을 함께 목표로 한다. Terraform을 활용하여 모든 AWS 리소스를 코드로 자동화하고 재사용 가능하도록 구성함으로써, 인프라 관리의 효율성과 일관성을 높이는 것을 최종 목표로 한다.
아키텍처

본 프로젝트는 AWS CodeCommit, CodeBuild, CodePipeline을 연동하여 CI/CD 파이프라인을 구축하고, ECS Fargate 기반의 Task를 통해 웹 서비스를 배포하도록 구성하였다. 인프라는 Terraform으로 선언형 코드화하여 ECS 서비스, 배포 파이프라인, 로깅 및 모니터링 설정까지 자동화하였다.
ECS Task의 컨테이너 로그는 LogConfiguration을 통해 CloudWatch Logs로 수집되며, Container Insights를 통해 수집된 리소스 메트릭은 CloudWatch Metrics와 Logs에서 확인할 수 있다. 또한, CPU나 메모리 사용률이 임계치를 초과하면 SNS를 통해 이메일 알림이 전송되도록 알람 정책을 구성하였다.
사용 기술 스택
- VPC
- EC2
- ASG
- Appliation Load Balancer
- ECS
- ECR
- CodeCommit
- CodeBuild
- CodePipeline
- S3
- CloudWatch
- SNS
GitHub
https://github.com/rlaehdwn0105/AWS-ECS-CICD-Terraform
프로젝트 수행 날짜
2023.12.26 ~ 2024.01.01
ECS 구성요소
ECS는 AWS에서 제공하는 완전관리형 컨테이너 오케스트레이션 서비스로, Docker 컨테이너를 손쉽게 배포하고 관리할 수 있도록 돕는다.
본 프로젝트에서는 Fargate 런타입을 사용하여, 인스턴스를 직접 관리하지 않고 컨테이너를 실행하며, 인프라 운영 부담을 줄이고 자동 확장이 가능하도록 구성하였다.
- ECS Cluster: 컨테이너가 배포되는 논리적인 집합
- Task Definition: 컨테이너의 실행 사양 (이미지, 포트, CPU, 메모리, 로그 등)을 정의한 설계서
- Task: Task Definition을 기반으로 실제 실행되는 컨테이너 단위. 하나의 Task는 하나 이상의 컨테이너를 포함할 수 있다.
- Service: 원하는 개수의 Task가 항상 실행되도록 유지하며, Auto Scaling, Load Balancing 등의 기능도 함께 제공한다
CI/CD 흐름
- 개발자가 CodeCommit에 소스 코드를 Git push한다.
→ AWS의 Git 저장소 역할로, 변경 사항이 발생하면 파이프라인이 자동으로 트리거된다. - CodePipeline이 실행되며 CI/CD 파이프라인이 시작된다.
→ 첫 단계인 Source 단계에서 CodeCommit의 변경 사항을 감지한다. - CodeBuild가 실행되어 Docker 이미지를 빌드한다.
→ buildspec.yml에 정의된 명령어에 따라 이미지를 빌드하고, 해당 이미지를 ECR에 푸시한다. - Docker 이미지 정보를 담은 imagedefinitions.json 파일을 생성한다.
→ 이 파일은 빌드 결과물로, 이후 ECS 배포에 사용된다.
→ CodePipeline은 이 파일을 S3에 저장한다. - Deploy 단계에서 CodePipeline이 imagedefinitions.json을 참조한다.
→ 최신 이미지가 지정된 ECS 서비스(Task Definition)에 자동 배포된다.
Terraform
폴더구조
Terraform 코드는 네트워크, 모니터링, 클러스터(ECS), CI/CD 등 주요 기능 단위로 디렉터리를 분리하여 구성하였으며, 각 디렉터리 내부에서는 AWS 리소스 단위로 파일을 세분화하여 가독성과 유지 보수성을 높였다. 이렇게 분리된 모듈들은 common 디렉터리의 main.tf 파일에서 모듈화하여 호출되며, 이를 통해 전체 인프라를 일관되게 구성하고 배포할 수 있도록 설계하였다.

1. Network
VPC
사용자 정의 CIDR로 전용 가상 네트워크(VPC)를 생성
#### VPC 생성 ####
resource "aws_vpc" "my_vpc" {
cidr_block = var.vpc_cidr
instance_tenancy = var.instance_tenancy
tags = var.vpc_tag
}
인터넷 게이트웨이 및 NAT 게이트웨이
퍼블릭 서브넷을 위한 인터넷 게이트웨이(IGW), 프라이빗 서브넷에서 외부 통신을 위한 NAT 게이트웨이 구성
### igw.tf ###
### Internet gateway 생성 ###
resource "aws_internet_gateway" "my_igw" {
vpc_id = var.vpc-id
tags = var.igw-tags
}
### nat.tf ###
### Elastic Ip 생성 ###
resource "aws_eip" "NAT-eip" {
domain = "vpc"
}
### NAT gateway 생성 ###
resource "aws_nat_gateway" "myNAT" {
allocation_id = var.myeip-id
subnet_id = var.pub-sub2-id
tags = var.nat-tags
}
서브넷
서로 다른 가용 영역에 퍼블릭 서브넷 2개와 프라이빗 서브넷 2개를 생성하고, 서브넷별로 라우팅을 분리한 구조이다. 퍼블릭 서브넷은 인터넷 게이트웨이(IGW)를 통해 외부 통신이 가능하며, 프라이빗 서브넷은 NAT 게이트웨이를 통해 제한된 외부 접근만 허용하도록 구성하였다. 프라이빗 서브넷에는 ECS가 배포된다.
### Subnet 생성 ###
### Public Subnet 생성 ###
resource "aws_subnet" "pub_sub1" {
vpc_id = aws_vpc.my_vpc.id
cidr_block = var.pub-sub1-cidr
map_public_ip_on_launch = true
availability_zone = var.zone_1
tags = var.pub-sub1-tags
}
resource "aws_subnet" "pub_sub2" {
vpc_id = aws_vpc.my_vpc.id
cidr_block = var.pub-sub2-cidr
map_public_ip_on_launch = true
availability_zone = var.zone_2
tags = var.pub-sub2-tags
}
### Public-Route 생성-연결 ###
resource "aws_route_table" "pub_rt" {
vpc_id = var.vpc-id
tags = var.pub-rt-tags
}
resource "aws_route" "pub_route" {
route_table_id = var.pub-rt-id
destination_cidr_block = var.destination_cidr_block
gateway_id = var.myigw-id
}
resource "aws_route_table_association" "pub_assoc1" {
subnet_id = var.pub-sub1-id
route_table_id = var.pub-rt-id
}
resource "aws_route_table_association" "pub_assoc2" {
subnet_id = var.pub-sub2-id
route_table_id = var.pub-rt-id
}
### Private-Route 생성 1,2 ###
### Private-Subnet 생성 ###
resource "aws_subnet" "pri_sub1" {
vpc_id = var.vpc-id
cidr_block = var.pri-sub1-cidr
availability_zone = var.zone_1
tags = var.pri-sub1-tags
}
resource "aws_subnet" "pri_sub2" {
vpc_id = var.vpc-id
cidr_block = var.pri-sub2-cidr
availability_zone = var.zone_2
tags = var.pri-sub2-tags
}
### Private-Route 생성-연결 ###
resource "aws_route_table" "pri_rt" {
vpc_id = var.vpc-id
tags = var.pri-rt-tags
}
resource "aws_route" "pri_route" {
route_table_id = var.pri-rt-id
destination_cidr_block = var.destination_cidr_block
nat_gateway_id = var.mynat-id
}
resource "aws_route_table_association" "private_assoc1" {
subnet_id = var.pri-sub1-id
route_table_id = var.pri-rt-id
}
resource "aws_route_table_association" "private_assoc2" {
subnet_id = var.pri-sub2-id
route_table_id = var.pri-rt-id
}
Bastion Host
### keypair ###
resource "aws_key_pair" "testkey" {
key_name = "testkey"
public_key = file("~/.ssh/testkey.pub")
}
### 보안그룹 - instance ###
resource "aws_security_group" "SG_instance" {
name = "SG_instance"
description = "Allow HTTP(80/tcp, 8080/tcp), ssh(22/tcp)"
vpc_id = var.vpc-id
ingress {
description = "Allow HTTP(80)"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "Allow HTTPs(8080)"
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "Allow ssh(22)"
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 = "SG_instance"
}
}
### EC2 역할 & 정책 ###
### EC2 정책 ###
data "aws_iam_policy_document" "ec2_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
### EC2 역할 ###
resource "aws_iam_role" "ec2-role" {
name = "ecr-role"
assume_role_policy = data.aws_iam_policy_document.ec2_role.json
### Policy
}
resource "aws_iam_role_policy_attachment" "AdministratorAccess" {
role = aws_iam_role.ec2-role.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
resource "aws_iam_instance_profile" "test_profile" {
name = "test_profile"
role = aws_iam_role.ec2-role.name
}
### bastion-Instance 생성 ###
resource "aws_instance" "bastion-host" {
ami = "ami-011ab7c70f5b5170a"
instance_type = "t2.micro"
iam_instance_profile = aws_iam_instance_profile.test_profile.name
vpc_security_group_ids = [aws_security_group.SG_instance.id]
subnet_id = var.pub-sub1-id
user_data = <<-EOF
#!/bin/bash
sudo -i sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
systemctl restart sshd
echo 'qwe123' | passwd --stdin root
yum install -y docker
EOF
root_block_device {
volume_size = 10
}
tags = var.ec2-tags
}
variable.tf
module에서 AWS 리소스를 연결하기 위해 variable.tf와 output.tf를 사용하여 변수 정의와 출력값을 설정하였다.
variable.tf에서는 모듈 내부에서 사용할 입력 값을 정의하고, output.tf에서는 모듈 실행 후 상위 구성에서 참조할 수 있도록 주요 리소스의 ID나 속성을 출력하도록 구성하였다.
##### VPC 생성 #####
variable "vpc_cidr" {
description = "VPC"
type = string
default = "10.16.0.0/16"
}
variable "instance_tenancy" {
description = "Instance Tenancy"
type = string
default = "default"
}
variable "vpc_tag" {
description = "VPC tags"
type = map(string)
default = {
Name = "my_vpc"
}
}
variable "vpc-id" {
description = "VPC ID"
type = string
}
### Subnet ###
### public subnet ###
variable "pub-sub1-cidr" {
description = "Pub_Sub1 CIDR Block"
type = string
default = "10.16.1.0/24"
}
variable "pub-sub2-cidr" {
description = "Pub_Sub2 CIDR Block"
type = string
default = "10.16.2.0/24"
}
variable "pub-sub1-tags" {
description = "Pub_Sub1 tags"
type = map(string)
default = { Name = "pub_sub1" }
}
variable "pub-sub2-tags" {
description = "Pub_Sub2 tags"
type = map(string)
default = { Name = "pub_sub2" }
}
### zone ###
variable "zone_1" {
description = "value"
type = string
default = "us-east-2a"
}
variable "zone_2" {
description = "value"
type = string
default = "us-east-2b"
}
### private-subnet ###
variable "pri-sub1-cidr" {
description = "Pri_Sub1 CIDR Block"
type = string
default = "10.16.3.0/24"
}
variable "pri-sub2-cidr" {
description = "Pri_Sub2 CIDR Block"
type = string
default = "10.16.4.0/24"
}
variable "pri-sub1-tags" {
description = "Pri_Sub1 tags"
type = map(string)
default = { Name = "pri_sub1" }
}
variable "pri-sub2-tags" {
description = "Pri_Sub2 tags"
type = map(string)
default = { Name = "pri_sub2" }
}
### route_table ###
variable "pub-rt-id" {
description = "Pub_RT_ID"
type = string
}
variable "pub-rt-tags" {
description = "Pub_RT tags"
type = map(string)
default = { Name = "pub_rt_table" }
}
variable "myigw-id" {
description = "Internet Gateway ID"
type = string
}
variable "pub-sub1-id" {
description = "Pub_Sub1 ID"
type = string
}
variable "pub-sub2-id" {
description = "Pub_Sub_2 ID"
type = string
}
variable "pri-rt-id" {
description = "Pri_RT ID"
type = string
}
variable "pri-rt-tags" {
description = "Pri_RT tags"
type = map(string)
default = { Name = "pri_rt_table" }
}
variable "pri-sub1-id" {
description = "Pri_Sub1 ID"
type = string
}
variable "pri-sub2-id" {
description = "Pri_Sub2 ID"
type = string
}
variable "destination_cidr_block" {
description = "Destination_cidr_block"
type = string
default = "0.0.0.0/0"
}
### internet gateway ###
variable "igw-tags" {
description = "Internet Gateway tags"
type = map(string)
default = { Name = "my_igw" }
}
### NAT gateway ###
variable "myeip-id" {
description = "Eip ID"
type = string
}
variable "mynat-id" {
description = "NAT Gateway ID"
type = string
}
variable "nat-tags" {
description = "Internet Gateway tags"
type = map(string)
default = { Name = "my_nat" }
}
### bation host ###
variable "ec2-tags" {
description = "Internet Gateway tags"
type = map(string)
default = { Name = "bastion-host" }
}
output.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.my_vpc.id
}
output "pub_sub1_id" {
description = "PUBLIC SUBNET1"
value = aws_subnet.pub_sub1.id
}
output "pub_sub2_id" {
description = "PUBLIC SUBNET2"
value = aws_subnet.pub_sub2.id
}
output "pri_sub1_id" {
description = "PRIVATE SUBNET1"
value = aws_subnet.pri_sub1.id
}
output "pri_sub2_id" {
description = "PRIVATE SUBNET2"
value = aws_subnet.pri_sub2.id
}
output "pub_rt_id" {
description = "ROUTING TABLE ID"
value = aws_route_table.pub_rt.id
}
output "pri_rt_id" {
description = "ROUTING TABLE ID"
value = aws_route_table.pri_rt.id
}
output "eip_id" {
description = "VALUE"
value = aws_eip.NAT-eip.id
}
output "igw_id" {
description = "IGW ID"
value = aws_internet_gateway.my_igw.id
}
output "nat_id" {
description = "VALUE"
value = aws_nat_gateway.myNAT.id
}
2. ECS
ECS Task Defintion
Fargate 기반 ECS Task 실행을 위한 구성으로, AmazonECSTaskExecutionRolePolicy를 부여하여 ECS Task가 ECR 이미지 다운로드, CloudWatch Logs 전송 등에 필요한 최소 권한을 갖도록 하였다.
network_mode = "awsvpc" 설정을 통해 Task마다 고유한 ENI가 생성되어, 각 Task에 보안 그룹과 서브넷이 직접 적용된다. 다른 network_mode로는 bridge, host, none이 있다.
container_definitions = jsonencode([...]) 구문을 사용하여 컨테이너 설정을 JSON 형식으로 작성하며, 이미지, 리소스, 포트, 로그 구성 등 ECS Task의 실행 환경이 포함된다.
### ECS 역할 정책 ###
### ECS-Task-Assume Role Policy ###
data "aws_iam_policy_document" "ecs_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
### ECS-Task-Role ###
resource "aws_iam_role" "ecs_role" {
name = "ecs-role"
assume_role_policy = data.aws_iam_policy_document.ecs_role.json
}
### ECS-Task-Policy ###
resource "aws_iam_role_policy_attachment" "AmazonECSTaskExecutionRolePolicy" {
role = aws_iam_role.ecs_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
### ECS 생성 ###
### ECS-Task-Definition ###
resource "aws_ecs_task_definition" "service" {
family = "service"
network_mode = "awsvpc"
execution_role_arn = aws_iam_role.ecs_role.arn
cpu = 256
memory = 512
requires_compatibilities = ["FARGATE"]
task_role_arn = aws_iam_role.ecs_role.arn
container_definitions = jsonencode([
{
"name": "service",
"image": "${var.ecr-url}",
"cpu": 256,
"memory": 512,
"essential": true,
"portMappings": [
{
"name": "serivce-80-tcp",
"containerPort": 80,
"hostPort": 80,
"appProtocol": "http"
}
],
"logconfiguration" : {
"logdriver" : "awslogs",
"options" : {
"awslogs-group" : "${var.log-group}",
"awslogs-region" : "us-east-2",
"awslogs-stream-prefix" : "${var.log-stream}",
}
}
}
])
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
}
ECS Cluster & Service
ECS Cluster에서는 containerInsights 기능을 활성화하여 Task와 컨테이너의 리소스 사용량을 모니터링할 수 있도록 구성하였다. ECS Service는 EC2와 Fargate 중 실행 환경을 선택할 수 있는데, 앞서 설명한 것처럼 이 구성에서는 Fargate 모드를 사용하였다. 실행되는 Task는 단순 웹 서버 테스트 목적이므로, desired_count를 1로 설정하여 단일 Task만 실행되도록 하였다.
외부에서 서비스에 접근할 수 있도록 Application Load Balancer(ALB)를 연결하여, 트래픽이 ECS Task로 전달될 수 있도록 구성하였다.
### ECS Cluster ###
resource "aws_ecs_cluster" "ECS_Cluster" {
name = "my_cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
### ECS service ###
resource "aws_ecs_service" "ECS-Service" {
name = "service"
cluster = aws_ecs_cluster.ECS_Cluster.id
task_definition = aws_ecs_task_definition.service.arn
launch_type = "FARGATE"
desired_count = 1
network_configuration {
security_groups = [aws_security_group.SG_alb.id]
subnets = [
var.pri-sub1-id,
var.pri-sub2-id
]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.ALB-TG.arn
container_name = aws_ecs_task_definition.service.family
container_port = 80
}
}
### LB 구성 ####
### 보안그룹 - ALB ###
resource "aws_security_group" "SG_alb" {
name = "WEBSG"
description = "Allow HTTP(80/tcp, 8080/tcp)"
vpc_id = var.vpc-id
ingress {
description = "Allow HTTP(80)"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "Allow HTTP(8080)"
from_port = 8080
to_port = 8080
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 = "SG_alb"
}
}
### ALB 생성 ###
resource "aws_lb" "ALB" {
name = "myALB"
load_balancer_type = "application"
subnets = [
var.pub-sub1-id,
var.pub-sub2-id
]
security_groups = [aws_security_group.SG_alb.id]
}
### ALB Listner 생성 ###
resource "aws_lb_listener" "ALB-Listener" {
load_balancer_arn = aws_lb.ALB.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.ALB-TG.arn
}
}
### Tagret Group 생성 ###
resource "aws_lb_target_group" "ALB-TG" {
name = "myALB-TG"
port = 80
protocol = "HTTP"
target_type = "ip"
vpc_id = var.vpc-id
}
ECR
ECR에 MUTABLE 옵션을 적용하여 동일한 태그의 이미지 덮어쓰기를 허용하고, 푸시 시 자동 보안 스캔이 수행되도록 구성하였다.
aws_ecr_lifecycle_policy를 통해 태그가 v로 시작하는 이미지 중 최근 30개만 유지하고, 나머지는 자동으로 삭제되도록 하였다. 현재 buildspec.yml에서는 latest 태그만 사용하지만, 운영 환경에서는 v태그를 명시하여 수명 주기 정책이 효과적으로 적용되도록 해야한다.
###ECR 생성 ###
resource "aws_ecr_repository" "my_ecr" {
name = "my_ecr"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = var.ecr-tags
}
resource "aws_ecr_lifecycle_policy" "ecr_policy" {
repository = aws_ecr_repository.my_ecr.name
policy = <<EOF
{
"rules": [
{
"rulePriority": 1,
"description": "Keep last 30 images",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["v"],
"countType": "imageCountMoreThan",
"countNumber": 30
},
"action": {
"type": "expire"
}
}
]
}
EOF
}
variables.tf
variable "ecr-url" {
description = "ECR-URL"
type = string
}
variable "pri-sub1-id" {
description = "Pri-Sub1 ID"
type = string
}
variable "pri-sub2-id" {
description = "Pri-Sub2 ID"
type = string
}
variable "pub-sub1-id" {
description = "Pub-Sub1 ID"
type = string
}
variable "pub-sub2-id" {
description = "Pri-Sub2 ID"
type = string
}
variable "vpc-id" {
description = "VPC ID"
type = string
}
variable "ecr-tags" {
description = "ECR tags"
type = map(string)
default = { Name = "ecr" }
}
variable "log-group" {
description = "cloudwatch log group ID"
type = string
}
variable "log-stream" {
description = "cloudwatch log stream ID"
type = string
}
output.tf
output "dns_name" {
description = "ALB DNS Name"
value = aws_lb.ALB.dns_name
}
output "ecr_name" {
description = "ECR NAME"
value = aws_ecr_repository.my_ecr.name
}
output "ecr_url" {
description = "ECR NAME"
value = aws_ecr_repository.my_ecr.repository_url
}
output "ecs-cluster-name" {
description = "ECS CLUSTER NAME"
value = aws_ecs_cluster.ECS_Cluster.name
}
output "ecs-service-name" {
description = "ECS SERVICE NAME"
value = aws_ecs_service.ECS-Service.name
}
3. CI/CD
CodeBuild
CodeBuild는 Docker 이미지를 빌드하여 ECR에 푸시하고, 그에 대한 artifact를 S3에 저장하도록 구성하였다. 이를 위해 S3, ECR, CodePipeline, CodeCommit에 대한 권한을 부여하는 IAM 정책을 연결하였다.
cache 옵션에는 LOCAL_DOCKER_LAYER_CACHE와 LOCAL_SOURCE_CACHE를 설정하여 Docker 계층과 소스 코드를 로컬에 캐싱함으로써 빌드 속도를 최적화하였다. 또한 environment 블록에서는 Amazon Linux 2 기반 표준 이미지를 사용하고, Docker 빌드를 위한 권한 상승이 가능하도록 privileged_mode = true를 설정하였다.
CodeBuild는 빌드 요청이 들어오면 내부적으로 임시 EC2 기반 환경을 생성하고, 해당 환경에서 Amazon Linux 2 기반의 Docker 컨테이너를 실행해 빌드 작업을 수행한다.
# VPC Defalut Security_group
data "aws_security_group" "default" {
name = "default"
vpc_id = var.vpc-id
}
### CodeBuild IAM Role & Policy ###
### CodeBuild-Assume Role ###
data "aws_iam_policy_document" "codebuild_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["codebuild.amazonaws.com"]
}
}
}
### CodeBuild-Role ###
resource "aws_iam_role" "codebuild_role" {
name = "build-role"
assume_role_policy = data.aws_iam_policy_document.codebuild_role.json
}
### codebuild-policy ###
resource "aws_iam_role_policy_attachment" "CodeBuildAccess" {
role = aws_iam_role.codebuild_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess"
}
resource "aws_iam_role_policy_attachment" "ECRAccess" {
role = aws_iam_role.codebuild_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess"
}
resource "aws_iam_role_policy_attachment" "S3Access" {
role = aws_iam_role.codebuild_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}
resource "aws_iam_role_policy_attachment" "CodeCommitAccess" {
role = aws_iam_role.codebuild_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeCommitFullAccess"
}
### CodeBuild ###
resource "aws_codebuild_project" "MyBuildProject" {
name = "MyBuildProject"
description = "CodeBuild Project"
service_role = aws_iam_role.codebuild_role.arn
artifacts {
type = "S3"
name = var.s3-id
location = var.s3-bucket
path = "/"
packaging = "ZIP"
}
cache {
type = "LOCAL"
modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"]
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/amazonlinux2-x86_64-standard:4.0"
type = "LINUX_CONTAINER"
image_pull_credentials_type = "CODEBUILD"
privileged_mode = true
}
vpc_config {
vpc_id = var.vpc-id
subnets = [
var.pri-sub1-id,
var.pri-sub2-id
]
security_group_ids = [
data.aws_security_group.default.id
]
}
source {
type = "CODECOMMIT"
location = var.code-repo-url
buildspec = "buildspec.yml"
}
logs_config {
cloudwatch_logs {
group_name = "Build-log-group"
status = "ENABLED"
}
}
}
CodeCommit
AWS에서 제공하는 Git 호환 소스 Repository 서비스이다. 일반적인 Git처럼 buildspec.yml, Dockerfile, 애플리케이션 소스 코드 등을 함께 업로드할 수 있다. CodePipeline과 연동하면, Push 이벤트가 발생했을 때 자동으로 트리거되어 CodeBuild가 실행되며, 설정된 대로 이미지를 빌드하고 ECR에 푸시하거나 다음 단계로 전달할 수 있다.
### Codecommit ###
### Codecommit-repository ###
resource "aws_codecommit_repository" "MyCommitRepository" {
repository_name = "MyCommitRepository"
description = "Repository for CodeCommit"
}
CodePipeline
CodePipeline 설정은 Source, Build, Deploy 단계로 구성되어 있다. 각 단계는 artifact를 통해 연결된다.
먼저 artifact_store는 빌드와 배포 과정에서 생성되는 산출물을 저장할 S3 버킷을 지정하며, 단계 간 데이터 전달에 사용된다.
- Source 단계에서는 RepositoryName과 BranchName(master)을 기준으로 CodeCommit 저장소에서 코드를 가져오고, 그 결과를 source_output이라는 이름의 artifact 로 생성한다.
- Build 단계에서는 이 source_output을 입력으로 받아, 지정된 ProjectName의 CodeBuild를 실행하여 Docker 이미지 빌드 등의 작업을 수행하고, 결과를 build_output이라는 artifact로 전달한다.
- Deploy 단계에서는 이 build_output 안의 imagedefinitions.json 파일을 참조하여, 지정된 ECS에 새 이미지를 배포한다.
### CodePipeline IAM Role & Policy ###
### CodePipeline-Assume Role ###
data "aws_iam_policy_document" "pipe_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["codepipeline.amazonaws.com"]
}
}
}
### CodePipeline-Role ###
resource "aws_iam_role" "pipe_role" {
name = "pipe-role"
assume_role_policy = data.aws_iam_policy_document.pipe_role.json
}
### CodePipeline-policy ###
resource "aws_iam_role_policy_attachment" "AWSCodePipeline_FullAccess" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodePipeline_FullAccess"
}
resource "aws_iam_role_policy_attachment" "AmazonS3FullAccess" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}
resource "aws_iam_role_policy_attachment" "AWSCodeBuildAdminAccess" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess"
}
resource "aws_iam_role_policy_attachment" "AmazonECSTaskExecutionRolePolicy" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy_attachment" "AmazonECS_FullAccess" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonECS_FullAccess"
}
/*
resource "aws_iam_role_policy_attachment" "AWSCodeDeployRoleForECS" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS"
}
*/
resource "aws_iam_role_policy_attachment" "AWSCodeCommitReadOnlyAccess" {
role = aws_iam_role.pipe_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeCommitReadOnly"
}
### CodePipeline ###
resource "aws_codepipeline" "codepipeline" {
name = "test-pipeline"
role_arn = aws_iam_role.pipe_role.arn
artifact_store {
location = var.s3-bucket
type = "S3"
}
stage {
name = "Source"
action {
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeCommit"
version = "1"
output_artifacts = ["source_output"]
configuration = {
RepositoryName = "MyCommitRepository"
BranchName = "master"
}
}
}
stage {
name = "Build"
action {
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
input_artifacts = ["source_output"]
output_artifacts = ["build_output"]
version = "1"
configuration = {
ProjectName = "MyBuildProject"
}
}
}
stage {
name = "Deploy"
action {
name = "Deploy"
category = "Deploy"
owner = "AWS"
provider = "ECS"
input_artifacts = ["build_output"]
version = "1"
configuration = {
ClusterName = "my_cluster"
ServiceName = "service"
FileName = "imagedefinitions.json"
}
}
}
}
S3
artifact에서 사용할 S3 Bucket이다. 별도 변수 없이 버킷 이름을 직접 명시하여 네이밍해주었다.
resource "aws_s3_bucket" "Mys3" {
bucket = "mys3-8596"
}
variable.tf
variable "vpc-id" {
description = "VPC ID"
type = string
}
variable "s3-id" {
description = "S3 NAME"
type = string
}
variable "s3-bucket" {
description = "S3 BUCKET"
type = string
}
variable "pri-sub1-id" {
description = "Pri-Sub1 ID"
type = string
}
variable "pri-sub2-id" {
description = "Pri-Sub2 ID"
type = string
}
variable "code-repo-url" {
description = "Code Commit Repository URL"
type = string
}
/*
variable "code-build-id" {
description = "code-build-project ID"
type = string
}
/*
variable "commit-repository-id" {
description = "Repository for CodeCommit"
type = string
}
*/
#####
output.tf
output "s3_url" {
description = "s3-url"
value = aws_s3_bucket.Mys3.arn
}
output "s3_id" {
description = "s3-name"
value = aws_s3_bucket.Mys3.id
}
output "s3_bucket" {
description = "s3 bucket"
value = aws_s3_bucket.Mys3.bucket
}
output "code_repo_url" {
description = "Code Commit Repository URL"
value = aws_codecommit_repository.MyCommitRepository.clone_url_http
}
output "code_build_id" {
description = "Code Build Project Name"
value = aws_codebuild_project.MyBuildProject
}
/*
output "log_group_id" {
description = "cloudwatch_log_group Name"
value = aws_cloudwatch_log_group.log-group.name
}
*/
4. Monitoring
CloudWatch
- aws_cloudwatch_dashboard 리소스를 통해 ECS의 CPU 및 메모리 사용률을 시각화하는 그래프를 추가하였다. AWS/ECS 네임스페이스에서 ServiceName과 ClusterName 기준으로 CPU와 Memory 메트릭을 추적한다.
- 로그 기록을 위해 aws_cloudwatch_log_group과 aws_cloudwatch_log_stream 리소스를 정의하였다. task-log-group이라는 로그 그룹은 14일간 로그를 보관하며, 그 하위에 log-stream이라는 스트림을 만들어 ECS에서 발생하는 로그 데이터를 수집할 수 있도록 하였다.
- aws_cloudwatch_metric_alarm 리소스를 사용하여 CPUUtilization과 MemoryUtilization 각각에 대해 알람을 생성하였다. 평균 사용률이 40% 이상으로 2번 연속 감지될 경우, 사전에 정의된 SNS Topic으로 알람을 발송하도록 구성하였다.
### CloudWatch Dashboard ###
resource "aws_cloudwatch_dashboard" "ecs_dashboard" {
dashboard_name = "ecs-dashboard"
dashboard_body = jsonencode({
widgets = [
{
type = "metric",
x = 0,
y = 0,
width = 12,
height = 6,
properties = {
metrics = [
["AWS/ECS", "CPUUtilization", "ServiceName", var.ecs-service-name, "ClusterName", var.ecs-cluster-name, {stat = "Average"}],
[".", "MemoryUtilization", ".", ".", ".", ".", {stat = "Average"}]
],
region = "us-east-2"
annotations = {
horizontal = [
{
color = "#ff9896",
label = "100% CPU",
value = 100
},
{
color = "#9edae5",
label = "100% Memory",
value = 100,
yAxis = "right"
},
]
}
yAxis = {
left = {
min = 0
}
right = {
min = 0
}
}
period = 300,
title = "ECS Service Metrics",
},
},
],
})
}
### CloudWatch Log-group & log-stream
resource "aws_cloudwatch_log_group" "log-group" {
name = "task-log-group"
retention_in_days = "14"
}
resource "aws_cloudwatch_log_stream" "log-stream" {
name = "log-stream"
log_group_name = aws_cloudwatch_log_group.log-group.name
}
### ECS CloudAlarm metric - CPU ###
resource "aws_cloudwatch_metric_alarm" "ecs_service_cpu_alarm" {
alarm_name = "ecs-service-cpu-alarm"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 300
statistic = "Average"
threshold = 40
actions_enabled = true
alarm_description = "This will alarm if ECS service CPU utilization is greater than or equal to 80%"
dimensions = {
ServiceName = var.ecs-service-name
ClusterName = var.ecs-cluster-name
}
alarm_actions = [var.sns-topic-arn]
}
### ECS CloudAlarm metric - Memory ###
resource "aws_cloudwatch_metric_alarm" "ecs_service_memory_alarm" {
alarm_name = "ecs-service-memory-alarm"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "MemoryUtilization"
namespace = "AWS/ECS"
period = 300
statistic = "Average"
threshold = 40
actions_enabled = true
alarm_description = "This will alarm if ECS service memory utilization is greater than or equal to 80%"
dimensions = {
ServiceName = var.ecs-service-name
ClusterName = var.ecs-cluster-name
}
alarm_actions = [var.sns-topic-arn]
}
SNS
aws_sns_topic 리소스를 통해 주제를 생성하고, aws_sns_topic_subscription을 통해 해당 주제에 이메일 주소(dongju08@naver.com)를 구독자로 등록한다. 이 설정으로 알림이 발생하면 지정된 이메일로 알림이 전송된다.
### SNS Topic 생성 ###
resource "aws_sns_topic" "sns_topic" {
name = "sns-topic"
display_name = "SNS Topic"
}
### SNS 구독 생성 ###
resource "aws_sns_topic_subscription" "example_subscription" {
topic_arn = var.sns-topic-arn
protocol = "email"
endpoint = "dongju08@naver.com" # 알람을 받을 Email 주소
}
variable.tf
variable "ecs-service-name" {
description = "ecs-service-name"
type = string
}
variable "ecs-cluster-name" {
description = "ecs-cluster-name"
type = string
}
variable "sns-topic-arn" {
description = "sns-topic-arn"
type = string
}
output.tf
output "cloudwatch-log-name" {
description = "cloudwatch-log-name"
value = aws_cloudwatch_log_group.log-group.name
}
output "cloudwatch-log-stream-name" {
description = "cloudwatch-log-steram"
value = aws_cloudwatch_log_stream.log-stream.name
}
output "sns-topic-arn" {
description = "cloudwatch-log-steram"
value = aws_sns_topic.sns_topic.arn
}
main.tf
각 tf 파일을 기능별로 분리하였기 때문에, 모듈화를 통해 각 리소스에 필요한 값들을 전달하도록 하였다.
module "network" {
source = "../modules/network"
vpc-id = module.network.vpc_id
pub-sub1-id = module.network.pub_sub1_id
pub-sub2-id = module.network.pub_sub2_id
pri-sub1-id = module.network.pri_sub1_id
pri-sub2-id = module.network.pri_sub2_id
pub-rt-id = module.network.pub_rt_id
pri-rt-id = module.network.pri_rt_id
myeip-id = module.network.eip_id
myigw-id = module.network.igw_id
mynat-id = module.network.nat_id
}
module "cluster" {
source = "../modules/cluster"
ecr-url = module.cluster.ecr_url
pri-sub1-id = module.network.pri_sub1_id
pri-sub2-id = module.network.pri_sub2_id
pub-sub1-id = module.network.pub_sub1_id
pub-sub2-id = module.network.pub_sub2_id
vpc-id = module.network.vpc_id
log-group = module.monitoring.cloudwatch-log-name
log-stream = module.monitoring.cloudwatch-log-stream-name
}
module "cicd" {
source = "../modules/cicd"
vpc-id = module.network.vpc_id
s3-id = module.cicd.s3_id
s3-bucket = module.cicd.s3_bucket
pri-sub1-id = module.network.pri_sub1_id
pri-sub2-id = module.network.pri_sub2_id
code-repo-url = module.cicd.code_repo_url
}
module "monitoring" {
source = "../modules/monitoring"
ecs-service-name = module.cluster.ecs-service-name
ecs-cluster-name = module.cluster.ecs-cluster-name
sns-topic-arn = module.monitoring.sns-topic-arn
}
AWS 콘솔 확인
Terraform 코드를 통해 AWS 리소스가 문제없이 성공적으로 프로비저닝되었다.
VPC

S3

ECR

ECS

CODE COMMIT REPOSITORY

CODE BUILD

CODEPIPELINE

Cloudwatch dashboard

Cloudwatch metric alarm

Cloudwatch Log Group

SNS

CI/CD 테스트
1. CodeCommit Repository Push
먼저 Dockerfile, index.html, buildspec.yml 등의 파일을 CodeCommit Repository에 푸시하기 위해, Bastion Host 서버에서 생성된 CodeCommit 저장소를 등록하였다. 이후 안내된 절차에 따라 Git 연결을 설정하였다.

현재는 간단한 CI/CD 테스트를 위해 Apache를 사용하여 HTML를 배포하는 방식으로 구성하였다. 사용되는 빌드 파일들은 다음과같다.
Dockerfile
CentOS 기반 이미지를 사용하여 Apache 웹 서버를 설치해주었다.
FROM tgagor/centos-stream
MAINTAINER dongju
RUN yum -y install httpd
COPY index.html /var/www/html/
CMD ["/usr/sbin/httpd", "-D", "FOREGROUND"]
EXPOSE 80
buildspec.yml
buildspec.yml은 CodeBuild에서 Docker 이미지를 빌드하고 ECR에 푸시한 뒤, ECS 배포를 위한 imagedefinitions.json 파일을 생성하는 설정이다.
- pre_build: ECR에 로그인한다.
- build: Docker 이미지를 service:1 이름으로 빌드하고, ECR 리포지토리에 latest 태그로 태깅한다.
- post_build: ECR에 이미지를 푸시하고, ECS에서 사용할 imagedefinitions.json을 생성한다.
- artifacts: 생성한 imagedefinitions.json 파일을 산출물로 저장한다.(S3에 저장)
version: 0.2
phases:
pre_build:
commands:
- echo Logging in to Amazon ECR...
- $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION)
build:
commands:
- echo Build started on `date`
- echo Building the Docker image...
- docker build -t service:1 .
- docker tag service:1 880076045111.dkr.ecr.us-east-2.amazonaws.com/my_ecr:latest
post_build:
commands:
- echo Build completed on `date`
- echo Pushing the Docker image...
- docker push 880076045111.dkr.ecr.us-east-2.amazonaws.com/my_ecr:latest
- printf '[{"name":"service","imageUri":"%s"}]' 880076045111.dkr.ecr.us-east-2.amazonaws.com/my_ecr:latest > imagedefinitions.json
artifacts:
files: imagedefinitions.json
index.html
간단하게 index.html을 만들어주었다.
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Dongju Last Project!</title>
<style>
html {
height: 100%;
}
...........
a:hover {
text-decoration: none;
}
</style>
</head>
<body>
<div class="centered">
<div id='shadow'>
<div class='shadow'>
Dongju Last Project!
-> repository
</div>
</div>
</div>
</body>
</html>
작성한 파일들을 CodeCommit 저장소에 Push하여 CI/CD 파이프라인이 트리거되도록 한다.
$ git init
$ git add.
$ git commit –m test
$ git push origin master
-> repository

다음과 같이 CodeCommit 저장소에 파일이 정상적으로 업로드된 것을 확인할 수 있다.

2. Pipeline확인 & ALB 접속
다음과 같이 파이프라인이 실행되면서, ECS에 웹 서버가 자동으로 배포될 것이다.

ECS 서비스와 연결된 로드밸런서에 접속해보면, 웹 서버가 정상적으로 배포되어 작동하는 것을 확인할 수 있다.

3. 코드 수정
CI/CD 파이프라인이 정상적으로 작동하는지 다시 확인하기 위해 소스코드를 수정한 뒤 CodeCommit 저장소에 Push하였다. 이후 index.html의 문구가 변경되자, 파이프라인이 자동으로 트리거되어 새로운 Docker 이미지가 Build되는 것을 확인할 수 있다.

정상적으로 CI/CD 파이프라인이 작동하는 것을 확인하였다.

ECS 부하테스트
Cloudwatch Alarm & Dashboard 확인
ECS Fargate는 인스턴스에 직접 접속할 수 없기 때문에, Dockerfile을 활용해 스트레스 테스트 도구가 포함된 이미지를 빌드한 뒤 해당 컨테이너를 실행하여 테스트를 수행하였다.
Stress Dockerfile
Ubuntu 기반 이미지에 stress 도구를 설치한 후, CPU에만 부하를 주는 Dockerfile이다. 이 Dockerfile를 CodeCommit Repository에 Git Push 하여 CodePipeline 작동시켜 ECS에 배포해주었다.
FROM ubuntu:latest
RUN apt-get update && apt-get install –y stress-ng
CMD ["stress-ng", "--cpu", "2"]
CloudWatch Dashboard 확인
이미지가 ECS에 배포된 후, CloudWatch Dashboard를 통해 CPU 사용률을 모니터링한 결과, 설정한 기준치를 초과한 것을 확인할 수 있었다.

CloudWatch Metric Alarm에서도 CPU 사용률이 임계값을 초과함에 따라 경보가 발생한 것을 확인할 수 있었다.CPU에만 부하를 주었기 때문에, 메모리 사용률에는 별다른 변화가 없어 Memory 관련 알람은 발생하지 않았다.

SNS Email 확인
CPU 부하가 발생하자, 설정해둔 CloudWatch Metric Alarm이 트리거되어 SNS 토픽을 통해 이메일로 경보 알림이 전송되었다.

CloudWatch 로그
ECS 컨테이너 로그
해당 로그는 ECS Task의 LogConfiguration 설정을 통해 수집된 로그를 확인할 수 있는 CloudWatch 로그 그룹이다.
Task 정의에서 logConfiguration을 지정하여, 컨테이너 내에서 발생하는 STDOU 및 STDERR를 CloudWatch Logs로 전송하도록 구성하였다.
아래에 보이는 로그는 컨테이너 내에서 stress-ng 명령어를 실행하면서 발생한 시스템 부하 테스트 로그이며, CPU, IO, 메모리 등 자원에 부하를 주는 동안의 동작 정보와 에러 메시지가 포함되어 있다.

ECS 성능 지표 로그
해당 로그는 ECS Cluster에 설정된 Container Insights를 통해 수집된 Log Event를 보여준다.
ECS Cluster를 생성할 때 containerInsights를 enabled로 설정함으로써, CloudWatch와 통합되어 클러스터 및 컨테이너 단위의 리소스 사용량(CPU, 메모리, 네트워크) 정보가 자동으로 수집된다.
예시로 출력된 로그는 특정 ECS Task가 실행 중일 때의 상태를 JSON 형식으로 보여주며, CPUUtilized, MemoryUtilized, NetworkRxBytes, NetworkTxBytes와 같은 필드를 통해 각 리소스의 사용량을 확인할 수 있다.

CodeBuild 빌드 로그
해당 로그그룹은 CodeBuild에서 빌드 작업 중 발생한 로그 이벤트를 CloudWatch Logs로 수집한 것이다.
CodeBuild 프로젝트를 정의할 때 logs_config 블록을 통해 로그 그룹을 CloudWatch에 연동하도록 설정했으며, 그 결과 빌드 과정의 각 단계(DOWNLOAD_SOURCE, INSTALL, BUILD, 등)가 상세히 기록된다.
로그 메시지를 보면 소스 코드 다운로드, buildspec.yml 위치 확인, S3 접속등의 정보가 출력되고 있으며, 이를 통해 에러 발생시 어느 단계에서 문제가 발생했는지를 모니터링할 수 있다.

개선할점 & 느낀점
- 이전에는 직접 애플리케이션을 개발하지 않고 웹 서버만 배포했기 때문에, 이번에는 직접 애플리케이션을 개발하고 데이터베이스를 연동한 뒤, Route 53 연결과 SSL 인증까지 포함한 배포 과정을 구현해보고 싶다.
- EC2 인스턴스와 Fargate 두 가지 형태를 모두 사용해본 결과, Fargate는 인프라 관리를 AWS에서 대신해주기 때문에 편리하다는 장점이 있지만, 반대로 세부적인 제어가 어려운 단점도 있다. 또한 비용 측면에서도 Fargate가 더 비싸기 때문에 아키텍처 설계 시 편의성과 비용 사이의 균형을 충분히 고민해야 할 것 같다.
- Terraform을 통해 많은 리소스를 관리하다 보니 코드의 가독성이 떨어지는 부분이 있었다. 따라서 전반적인 Terraform 코드에 대한 리팩토링이 필요하다.
- 추후 만약 직접 서비스를 배포한다면 ECS Service AutoScaling 정책을 추가해야겠다.
'AWS' 카테고리의 다른 글
| [AWS] DynamoDB (0) | 2024.12.31 |
|---|---|
| [AWS] Site to Site VPN vs Client VPN (0) | 2024.12.31 |
| [AWS] EFS (0) | 2024.12.31 |
| [AWS] EBS 용량 축소하기 (0) | 2024.12.31 |
| [AWS] MediaConvert + lambda를 이용한 Vod 스트리밍 파일 업로드 (0) | 2024.12.31 |