캐치테이블의 인프라를 관리하는 방법 (feat. Terraform, AWS)

홍수민
캐치테이블
Published in
17 min readMay 17, 2024

안녕하세요! 캐치테이블 DevOps 엔지니어 홍수민입니다.

저희 DevOps파트는 회사의 성장을 위한 Growth팀에 소속되어 있으며, 개발자 생산성 및 서비스 안정성 향상/인프라 비용 및 보안을 목표로 일하고 있습니다.

저는 입사한 지 이제 막 1년이 지났는데요, 오늘은 그동안 구축해 둔 테라폼 환경과 저희 팀에서 테라폼을 사용하는 방식에 대해 소개 해드리려고 합니다.

# 입사했을 당시의 인프라 상황

제가 입사했을 당시(2023년 4월), 캐치테이블의 인프라는 대격변을 기다리고 있었습니다. 전사 서비스가 쿠버네티스(정확히는 AWS EKS)로 전환되기로 결정되었고, 그를 위한 신규 네트워크 인프라를 제공하기로 한 상황이었죠.

때마침 입사한 저는 심각한 테라폼 중독자기 때문에 모든 신규 인프라를 테라폼으로 구성했습니다.

by DALL-E (awss 😅)

# 테라폼 환경 설명

테라폼 버전은 1.4.5일 때 시작하여, 현재는 1.8.0 으로 운영하고 있습니다. 테라폼 백엔드로는 AWS S3와 DynamoDB(lock관리)를 사용하고 있습니다.

테라폼을 실행하는 환경 (이하 실행 환경)은 현재 10개 이상인데요, 아래와 같은 기준들을 가지고 실행 환경을 분리하였습니다.

  • 라이프사이클이 비슷한 리소스들은 한 환경으로 묶는다.
  • 그러나 한 환경에 지나치게 많은 서비스들을 담지 않는다.
  • 민감한 서비스는 별도로 분리한다.

따라서 아래와 같은 환경들이 만들어져 있습니다.

  • account/{계정 이름} : 각 계정별 기본 리소스들 (vpc, nat, ec2, sg …) 관리
  • global/s3 : 모든 계정의 s3 통합 관리
  • global/iam : 모든 계정의 iam 통합 관리
  • global/domain_topology : 모든 계정을 걸친 Route53 / ACM 통합 관리
  • global/network_topology: 모든 계정을 걸친 Transit Gateway / Routing 통합 관리
  • eks/{계정 이름}: 각 계정별 eks / eks용 lb / 클러스터별 k8s 리소스 관리
  • argocd : 관리중인 argocd 들 관리 (helm release, root object)
  • grafana : 그라파나 alert 관련 리소스들 관리 (메세지 템플릿 등)

위 중 몇 가지 실행 환경을 선정해 소개 해드리겠습니다.

# network_topology 환경 소개

by DALL-E

캐치테이블은 2개 이상의 AWS 계정을 사용하고 있고, 이 계정들의 VPC들은 하나의 TGW로 묶여있습니다. 또 사무실 VPN도 같은 TGW에 묶여있는데요, 이 경우 생성해야 하는 리소스들은 다음과 같습니다.

  • TGW, RAM(TGW Share), TGW Route Table, VPN (중앙 계정)
  • RAM Share Accept, VPC Routing (각 계정)

VPC간 통신이 잘못되는 경우 장애로 이어지기에 민감한 리소스들이라고 할 수 있어요. 또한 tgw부터 vpn, vpc routing까지 상당히 많은 리소스들을 통합적으로 체크해야 하기 때문에 플랜 시간이 길어집니다. 따라서 별도 실행 환경으로 분리했습니다.

이 network_topology 환경은 하나의 topology.yaml 파일로 관리되는데요, 이 파일은 아래와 같은 형태를 가집니다.

# global/network_topology/topology.yaml

vpc_attach:
account_a:
vpc_1:
tgw_rt: rt_x
vpc_2:
tgw_rt: rt_z
account_b:
vpc_3:
tgw_rt: rt_y
vpc_4:
tgw_rt: rt_y
account_c:
vpc_5:
tgw_rt: rt_z

