Project: Shoong-Delivery

[Terraform] shoong-delivery 테라폼 구조 및 회고

2-30 2026. 5. 20. 18:00

테라폼으로 인프라 처음부터 짜보기 — shoong-delivery

들어가며

테라폼은 인터넷 강의랑 공식 문서 보면서 공부했다. 예제 따라치는 거 말고 프로젝트 인프라를 IaC로 깐 건 이번이 처음이었다. 단순히 동작하는 코드를 만들기보다는 실무에서 이 코드를 누가 받아서 운영한다면? 을 계속 떠올리면서 구조를 짰다. 이 글은 그 과정에서 내가 했던 선택과 그 이유를 정리한 글이다.

레포: shoong-terraform (배달 서비스 인프라 — VPC, EKS, RDS, ALB, CloudFront 등 다 들어 있다)


디렉토리 구조부터

shoong-terraform/
├── bootstrap/           # state 백엔드 자체를 만드는 별도 프로젝트
│   ├── main.tf          # S3 + DynamoDB
│   ├── provider.tf
│   ├── variables.tf
│   └── outputs.tf
├── environments/
│   ├── dev/
│   │   ├── Makefile         # terraform 명령 래퍼
│   │   ├── backend.tf       # state 백엔드 설정 (하드코딩)
│   │   ├── provider.tf      # aws / aws.us_east_1
│   │   ├── variables.tf     # 변수 선언
│   │   ├── terraform.tfvars # 환경별 값
│   │   ├── network.tf       # VPC, SecurityGroup, VPC Endpoint
│   │   ├── cluster.tf       # EKS, OIDC, IRSA 3종, Bastion EC2
│   │   ├── edge.tf          # Route53, ACM, WAF, ALB, S3, CloudFront
│   │   ├── database.tf      # RDS + Secrets Manager data
│   │   ├── pipeline.tf      # ECR, GitHub OIDC, SSM Parameter
│   │   └── outputs.tf
│   └── prod/                # dev와 거의 동일한 구성
├── modules/
│   ├── vpc/
│   ├── vpc_endpoint/
│   ├── security_group/
│   ├── eks/
│   ├── rds/
│   ├── alb/
│   ├── acm/
│   ├── route53/
│   ├── waf/
│   ├── cloudfront/
│   ├── s3/
│   ├── iam_oidc/     # GitHub Actions OIDC Role
│   └── ecr/
└── scripts/                # 클러스터 부트스트랩 스크립트
    ├── env.sh.example      # 환경변수 템플릿 (계정ID, 리전 등)
    └── init.sh             # apply 이후 ArgoCD·ESO·헬스체크 자동화

큰 줄기는 세 갈래다. bootstrap / environments / modules. 각자 하는 일이 다르다.


bootstrap을 따로 뺀 이유

처음 테라폼을 작성할 때 가장 헷갈렸던 건 "혼자가 아니라 팀으로 여러 명이 관리하면 어떻게 하지?"였다. 지금이야 혼자 하는 프로젝트지만 실무에서는 결국 여러 사람이 같은 인프라를 같이 만지게 된다. 테라폼은 state 파일(현재 인프라가 어떤 상태인지 기록해두는 파일)을 기준으로 다음에 뭘 만들고 뭘 지울지 판단하는데 이게 사람마다 다르면 협업이 깨진다. 그러면 state 파일은 어디에 둘까? 로컬에 두면 협업이 안 된다. 결국 S3 백엔드에 올려놓고 공유해야 하는데, 그 S3 버킷은 또 누가 만드나? 닭이 먼저냐 달걀이 먼저냐 같은 문제다.

세 가지 선택지가 있었다.

  1. AWS 콘솔에서 손으로 만든다 → 그러면 그 부분은 IaC가 아니게 됨
  2. CLI로 만든다 → 그래도 코드로 안 남음
  3. 별도 테라폼 프로젝트로 만든다 → 그 자체의 state는 로컬에 두지만, 처음에 한 번만 apply하고 끝

