본문 바로가기
Dev/인프라

AWS SAM 사용 시, 테라폼으로 인프라 구성하기

by 싯벨트 2025. 5. 22.
728x90

배경

람다 기반의 서비스를 SAM과 테라폼을 사용하여 구현하고자 할 때, SAM 에서는 API Gateway의 참조가 불가능하기 때문에 모든 리소스를 관리를 목적으로 하는 테라폼과 연동이 난해하다. 그래서 람다에 API G/W를 연결하고자 한다면 SAM에서 생성하고, 나머지 리소스들의 생성은 테라폼에서 담당해야 한다. 이런 상황에서 어떤 부분을 주의하며, 어떤 순서로 리소스들을 생성해야 하는지 알아보자.

목차

1. 인프라 개요

2. 구성 순서

  • 미리 준비할 것들
  • 테라폼으로 구성할 것들
  • SAM으로 구성할 것들
  • SAM 배포 후 테라폼에서 해야 할 것들

3. 사전 준비

  • 테라폼 backend 목적 S3 버킷

4. 모듈 구성 및 배포

  • VPC
  • RDS
  • EC2
  • Parameter Store

5. SAM 구성 및 배포

  • subnet, security group 구성
  • api g/w 구성
  • Lambda 리소스 구성

6. API G/W 매핑

  • API G/W 가져오기
  • Route53 연결
  • API G/W 매핑

 


1. 인프라 개요

이어지는 단계들을 통해 아래와 같은 인프라를 구성할 것이다. 

  • VPC를 만들어주고,
  • 2개의 가용역역을 생성했다.
  • 각 가용영역별로 3개의 서브넷(퍼블릭, 프라이빗, 데이터베이스)를 생성했다.
  • RDS는 MSA를 따라, 서비스별로 구성했으며, 서브넷의 배정은 AWS 자체적으로 처리한다.
  • Lambda 또한 함수가 실행될 대 AWS 자체적으로 처리한다.
  • 람다는 도메인 이름으로 호출을 위해 API G/W를 연결했다.
  • Route53의 호스팅 영역은 이미 존재한다.
  • 람다는 Axios를 통해 외부 api를 호출하므로, NAT G/W를 필요로 한다. 
  • EIP도 설정하여 NAT G/W에 할당해준다.
  • NAT G/W는 1개만 구성했고,
  • Bastion Server 역시 1개만 구성했다.

 

2. 구성

2.1. 테라폼 모듈 및 환경별 구성

코드로 인프라 관리를 한다는 것과 더불어, 개발 환경에서 인프라를 구성하고, 동일한 구성으로 상용 환경을 손쉽게 구성할 수 있다는 게 테라폼의 장점이다. 이런 장점과 함께 리소스는 구성 자체는 동일하지만 상용에서는 RDS에 높은 성능의  인스턴스 클래스를 적용하는 등 유연성을 확보하기 위해 모듈로 구성을 할 것이다.

모듈은 연관된 리소스를 디텍터리로 묶어서 구성해주고, 버전 관리를 위해 하위 디렉터리를 구성했다. 모듈을 배포하는 순서도 중요한데, 이는 뒤에서 차근차근 기술해보겠다.

테라폼 리소스 모듈 구성

2.2. SAM 구성

sam은 람다 함수 리소스와 API G/W의 생성을 담당한다. RDS와 통신이 필요하기 때문에 람다 함수는 VPC 내부의 private subnet에 위치시킬 것이다. template.yaml 에서 Globals 부분의 VpcConfig를 보면, 보안 그룹과 서브넷 그룹이 필요하다. 때문에 테라폼에서는 이와 관련된 리소스의 배포가 선행되어야 한다.

Globals:
  Function:
    Runtime: nodejs20.x
    VpcConfig:
      SecurityGroupIds:
        - !Ref SecurityGroupIds
      SubnetIds: !Ref SubnetIds

 

Resources에서는 API G/W와 람다 함수 리소스들을 구성해준다. 함수 리소스에서 RestApiId에서 앞서 구성한 리소스의 이름을 참조(!Ref)함으로써 람 함수와 API G/W를 쉽게 연결할 수 있다.