vpn_attach:
vpn_00:
public_ip: 1.1.1.1
private_ips: ["192.168.0.0/24"]
tgw_rt: rt_z
vpn_10:
public_ip: 1.1.1.10
private_ips: ["192.168.10.0/24"]
tgw_rt: rt_z
vpn_20:
public_ip: 1.1.1.20
private_ips: ["192.168.20.0/24", "192.168.22.0/24"]
tgw_rt: rt_z

tgw_rt:
rt_x:
vpc: ["vpc_1", "vpc_2", "vpc_5"]
vpn: ["vpn_00", "vpn_10", "vpn_20"]
rt_y:
vpc: ["vpc_2", "vpc_3", "vpc_4", "vpc_5"]
vpn: ["vpn_00", "vpn_10", "vpn_20"]
rt_z:
vpc: ["vpc_1", "vpc_2", "vpc_3", "vpc_4", "vpc_5"]
vpn: ["vpn_00", "vpn_10", "vpn_20"]
  • vpc_attach : TGW Attach될 VPC들을 명시합니다. 실제 vpc, subnet, route table들의 id값은 각 계정별로 account/{계정 이름} 환경의 output에서 받아옵니다. 또한 각 vpc별로 사용하는 Transit Gateway Route Table이 다르기 때문에 tgw_rt라는 인자값을 받도록 설정해 두었습니다.
  • vpn_attach: TGW Attach될 VPN들을 명시합니다. 퍼블릭 IP를 통해 저희 내부 기기에 맞는 Customer Gateway도 생성해줍니다.
  • tgw_rt : Transit Gateway Route Table들을 생성합니다. 각 테이블들에는 위에서 명시된 vpc, vpn들의 정보를 불러와 Static Route를 생성하게 됩니다.

즉, 위 토폴로지 문서 상으로는 아래와 같은 네트워크 환경을 보장해야 합니다. (참고로 위 문서 내의 리소스 이름들은 모두 이해를 돕기 위한 예시입니다.)

  • rt_x를 사용하는 vpc_1은 vpc_2, 5와만 통신 가능합니다.
  • rt_y를 사용하는 vpc_3,4는 vpc_2~5와 통신 가능합니다.
  • rt_z를 사용하는 vpc_2,5와 모든 vpn들은 모든 대역과 통신 가능합니다.

즉, 각 vpc_attach와 vpn_attach에 명시된 tgw_rt는 aws_ec2_transit_gateway_route_table_association 리소스를 구성하고, 각 tgw_rt 내에 명시된 vpc, vpn은 aws_ec2_transit_gateway_route (Static Route)를 구성하게 됩니다.

이 파일의 내용은 yamldecode라는 테라폼 함수를 통해 .tf파일에서 해독가능한 형태가 됩니다. 해독된 데이터들을 요리하여 필요한 리소스들을 모두 만들 수 있게 됩니다.

# global/network_topology/main.tf

locals {
network_topology = yamldecode(file("./topology.yaml"))
}

#########################################################
# TGW
#########################################################
resource "aws_ec2_transit_gateway" "this" {
...
}

module "tgw_rt" "this" {
for_each = local.network_topology.tgw_rt
source = "../../modules/tgw_rt"
...
}

...그 외 RAM 리소스 등 작성...

#########################################################
# VPN Attach
#########################################################
module "vpn" {
for_each = local.network_topology.vpn_attach
source = "../../modules/tgw_vpn_attach"
...
}

#########################################################
# VPC Attach (계정별로 모듈코드 반복) - 프로바이더는 반복이 불가능
#########################################################
module "vpc_account_a" {
for_each = local.network_topology.vpc_attach["account_a"]
source = "../../modules/tgw_vpc_attach"
providers = {
aws.central = aws.central
aws.sub = aws.account_a
}
...
}

module "vpc_account_b" {
for_each = local.network_topology.vpc_attach["account_b"]
source = "../../modules/tgw_vpc_attach"
providers = {
aws.central = aws.central
aws.sub = aws.account_b
}
...
}

모듈 속 상세한 코드까지는 공개하지 않았지만 ‘이런 방식으로 네트워크 환경을 관리하고 있다’ 라고 생각해주시면 될 것 같습니다.

# domain_topology 환경 소개

by DALL-E

