lhywk 님의 블로그

AWS 3-Tier 및 Data Pipeline 구축 With Terraform (3) 본문

AWS

AWS 3-Tier 및 Data Pipeline 구축 With Terraform (3)

lhywk 2026. 3. 13. 21:53

참고 자료: https://kujung.tistory.com/entry/AWS-3-Tier-Architecture-%EA%B5%AC%EC%B6%95%ED%95%B4%EB%B3%B4%EA%B8%B0-2-2

 

AWS 3 Tier Architecture 구축해보기 2-2

https://github.com/estrellaSia/AWS_3Tier_Infra_Data_Pipeline/tree/main참고 깃허브 Main.tf 작성하기(2) 1. ALB, ALB SG# ALBresource "aws_lb" "alb-web" {name = "ddong-alb-web"internal = falseload_balancer_type = "application" # Application Load Balan

kujung.tistory.com

 

1. ALB, ALB SG

# ALB
resource "aws_lb" "alb-web" {
    name = "ho-alb-web"
    internal = false
    load_balancer_type = "application" # Application Load Balancer
    security_groups = [aws_security_group.alb-sg-web.id]
    subnets = [aws_subnet.pub-sub1.id, aws_subnet.pub-sub2.id]
}

resource "aws_lb" "alb-was" {
    name = "ho-alb-was"
    internal = true
    load_balancer_type = "application"
    security_groups = [aws_security_group.alb-sg-was.id]
    subnets = [aws_subnet.was-sub1.id, aws_subnet.was-sub2.id]
}

# ALB SG
resource "aws_security_group" "alb-sg-web" { # Web ALB SG
    name = "ho-alb-sg-web"
    description = "ALB Security Group"
    vpc_id = aws_vpc.vpc.id
    ingress {
        description = "HTTP from Web Tier"
        from_port = 80
        to_port = 80
        protocol = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
    }
    ingress { 
        description = "HTTPS from web Tier"
        from_port = 443
        to_port = 443
        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 = "ho-alb-sg-web"
    }
}

resource "aws_security_group" "alb-sg-was" { # Was ALB SG
    name = "ho-alb-sg-was"
    description = "ALB Security Group"
    vpc_id = aws_vpc.vpc.id
    ingress {
        description = "HTTP from Internet"
        from_port = 80
        to_port = 80
        protocol = "tcp"
        security_groups = [aws_security_group.asg-sg-web.id]
        # asg-security-group-web이라는 SG에 속한 인스턴스만이 이 포트를 통해 ALB에 접근할수 있도록 제한
    }
    egress {
        from_port = 0
        to_port = 0
        protocol = "-1"
        cidr_blocks = ["0.0.0.0/0"]
    }
    tags = {
        Name = "ho-alb-sg-was"
    }
}

resource "aws_lb" "alb-web/was"

이 리소스는 트래픽을 각 서버 계층으로 분산해주는 역할

  • resource "aws_lb" "alb-web"
    • internal = false: 외부 인터넷에서 접근 가능한 Public ALB로 설정. 사용자의 첫 진입점
    • subnets: 로드밸런서가 위치할 서브넷. Public ALB이므로 이전에 만든 Public Subnet 2개를 지정
  • resource "aws_lb" "alb-was"
    • internal = true: VPC 내부에서만 접근 가능한 Internal ALB. Web 계층에서 전달된 요청을 WAS 계층으로 넘겨주는 역할
    • subnets: WAS 전용 프라이빗 서브넷들을 지정하여 계층 간 격리를 구현

resource "aws_security_group" "alb-sg-web"

Public ALB를 위한 SG

  • ingress (HTTP/HTTPS): 0.0.0.0/0으로부터 80(HTTP)과 443(HTTPS) 포트 접속을 허용. 웹 서비스의 일반적인 설정
  • egress: 모든 포트에 대해 외부로 나가는 통신을 허용