Resources:
  SampleApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub ${Stage}-sample-api
      StageName: !Ref Stage
      OpenApiVersion: "3.0.0"
      EndpointConfiguration:
        Type: REGIONAL

  GetSamplesFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${Stage}-get-samples
      CodeUri: handlers/sample/get-samples
      Events:
        GetSamples:
          Type: Api
          Properties:
            Path: /samples
            Method: get
            RestApiId:
              Ref: SampleApiGateway

3. 사전 준비

3.1. 테라폼 backend 목적 S3 버킷

테라폼의 스테이트는 상태 파일을 통해 관리된다. 혼자서 개발을 한다면 로컬에서도 진행해도 무관하지만 팀단위로 진행할 경우, 공유가 가능한 원격 저장소에 상태 파일을 저장해야 한다. 이를 위한 s3 버킷을 만들고, 퍼블릭 엑세스를 차단하여 노출을 방지한다.

terraform {
  backend "s3" {
    bucket       = "your_bucket"
    key          = "live/dev/resource/terraform.tfstate"
    region       = "ap-northeast-2"
    profile      = "sample"
    encrypt      = true
    use_lockfile = true
  }
}

4. 모듈 구성 및 배포

모듈을 구성하면 미리 설정한 리소스를 생성하고 관리하기 용이하며, 개발 환경에 배포한 인프라 구성을 상용 환경에도 간편하게 구성할 수 있다. 더불어, 변수를 활용하면 상용에서는 높은 성능의 인스턴스 클래스를 설정하는 등의 유연성도 확보할 수 있다.

4.1. VPC

4.1.1. VPC 모듈 구성

  • locals: 지역 변수로, 모든 리소스들을 작성하고 겹치는 항목에 대해 설정해준다.
  • aws_vpc: cidr_block 속성에 변수를 입력하여, 모듈을 가져와 사용할 때 직접 입력하도록 구성했다. 실제 사용은 뒤에 이어지는 글에서 살펴보자.
    • enable_dns_support: VPC 내에서 DNS 해석 기능 활성화에 대한 옵션이다.
      VPC 내부에서 도메인 이름으로 리소스에 접근하거나, Route 53 프라이빗 호스트존 사용 여부을 담당한다
    • enable_dns_hostnames: VPC 내의 인스턴스에 자동으로 DNS 이름을 할당하는 옵션이다.
      EC2 인스턴스에 프라이빗 DNS 호스트 이름이 할당되거나, 퍼블릭 IP 존재할 때 퍼블릭 DNS 이름 할당을 담당한다.
  • aws_internet_gateway: VPC가 외부 인터넷과 통신하기 위한 통로로써, 외부 통신을 하는 경우 반드시 설정해줘야 한다.
# 지역 변수
locals {
  service_name = "your_service"
  all_ips      = "0.0.0.0/0"
  any_port     = 0
  any_protocol = "-1"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}
  • aws_eip: Elastic IP(탄력적 IP)로, 값이 고정된 공인 IP 주소다. 기본값은 계정당 5개이므로, 이미 사용중이라면 증가 요청을 하거나 삭제를 통해 여유분을 확보해야 정상적으로 생성된다.
  • aws_nat_gateway: 프라이빗 서브넷에 위치할 람다가 외부와 통신하기 위해 필요한 NAT(Network Address Translation) 기능을 가진 게이트웨이다. EIP를 할당해줘야 한다.
    • depends_on: 명시적으로 생성 순서를 나타낸 것이다. VPC가 외부와 통신하기 위해서는 igw가 필수이며, nat g/w 또한 igw가 있어야만 외부와 통신이 가능하다. 
  • aws_subnet: vpc 내부에 있으므로, 어떤 vpc, 어떤 가용 영역에 있는지와 어떤 CIDR 블록을 갖는지를 설정해준다.
    • count: 변수를 통해 서브넷의 cidr 배열을 입력하면 그 길이에 맞는 개수만큼 리소스가 생성된다.
      count.index를 통해 변수 배열에서 인덱스에 해당하는 값을 가져올 수 있다.
# EIP
resource "aws_eip" "nat" {
  domain = "vpc"
}