결국 3번을 택했다. bootstrap 폴더가 그래서 따로 존재한다. 여기서 만드는 건 단순하다.

  • aws_s3_bucket — 이름 shoong-terraform-state
  • aws_s3_bucket_versioning — state 덮어써도 이전 버전 살아있게
  • aws_s3_bucket_server_side_encryption_configuration — SSE-S3 (KMS는 비용 때문에 패스)
  • aws_s3_bucket_public_access_block — 4가지 다 차단
  • aws_dynamodb_table — state 잠금용. PAY_PER_REQUEST
  • aws_s3_bucket_lifecycle_configuration — noncurrent 버전 60일 후 자동 삭제

bootstrap state는 로컬에만 있다. 어차피 한 번 만들고 거의 안 건드린다. 그리고 이 친구가 만든 S3가 environments/dev, environments/prod의 state 백엔드가 된다.

# environments/dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "shoong-terraform-state"
    key            = "dev/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "shoong-terraform-state-lock"
    encrypt        = true
  }
}

여기서 한 가지 함정이 있었다. backend.tf 안에서는 변수를 못 쓴다. 처음에 var.bucket_name처럼 변수로 뺐다가 에러나서 헤맨 적이 있었다. 이유는 실행 순서 때문이었다.

1. terraform init → 백엔드 초기화
2. provider 초기화
3. 변수 로드 (var.xxx 사용 가능)
4. 리소스 생성

1단계에서 변수가 아직 로드 안 됐기 때문에 backend 블록 안에서는 하드코딩.
알고 나면 당연하지만 모르면 시간을 꽤 날린다. 까먹을까봐 코드에 주석으로 남겨놨다.


environments를 폴더로 나눈 이유 — workspace 안 씀

테라폼 환경 분리에는 보통 두 가지 방식으로 진행하는거 같다.

  • terraform workspace — 같은 코드, state만 분리
  • 폴더 분리 — dev/, prod/ 폴더 따로

workspace는 처음 봤을 땐 "간편한거 같은데?" 싶었는데 알아보니 함정이 있었다. 같은 코드에서 dev/prod를 갈라야 하니까 결국 if env == "prod" 같은 분기를 코드 곳곳에 넣어야 되더라. 그리고 prod와 dev의 차이가 단순히 인스턴스 사이즈만이면 모르겠는데 shoong-delivery는 차이가 있는 부분이 있어서 폴더 분리가 차라리 낫다고 생각했다. 또 앞으로도 다른 부분이 생길 수도 있겠다 싶었다.

예를 들면 dev에는 있고 prod에는 없는 것들:

  • Bastion EC2의 SSM에서 RDS 직접 접근 허용 (allow_ssm_db_access)
  • EKS API endpoint의 public access (endpoint_public_access)

prod는 보안 강화 때문에 막아야 하는데 같은 코드에 분기 박는 거보다 그냥 폴더를 나누고 root에서 모듈 호출 인자를 다르게 주는 게 훨씬 깔끔했다.

# dev/network.tf
module "security_group" {
  source = "../../modules/security_group"
  ...
  allow_ssm_db_access = true
}

# prod/network.tf
module "security_group" {
  source = "../../modules/security_group"
  ...
  allow_ssm_db_access = false
}

코드 중복이 좀 생기긴 한다. 그런데 그 중복이 "환경 간에 실제로 다른 것"을 명시적으로 보여주는 역할을 한다. workspace였으면 어디가 다른지 변수 비교해야 알 수 있는데, 폴더 비교하면 diff로 한 번에 보인다.

prod 환경 추가가 필요해지면 dev 폴더 복사 + tfvars와 일부 boolean 인자(endpoint_public_access, allow_ssm_db_access 등)만 조정하면 끝. 처음 잡을 때만 좀 번거롭고 그 뒤로는 편했다.


모듈 13개 — 어디까지 모듈로 빼고 어디는 root에 둘 건가

이게 가장 오래 고민한 부분이었다. 모듈을 너무 잘게 쪼개면 변수 전달이 길어지고, 너무 안 쪼개면 재사용이 안 된다. 내 기준은 이거였다.

재사용 가능한 리소스는 모듈로 뺀다. 환경 간에 100% 중복되는 리소스도 OIDC URL 같은 환경 차이만 변수로 받으면 모듈로 간다.