resource "aws_security_group" "alb-sg-was"

  • security_groups = [aws_security_group.asg-sg-web.id]:
    • 특정 보안 그룹(asg-sg-web)을 가진 리소스의 접근만 허용
    • 웹 서버의 IP가 오토스케일링으로 인해 수시로 변하더라도 해당 보안 그룹만 가지고 있다면 통신이 허용

 

2. SSM 접속을 위한 IAM

resource "aws_iam_role" "ec2_ssm_role" {
    name = "ho-EC2SSM"
    assume_role_policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
            Action = "sts:AssumeRole"
            Effect = "Allow"
            Principal = {
                Service = "ec2.amazonaws.com"
            }
        }]
    })
}

# IAM 역할 정책 연결
resource "aws_iam_role_policy_attachment" "ec2_ssm_policy_attachment" {
    for_each = toset([
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess",
        "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
        "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole"
    ])
    role = aws_iam_role.ec2_ssm_role.name
    policy_arn = each.key
}
# EC2 인스턴스 프로파일 생성
# EC2 인스턴스를 구분하고 그 인스턴스에 권한을 주기 위한 개념
# 인스턴스 프로파일이 지정된 EC2는 시작 시 역할 정보를 받아오고 해당 역할로 필요한 권한들을 얻게 됨

resource "aws_iam_instance_profile" "ec2_ssm_instance_profile" {
    name = "ho-EC2SSM-Instance-Profile"
    role = aws_iam_role.ec2_ssm_role.name
}

resource "aws_iam_role" "ec2_ssm_role" 분석

이 부분은 "누가 이 역할을 수행할 수 있는가?"를 정의하는 단계

  • resource "aws_iam_role" "ec2_ssm_role"
    • aws_iam_role: AWS에서 특정 권한의 묶음(Job Description)인 IAM 역할을 생성
  • assume_role_policy:
    • jsonencode 내부를 보면 Service = "ec2.amazonaws.com"이라고 되어 있다. 이는 이 역할은 오직 EC2 서비스만 가져다가 쓸 수 있다라고 선언

resource "aws_iam_role_policy_attachment" "ec2_ssm_policy_attachment"

이 부분은 "이 신분증을 가지면 어떤 일을 할 수 있는가?"를 정의하는 단계

  • for_each = toset([...]):
    • 테라폼의 반복문. 아래 3가지 정책을 하나씩 돌아가며 이 역할에 붙여줌.
  • 연결된 정책들:
    1. AmazonS3ReadOnlyAccess: S3 버킷에 저장된 파일을 읽을 수 있는 권한
    2. AmazonSSMManagedInstanceCore: 이 권한이 있어야만 Session Manager를 통해 SSH 키 없이도 브라우저에서 서버에 접속하거나 관리할 수 있다
    3. AWSCodeDeployRole: 아키텍처 다이어그램 왼쪽에 있던 CodeDeploy가 이 서버에 코드를 배포할 수 있도록 허용하는 권한

resource "aws_iam_instance_profile" "ec2_ssm_intance_profile"

이 부분은 "어떻게 이 신분증을 EC2에게 전달할 것인가?"를 결정하는 단계

  • resource "aws_iam_instance_profile" "ec2_ssm_intance_profile"
    • 인스턴스 프로파일: AWS 콘솔에서는 역할을 만들면 자동으로 만들어지기도 하지만 테라폼에서는 명시적으로 만들어줘야 함
  • 역할: EC2 인스턴스는 '역할'을 직접 입을 수 없고, '인스턴스 프로파일'이라는 주머니에 담긴 '역할'을 전달받는 구조. 나중에 EC2 코드를 짤 때 이 프로파일 이름을 적어주게 된다.

 

3. Web 시작 템플릿