# NAT Gateway
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  depends_on = [aws_internet_gateway.main]
}

# Subnet
resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]
}
  • aws_route_table: 라우팅을 어떻게 해야 할지, 즉 특정 목적지 IP주소에 대한 통신을 어디로 보내야 하는지를 설정해준다. 로컬 통신에 대해서는 디폴트로 입력된다. public 서브넷의 통신은 igw로 보내고, private 서브넷의 통신은 nat로 보낸다. db 서브넷의 통신은 로컬만 존재한다.
  • aws_route_table_association: 설정한 라우팅 테이블에 서브넷을 할당한다.
  • aws_security_group: VPC 내부 리소스끼리 통신을 허용하는 디폴트 보안그룹을 구성한다.
    • ingress 속성에서 self = true 설정은 해당 보안그룹을 가진 리소스끼리만 통신을 허용한다는 말이다.
# Route Table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = local.all_ips
    gateway_id = aws_internet_gateway.main.id
  }
}

# Route Table Association
resource "aws_route_table_association" "public" {
  count          = length(var.public_subnet_cidrs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Security Group
resource "aws_security_group" "default" {
  name        = "${local.name_prefix}-sg-default"
  description = "SG for VPC internal"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port = local.any_port
    to_port   = local.any_port
    protocol  = local.any_protocol
    self      = true
  }

  egress {
    from_port   = local.any_port
    to_port     = local.any_port
    protocol    = local.any_protocol
    cidr_blocks = [local.all_ips]
  }
}

4.1.2. VPC 개발 환경 배포

환경을 나타내는 변수를 설정하고, 모듈에서 디폴트 값이 없는 변수로 설정한 값들을 직접 입력해준다. 서브넷의 경우, 각 종류별로 간격을 둬서 추후 확장이 될 경우를 고려했다.

locals {
  environment = "dev"
}

module "vpc" {
  source = "../../../modules/vpc/v0.0.1"

  environment           = local.environment
  vpc_cidr              = "10.0.0.0/16"
  public_subnet_cidrs   = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs  = ["10.0.11.0/24", "10.0.12.0/24"]
  database_subnet_cidrs = ["10.0.21.0/24", "10.0.22.0/24"]
  availability_zones    = ["ap-northeast-2a", "ap-northeast-2b"]
}

4.2. RDS

4.2.1. RDS 모듈 구성

  • aws_db_subnet_group: RDS 인스턴스를 위치시킬 서브넷 그룹을 지정한다. 테라폼으로 배포를 하면 AWS가 자체적으로 서브넷 그룹 내에서 서브넷을 지정하여 RDS 인스턴스를 생성한다.
  • aws_db_parameter_group: 데이터베이스 엔진의 문자셋, 타임존, 메모리 설정 등 설정값을 지정하는 리소스다. 한글 및 이모지 입력에 대한 문자셋 설정값만 입력했다.
  • aws_db_option_group: 데이터베이스의 추가 기능을 관리하는 리소스다. 데이터 암호화, 성능 모니터링 등을 설정할 수 있다. 엔진과 엔진 버전 등만 사용했으며, 따로 추가적인 기능이 들어가진 않았기 때문에 삭제해도 무관하다.
# RDS 서브넷 그룹
resource "aws_db_subnet_group" "subnet_group" {
  name       = "${local.name_prefix}-subnet-group"
  subnet_ids = var.subnet_ids
}

# RDS 파라미터 그룹
resource "aws_db_parameter_group" "parameter_group" {
  name   = "${local.name_prefix}-parameter-group"
  family = "mysql8.0"

  parameter {
    name  = "character_set_server"
    value = "utf8mb4"
  }

  parameter {
    name  = "character_set_client"
    value = "utf8mb4"
  }
}

# RDS 옵션 그룹
resource "aws_db_option_group" "option_group" {
  name                     = "${local.name_prefix}-option-group"
  option_group_description = "Option group for RDS"
  engine_name              = "mysql"
  major_engine_version     = "8.0"
}
  • aws_db_instance: 인스턴스 클래스, username, password 등 환경별로 다르게 구성할 것들은 변수로 구성하여 모듈을 사용할 때 직접 입력하게 했다. 필요한 값들은 추가로 변수로 설정하여 입력하면 된다.
  • aws_security_group: 보안그룹 리소스를 VPC모듈에 몰아서 구성할까 싶었지만, 모듈별로 구성할 때 응집도가 더 높아진다고 판단했다. vpc 디폴트 보안그룹을 인바운드 규칙의 소스로 지정할 것이다.
# RDS 인스턴스
resource "aws_db_instance" "rds" {
  count                  = length(var.rds_name)
  identifier             = "${local.name_prefix}-rds-${var.rds_name[count.index]}"
  engine                 = "mysql"
  engine_version         = "8.0"
  instance_class         = var.instance_class
  allocated_storage      = 20
  max_allocated_storage  = 100
  storage_type           = "gp3"
  username               = var.db_username
  password               = var.db_password
  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.subnet_group.name
  parameter_group_name   = aws_db_parameter_group.parameter_group.name
  option_group_name      = aws_db_option_group.option_group.name
  publicly_accessible    = false
  skip_final_snapshot    = var.skip_final_snapshot
  deletion_protection    = true
}

# 보안 그룹
resource "aws_security_group" "rds" {
  name        = "${local.name_prefix}-sg-rds"
  description = "SG for RDS"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = local.mysql_port
    to_port         = local.mysql_port
    protocol        = "tcp"
    security_groups = [var.source_security_group_id]
  }
}