modules/에 들어간 13개:

  • vpc, vpc_endpoint, security_group (네트워크 3종)
  • eks, rds (컴퓨트/DB)
  • alb, acm, route53, waf, cloudfront, s3 (엣지 6종)
  • iam_oidc (GitHub Actions OIDC)
  • ecr (이미지 저장소)

각 모듈은 변수만 받고 자기 일만 한다. 의존성은 root(environments/dev or prod)에서 output → input으로 연결한다.

# dev/network.tf
module "vpc" { ... }

module "security_group" {
  source = "../../modules/security_group"
  vpc_id = module.vpc.vpc_id   # root에서 의존성 연결
  ...
}

반대로 root에 직접 둔 것들:

  • ECR 리포지토리에 대한 import 블록
  • SSM Parameter Store 값들 (앱 환경변수)
  • Secrets Manager의 data 참조

이건 모듈로 뺄 만한 게 아니라고 판단했다. ECR import는 한 번 마이그레이션하면 끝이고, SSM Parameter는 그 환경의 앱 환경변수라서 환경별로 내용이 완전히 다르다.
Secrets Manager data는 root에서 RDS 모듈에 자격증명을 넘기는 역할이라 위치가 정해져 있다.

SecurityGroup(SG) rule이 모듈을 넘나드는 케이스

처음엔 SG와 관련 rule은 security_group 모듈에 다 몰아두고 싶었다. 그런데 실제로는 SG rule이 세 군데에 흩어져 있다.

1. security_group 모듈 안 — ALB / EKS Node / RDS / SSM / VPC Endpoint SG 본체와 그 사이의 내부 rule.

2. cluster.tf (root) 안 — RDS SG가 EKS cluster primary SG로부터 5432를 허용하는 rule.

# environments/dev/cluster.tf
resource "aws_security_group_rule" "rds_from_eks_cluster_sg" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  security_group_id        = var.rds_sg_id
  source_security_group_id = var.cluster_primary_security_group_id
  description              = "PostgreSQL from EKS cluster primary SG (worker nodes)"
}

이유는 이거다. EKS 워커 노드는 내가 만든 SG가 아니라 EKS가 자동으로 만든 cluster primary SG를 쓴다. 그러면 RDS SG가 그 primary SG에서 오는 5432를 허용해야 한다. 두 SG ID가 다 있어야 만들 수 있는 rule이고, EKS 클러스터가 만들어진 다음에야 primary SG ID를 안다. 그런데 security_group 모듈은 EKS 모듈에 SG ID를 넘겨주는 입장이라 의존성 그래프상 EKS보다 먼저 돌 수밖에 없다.(테라폼은 변수/output 연결을 따라 실행 순서를 자동으로 결정한다.) SG 모듈이 실행되는 시점에는 EKS 클러스터 자체가 아직 없고, 따라서 cluster primary SG ID도 존재하지 않는다. 그래서 EKS 모듈 호출 다음에 오는 root의 cluster.tf에 이 rule을 직접 작성했다.

3. eks 모듈 안 — ALB → Istio Ingress Gateway 트래픽을 허용하는 rule.

# modules/eks/main.tf
resource "aws_security_group_rule" "alb_to_istio_http" {
  type                     = "ingress"
  ...
  security_group_id        = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
  source_security_group_id = var.alb_sg_id
}

이 rule이 가리키는 cluster_security_group_id는 EKS 클러스터가 자체적으로 만든 SG라서 EKS 모듈 안에서만 참조 가능하다. 그래서 EKS 모듈 안에 두는 게 자연스러웠다.

RDS의 Secrets Manager — 모듈에서 뺐다

원래 RDS 모듈 안에 aws_secretsmanager_secret을 같이 만들었었다. 그런데 시크릿 값을 변수로 받게 되니까 결국 tfvars로 평문이 새는 문제가 생겼다. 그래서 모듈에서 빼고 → AWS CLI로 외부에서 만든 뒤 → root에서 data로 읽기로 바꿨다. 시크릿은 아예 테라폼 관리 밖에 두었다.

RDS 가용성 — 설계는 Primary-Standby, 지금은 Single