resource "aws_launch_template" "template-web" {
  name = "ho-launch-template-web"
  image_id = "ami-0ecfdfd1c8ae01aec" # Amazon Linux 2023 등 최신 AMI 확인 필요
  instance_type = "t3.micro"

  # IMDSv2 설정: 인스턴스 메타데이터 탈취 공격 방어
  metadata_options {
    http_endpoint = "enabled"
    http_tokens = "required" # 토큰 없는 요청 거부 (보안 강화)
    http_put_response_hop_limit = 1 # 외부에서의 비정상 접근 방지
    instance_metadata_tags = "enabled"
  }

  # 네트워크 및 보안 그룹 설정
  network_interfaces {
    device_index = 0
    security_groups = [aws_security_group.asg-sg-web.id]
  }

  # 이전 단계에서 만든 '신분증 케이스' 전달
  iam_instance_profile {
    name = aws_iam_instance_profile.ec2_ssm_instance_profile.name
  }

  # 실행 시 WAS ALB의 주소를 주입 (Web -> WAS 통신용)
  user_data = base64encode(templatefile("web-user-data.sh", {
    alb_dns = "${aws_lb.alb-was.dns_name}"
  }))

  depends_on = [aws_lb.alb-web]

  tag_specifications {
    resource_type = "instance"
    tags = { Name = "ho-web-instances" }
  }
}
  • resource "aws_launch_template" "template-web": 나중에 오토스케일링 그룹(ASG)이 인스턴스를 찍어낼 때 참조할 틀을 정의
  • image_id & instance_type:최신 x86 AMI와 t3.micro를 적용 (별도로 확인 필요)
  • http_tokens = "required": IMDSv2(Instance Metadata Service v2)를 강제SSRF(서버 측 요청 위조) 공격을 통해 공격자가 인스턴스의 IAM 역할 정보를 탈취하는 것을 원천 봉쇄. 토큰 기반의 세션 방식을 사용하여 보안 수준을 극대화한 설정
  • http_put_response_hop_limit = 1: 응답이 네트워크 홉을 한 번 이상 넘지 못하게 하여 인스턴스 밖으로 메타데이터 응답이 유출되는 것을 방지
  • network_interfaces: asg-sg-web 보안 그룹을 지정
  • iam_instance_profile:이전에 만든 프로파일을 씌워줌. 이 덕분에 서버가 태어나자마자 SSM이나 S3에 접근할 수 있다.
  • templatefile("web-user-data.sh", { ... }):미리 작성된 .sh 스크립트 파일에 테라폼의 변수를 실시간으로 꽂아 넣음
  • alb_dns = "${aws_lb.alb-was.dns_name}":Web 서버는 사용자의 요청을 처리한 후 내부 로드밸런서(WAS ALB)로 트래픽을 넘겨야 한다. 그래서 WAS ALB의 주소를 인자로 전달하는 것
  • depends_on = [aws_lb.alb-web]:로드밸런서가 먼저 존재해야 서버가 트래픽을 받을 준비를 할 수 있으므로, 생성 순서를 명확히 지정했습니다.
  • tag_specifications:템플릿 자체가 아니라 이 템플릿으로 생성될 실제 EC2 인스턴스들에 ho-web-instances라는 이름을 붙여줌.

 

4. Was 시작 템플릿

