본문 바로가기
School/TFAS

[TFAS] 챕터 13 VPC 와 보안 그룹 모듈의 출력값을 활용하는 EC2 모듈 만들기

by 세똔구리 SEDDONGURI 2025. 12. 6.
TFAS (TerraForm Addicted School)의 내용을 정리한 글입니다.
심각한 테라폼 중독입니다 - 책으로 스터디를 진행합니다.


YAML 파일을 입력으로 받고, 앞서 만든 VPC 모듈과 보안 그룹 모듈의 출력값을 모두 활용하는 EC2 모듈을 만들어 보자.

입력값 정하기

입력값을 결정하기 위해 필요한 모듈의 요구사항부터 생각해보자.

  • EC2 인스턴스별로 하나의 YAML 파일을 가진다.
  • 앞서 만든 모듈에서 생성된 서브넷과 보안그룹만 사용한다.
  • 루트 볼륨 외 추가 볼륨을 원하는대로 붙일 수 있다. (단, instance store 는 고려하지 않는다.)
  • 퍼블릭 인스턴스인 경우 EIP 를 반드시 연결한다.
  • AMI 는 직접 입력한다.
  • EC2 키 페어와 IAM 역할은 이미 생성된 것을 사용하거나 지정하지 않는다.
  • 필요한 경우 private ip 를 지정할 수 있다.
  • EBS 장치 이름은 OS 별 AWS 권장 사항을 따른다.

 

복잡한 모듈 요구사항이 필요한 이유

AMI 아이디를 직접 입력하는 점이 불편할 수 있다. 아마존 리눅스 최신 이미지나 우분투 최신 이미지 등을 데이터 블록으로 찾아와서 구성하는 것이 사용성이 좋을 것 같다. 다만 이것은 매우 구현하기 매우 어려울 수 있다.

사용자가 테라폼 명령 반영 시점을 기준으로 가장 최신의 이미지를 말한다. EC2 인스턴스 생성 후 테라폼 상태 동기화가 포함된 명령을 실행하면 최신 이미지가 갱신되어 이미지가 달라지고 기대 상태의 인프라와 현재 인프라 상태의 리소스 드리프트로 인해 EC2 가 다시 생성될 것이다.

특정 시점의 이미지를 검색하는 방법을 사용하면 좋겠지만, AWS 상에서 해당 검색 필터를 지원하지 않는다.

그렇다면 ignore_changes 플래그를 통해 무시하여  AMI 를 사용할 수 있겠다. 처음 프로비저닝 할 때 최신의 이미지를 사용하고 이후에는 변경사항을 무시할 수 있다. 이 방법은 리소스 드리프트가 발생하지 않기 때문에 나은 방법일 수 있다.

그러나 근본적으로 검색을 통해 이미지를 지정하는 것은 어렵다. 검색어도 이름 사이 일관된 규칙이 없을 뿐더라 사용할 수 있는 운영체제의 종류가 많은데 그중 필요한 인스턴스 이미지를 딱 정확하게 검색해서 사용하는 것은 더 어려운 일이다. 다양한 os 를 고려하고 있다면 이미지에 대한 관리가 더욱 복잡해질것이다. 따라서 AMI 아이디를 직접 입력해서 사용하는 것이다.

EC2 키페어에 대한 요구사항도 확인해보자. EC2 키페어는 테라폼으로 관리하지 않는것이 좋다. 키페어를 사용하기 위해서는 펨파일을 로컬 환경에 다운로드 해야하는데, 이를 output 블록을 통해 노출하거나 로컬 파일로 저장하면 테라폼 프로젝트에 접근할 수 있는 모든 사람이 해당 키를 볼 수 있게 된다.

최근 발생한 쿠팡의 보안 사고에서도 서버에 접근 가능한 파일은 보안적으로 굉장히 중요하기 때문에 사용이 허가된 사람만 보고 가지고 있어야 한다. 따라서 AWS 상에서 키페어를 수동으로 생성하고 사용하는 것이 좋다. 