아키텍처를 처음 그릴 때는 RDS를 Primary-Standby(Multi-AZ)로 잡았다. 한 AZ가 죽어도 standby로 failover돼서 DB가 살아있는 구성이 정석이라고 봤다.

그런데 테스트 해보려고 콘솔에 들어가니까 다중 AZ 옵션이 아예 비활성화돼 있었다. 지금 프리티어 계정을 쓰는데, RDS 프리티어는 Single-AZ만 지원하기 때문이다. Multi-AZ는 standby 인스턴스가 한 대 더 뜨니까 프리티어 범위를 벗어나는 거였다.

그래서 모듈은 multi_az를 변수로 빼두고, dev는 일단 multi_az = false로 Single 구성해서 올렸다. 설계 의도(Primary-Standby)는 그대로 두되, 환경 변수로 켜고 끌 수 있게만 해뒀다.

나중에 프리티어가 끝나면 그때 multi_az = true로 재구성하면서 failover가 실제로 어떻게 동작하는지, 전환 시간은 얼마나 걸리는지, 그 사이 커넥션은 어떻게 끊겼다 붙는지 같은 걸 직접 테스트해보려고 한다. 지금은 못 해본 부분이라 숙제로 남겨뒀다.


"모듈/root 기준은 절대적인 게 아니라 의존성 방향이 그 자리를 결정한다고 본다." 어떤 리소스가 다른 모듈의 output에 의존한다면 그 두 output을 동시에 볼 수 있는 자리에 둘 수밖에 없다. 처음엔 깔끔하게 나누고 싶었지만 인프라가 서로 얽혀 있다보니 100%는 안 됐다.


data 소스로 "만들지 않고 읽기만" 하는 것들

테라폼은 만들어진 리소스를 가져올 때 data를 쓴다. 처음엔 그냥 "조회용"이라고만 생각했는데 실제로 써보니까 "이건 테라폼이 관리하지 않겠다"는 의사 표현이기도 했다.

내가 data로만 다룬 것들이 두 군데 있다.

1. Route53 호스팅 존

modules/route53/main.tf에는 resource가 없고 data만 있다.

data "aws_route53_zone" "this" {
  name         = var.zone_domain
  private_zone = false
}

이유: 도메인은 가비아에서 사고 Route53에 호스팅 존을 콘솔에서 미리 만들어 뒀다. 이걸 테라폼이 관리하면 terraform destroy 한 번에 도메인 NS 설정 다 날아간다. 잘못 건드리면 복구가 어려운 자원은 테라폼 밖에 두는 게 안전하다고 판단했다.

2. DB 자격증명 — 시크릿은 껍데기도 만들지 않는다

# resource "aws_secretsmanager_secret" "db_credentials" {
#   name = "${var.project}/${var.env}/db-credentials"
#   ...
# }
# resource "aws_secretsmanager_secret_version" "db_credentials" {
#   secret_id     = aws_secretsmanager_secret.db_credentials.id
#   secret_string = jsonencode({
#     username = var.db_username
#     password = var.db_password
#   })
# }

처음엔 평범하게 위처럼 aws_secretsmanager_secret_version 리소스로 값까지 다 적었다.
근데 그 값이 어디로 들어가지? var.db_password다. 그러면 terraform.tfvars에 평문으로 적게 된다. 그 파일은 git에 올라간다. 시크릿이 git history에 박혀버린다.

그래서 대신 root에서 data로만 읽는 것으로 변경했다.

# environments/dev/database.tf
data "aws_secretsmanager_secret_version" "db" {
  secret_id = "/shoong/dev/db-credentials"
}

locals {
  db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}

module "rds" {
  ...
  db_username = local.db_credentials["username"]
  db_password = local.db_credentials["password"]
}
  1. 테라폼은 시크릿의 껍데기(Secret 이름)조차 만들지 않는다
  2. AWS CLI로 미리 시크릿을 직접 생성 + 값 주입
    aws secretsmanager create-secret \
      --name /shoong/dev/db-credentials \
      --secret-string '{"username":"{{admin}}","password":"..."}'
  3. 테라폼은 그 시크릿을 data로 읽기만 한다
  4. 읽은 값으로 RDS 만든다