# 2. WAS 시작 템플릿 선언문
resource "aws_launch_template" "template-was" {
  name = "ho-launch-template-was"
  image_id = "ami-0ecfdfd1c8ae01aec"
  instance_type = "t3.micro"

  network_interfaces {
    device_index = 0
    security_groups = [aws_security_group.asg-sg-was.id]
  }

  iam_instance_profile {
    name = aws_iam_instance_profile.ec2_ssm_instance_profile.name
  }

  metadata_options {
    http_endpoint = "enabled"
    http_tokens = "required"
    http_put_response_hop_limit = 1
    instance_metadata_tags = "enabled"
  }

  # DB 접속 정보를 스크립트에 주입
  user_data = base64encode(templatefile("app-user-data.sh", {
    host = "${local.host}"
    rds_endpoint = "${aws_db_instance.rds-db.endpoint}" # data 소스 대신 직접 참조 권장
    username = "ho_admin"
    password = "ho_password123!" # 실제 운영시에는 Secret Manager 사용 권장
    db = "hodb"
  }))

  depends_on = [aws_db_instance.rds-db]

  tag_specifications {
    resource_type = "instance"
    tags = { Name = "ho-was-instances" }
  }
}
  • resource "aws_launch_template" "template-was": WAS 서버를 생성하기 위한 '원본 틀'
  • security_groups = [aws_security_group.asg-sg-was.id]: WAS 전용 보안 그룹. 앞서 설계한 대로 Internal ALB에서 오는 트래픽만 골라서 받도록 설정.
  • iam_instance_profile: Web 서버와 동일하게 SSM/S3/CodeDeploy 권한을 부여.
  • metadata_options: Web 서버와 동일한 보안 설정을 적용. 특히 WAS 서버는 DB 접속 정보 등 민감한 데이터를 다룰 가능성이 높으므로, 토큰(http_tokens = "required")을 강제하여 메타데이터 탈취를 막는 것이 중요
  • templatefile("app-user-data.sh", { ... }): WAS 서버는 데이터베이스에 접속해야 함. 이를 위해 DB 주소, 계정명, 비밀번호 등의 환경 변수를 런타임에 주입
  • 변수 매핑:
    • host = "${local.host}": locals에서 포트 번호를 떼어낸 순수 DB 주소를 전달
    • rds_endpoint: 생성된 RDS의 전체 엔드포인트 주소를 가져옴
    • username, password, db: 애플리케이션 코드가 DB에 로그인할 때 사용할 자격 증명
  • depends_on = [aws_db_instance.rds-db]: DB가 아직 만들어지지도 않았는데 WAS 서버가 먼저 뜨면 접속 에러가 남. 테라폼에게 "DB가 완전히 생성된 후에 이 템플릿을 활성화해라"라고 명령하는 것
  • tag_specifications: 생성될 서버들에게 ho-was-instances라는 이름표를 달아주어 Web 서버와 구분
# 3. 로컬 변수 선언문
locals {
  # RDS 엔드포인트는 보통 '주소:포트' 형태인데, 여기서 포트(:3306)를 제거하여 순수 호스트 주소만 추출
  # replace(대상, "찾을문자", "바꿀문자") 함수를 사용하여 가공
  host = replace(aws_db_instance.rds-db.endpoint, ":3306", "")
}

locals { ... }

  • 테라폼 코드 전체에서 공통으로 사용할 수 있는 변수를 선언
  • 한 번 계산된 값을 여러 리소스(aws_launch_template 등)에서 local.host라는 이름으로 재사용할 수 있다
host = replace(aws_db_instance.rds-db.endpoint, ":3306", "")
  • 원래 값 (aws_db_instance.rds-db.endpoint):
    • AWS RDS가 생성되면 보통 terraform-db.xxxx.ap-northeast-2.rds.amazonaws.com:3306과 같은 형태의 주소를 줌
    • 끝에 :3306(포트 번호)이 붙어 있다
  • replace(대상, 찾을값, 바꿀값):
    • 대상: RDS 엔드포인트 문자열
    • 찾을값: :3306
    • 바꿀값: "" (빈 문자열)
  • 결과값 (host):
    • 포트 번호가 삭제된 terraform-db.xxxx.ap-northeast-2.rds.amazonaws.com만 남게 됨

 

5. Target Group

# TG-Web
resource "aws_lb_target_group" "tg-web" {
    name = "ho-tg-web"
    port = 80
    protocol = "HTTP"
    vpc_id = aws_vpc.vpc.id
    health_check {
        path = "/"
        matcher = "200-299"
        # health check 위해 기대되는 http 응답 코드 범위(200~299: 성공 응답)
        interval = 5 # 5초마다 health check 수행
        timeout = 3 # 3초 내에 반환하지 않으면 실패로 간주
        healthy_threshold = 3
        # 성공적인 health check 횟수(연속적으로 건강한 것으로 간주되기 위함)
        unhealthy_threshold = 5
        # 실패한 health check 횟수(연속적으로 비건강한 것으로 간주되기 위함)
    }
}
  • path = "/": 로드밸런서가 서버의 루트 경로(index.html 등)에 요청을 보냄.
  • matcher = "200-299": 서버가 응답으로 "200 OK" 같은 성공 메시지를 주면 살아있다고 판단
  • interval = 5: 5초마다 한 번씩 확인
  • timeout = 3: 3초 안에 응답이 없으면 이번 검사는 실패로 처리
  • healthy_threshold = 3: 연속으로 3번 200 코드가 와야 트래픽을 다시 보내줌
  • unhealthy_threshold = 5: 멀쩡하던 서버가 대답을 못 하거나 에러를 뱉으면 5번까지 기다리고 그 이상 실패하면 이 서버는 죽었다고 선언하고 명단에서 제외