캐치테이블은 여러 퍼블릭 도메인을 사용하고 있고, 필요에 따라 서브도메인도 만들어 사용합니다. (ex — catchtable.co.kr) 이 때, 서브도메인들은 루트도메인과 계정이 달라질 수도 있습니다. 또한 각 도메인별 ACM은 다른 계정 또는 다른 리전에서 필요해질수도 있습니다.

domain_topology 환경은 이 내용들을 쉽게 관리하기 위해 만들어졌습니다. 그리고 DNS Host Zone과 ACM은 생성하거나 수정하는 일이 많지는 않지만, network쪽과 마찬가지로 잘못 건드리면 장애 발생 가능성이 있기 때문에 실행 환경을 분리하게 되었습니다.

domain_topology 환경 또한 하나의 토폴로지 파일로 관리됩니다.

# global/domain_topology/topology.yaml

r53_zone:
AAA.com:
parent_account: account_a
childs:
account_a: ["XXX"]
account_b: ["YYY"]

BBB.com:
parent_account: account_b
childs:
account_b: ["ZZZ"]

additional_acm:
AAA.com: # account_a에 존재
- account: account_a
region: us-east-1
- account: account_b
region: ap-northeast-2

YYY.AAA.com: # account_b에 존재
- account: account_a
region: ap-northeast-2

위 토폴로지 파일은 아래 내용을 보장합니다.

  • account_a 계정에는 AAA.com, XXX.AAA.com, ZZZ.BBB.com 호스트존이 존재합니다.
  • account_b 계정에는 YYY.AAA.com, BBB.com 호스트존이 존재합니다.
  • AAA.com의 인증서(ACM)은 account_a 계정의 서울/버지니아 리전 및 account_b 계정의 서울 리전에 존재합니다.
  • YYY.AAA.com의 인증서(ACM)은 account_b 계정 및 account_a 계정의 서울 리전에 존재합니다.

규칙을 눈치채셨나요?

r53_zone 항목에는 Route53 호스트 존을 만들 수 있는 정보들이 명시되어 있습니다. 이 정보를 통해 호스트존이 만들어지면, 해당 계정의 서울 리전에 자동으로 ACM 및 ACM Validation 레코드까지 만들게 해두었습니다. 그리고 다른 계정 또는 다른 리전에 ACM을 추가로 만들어야 하는 경우, additional_acm 이라는 항목 아래에 작성함으로써 추가 ACM을 만들 수 있게 해두었습니다.

여기서 애를 먹은 문제가 하나 있었는데요,

ACM 리소스 자체는 리전 종속이지만, Route53 레코드는 글로벌 리소스입니다. 따라서 ACM Validation 레코드는 동일한 도메인에 대해 리전이 달라도 동일하게 생성됩니다. 즉, AAA.com에 대한 인증서를 account_a의 서울/버지니아 리전에서 각각 만들게 되어도 _aaa.AAA.com 이란 레코드 하나로 검증됩니다. 그런데 문제는 다른 계정에서 AAA.com 인증서를 만들게 되면 별도 검증 레코드가 필요해집니다.

따라서 처음에는 additional_acm 에서 검증 레코드 리소스 블럭에는 호스트존이 존재하는 계정과 같은 계정인지 여부에 따라 생성할지 말지를 결정해줄까? 하고 고민했습니다. 그러나 테라폼 특성상 리전이 달라지면 프로바이더도 달라지기 때문에 이를 일괄적으로 체킹하기가 쉽지 않았고, 결국은 검증레코드에 대한 자동화는 포기하게 되었습니다.

대신! check 블럭을 사용해 검증레코드를 수동으로 추가하기 쉽게 해두었습니다.

#########################################################
# ACM Validation 확인 - 수동 추가 필요
#########################################################
data "aws_acm_certificate" "this" {
domain = aws_acm_certificate.this.domain_name
statuses = ["VALIDATION_TIMED_OUT", "PENDING_VALIDATION", "EXPIRED", "INACTIVE", "ISSUED", "FAILED", "REVOKED"]
}

# ACM이 PENDING_VALIDATION 상태면 WARNING 발생
locals {
origin_account = {Route53 Host Zone이 존재하는 계정 이름}
acm_account = {추가 ACM을 만들 계정 이름}
acm_validate_value = tolist(aws_acm_certificate.this.domain_validation_options)[0]
}