이러면 terraform.tfvars에도, 코드에도 시크릿이 안 적힌다.(정확히는 state 파일에는 평문으로 남는다. 그래서 S3 백엔드에 SSE-S3 암호화를 걸어둔다.)

이 패턴의 장점은 또 있다. 운영 중 비밀번호 로테이션할 때 테라폼 안 건드려도 된다. CLI로 시크릿 값만 갈아끼우면 끝. 테라폼 입장에선 data가 자동으로 새 값을 읽는다.

External Secrets Operator도 같은 정책으로 붙였다. EKS Pod의 환경변수도 SSM Parameter Store에 미리 만들어두고, ESO가 동기화해서 Pod에 ConfigMap/Secret으로 주입한다. 시크릿이 git에 안 닿게 하는 게 일관된 원칙이었다.

그런데 이렇게 되면 테라폼으로 모든 인프라를 관리한다는 말은 성립되지 않는다.
그래도 시크릿 같은 민감정보는 테라폼이 아니라 직접 관리하는게 낫지 않나 싶다.


ECR import 블록 — 이미 있는 리소스 흡수하기

프로젝트 초기에는 ECR 리포지토리 5개를 콘솔에서 미리 만들어서 빌드/푸시 테스트를 돌리고 있었다.
이후 테라폼으로 관리했는데 테라폼으로 한 번 지우고 다시 만들면 그동안 쌓인 이미지가 다 날아갔다.

해결책: 테라폼 1.5에서 추가된 import 블록.

# environments/dev/pipeline.tf
module "ecr" {
  source = "../../modules/ecr"
  ...
}

import {
  to = module.ecr.aws_ecr_repository.this["shoong-batch"]
  id = "shoong-batch"
}
import {
  to = module.ecr.aws_ecr_repository.this["shoong-delivery"]
  id = "shoong-delivery"
}
# ... 5개 다 import

terraform plan을 돌리면 기존 ECR을 자동으로 state로 끌어들이고 코드와 동기화한다. CLI로 terraform import 명령어 매번 치는 거보다 훨씬 깔끔하다. 그리고 import 블록은 코드로 남기 때문에 누가 와서 봐도 "얘는 이미 있던 거 가져온 거구나"라고 알 수 있다.


Security Group — drift 무한루프 피하기

  • 무한 dirft: 테라폼 코드와 실제 인프라 상태가 계속 어긋나서 테라폼을 실행할 때마다 매번 불필요한 수정/삭제를 시도하는 무한 루프 상태

테라폼 쓰기 시작한 초기에 헤맸던 부분이 있었다.
aws_security_groupingress 블록을 inline으로 쓰면서 동시에 aws_security_group_rule 리소스를 별도로 또 쓰는 경우.

예를 들어 RDS SG에 EKS Node SG에서 오는 5432를 처음에 inline ingress로 박아뒀다. 그러다 나중에 SSM EC2에서 오는 5432도 필요해서 aws_security_group_rule 리소스로 따로 추가했다. 그러면 어떻게 되냐?

테라폼이 보기엔 SG에 inline ingress가 1개 있어야 하는데 실제로는 2개가 있다 → "어, 누가 추가했네? 지워야지" → 다음 plan에선 별도 rule이 사라졌다 → "어, 별도 rule이 있어야 하는데?" → 무한 drift.

그래서 SG 모듈에서는 inbound 규칙은 aws_security_group_rule로 분리, outbound는 inline 식으로 일관되게 만들었다.

resource "aws_security_group" "rds" {
  ...
  egress { ... }   # outbound는 inline OK
  # ingress는 inline으로 안 적음
}

resource "aws_security_group_rule" "rds_from_eks_node" {
  type                     = "ingress"
  ...
  security_group_id        = aws_security_group.rds.id
  source_security_group_id = aws_security_group.eks_node.id
}

이런 건 한 번 당해봐야 습득하는거 같다.


IRSA — 그 길고 끔찍한 trust policy