4.2.2. RDS 개발 환경 배포

RDS 모듈을 보면 VPC의 id, 기본 보안그룹 id, db 서브넷 id들 등을 필요로 하는 것을 확인할 수 있다. 그리고 이는 앞서 terraform apply를 통해 배포한 VPC의 output 값에서 확인할 수 있다. s3 backend를 통해 tfstate 파일을 관리하고 있으므로, 해당 버킷에서 스테이트 파일을 가져오고, 거기서 output의 값을 확인해야 한다. 물론, 이렇게 읽어올 값이라면 VPC 모듈에서 output 값을 미리 설정해놔야 한다.

  • terraform_remote_state 데이터 블록을 사용하여 backend에서 tfstate 파일을 읽는다.
locals {
  environment = "dev"
}

data "terraform_remote_state" "vpc" {
  backend = "s3"

  config = {
    bucket       = "your_bucket"
    key          = "live/dev/vpc/terraform.tfstate"
    region       = "ap-northeast-2"
    profile      = "sample"
    encrypt      = true
    use_lockfile = true
  }
}

module "rds" {
  source = "../../../modules/rds/v0.0.1"

  environment              = local.environment
  vpc_id                   = data.terraform_remote_state.vpc.outputs.vpc_id
  source_security_group_id = data.terraform_remote_state.vpc.outputs.default_security_group_id
  subnet_ids               = data.terraform_remote_state.vpc.outputs.database_subnet_ids
  db_username              = "admin"
  db_password              = "pw123!"
  skip_final_snapshot      = true
  rds_name                 = ["first", "second", "third"]
  instance_class           = "db.t4g.micro"
}

# live/dev/vpc/outputs.tf
output "vpc_id" {
  description = "vpc ID"
  value       = module.vpc.vpc_id
}

output "database_subnet_ids" {
  description = "database subnet ID list"
  value       = module.vpc.database_subnet_ids
}

output "default_security_group_id" {
  description = "Default security group ID"
  value       = module.vpc.default_security_group_id
}

4.3. EC2

4.3.1. EC2 모듈 구성

RDS에 접속할 때 사용할 bastion 서버를 구성한다. 먼저, 기존에 발급한 키페어가 있다면 추후 모듈을 사용할 때 입력해주고,없다면 키페어를 생성한 뒤 입력한다.

  • aws_instance: ami는 콘솔에서 확인하여 최신버전의 리눅스, 우분투를 입력해줄 것이다. 퍼블릭 서브넷에 위치하므로, public IP 할당을 활성화한다.
  • aws_security_group: tcp 포트인 22번에 대해 인바운드 규칙을 설정해준다.
