테라폼 실전 강좌 #5 RDS와 S3: 데이터 계층 코드화와 안전장치

4 분 소요

웹 계층이 섰으니 데이터 계층 차례입니다. 이번 편은 리소스 자체는 어렵지 않지만, 데이터를 품는 리소스는 실수의 대가가 다르다는 긴장이 처음 등장하는 편입니다. 웹 서버는 날려도 다시 만들면 되지만 데이터베이스는 아닙니다. 그래서 기초 #7에서 배운 안전장치들이 이번 편에서 실전 배치됩니다.

DB 서브넷 그룹과 보안 그룹 #

RDS는 서브넷을 직접 지정하지 않고 DB 서브넷 그룹이라는 묶음을 받습니다. 프라이빗 서브넷들로 만듭니다.

db.tf
# db.tf (새 파일)
resource "aws_db_subnet_group" "main" {
  name_prefix = "myapp-${var.env}-"
  subnet_ids  = [for s in aws_subnet.private : s.id]
}

resource "aws_security_group" "db" {
  name_prefix = "myapp-${var.env}-db-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]   # 웹 계층에서만
  }

  lifecycle {
    create_before_destroy = true
  }
}

#3에서 잡은 체이닝 원칙이 한 단계 더 내려왔습니다. 인터넷 → ALB SG → web SG → db SG. 데이터베이스의 5432 포트는 웹 계층의 보안 그룹에서 오는 트래픽만 받고, 어떤 CIDR에도 열려 있지 않습니다. 트래픽 경로 전체가 보안 그룹 참조의 사슬로 코드에 남았습니다.

RDS 인스턴스 #

db.tf
resource "aws_db_instance" "main" {
  identifier_prefix = "myapp-${var.env}-"

  engine         = "postgres"
  engine_version = "17"
  instance_class = "db.t4g.micro"

  allocated_storage = 20
  storage_type      = "gp3"

  db_name  = "myapp"
  username = "myapp"
  password = var.db_password        # 임시. 다음 편에서 바꿉니다

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]

  backup_retention_period = 7
  skip_final_snapshot     = true    # 실습용. 운영은 false

  lifecycle {
    prevent_destroy = false         # 실습용. 운영은 true
  }
}

한 줄 한 줄이 설계 결정입니다.

  • db.t4g.micro, gp3 20GB: 실습 최소 크기입니다. 인스턴스 클래스 선택 기준은 RDS 인스턴스 클래스 비교에 정리해 두었습니다.
  • backup_retention_period = 7: 자동 백업 7일. 백업 없는 데이터베이스는 운영이 아니므로 실습에서도 켜는 습관을 들입니다.
  • skip_final_snapshot = true: destroy 때 최종 스냅샷을 남기지 않습니다. 실습에서는 지웠다 만들기를 반복하니 true지만, 운영에서는 false로 두어 삭제 순간에도 스냅샷이 남게 합니다.
  • prevent_destroy: 같은 이유로 실습에서는 끄고, 운영에서는 켭니다. 이 두 값이 환경에 따라 달라진다는 사실 자체가 #9 환경 분리의 예고입니다.

apply에는 몇 분이 걸립니다. RDS 생성은 원래 느리고, 테라폼은 리소스가 사용 가능 상태가 될 때까지 기다렸다가 다음으로 넘어갑니다.

정적 자산용 S3 버킷 #

이미지, CSS 같은 정적 자산이 갈 버킷입니다. 기초 #8에서 만든 것과 같은 구성(버저닝 + 퍼블릭 차단)이므로 코드는 압축해 보입니다.

storage.tf
# storage.tf (새 파일)
resource "aws_s3_bucket" "assets" {
  bucket = "myapp-${var.env}-assets-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket                  = aws_s3_bucket.assets.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

버킷 이름 끝에 계정 ID를 붙인 것은 실무 관용구입니다. 버킷 이름은 전 세계에서 유일해야 하는데, data.aws_caller_identity(기초 #4)로 가져온 계정 ID를 붙이면 이름 충돌 걱정 없이 어느 계정에서도 같은 코드가 동작합니다. 퍼블릭 차단을 걸었는데 정적 자산을 어떻게 서빙하는지 의문이 남을 텐데, 답은 #10의 CloudFront입니다. 버킷은 계속 닫아 두고 CDN만 읽게 만드는 것이 현행 표준입니다.

남겨 둔 문제: 비밀번호가 state에 있습니다 #

var.db_password는 sensitive 변수로 선언했습니다.

variables.tf
variable "db_password" {
  type      = string
  sensitive = true
}

plan 화면에서는 (sensitive value)로 가려집니다. 하지만 기초 #5에서 확인한 대로, state 파일 안에는 이 비밀번호가 평문으로 저장되어 있습니다. 직접 확인해 볼 수도 있습니다. terraform state show aws_db_instance.main을 실행하면 password가 그대로 보입니다. state 버킷에 접근할 수 있는 모든 사람과 시스템이 운영 DB 비밀번호를 읽을 수 있다는 뜻입니다. tfvars 파일로 값을 넣고 있다면 그 파일도 평문입니다. 이 문제를 임시방편이 아니라 구조적으로 푸는 것이 다음 편 전체의 주제입니다.

정리 #

이번 글에서 다룬 내용입니다.

  • RDS는 DB 서브넷 그룹으로 프라이빗 서브넷에 놓고, 보안 그룹 체이닝을 db 계층까지 연장했습니다. 5432는 웹 SG에서만 열립니다
  • 백업 7일은 실습에서도 켭니다. skip_final_snapshot과 prevent_destroy는 실습과 운영이 반대 값을 가지며, 이 차이가 환경 분리의 필요를 보여 줍니다
  • 자산 버킷 이름에는 계정 ID를 붙여 충돌을 피합니다. 퍼블릭 차단은 유지하고 서빙은 #10의 CloudFront가 맡습니다
  • sensitive는 화면만 가립니다. DB 비밀번호가 state와 tfvars에 평문으로 남아 있는 문제를 확인했고, 다음 편에서 구조적으로 풉니다

다음 글(#6 시크릿 관리)에서는 SSM Parameter Store와 Secrets Manager를 비교하고, 테라폼 1.11의 write-only 인수로 비밀번호가 state에 아예 남지 않는 구성을 만듭니다.

X