또한 IAM 역할도 별도의 테라폼 실행환경에서 만들어 관리하는 것을 권장한다. IAM 역할을 생성하게 되면 전반적인 IAM 관리 기능까지 모듈에 포함되어야하는데 이는 관심사의 분리에서 벗어나 모듈의 목적이 불분명 해질 수 있다. 따라서 IAM 은 별도의 실행환경을 만들어서 관리할 수 있도록 하는 것이 좋다. 참조할 수 있는 role 을 output 블록으로 선언하고 다른 모듈에서 참조하자.

아래는 위 요구사항을 반영한 입력값을 작성하는 yaml 파일의 명세이다.

env/info_files/production/ec2/amazon-linux.yaml

# tag
env: production
team: devops
service: application
os_type: linux

# network
subnet: pri-app
az: a
security_group:
  - linux

# instance
ami_id: ami-0c55b159cbfafe1f0
instance_type: t3.micro
ec2_key: terraform-ec2-key
ec2_role: etrraform-ec2-role

# volume
root_volume:
  size: 20
  type: gp3
additional_volumes:
  - device: /dev/sdf
    size: 10
    type: gp3

입력값을 모듈에 전달할 방법 정하기

보안그룹의 입력값을 전달할 때와 마찬가지로 반복에 대한 관심사를 ec2 인스턴스로 생각하자. vpc 에 포함된 ec2 를 반복적으로 생성할 것이다. 

env/main.tf

module "ec2" {
  for_each = local.vpc_set
  source   = "../modules/ec2"

  vpc_name = each.key
  vpc_id   = module.vpc[each.key].vpc_id

  subnet_id_map = module.vpc[each.key].subnet_ids_with_az
  sg_id_map     = module.sg[each.key].sg_id

  ec2_set = {
    for ec2file in fileset(local.info_files, "${each.key}/ec2/*.yaml") :
    trimsuffix(basename(ec2file), ".yaml") => yamldecode(file("${local.info_files}/${ec2file}"))
  }

  tags = local.env_tags
}

ec2_set 로컬 변수를 선언하는 부분은 앞서 만든 sg_set 을 선언하는 부분과 비슷하다. vpc 디렉토리 하위에 ec2 디렉토리에 있는 모든 yaml 파일을 읽어온다. 인스턴스를 마늗ㄹ기 위해선 반드시 한개의 서브넷 정보아 하나 이상의 보안그룹 정보가 필요하다. 해당 정보는 ec2 모듈에서 생성하는 것이 아닌 vpc 모듈과 보안 그룹 모듈에서 전달받아야 한다.

이때, 현재 모듈 구조상 vpc 기준으로 연결되는 리소스를 생성한다. vpc 마다 ec2 모듈이 호출되어 실제 리소스가 생성되는데, 인스턴스마다 서브넷과 보안그룹이 지정된다. 그러므로 맵 형태의 데이터로 vpc 전체 서브넷 정보와 보안그룹 정보를 전달하고 ec2 모듈에서 필요한 정보는 for_each 반복문의 현재 항목을 사요해 참조하도록 구성할 수 있다.

vpc 모듈과 sg 모듈에서 선언한 output 블록에 참조할 수 있는 변수 값을 사용하여 ec2 를 생성하는데 참조하여 사용할 수 있다.


모듈 만들기

변수에 대한 임시 블록을 만들어두고 모듈 작성을 시작한다.

modules/ec2/variables.tf

variable "vpc_name" {}

variable "vpc_id" {}

variable "subnet_id_map" {}

variable "sg_id_map" {}

variable "ec2_set" {}

variable "tags" {
  default = {}
}

 

공통적으로 사용할 태그를 설정하고 리소스 블록을 정의하자.

modules/ec2/main.tf

# tags
locals {
  vpc_name = var.vpc_name
  vpc_id   = var.vpc_id

  module_tag = merge(
    var.tags,
    {
      tf_module = "ec2"
    }
  )
}

 

ec2 인스턴스 생성

modules/ec2/main.tf