# EC2
resource "aws_instance" "bastion" {
  ami                         = var.ami
  subnet_id                   = var.public_subnet_id
  vpc_security_group_ids      = [aws_security_group.bastion.id, var.default_security_group_id]
  instance_type               = var.instance_type
  key_name                    = var.key_name
  associate_public_ip_address = true
}

# 보안 그룹
resource "aws_security_group" "bastion" {
  name        = "${local.name_prefix}-sg-bastion"
  description = "Security group for bastion host"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = local.tcp_port
    to_port     = local.tcp_port
    protocol    = local.tcp_protocol
    cidr_blocks = [local.all_ips]
  }

  egress {
    from_port   = local.any_port
    to_port     = local.any_port
    protocol    = local.any_protocol
    cidr_blocks = [local.all_ips]
  }
}

4.3.2. EC2 개발 환경 배포

VPC의 output이 필요하므로 앞선 RDS의 경우와 마찬가지로 상태 파일을 읽어서 변수를 입력해주고, ami, instance_type, key_name 등의 변수를 입력해준다.

locals {
  environment = "dev"
}

data "terraform_remote_state" "vpc" {
  backend = "s3"
  #.. 상동 ..
}

module "ec2" {
  source = "../../../modules/ec2/v0.0.1"

  environment               = local.environment
  vpc_id                    = data.terraform_remote_state.vpc.outputs.vpc_id
  default_security_group_id = data.terraform_remote_state.vpc.outputs.default_security_group_id
  public_subnet_id          = data.terraform_remote_state.vpc.outputs.public_subnet_ids[0]
  ami                       = "ami-05377cf8cfef186c2" # linux 2023
  instance_type             = "t3a.nano"
  key_name                  = "your_key_name"
}

4.4. Parameter Store

4.4.1. Parameter Store 모듈 구성

MSA 구현을 위해 상용에서는 DB를 여러 개 생성하더라도, 개발에서는 비용 최적화를 위해 1개의 DB로 테스트를 하는 경우가 많다. 이를 구현하기 위해 DB에 관련된 파라미터들을 컨텍스트에 맞게 생성할 수 있도록 구성을 했다.

 

먼저, 관련된 terraform의 문법을 먼저 살펴보자.

4.4.1.1. for in 표현식

for in 표현식을 간단하게 표현하면 아래와 같다.

for 요소 in 대상: 결과
  • 대상이 맵이면 요소는 2개(key, value)가 된다.
  • 대상이 리스트면 요소는 1개(item)가 된다.
  • 결과를 맵으로 나타내고 싶으면 화살표를 통해 key => vlaue 로 나타낸다.
  • 결과를 리스트로 나타내고 싶으면 화살표없이 item 으로 나타낸다.

각 경우의 수에 따른 표현은 정리하면 아래 4가지와 같다.

  • for key, value in target: key => value
  • for key, value in target: key
  • for item in target: item
  • for item in target: item => value

4.4.1.2. for_each

리소스를 여러 개 생성할 때 사용하는 반복문이다. 맵과 리스트로 사용할 수 있고, 사용법은 아래와 같다.

  • 맵을 사용할 때는 each.key, each.value로 각 키벨류 페어들에 접근할 수 있다.
  • 리스트를 사용할 때는 중복을 제거할 경우, toset으로 리스트를 Set으로 만든 뒤 사용하며, each.key, each.value는 모두 Set의 요소 값으로 동일하다.
resource "aws_ssm_parameter" "example" {
  for_each = {
    "param1" = "value1"
    "param2" = "value2"
    "param3" = "value3"
  }

  name  = each.key    # "param1", "param2", "param3"
  value = each.value  # "value1", "value2", "value3"
}

resource "aws_ssm_parameter" "example" {
  for_each = toset(["param1", "param2", "param3"])

  name  = each.value  # "param1", "param2", "param3"
  value = "some-value"
}

4.4.1.3. Parameter Store 모듈