resource "aws_lb_listener" "myhttp" {
  load_balancer_arn = aws_lb.alb-web.arn
  port = 80
  protocol = "HTTP"

  default_action {
    # 'redirect'를 'forward'로 바꿔서 바로 Web 서버로 보냄.
    type = "forward" 
    target_group_arn = aws_lb_target_group.tg-web.arn 
  }
}
# TG-WAS
resource "aws_lb_target_group" "tg-was" {
    name = "ho-tg-was"
    port = 80
    protocol = "HTTP"
    vpc_id = aws_vpc.vpc.id
    health_check {
        path = "/"
        matcher = "200-299"
        interval = 5
        timeout = 3
        healthy_threshold = 3
        unhealthy_threshold = 5
    }
}

resource "aws_lb_listener" "alb_listener-was" {
    load_balancer_arn = aws_lb.alb-was.arn
    port = 80
    protocol = "HTTP"
    default_action {
        type = "forward"
        target_group_arn = aws_lb_target_group.tg-was.arn
    }
}
  • load_balancer_arn: 이전에 만든 내부용 로드밸런서(alb-was)의 식별번호
  • port = 80: Web 서버들이 이 로드밸런서를 찌를 때 사용하는 포트
  • default_action:
    • type = "forward": 들어온 요청을 아무 묻지도 따지지도 않고 바로 tg-was로 보냄
    • 이미 위에서 검증됐기 때문이다.

 

6. Auto Scaling Group, Auto Scaling Group SG

# ASG-Web
resource "aws_autoscaling_group" "asg-web" {
    name = "ho-asg-web"
	desired_capacity = 2 # 항상 유지하고 싶은 목표 서버 대수
	max_size = 4 # 트래픽이 폭주할 때 늘어날 수 있는 최대치
	min_size = 2 # 아무리 트래픽이 없어도 유지할 최소치
    target_group_arns = [aws_lb_target_group.tg-web.arn]
    health_check_type = "EC2"
    vpc_zone_identifier = [aws_subnet.web-sub1.id, aws_subnet.web-sub2.id]
    tag {
        key = "asg-web-key"
        value = "asg-web-value"
        propagate_at_launch = true
        # ASG에서 생성된 EC2 인스턴스에 태그를 자동으로 적용할지에 대한 여부 지정
    }
    launch_template {
        id = aws_launch_template.template-web.id
        version = aws_launch_template.template-web.latest_version
    }
    instance_refresh {
        strategy = "Rolling"
        preferences {
            min_healthy_percentage = 50
        }
        triggers = ["tag"]
        # Terraform은 기본적으로 리소스의 설정이 바뀔 때만 변경 작업을 함.
        # 그런데 외부 환경이나 코드 외의 조건에 따라 강제로 실행하고 싶을때가 있음.
        # 이때 triggers를 써서 "이 값이 바뀌면 무조건 다시 실행하라"고 알려줌
    }
}
  • vpc_zone_identifier: 서버들이 태어날 주소지. 아까 만든 Private Subnet(web-sub1, 2)을 지정함. 외부에서 직접 접속은 못 하고 오직 ALB를 통해서만 들어오는 구조
  • target_group_arns: 새로 태어난 서버들을 어느 타겟 그룹에 등록할지 정함. 그래야 로드밸런서가 새 서버를 인식하고 트래픽을 보낸다
  • health_check_type = "EC2": EC2: 서버가 켜져 있는지만 확인
  • version = aws_launch_template.template-web.latest_version: 항상 가장 최신 버전의 설계도를 사용하도록 설정. 테라폼에서 설계도를 수정하면 ASG가 알아서 최신본을 인지
  • strategy = "Rolling": 한꺼번에 다 바꾸지 않고 하나씩 차례대로 새 서버로 교체
  • min_healthy_percentage = 50: 교체 작업 중에도 전체 서버의 최소 50%는 항상 살아있어야 한다는 마지노선 (현재 2대 중 1대는 무조건 살아있어야 함)
  • triggers = ["tag"]: 태그만 바뀌어도 서버를 새 버전으로 싹 갈아치우라는 신호