check "validation_record" {
assert {
condition = data.aws_acm_certificate.this.status != "PENDING_VALIDATION"
error_message = "You must add a ${local.acm_validate_value.resource_record_name}/${local.acm_validate_value.resource_record_value} record in ${upper(local.origin_account)} account for ${upper(local.acm_account)} account."
}
}

(allow_overwrite = true를 사용함으로써 에러를 해소할 수도 있지만, 이 경우 두 인증서 중 하나만 삭제하는 경우에도 사용 중인 검증 레코드를 삭제해버릴 수 있게 됩니다. 물론 state rm 하면서 피해갈 순 있겠지만, 언젠가 반드시 발생할 휴먼 에러를 방지하기 위해 해당 방법은 사용하지 않았습니다.)

# current_resource 소개

사실 아직 소개하고 싶은 환경이 많이 남아있지만, 글이 너무 길어질 것 같아서 마지막 current_resource 만 설명해드리려고 해요.

바로 위에서 본 domain_topology 환경의 경우 topology.yaml 파일을 통해 어느 계정에 호스트 존과 acm이 있는지 등을 쉽게 확인 가능하지만, 이 파일을 통해 생성된 호스트 존의 Zone ID나 ACM의 ARN을 확인할 수 없습니다.

처음에는 생성된 리소스들에 대한 정보를 output 출력으로 확인할 수 있게 해두었었지만, 이 경우는 단순히 리소스 정보만 확인하고 싶어도 굳이terraform apply 를 날려야 했습니다. 그러다가 문서 자동화를 고민하게 되었는데, 굳이 사내 위키까지 갈 필요는 없어서 테라폼 코드가 존재하는 깃헙 레포지토리 내에 local_file 을 만들게 되었습니다.

로컬 파일을 만들기 위해, 먼저 템플릿 파일을 만들어 줍니다.

# current_resource/template_files/acm.tftpl

%{ for account, acms in acm_map ~}
## ${account} Account
| Name | Region | ARN |
|---|---|:---|
%{ for k, v in acms ~}
| [${v.zone_name}](https://${v.region}.console.aws.amazon.com/acm/home?region=${v.region}#/certificates/${split("/", v.acm_arn)[1]}) | ${v.region} | `${v.acm_arn}` |
%{ endfor ~}
%{ endfor ~}

account, acms 정보가 있는 acm_map 이란 변수를 입력받아서 계정별로 ACM 정보(호스트존, AWS 링크, 리전, ARN)를 표 형태로 표현합니다. 이제 이 템플릿 파일을 활용한 local_file 테라폼 코드는 아래와 같습니다.

# global/domain_topology/main.tf

locals {
current_resource_path = "../../current_resoruce_path"

acm_map = { # 만들어진 리소스 데이터들로 알아서 조합해서 아래와 같은 형태를 만들어 사용한다.
account_a = {
AAA.com_ap-northeast-2 = {
region = "ap-northeast-2"
acm_arn = ""
zone_name = "AAA.com"
}
...
}
...
}
}

resource "local_file" "acm" {
content = templatefile("${local.current_resource_path}/template_files/acm.tftpl", {
acm_map = local.acm_map
)
filename = "${local.current_resource_path}/ACM.md"
}

이렇게 하면 아래와 같은 마크다운 파일이 생성됩니다. (current_resource/ACM.md)

# 마무리

개인적으로 테라폼을 참 좋아하고 업무상으로도 딥하게 사용해왔지만, 이렇게 팀블로그를 통해 구성해 둔 환경을 공유하는 건 처음이라 뭔가 부끄럽기도 합니다.

회사마다 테라폼을 사용하는 방식은 조금씩 다르겠지만, 저는 이렇게 “운영 편의성"을 최우선으로 생각하며 (yaml 파일만 고치면 뒤에서 흑마법이 작동되어 원하는 대로 모두 다 고쳐지도록.. +.+) 테라폼 코드를 짜왔습니다.

테라폼 글인데 막상 테라폼 코드는 거의 없다는게 조금 웃기기도 하지만, 어떤 지향점을 가지고 테라폼 환경을 구성해야할까 하고 고민하시는 분들께 조금이나마 도움이 되었으면 좋겠습니다.

긴 글 읽어주셔서 감사합니다 👍

--

--