# 지역변수
locals {
  service_name = "sample"
  name_prefix  = "/${var.environment}/${local.service_name}"

  # 파라미터 경로 생성
  param_paths = {
    for context in var.contexts : context => {
      host     = "${local.name_prefix}/${context}/database/mysql/host"
      port     = "${local.name_prefix}/${context}/database/mysql/port"
      username = "${local.name_prefix}/${context}/database/mysql/username"
      password = "${local.name_prefix}/${context}/database/mysql/password"
      dbname   = "${local.name_prefix}/${context}/database/mysql/dbname"
    }
  }
}

# Parameter Store
resource "aws_ssm_parameter" "db_params" {
  for_each = {
    for param in flatten([
      for context, paths in local.param_paths : [
        for param_name, path in paths : {
          name  = path
          value = contains(keys(var.db_params), context) ? var.db_params[context][param_name] : var.db_params[param_name]
        }
      ]
    ]) : param.name => param.value
  }

  name  = each.key
  type  = "String"
  value = each.value
}

resource "aws_ssm_parameter" "default_security_group_id" {
  name  = "${local.name_prefix}/default/security-group-id"
  type  = "String"
  value = var.default_security_group_id
}

resource "aws_ssm_parameter" "private_subnet_ids" {
  name  = "${local.name_prefix}/private/subnet-ids"
  type  = "StringList"
  value = join(",", var.private_subnet_ids)
}

resource "aws_ssm_parameter" "email_from" {
  name  = "${local.name_prefix}/email/from"
  type  = "String"
  value = var.email_from
}

resource "aws_ssm_parameter" "email_app_password" {
  name  = "${local.name_prefix}/email/app-password"
  type  = "String"
  value = var.email_app_password
}

4.4.2. Parameter Store 개발 환경 배포

개발 환경은 1개의 DB를 사용하고, 상용 환경에서는 3개의 DB를 사용한다. 두 환경에서 모듈을 사용하는 방법을 살펴보자.

 

4.4.2.1. 개발 환경

개발환경에서는 union이라는 이름으로 하나의 DB를 생성했다. 컨텍스트가 달라지더라도 동일한 host, port 값을 넣어줘야 하기 때문에 context 값들을 직접 입력했다.

locals {
  environment = "dev"
}

data "terraform_remote_state" "rds" {
  backend = "s3"
  ...
}

data "terraform_remote_state" "vpc" {
  backend = "s3"
  ...
}

module "parameter_store" {
  source = "../../../modules/parameter_store/v0.0.1"

  environment = local.environment
  db_params = {
    for context in ["context1", "context2", "context3"] : context => {
      host     = data.terraform_remote_state.rds.outputs.rds_instances["union"].address
      port     = data.terraform_remote_state.rds.outputs.rds_instances["union"].port
      username = "admin"
      password = "pw123!"
      dbname   = "sample"
    }
  }

  contexts                  = ["context1", "context2", "context3"]
  default_security_group_id = data.terraform_remote_state.vpc.outputs.default_security_group_id
  private_subnet_ids        = data.terraform_remote_state.vpc.outputs.private_subnet_ids
  email_from                = "sample@gmail.com"
  email_app_password        = "sample_app_pw"
}

 

4.4.2.2. 상용 환경

상용환경에서는 RDS 리소스를 여러 개 생성한다. RDS 인스턴스가 DB name을 키값으로 갖고, address, port로 이루어진 객체를 값으로 갖는 Map 이므로, for in 문을 통해서 context별 host, port를 입력해준다.

locals {
  environment = "dev"
}

data "terraform_remote_state" "rds" {
  backend = "s3"
  ...
}

data "terraform_remote_state" "vpc" {
  backend = "s3"
  ...
}

module "parameter_store" {
  source = "../../../modules/parameter_store/v0.0.1"

  environment = local.environment
  db_params = {
    for name, instance in data.terraform_remote_state.rds.outputs.rds_instances : name => {
      host     = instance.address
      port     = instance.port
      username = "admin"
      password = "pw123!"
      dbname   = "sample_${name}"
    }
  }

  contexts                  = keys(data.terraform_remote_state.rds.outputs.rds_instances)
  default_security_group_id = data.terraform_remote_state.vpc.outputs.default_security_group_id
  private_subnet_ids        = data.terraform_remote_state.vpc.outputs.private_subnet_ids
  email_from                = "sample@gmail.com"
  email_app_password        = "sample_app_pw"
}