EKS Pod가 AWS API를 호출하려면 IAM 권한이 필요하다. 옛날엔 Node EC2에 Role을 붙였는데 그러면 같은 노드 위의 모든 Pod가 같은 권한을 공유하는 보안 구멍이 생긴다. 그래서 찾은 게 IRSA(IAM Roles for Service Accounts) — Pod의 ServiceAccount 단위로 IAM Role을 부여하는 방식이다.

원리:

  1. EKS가 OIDC Provider를 통해 SA에 JWT 발급
  2. Pod가 그 JWT로 STS에 AssumeRoleWithWebIdentity 요청
  3. STS가 검증 후 임시 자격증명 반환
  • STS: Security Token Service. IAM 사용자나 역할(Role)에게 일정 시간 동안만 유효한 임시 보안 자격 증명(토큰)을 발급해 주는 AWS 서비스
  • AssumeRoleWithWebIdentity: OIDC(OpenID Connect) 인증 정보(IdP 토큰)를 기반으로 AWS STS에 요청하여 임시 보안 자격 증명(Access Key, Secret Key, Token)을 받아오는 API(기능)

root cluster.tf에 직접 작성했다. 코드는 이렇게 생겼다.

# environments/dev/cluster.tf
locals {
  oidc_host = replace(aws_iam_openid_connect_provider.eks.url, "https://", "")
}

resource "aws_iam_role" "aws_lb_controller" {
  name = "${var.project}-${var.env}-aws-lb-controller-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.eks.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "${local.oidc_host}:sub" = "system:serviceaccount:kube-system:aws-load-balancer-controller"
          "${local.oidc_host}:aud" = "sts.amazonaws.com"
        }
      }
    }]
  })
}

Condition:sub 부분이 핵심이다. system:serviceaccount:<namespace>:<sa-name> 형식으로 정확히 이 SA만 이 Role을 Assume할 수 있게 잠근다. 다른 네임스페이스나 다른 SA는 토큰을 들고 와도 거절당한다.

모듈이 만들어 주는 IRSA 3종:

  • AWS Load Balancer Controller — Ingress/Service 감지해서 ALB 만드는 컨트롤러. 권한이 진짜 많다 (16개 statement)
  • EBS CSI Driver — PVC용 EBS 동적 프로비저닝
  • External Secrets Operator — SSM Parameter Store / Secrets Manager 값을 Pod에 동기화