# ec2 인스턴스
resource "aws_instance" "this" {
  for_each = var.ec2_set

  # required
  ami                    = each.value.ami
  instance_type          = each.value.instance_type
  subnet_id              = var.subnet_id_map[each.value.subnet][each.value.az]
  vpc_security_group_ids = [for sg_name in each.value.security_group : var.sg_id_map[sg_name]]

  # optional
  iam_instance_profile = each.value.ec2_role
  key_name             = each.value.ec2_key
  private_ip           = each.value.private_ip

  # root volume
  root_block_device {
    volume_type           = each.value.root_volume.type
    volume_size           = each.value.root_volume.size
    delete_on_termination = true

    tags = merge(
      local.module_tag,
      {
        Name    = "${var.vpc_name}-${split("-", each.value.subnet)[0]}-${each.key}-root"
        EC2     = "${var.vpc_name}-${split("-", each.value.subnet)[0]}-${each.key}"
        Env     = each.value.env
        Team    = each.value.team
        Service = each.value.service
        OS      = upper(each.value.os_type)
      }
    )
  }

  # tags
  tags = merge(
    local.module_tag,
    {
      Name    = "${var.vpc_name}-${split("-", each.value.subnet)[0]}-${each.key}"
      EC2     = "${var.vpc_name}-${split("-", each.value.subnet)[0]}-${each.key}"
      Env     = each.value.env
      Team    = each.value.team
      Service = each.value.service
      OS      = upper(each.value.os_type)
    }
  )
}

 

별도의 리소스 블록이 제공되지 않는 root volume 은 중첩 블록으로 사용했다. private ip 의 경우 당장 사용하지 않을 설정값 이지만, 추후 입력받을 수 있도록 선언해둔 상태이다.

위에서 작성한 테라폼 코드에서는 태그가 반복적으로 사용되고 있는데 루트 볼륨 뿐만아니라 앞으로 ec2 모듈을 생성하면서 만들어야할 EIP, EBS 등 다른 리소스들에도 태그를 적용해야하기 때문에, 공통적으로 사용 가능한 태그를 로컬 변수로 지정하는 것이 좋다.

ec2_set 변수 및 인스턴스별 공통 태그 재정의

네임 태그를 리소스 블록에 직접 사용하지 않고 ec2_set 입력 변수에서 참조할 수 있도록 하자. 이를 위해서는 variables 로 선언된 ec2_set 변수를 local 변수로 변환하고 full_name 이라는 attribute 로 선언한다. 그리고 해당 로컬 변수를 사용하여 ec2_tags 로컬 변수도 작성해준다.

modules/ec2/main.tf

locals {
  ec2_set = {
    for k, v in var.ec2_set : k => merge(v, {
      full_name = "${var.vpc_name}-${split("-", v.subnet)[0]}-${k}"
    })
  }

  ec2_tags = {
    for k, v in local.ec2_set : k => merge(
      local.module_tag,
      {
        Name    = v.full_name
        EC2     = v.full_name
        Env     = v.env
        Team    = v.team
        Service = v.service
        OS      = upper(v.os_type)
      }
    )
  }
}

 

이렇게 선언한 로컬 별수로 앞서 작성한 ec2 리소스 블록을 변경한다. 공통적으로 사용되는 부분이 로컬 변수로 선언되면서 가독성이 올라갔다.

modules/ec2/main.tf

resource "aws_instance" "this" {
  # ...
  # root volume
  root_block_device {
    # ...

    tags = merge(
      local.ec2_tags[each.key],
      {
        Name = "${each.value.full_name}-root"
      }
    )
  }

  # tags
  tags = local.ec2_tags[each.key]
}

 

퍼블릭 인스턴스의 경우 EIP 사용

인스턴스가 퍼블릭 인스턴스인 경우 반드시 eip 를 연결하는 요구사항을 만족하기 위한 리소스 블록을 구현하자.

modules/ec2/main.tf

# EIP 리소스 블록 정의
resource "aws_eip" "this" {
  for_each = {
    for k, v in local.ec2_set : k => v
    if split("-", v.subnet)[0] == "pub"
  }

  domain = "vpc"

  instance                  = aws_instance.this[each.key].id
  associate_with_private_ip = aws_instance.this[each.key].private_ip

  tags = local.ec2_tags[each.key]
}

for_each 에서 ec2 인스턴스가 퍼블릭 서브넷에 생성되는 경우에만 EIP 를 만들수 있게 if 조건을 지정하였다.

 

추가 EBS 볼륨