5. SAM 구성 및 배포

5.1. subnet, security group 구성

위에서 VPC의 디폴트 보안그룹과 프라이빗 서브넷 아이디들을 Parameter Store를 저장한 경우를 보면, 보안그룹은 string이고, 프라이빗 아이디는 쉼표로 2개의 서브넷 아이디가 연결된 StringList임을 살펴볼 수 있다. 그리고 이 경우, 아래처럼 입력하면 SubnetIds가 쉼표로 연결된 문자열 그대로("subnet-123,subnet-456") 입력되므로 에러가 난다.  왜냐하면 공식문서가 원하는 구조는 SecurityGroupIds 처럼 - 로 서브넷을 기술한 형태이기 때문이다.

Globals
  Function:
    VpcConfig:
      SecurityGroupIds:
        - !Sub "{{resolve:ssm:/${Stage}/security-group-id}}"
      SubnetIds: !Sub "{{resolve:ssm:/${Stage}/subnet-ids}}"

# TOBE
Globals
  Function:
    VpcConfig:
      SecurityGroupIds:
        - sg-111
      SubnetIds:
      	- subnet-123
        - subnet-456

 

혹은 !Split 내장 함수를 활용해서 문자열을 배열로 나타난다고 해도 에러가 난다. ["subnet-123", "subnet-456"]형태로 입력되기 때문이다. 

Globals
  Function:
    VpcConfig:
      SecurityGroupIds:
        - !Sub "{{resolve:ssm:/${Stage}/security-group-id}}"
      SubnetIds: !Split[",", !Sub "{{resolve:ssm:/${Stage}/subnet-ids}}"]

 

리스트 형태를 표현하기 위해 다음과 같은 방법을 찾아서 구현했다. 템플릿에서 파라미터를 설정할 때, 타입으로 애초에 List 형태임을 명시하는 것이다. 이렇게 하면 파라미터 스토어의 StringList 타입을 인지하여 !Ref 내장 함수로 가져올 때 리스트로 구현해준다.

Parameters:
  SecurityGroupIds:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::SecurityGroup::Id>
    Default: /dev/default/security-group-id
    AllowedValues:
      - /dev/default/security-group-id
      - /prod/default/security-group-id
  SubnetIds:
    Type: AWS::SSM::Parameter::Value<List<AWS::EC2::Subnet::Id>>
    Default: /dev/private/subnet-ids
    AllowedValues:
      - /dev/private/subnet-ids
      - /prod/private/subnet-ids

Globals
  Function:
    VpcConfig:
      SecurityGroupIds:
        - !Ref SecurityGroupIds
      SubnetIds: !Ref SubnetIds

 

그리고 환경별로 직접 값을 넣어주기 위해 samconfig.toml에서 parameter_overrides를 구성했다.

version = 0.1

[dev]
[dev.deploy]
[dev.deploy.parameters]
profile = "sample"
region = "ap-northeast-2"
parameter_overrides = [
  "Stage=dev",
  "SecurityGroupIds=/dev/default/security-group-id",
  "SubnetIds=/dev/private/subnet-ids",
]
stack_name = "sample-api"
capabilities = "CAPABILITY_IAM"
resolve_s3 = true
s3_prefix = "sample-bucket"
image_repositories = []

[prod]
[prod.deploy]
[prod.deploy.parameters]
region = "ap-northeast-2"
parameter_overrides = [
  "Stage=prod",
  "SecurityGroupIds=/prod/default/security-group-id",
  "SubnetIds=/prod/private/subnet-ids",
]
stack_name = "sample-api"
capabilities = "CAPABILITY_IAM"
resolve_s3 = true
s3_prefix = "sample-bucket"
image_repositories = []

5.2. SAM 배포

비즈니스 로직을 완성했다면 sam build > sam deploy 명령어를 통해 개발 환경 배포, 깃업 액션 파이프라인 등을 통한 상용 배포를 진행한다.

6. API G/W 매핑

6.1. API G/W 가져와서 매핑

6.1.1. API g/w 모듈 구성

