1. 실습 개요
이번 실습의 최종 목표는 GitHub에 코드를 Push하면 Docker 이미지가 자동으로 빌드되고, Auto Scaling Group의 EC2 인스턴스까지 자동 배포되는 CI/CD 환경을 구축하는 것이다.
이번 1편에서는 그중 실제 애플리케이션이 배포될 AWS 인프라를 Terraform으로 구성했다.
전체적으로 필요한 리소스는 다음과 같다.
VPC / Subnet
↓
Security Group
↓
IAM Role / Instance Profile
↓
Launch Template
↓
Auto Scaling Group
↓
EC2 Instance
↓
CodeDeploy Application
↓
CodeDeploy Deployment Group
GitHub Actions를 구성하기 전에 먼저 CodeDeploy가 실제로 배포할 EC2 환경을 준비하는 단계라고 볼 수 있다.
2. 전체 아키텍처 및 작업 흐름
AWS
┌─────────────┐
│ VPC │
└──────┬──────┘
│
Private Subnet
│
▼
┌─────────────────┐
│ Auto Scaling │
│ Group │
└───────┬─────────┘
│
┌─────────┴─────────┐
▼ ▼
EC2 #1 EC2 #2
│ │
└─────────┬─────────┘
│
▼
AWS CodeDeploy
▲
│
Deployment Group
| VPC / Subnet | EC2가 배치될 네트워크 |
| Security Group | EC2의 HTTP/SSH 통신 제어 |
| IAM Role | EC2가 ECR, S3, SSM 등에 접근할 권한 |
| Instance Profile | IAM Role을 EC2에 연결 |
| Launch Template | EC2 생성 설정 정의 |
| Auto Scaling Group | 여러 EC2 인스턴스 관리 |
| CodeDeploy Application | 배포 대상 애플리케이션 정의 |
| Deployment Group | 어떤 EC2/ASG에 배포할지 정의 |
3. 기존 네트워크 환경 생성
이번 실습의 VPC와 Subnet은 기존 Terraform 네트워크 모듈을 재사용했다.
비용 절감을 위해 이전 실습 종료 후 리소스를 삭제해둔 상태였기 때문에 필요한 네트워크만 다시 생성했다.
cd ~/cicd/<NETWORK_PROJECT>/infra/resource
terraform plan -target=module.network
terraform apply -target=module.network
[출력 결과]
Apply complete! Resources: 28 added, 0 changed, 0 destroyed.
Outputs:
cluster_subnet_ids = [
"<CLUSTER_SUBNET_ID_1>",
"<CLUSTER_SUBNET_ID_2>",
"<CLUSTER_SUBNET_ID_3>",
]
private_subnet_ids = [
"<PRIVATE_SUBNET_ID_1>",
"<PRIVATE_SUBNET_ID_2>",
"<PRIVATE_SUBNET_ID_3>",
]
public_subnet_ids = [
"<PUBLIC_SUBNET_ID_1>",
"<PUBLIC_SUBNET_ID_2>",
"<PUBLIC_SUBNET_ID_3>",
]
vpc_id = "<VPC_ID>"
이번 실습에서는 ASG 인스턴스를 Private Subnet에 배치한다.
① Private Subnet 태그 확인
Terraform에서는 다음과 같이 Type=private 태그가 있는 Subnet을 조회하도록 했다.
data "aws_subnets" "target_subnets" {
filter {
name = "vpc-id"
values = [data.aws_vpc.vpc.id]
}
filter {
name = "tag:Type"
values = ["private"]
}
}
따라서 실제 Subnet에도 다음 태그가 존재해야 한다.
Type = private
AWS CLI로 확인했다.
aws ec2 describe-subnets \
--region us-east-2 \
--filters \
"Name=tag:Type,Values=private" \
--query 'Subnets[*].[SubnetId,AvailabilityZone,Tags[?Key==`Name`].Value|[0],Tags[?Key==`Type`].Value|[0]]' \
--output table
[출력 결과]
-----------------------------------------------------------------------
| DescribeSubnets |
+-----------------------+-------------+----------------------+---------+
| <PRIVATE_SUBNET_ID_1> | us-east-2a | <PREFIX>-private-1 | private |
| <PRIVATE_SUBNET_ID_2> | us-east-2b | <PREFIX>-private-2 | private |
| <PRIVATE_SUBNET_ID_3> | us-east-2c | <PREFIX>-private-3 | private |
+-----------------------+-------------+----------------------+---------+
정리
ASG
↓
data.aws_subnets
↓
Type=private 검색
↓
Private Subnet 3개 조회
↓
EC2 배치
4. 기존 VPC와 AMI 조회
이번 Terraform에서는 새로운 VPC를 만드는 것이 아니라 기존 네트워크의 VPC를 조회해서 사용했다.
data "aws_vpc" "vpc" {
filter {
name = "tag:Name"
values = ["${local.tag_header}vpc"]
}
}
EC2에서 사용할 Amazon Linux 2023 AMI도 Data Source를 통해 최신 이미지를 자동으로 조회했다.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
}
그리고 Local 변수에는 AMI 객체 전체가 아닌 실제 AMI ID를 저장한다.
locals {
vpc_id = data.aws_vpc.vpc.id
ami_id = data.aws_ami.al2023.id
}
5. Security Group 구성
이번 실습에서는 HTTP와 SSH 용도로 두 개의 Security Group을 구성했다.
① HTTP Security Group
resource "aws_security_group" "external_alb_sg" {
name = "${local.tag_header}external-alb-sg"
description = "External ALB Security Group"
vpc_id = local.vpc_id
ingress {
from_port = 80
to_port = 80
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 = "${local.tag_header}external-alb-sg"
}
}
② SSH Security Group
resource "aws_security_group" "ssh_sg" {
name = "${local.tag_header}ssh-sg"
description = "SSH Security Group"
vpc_id = local.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 = "${local.tag_header}ssh-sg"
}
}
실습에서는 전체 IP에 SSH를 허용했지만, 실제 환경에서는 Bastion Host의 Security Group 등 필요한 Source만 허용하는 것이 더 안전하다.
6. EC2 IAM Role 구성
ASG를 통해 생성되는 EC2는 이후 다음 작업을 해야 한다.
ECR → Docker Image Pull
S3 → 배포 파일 접근
SSM → Session Manager 접속
따라서 필요한 AWS 관리형 정책을 정의했다.
locals {
ec2_policy_arns = toset([
"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
"arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess",
"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
])
}
EC2가 사용할 IAM Role을 생성한다.
resource "aws_iam_role" "node_role_asg" {
name = "${local.tag_header}AmazonASGNodeEC2-Role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}
]
})
}
그리고 for_each로 정책을 하나씩 연결했다.
resource "aws_iam_role_policy_attachment" "node_policies_asg" {
for_each = local.ec2_policy_arns
role = aws_iam_role.node_role_asg.name
policy_arn = each.value
}
7. Instance Profile 생성
IAM Role을 만들었다고 바로 EC2에서 사용할 수 있는 것은 아니다.
EC2가 IAM Role을 사용할 수 있도록 Instance Profile을 생성했다.
resource "aws_iam_instance_profile" "node_profile_asg" {
name = "${local.tag_header}ASGNodeInstance-profile"
role = aws_iam_role.node_role_asg.name
}
구조는 다음과 같다.
EC2
↓
Instance Profile
↓
IAM Role
↓
ECR / S3 / SSM 권한
8. CodeDeploy Service Role 생성
CodeDeploy 역시 ASG와 EC2를 대상으로 배포를 수행할 권한이 필요하다.
resource "aws_iam_role" "codedeploy_role" {
name = "${local.tag_header}AmazonCodeDeployService-Role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "codedeploy.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
AWS 관리형 정책을 연결했다.
resource "aws_iam_role_policy_attachment" "codedeploy_policy" {
role = aws_iam_role.codedeploy_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole"
}
9. Launch Template 구성
ASG가 새로운 EC2를 만들 때 사용할 설정을 Launch Template에 정의했다.
resource "aws_launch_template" "asg_lt" {
name_prefix = "${local.tag_header}asg-launch-template-"
image_id = local.ami_id
instance_type = "t3.small"
key_name = local.key_name
vpc_security_group_ids = local.security_groups_ids
update_default_version = var.default_version == "latest" ? true : false
default_version = var.default_version != "latest" ? tostring(var.default_version) : null
iam_instance_profile {
name = aws_iam_instance_profile.node_profile_asg.name
}
}
Launch Template은 실제 EC2가 아니라 EC2를 어떻게 생성할지 정의한 설계도다.
Launch Template
├─ AMI
├─ Instance Type
├─ Key Pair
├─ Security Group
├─ IAM Instance Profile
├─ UserData
└─ Tags
10. UserData로 Docker와 CodeDeploy Agent 설치
EC2가 ASG에 의해 자동으로 생성되기 때문에 서버마다 직접 접속해서 Docker나 CodeDeploy Agent를 설치할 수는 없다.
따라서 Launch Template의 UserData를 이용했다.
user_data = base64encode(<<-EOF
#!/bin/bash
dnf update -y
# ruby: CodeDeploy Agent 설치에 필요
dnf install -y ruby wget docker
systemctl start docker
systemctl enable docker
usermod -aG docker ec2-user
cd /tmp
wget https://aws-codedeploy-us-east-2.s3.us-east-2.amazonaws.com/latest/install
chmod +x ./install
./install auto
systemctl start codedeploy-agent
systemctl enable codedeploy-agent
EOF
)
이렇게 하면 EC2 생성 시 자동으로:
Docker 설치
↓
Docker 실행
↓
CodeDeploy Agent 다운로드
↓
CodeDeploy Agent 설치
↓
CodeDeploy Agent 실행
까지 완료된다.
11. Auto Scaling Group 생성
이제 Launch Template을 이용해 실제 EC2를 생성할 ASG를 구성했다.
resource "aws_autoscaling_group" "asg" {
name = "${local.tag_header}codedeploy-asg"
min_size = 1
max_size = 3
desired_capacity = 2
vpc_zone_identifier = data.aws_subnets.target_subnets.ids
launch_template {
id = aws_launch_template.asg_lt.id
version = "$Latest"
}
}
이번 실습에서는:
Minimum : 1
Desired : 2
Maximum : 3
으로 설정했다.
따라서 기본 상태에서는 EC2 두 대가 실행된다.
12. CodeDeploy Application 및 Deployment Group
CodeDeploy Application을 생성한 뒤 Deployment Group을 ASG와 연결했다.
resource "aws_codedeploy_deployment_group" "dg" {
app_name = aws_codedeploy_app.app.name
deployment_group_name = "${local.tag_header}asg-deployment-group"
service_role_arn = aws_iam_role.codedeploy_role.arn
autoscaling_groups = [
aws_autoscaling_group.asg.name
]
deployment_style {
deployment_option = "WITHOUT_TRAFFIC_CONTROL"
deployment_type = "IN_PLACE"
}
}
이번 실습은 IN_PLACE 방식이다.
기존 EC2
↓
CodeDeploy
↓
새 버전 배포
↓
같은 EC2에서 애플리케이션 교체
새로운 EC2 세트를 따로 만들어 트래픽을 전환하는 Blue/Green 방식과는 차이가 있다.
13. Terraform 적용
설정이 끝난 뒤 Terraform으로 실제 리소스를 생성했다.
terraform fmt
terraform validate
terraform plan
terraform apply
ASG까지 생성되면서 실제 EC2 인스턴스도 함께 생성됐다.
확인은 다음과 같이 했다.
aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names <ASG_NAME> \
--region us-east-2 \
--query 'AutoScalingGroups[0].[DesiredCapacity,Instances[*].[InstanceId,LifecycleState,HealthStatus]]' \
--output json
정상 상태:
DesiredCapacity : 2
<INSTANCE_ID_1>
InService
Healthy
<INSTANCE_ID_2>
InService
Healthy
14. Security Group 적용 문제와 Instance Refresh
실습 후 Private EC2에 SSH 접속을 테스트하는 과정에서 문제가 하나 발견됐다.
Private EC2의 Security Group을 확인해보니 의도했던 SG가 아니라:
default
Security Group만 연결되어 있었다.
반면 EC2 내부의 SSH 서버 자체는 정상 상태였다.
sudo systemctl status sshd --no-pager
sudo ss -lntp | grep ':22'
[출력 결과]
Active: active (running)
LISTEN 0 128 0.0.0.0:22
LISTEN 0 128 [::]:22
즉 문제는 SSH 서비스가 아니라 EC2에 연결된 Security Group이었다.
Launch Template에서 생성한 Security Group을 직접 참조하도록 수정했다.
locals {
security_groups_ids = [
aws_security_group.external_alb_sg.id,
aws_security_group.ssh_sg.id
]
}
그리고 다시 Terraform을 적용했다.
terraform fmt
terraform plan
terraform apply
하지만 Launch Template을 수정해도 이미 실행 중인 EC2는 자동으로 교체되지 않는다.
따라서 ASG의 Instance Refresh를 실행했다.
aws autoscaling start-instance-refresh \
--auto-scaling-group-name <ASG_NAME> \
--region us-east-2
진행 상태 확인:
aws autoscaling describe-instance-refreshes \
--auto-scaling-group-name <ASG_NAME> \
--region us-east-2 \
--query 'InstanceRefreshes[0].[Status,PercentageComplete]' \
--output table
[진행 중]
---------------------------
|DescribeInstanceRefreshes|
+-------------------------+
| InProgress |
| 25 |
+-------------------------+
최종적으로 새로 만들어진 인스턴스에는 원하는 SG가 정상적으로 연결됐다.
aws ec2 describe-instances \
--instance-ids <INSTANCE_ID> \
--region us-east-2 \
--query 'Reservations[0].Instances[0].[PrivateIpAddress,SecurityGroups[*].[GroupId,GroupName]]' \
--output json
[출력 결과]
[
"<PRIVATE_IP>",
[
[
"<SSH_SG_ID>",
"<PREFIX>-ssh-sg"
],
[
"<HTTP_SG_ID>",
"<PREFIX>-external-alb-sg"
]
]
]
💥 실습 중 만난 문제와 해결
① for_each에 List/Tuple 사용
오류
Error: Invalid for_each argument
local.ec2_policy_arns is tuple with 3 elements
The "for_each" argument must be a map,
or set of strings.
기존 값이 List 형태였기 때문이다.
ec2_policy_arns = [
"...",
"...",
"..."
]
toset()을 사용해 Set으로 변경했다.
ec2_policy_arns = toset([
"...",
"...",
"..."
])
정리
for_each
→ map 또는 set(string) 사용
② aws_subnets를 Resource로 선언
오류
The provider hashicorp/aws does not support resource type "aws_subnets".
Did you intend to use the data source "aws_subnets"?
잘못 작성한 코드:
resource "aws_subnets" "target_subnets" {
수정:
data "aws_subnets" "target_subnets" {
aws_subnets는 Subnet을 생성하는 Resource가 아니라 여러 기존 Subnet을 조회하는 Data Source다.
③ ASG에서 Subnet을 찾지 못함
오류
You must specify 1 of either
AvailabilityZones,
AvailabilityZoneIds,
or Subnets
Terraform 문법은 정상적이었지만:
vpc_zone_identifier = data.aws_subnets.target_subnets.ids
에 전달된 Subnet이 하나도 없었다.
원인은 Private Subnet에 Terraform이 검색하던:
Type = private
태그가 존재하지 않았기 때문이다.
태그를 추가한 뒤 Private Subnet 3개가 정상 조회되면서 해결됐다.
④ Resource 객체 전체를 전달
오류
string required, but have object
잘못된 코드:
app_name = aws_codedeploy_app.app
Terraform Resource 자체는 여러 속성을 가진 객체이기 때문에 문자열을 요구하는 곳에는 필요한 속성을 명시해야 한다.
수정:
app_name = aws_codedeploy_app.app.name
ASG 역시:
autoscaling_groups = [
aws_autoscaling_group.asg.name
]
처럼 .name을 사용했다.
⑤ Launch Template을 수정했는데 기존 EC2는 그대로
Launch Template에 올바른 Security Group을 설정했지만 기존 ASG EC2에는 바로 적용되지 않았다.
Launch Template은 앞으로 생성될 인스턴스의 설계도이기 때문이다.
따라서 기존 인스턴스를 새로운 Launch Template 설정으로 교체하기 위해:
aws autoscaling start-instance-refresh \
--auto-scaling-group-name <ASG_NAME> \
--region us-east-2
를 실행했다.
Instance Refresh 이후 새 EC2에는 원하는 Security Group이 정상 연결됐다.
15. 1편 정리
이번 단계에서는 CI/CD 파이프라인이 실제로 배포할 AWS 인프라를 Terraform으로 구성했다.
최종 구조는 다음과 같다.
Existing VPC
↓
Private Subnet
↓
Launch Template
├─ Amazon Linux 2023
├─ Docker
├─ CodeDeploy Agent
├─ IAM Instance Profile
└─ Security Groups
↓
Auto Scaling Group
↓ ↓
EC2 #1 EC2 #2
│ │
└────┬─────┘
↓
CodeDeploy
특히 이번 실습에서 중요하게 느낀 점은 Launch Template은 EC2 자체가 아니라 EC2 생성 설정을 저장하는 설계도라는 점이었다.
또한 Launch Template을 변경하더라도 기존 ASG 인스턴스가 자동으로 해당 설정을 적용받는 것은 아니기 때문에, 필요한 경우 Instance Refresh를 통해 기존 인스턴스를 새 설정으로 교체해야 한다.
다음 편에서는 이 인프라 위에 GitHub Actions를 연결해서:
GitHub Push
↓
Docker Build
↓
Amazon ECR Push
↓
CodeDeploy Bundle 생성
↓
Amazon S3 Upload
↓
CodeDeploy 실행
까지 자동화하는 CI/CD Workflow를 구성해본다.
'코딩 > Web개발' 카테고리의 다른 글
| [CI/CD 실습 3] CodeDeploy로 ASG EC2에 Docker Nginx 자동 배포하고 검증하기 (0) | 2026.09.22 |
|---|---|
| [CI/CD 실습 2] GitHub Actions로 Docker Build → ECR → S3 → CodeDeploy 자동화하기 (0) | 2026.09.22 |
| [Terraform AWS 실습 4] RDS MySQL·S3 구성과 Terraform 리소스 정리 (1) | 2026.09.09 |
| [Terraform AWS 실습 3] ALB Listener Rule과 URL Rewrite 구성 (0) | 2026.09.09 |
| [Terraform AWS 실습 2] EC2 AMI와 Auto Scaling Group 구성 (0) | 2026.09.09 |