ec2_set 하위에 additional_volumes attribute 를 반복하면서 추가 볼륨을 생성할 수 있도록 리소스 블록을 선언하자. 2차원 맵을 1차원의 맵으로 평탄화하는 작업을 로컬 블록으로 선언하고, 평탄화된 맵을 순회하면서 추가 ebs 볼륨을 생성한다. 

modules/ec2/main.tf

# additional ebs volume info prepare
locals {
  ec2_volume_set = [
    for ec2_name, ec2_attribute in local.ec2_set : {
      for volume in ec2_attribute.additional_volume : "${ec2_name}_${volume.device}" => merge(
        { ec2_name = ec2_name }, volume
      )
    }
  ]

  merged_ec2_volume_set = module.merge_ec2_volume_set.output
}

module "merge_ec2_volume_set" {
  source = "../../../ch09/merge_map_module"
  input  = local.ec2_volume_set
}

 

순회 가능한 데이터 형식을 가공한 후 준비된 데이터를 기반으로 추가적인 ebs 를 생성하고 volume attachment 를 생성하여 적절한 인스턴스에 연결한다.

modules/ec2/main.tf

# additional ebs volume
locals {
  valid_iops_type = ["gp3", "io1", "io2"]
}

resource "aws_ebs_volume" "this" {
  for_each          = local.merged_ec2_volume_set
  availability_zone = aws_instance.this[each.value.ec2_name].availability_zone
  size              = each.value.size
  type              = each.value.type
  iops              = contains(local.valid_iops_type, each.value.type) ? each.value.iops : null

  tags = merge(
    local.ec2_tags[each.value.ec2_name],
    {
      Name = "${each.value.full_name}-${each.value.device}"
    }
  )
}

resource "aws_volume_attachment" "this" {
  for_each    = local.merged_ec2_volume_set
  device_name = each.value.device
  volume_id   = aws_ebs_volume.this[each.key].id
  instance_id = aws_instance.this[each.value.ec2_name].id
}

여기서 iops 의 경우 io1, io2, gp3 타입의 볼륨에만 적용할 수 있는 값을 확인하고 올바른 타입만 입력을 받을 수 있도록 설정한다.


변수 유효성 검사

입력값에 대한 변수 유효성 검사를 설정하여 모듈의 완성도를 높일 수 있다. 변수 타입 유효성 검사와 변수 입력값의 유효성 검사를 나눠서 확인해보자.

타입 유효성 검사

optional 로 설정해야하는 변수와 필수로 설정해야하는 변수들을 설정한다. 필요한 경우 기본값을 설정해서 값이 누락된 경우에도 기본적으로 사용할 수 있도록 설정한다.

variable "vpc_name" {
  description = "EC2 가 존재할 vpc 이름"
  type        = string
}

variable "vpc_id" {
  description = "EC2 가 존재할 vpc id"
  type        = string
}

variable "subnet_id_map" {
  description = "subnet id 맵 데이터"
  type        = map(map(string))
}

variable "sg_id_map" {
  description = "sg id 맵 데이터"
  type        = map(string)
}


variable "tags" {
  description = "모든 리소스에 적용될 태그"
  type        = map(string)
  default     = {}
}

variable "ec2_set" {
  description = "EC2 인스턴스 별 명세 Set"
  type = map(object({
    # required
    env             = string
    team            = string
    service         = string
    ami_id          = string
    instance_type   = string
    subnet          = string
    az              = string
    security_groups = list(string)


    # optional
    os_type    = optional(string, "linux")
    ec2_key    = optional(string)
    ec2_role   = optional(string)
    private_ip = optional(string)

    # root volume
    root_volume = object({
      size = number
      type = optional(string, "gp3")
    })

    # additional volumes
    additional_volume = optional(list(object({
      device = string
      size   = number
      type   = optional(string, "gp3")
      iops   = optional(number)
    })), [])
  }))
}

 

변수 입력값 유효성 검사

env: 정해진 환경 이름중에서만 사용 가능