API G/W에서 도메인 이름을 만들고, SAM 템플릿에서 만든 API G/W를 경로와 함께 연결하기 위해서는 ACM 인증부터 거쳐야 한다.

  • aws_acm_certificate: ACM 인증서를 발급할 도메인 이름을 변수로 받아서 DNS 방식으로 검증한다. 
  • aws_route53_zone: data 블록으로 존재하는 호스팅 영역을 가져온다.
  • aws_route53_record: ACM 인증서를 발급하면 인증을 위한 cname 레코드가 생성된다. 이것을 호스팅 영역의 레코드로 입력하여 해당 도메인을 가지고 있는 게 맞는지 검사해야 하는데 이를 위한 레코드 입력을 한다.
  • aws_acm_certificate_validation: 인증서 검사를 한다.
  • aws_apigatewayv2_domain_name: API G/W의 도메인 이름을 설정한다.
  • aws_route53_record: A 레코드를 통해 도메인 이름과 API G/W를 별칭으로 연결한다. 이를 통해 도메인 이름으로 호출된 통신이 API G/W로 경유된다.
  • aws_api_gateway_rest_api: rest api 유형인 경우, http 유형과 다르게 apiId가 아니라 name만 설정해주면 식별이 된다. SAM을 통해 생성한 이름으로 가져온다.
  • aws_apigatewayv2_api_mapping: API G/W의 도메인 이름, rest api의 값, 스테이지, 연결한 경로를 설정하여 매핑한다.
# ACM 인증서
resource "aws_acm_certificate" "api_cert" {
  domain_name       = var.api_domain_name
  validation_method = "DNS"
}

# Route53 호스팅 영역
data "aws_route53_zone" "main" {
  name = "sample.com"
}

# ACM 인증서 유효성 검사를 위한 Route53 레코드
resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.api_cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.main.zone_id
}

# ACM 인증서 유효성 검사
resource "aws_acm_certificate_validation" "api_cert_validation" {
  certificate_arn         = aws_acm_certificate.api_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

# API Gateway 도메인 이름
resource "aws_apigatewayv2_domain_name" "api_domain_name" {
  domain_name = var.api_domain_name
  domain_name_configuration {
    certificate_arn = aws_acm_certificate_validation.api_cert_validation.certificate_arn
    endpoint_type   = "REGIONAL"
    security_policy = "TLS_1_2"
  }
}

# Route53 레코드
resource "aws_route53_record" "api_custom_alias" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = var.api_domain_name
  type    = "A"

  alias {
    name                   = aws_apigatewayv2_domain_name.api_domain_name.domain_name_configuration[0].target_domain_name
    zone_id                = aws_apigatewayv2_domain_name.api_domain_name.domain_name_configuration[0].hosted_zone_id
    evaluate_target_health = false
  }
}

# API Gateway APIs
data "aws_api_gateway_rest_api" "apis" {
  for_each = var.api_gateway_api_names
  name     = each.key
}

# API Gateway API Mappings
resource "aws_apigatewayv2_api_mapping" "api_mappings" {
  for_each = var.api_gateway_api_names

  domain_name     = aws_apigatewayv2_domain_name.api_domain_name.id
  api_id          = data.aws_api_gateway_rest_api.apis[each.key].id
  stage           = var.api_gateway_stage_name
  api_mapping_key = each.value
}

6.1.2. API g/w 개발 환경 배포

변수로 설정한 값들을 입력해준다.

module "api_gateway" {
  source = "../../../modules/api_gateway/v0.0.1"

  api_domain_name = "dev.api.sample.com"
  api_gateway_api_names = {
    dev-context1-api     = "context1-api"
    dev-context2-api     = "context2-api"
    dev-context2-api     = "context3-api"
  }
  api_gateway_stage_name = "dev"
}

 

마무리

테라폼의 배포는 dev/vpc 등 모듈을 사용하는 환경별 리소스에 들어가서 terraform init > terraform plan > terraform apply로 배포를 진행하면 된다. 주의할 점은 terraform apply를 실행하기 전에 반드시 terraform plan 명령어를 통해 어떤 리소스가 생성되고, 삭제되는지 잘 살펴봐야 한다.