dev/prod root에서는 eks_addons 모듈을 호출하면서 클러스터의 OIDC issuer URL만 넘기면 끝이다. ESO Policy의 SSM Resource ARN(/shoong/dev/* vs /shoong/prod/*)도 모듈 안에서 var.project, var.env로 동적으로 만든다.


GitHub Actions OIDC — Access Key 없는 CI

GitHub Actions에서 AWS로 이미지 푸시할 때 AWS Access Key를 GitHub Secrets에 넣는 방법이 있다. 이게 장기 자격증명이라 유출 위험이 있지 않을까 생각이 들어서 찾아봤는데
OIDC를 쓰면 access key 자체가 없다.

# modules/iam_oidc/main.tf
resource "aws_iam_openid_connect_provider" "github" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

resource "aws_iam_role" "github_actions" {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringLike = {
          "token.actions.githubusercontent.com:sub" = [
            for repo in var.github_repos : "repo:${var.github_org}/${repo}:*"
          ]
        }
      }
    }]
  })
}

원리는 IRSA와 똑같다. 신뢰하는 IdP가 EKS 대신 GitHub일 뿐. 특정 org의 특정 repo에서만 이 Role을 Assume할 수 있다. GitHub Actions workflow에서는 그냥 role-to-assume만 지정하면 끝.


Bastion EC2 — Session Manager 점프 호스트

dev 환경의 EKS는 편의를 위해 public access를 열어놨지만
prod는 endpoint_public_access=false다. 그러면 어떻게 kubectl을 친단 말인가?

그래서 VPC 안에 Bastion EC2를 하나 띄워뒀다. SSH는 안 쓰고 SSM Session Manager로 붙는다. 공인 IP도 안 주고 22번 포트도 안 열어뒀다. root cluster.tf에 직접 작성했다.

# environments/dev/cluster.tf
resource "aws_instance" "ssm" {
  ami                         = var.ami
  instance_type               = var.instance_type
  subnet_id                   = var.subnet_id
  iam_instance_profile        = aws_iam_instance_profile.this.name
  vpc_security_group_ids      = var.security_group_ids
  associate_public_ip_address = false   # 공인 IP 없음

  user_data = <<-EOF
    #!/bin/bash
    # kubectl, psql, awscli 설치
    ...
    aws eks update-kubeconfig --region ${var.aws_region} --name ${var.cluster_name}
  EOF
}

IAM Role과 EC2 인스턴스를 cluster.tf 안에 직접 선언한다. 서브넷은 VPC 모듈 output, SG는 security_group 모듈 output에서 가져온다.

# environments/dev/cluster.tf
resource "aws_instance" "ssm" {
  ami                         = var.ssm_ec2_ami
  instance_type               = var.ssm_ec2_instance_type
  subnet_id                   = module.vpc.private_subnet_ids[0]
  iam_instance_profile        = aws_iam_instance_profile.ssm_ec2.name
  vpc_security_group_ids      = [module.security_group.ssm_ec2_sg_id]
  associate_public_ip_address = false
  ...
}

aws ssm start-session --target i-xxx로 접속하면 끝. SSH 키 관리 안 해도 되고 22번 포트가 인터넷에 노출되지 않는다. IAM 권한으로 접속자를 통제한다.

포트를 최소한으로 열어야지 항상 생각해왔는데 22번 포트도 닫을 수 있다해서 이렇게 구성했다.
근데 실무에서는 어떻게 하는지 궁금하긴 하다. 다른 사람들과 회사들은 어떻게 접근하는지 궁금하다.


CloudFront/WAF는 us-east-1만 — provider alias

이건 AWS의 제약인데, CloudFront 인증서와 WAF Web ACL은 us-east-1에서만 만들 수 있다. 다른 리전에서 만들면 CloudFront가 못 가져다 쓴다고 한다.

그래서 provider alias로 처리했다.

# environments/dev/provider.tf
provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile
}

provider "aws" {
  alias   = "us_east_1"
  region  = "us-east-1"
  profile = var.aws_profile
}

# environments/dev/edge.tf
module "acm" {
  source = "../../modules/acm"
  providers = {
    aws = aws.us_east_1   # 이 모듈만 us-east-1로
  }
  ...
}

module "waf" {
  providers = {
    aws = aws.us_east_1
  }
  ...
}

provider 블록만 두 개 만들고, 특정 모듈에만 alias를 넘기면 된다.


정리하면서 느낀 것들

  1. 테라폼은 "코드만 잘 짠다"가 아니라 "운영을 어떻게 할지" 고민이 더 중요하다. 시크릿을 어디에 둘지, state를 어디에 둘지, 환경을 어떻게 나눌지 같은 것들. 코드 잘 짜는 건 두 번째인거 같다.
  2. 시크릿 관리는 진짜 신경 써야 한다. "테라폼이 시크릿을 만들고 값을 넣는다"는 함정에 빠지면 안 된다. 그리고 한번 git 같은 곳에 노출되면 골치 아파질게 선명하게 그려진다.
  3. data 소스는 "테라폼이 관리 안 함" 선언이다. Route53 호스팅 존이나 Secrets Manager 값처럼 잘못 건드리면 큰일 나는 건 data로만 다룬다.
  4. drift는 코드 잘 짜면 안 생긴다. SG inline/별도 rule 섞지 않기 같은 작은 규칙들.

테라폼은 처음 한 달은 정말 어려웠다. 강의 보면서 흉내내는 거랑 실제로 내 프로젝트 구조 잡는 건 완전히 다른 차원의 일이었다. 근데 한 번 구조를 잡아두니까, 나중에 prod 환경 추가할 때 폴더 복사 + tfvars 수정 + 시크릿 CLI로 한 번 만들면 끝났다. 또 비용때문에 매일 terraform apply/destroy를 반복했었는데 이거를 콘솔로 작업한다고 생각했을 때 "아, 이래서 IaC를 하는구나" 싶었던 순간이었다.