검사 블록이 라이프 사이클 블록보다 먼저 검사한다. 그렇기 때문에 동일한 내용을 검증한다면 검사 블록으로 검증하는 것이 좋다. 하지만, ec2 모듈의 경우는 vpc 모듈 내부에서 반복적으로 ec2 생성 로직을 수행하기 때문에 ec2 인스턴스마다 검사를 해야한다. 그러나 어떤 요소에서 유효성 검사가 실패했는지 알 수 없다. 따라서 라이프사이클의 precondition 을 통해 더 자세한 오류 메시지를 보여준다.

이때 aws_instance 리소스 블록에 precondition 을 사용하는 이유는 ec2 리소스를 가장 먼저 생성하기 때문이다.

# ec2 인스턴스
resource "aws_instance" "this" {
  # ...
  # lifecycle
  lifecycle {
    precondition {
      condition     = contains(["develop", "staging", "rc", "production"], each.value.env)
      error_message = "[${local.vpc_name}] env must be one of [develop, staging, rc, production]"
    }
  }
}

 

볼륨 타입은 지정된 이름 중에 선택

볼륨 타입으로 지정할 수 있는 이름은 정해져있다. 타입 이름을 잘못 입력했을 때 어떤 값들이 사용 가능한 볼륨 타입 이름인지 알려줄 수 있다.

locals {
  valid_ebs_type = ["standard", "gp2", "gp3", "io1", "io2", "sc1", "st1"]
}

resource "aws_instance" "this" {
  # ...

  lifecycle {
    # ...

    precondition {
      condition     = contains(local.valid_ebs_type, each.value.root_volume.type)
      error_message = "[${local.vpc_name} VPC/${each.key}] root_volume.type must be one of ${join(", ", local.valid_ebs_type)}"
    }
  }
}


resource "aws_ebs_volume" "this" {
  # ...
  lifecycle {

    precondition {
      condition     = contains(local.valid_ebs_type, each.value.root_volume.type)
      error_message = "[${local.vpc_name} VPC/${each.key} EC2:${each.value.device} EBS] additional_volume.type must be one of ${join(", ", local.valid_ebs_type)}"
    }
  }
}

 

iops 는 지정된 볼륨 타입에서만 선언 가능

iops 를 설정할 수 있는 볼륨 타입은 3가지다. 볼륨 타입에서는 iops 를 지정할 수 없고 null 값으로 처리한다. iops 를 설정할 수 있는 볼륨 타입이 아닌데 iops 를 설정된 경우를 잡아내는 precondition 을 작성하자.

resource "aws_ebs_volume" "this" {
  # ...
  
  lifecycle {
	# ...

    precondition {
      condition     = !(!contains(local.valid_iops_type, each.value.type) && each.value.iops != null)
      error_message = "[${local.vpc_name} VPC/${each.key} EC2:${each.value.device} EBS] additional_volume.iops must be null if additional_volume.type is not one of ${join(", ", local.valid_iops_type)}"
    }

  }
}

 

ebs 볼륨 장치 이름은 운영체제별 aws 권장사항을 따름

AWS 공식 문서에는 볼륨에 사용할 수 있는 장치 이름이 기재되어 있다. 리눅스와 윈도우 운영체제를 기준으로 항목이 많지만, aws 권장하는 항목만 사용하도록 강제하는 것이 불필요한 고민 여지를 줄여줄 수 있다. 그렇기 때문에 기본적으로 권장 항목 안에서 사용하도록 강제하면서 적당한 수준에서 제약을 걸어보자.


모듈 출력값 설정

이제 출력 블록으로 내보낼 값을 설정한다.

output "ec2_id" {
  description = "EC2 ID 맵"
  value = {
    for k, v in aws_instance.this : k => v.id
  }
}

output "ec2_info" {
    description  = "EC2 정보 맵"
    value = {
        for k, v in aws_instance.this : k => {
            full_name = local.ec2_set[k].full_name
            instance_id = v.id
            private_ip = v.private_ip
            public_ip = try(v.aws_eip.this[k].public_ip, "")
            eni_id = v.primary_network_interface_id
            availability_zone = v.availability_zone
        }
    }

 

이외 해당 모듈에 추가적으로 고려해볼 만한 것들은 다음과 같은 것들이 있을 수 있다.

  • 사용자 데이터 추가: user_data 속성 활용
  • 시작 템플릿과 오토 스케일링 그룹 추가
  • route53 을 사용한 도메인 연결