# ASG-Web-SG
resource "aws_security_group" "asg-sg-web" {
    name = "ho-asg-sg-web"
    description = "ASG Security Group"
    vpc_id = aws_vpc.vpc.id
    ingress {
        description = "HTTP from ALB"
        from_port = 80
        to_port = 80
        protocol = "tcp"
        security_groups = [aws_security_group.alb-sg-web.id]
    }
    ingress {
        description = "SSH From Anywhere or Your-IP"
        # 원격으로 서버 접속해 SW 업데이트, 구성 변경 등
        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 = "ho-asg-sg-web"
    }
}

인바운드 규칙 1: ALB에서 오는 트래픽만 허용

  • alb-sg-web의 ID를 넣음
  • 오직 Web ALB를 거쳐서 검증된 패킷만 80번 포트로 들어와라는 뜻

인바운드 규칙 2: SSH 원격 접속 허용

  • 관리자가 서버에 접속해서 설정을 바꾸거나 로그를 볼 수 있도록 22번 포트를 열어둠

아웃바운드 규칙 (Egress)

  • 서버가 외부 인터넷에 자유롭게 연결할 수 있게 함
# ASG-WAS
resource "aws_autoscaling_group" "asg-was" {
    name = "ho-asg-was"
    desired_capacity = 2
    max_size = 4
    min_size = 2
    target_group_arns = [aws_lb_target_group.tg-was.arn]
    health_check_type = "EC2"
    vpc_zone_identifier = [aws_subnet.was-sub1.id, aws_subnet.was-sub2.id]
    tag {
        key = "asg-app-key"
        value = "asg-app-value"
        propagate_at_launch = true
        # ASG에서 생성된 EC2 인스턴스에 태그를 자동으로 적용할지에 대한 여부 지정
    }
    launch_template {
        id = aws_launch_template.template-was.id
        version = aws_launch_template.template-was.latest_version
    }
    instance_refresh {
        strategy = "Rolling"
        preferences {
            min_healthy_percentage = 50
        }
        triggers = ["tag"]
    }
}

# ASG-WAS-SG
resource "aws_security_group" "asg-sg-was" {
    name = "ho-asg-sg-was"
    description = "ASG Security Group"
    vpc_id = aws_vpc.vpc.id
    ingress {
        description = "HTTP from ALB"
        from_port = 80
        to_port = 80
        protocol = "tcp"
        security_groups = [aws_security_group.alb-sg-was.id]
    }
    ingress {
        description = "SSH from Web Tier"
        from_port = 22
        to_port = 22
        protocol = "tcp"
        # Web 서버 보안 그룹을 가진 인스턴스만 WAS로 SSH 접속 가능하도록 제한
        security_groups = [aws_security_group.asg-sg-web.id]
    }
    egress {
        from_port = 0
        to_port = 0
        protocol = "-1"
        cidr_blocks = ["0.0.0.0/0"]
    }
    tags = {
        Name = "ho-asg-sg-was"
    }
}

 

  • security_groups = [aws_security_group.alb-sg-was.id]
    • Internal ALB(WAS용 로드밸런서)를 거쳐서 온 요청만 허용
  • SSH 접속 (22번 포트):
    • Web 서버 보안 그룹을 가진 인스턴스만 WAS로 SSH 접속 가능하도록 제한