본문 바로가기!

Terraform/고급 활용

Terraform으로 기존 AWS 보안 그룹을 안전하게 가져오고 관리하는 방법 (CSV + Import Block 활용)

728x90
반응형

Terraform을 도입하려는 조직에서 기존에 수동으로 구성된 AWS 리소스를 코드화하는 것은 중요한 과제입니다.

특히 Security Group처럼 많은 룰과 조합을 포함하는 리소스는 수작업이 어렵고 실수가 발생하기 쉽습니다.

이 글에서는 Terraform 1.5 이상의 import block 기능과 CSV 기반 동적 관리 전략을 이용해 AWS 보안 그룹을 선언적으로 가져오는 방법을 공유하려합니다.

 

 

왜 이걸 하게 되었을까?

저희 팀에서는 Terraform Cloud 기반으로 고객사의 IaC 환경 마이그레이션을 지원하고 있으며, 그 중 한 고객사는 Terraform을 처음 도입한 사례였습니다.

 

이 고객사는 기존에 AWS 인프라를 수동으로 운영하고 있었고, Terraform Cloud 도입과 함께 기존 리소스를 코드로 전환하는 작업을 저희 팀에서 지원하게 되었습니다.

 

이 과정에서 팀원분이 Security Group(SG)의 import 처리 방식에 대해 고민하고 계셨고, 저에게도 의견을 물어보시면서 이 주제에 대해 함께 이야기를 나누게 되었는데... 개인적으로도 흥미가 생겨, 직접 테스트를 진행하며 정리해보기로 했습니다.

 

사실 저도 예전에 Terraform의 import와 모듈에 대한 고찰을 블로그로 정리한 적이 있었는데, 그 당시에는 주로 GitLab의 그룹 구조를 예제로 다뤘었는데, 이번에는 그 연장선에서, AWS Security Group과 그에 연결된 Rule들을 어떤 방식으로 코드화하고, 어떻게 안정적으로 import할 수 있을지에 대해 공유드리려합니다.

 

 

설계 방향

첫번째로는, 일반적으로 많은 고객사에서 보안 그룹을 문서화할 때, SG마다 별도 파일을 나누기보다는 환경(예: dev, prod) 또는 목적(예: app, db)별로 하나의 파일로 관리하는 경우가 많기 때문에 보안 그룹(SG)과 그에 속한 규칙(Rule) 정보를 하나의 CSV 파일로 통합 관리하도록 설계했습니다.

 

두번째로는, Terraform에서는 SG의 룰을 정의하는 방식이 두 가지가 존재합니다

1. aws_security_group 리소스 블록 내부에 ingress / egress 블록을 정의하는 방식

2. 별도의 리소스인 aws_security_group_rule 블록으로 각각의 룰을 개별 관리하는 방식

 

하지만 이번 코드에서는 두 번째 방식(aws_security_group_rule)은 사용하지 않았습니다.

그 이유는, 해당 방식으로 import를 진행하려면 각 Rule에 대해 복잡한 ID 포맷을 구성해야 하고, 자동화가 어렵기 때문인데 해당 내용은 아래에서 더 자세히 설명드리겠습니다.

 

세번째로는, 하나의 CSV에 모든 SG와 룰 정보가 들어 있으므로, Terraform 코드에서는 for_each를 활용해 SG별로 리소스를 반복 생성하고, 각 SG 안에 dynamic 블록을 활용해 ingress / egress 룰을 자동 적용할 수 있는 구조로 구성하는 것이였습니다.

 

요약하자면 다음과 같습니다.

  1. SG와 그 룰 정보를 모두 포함하는 하나의 CSV 파일로 관리
  2. aws_security_group_rule 블록 대신 aws_security_group 블록 하나로 Rule을 포함한 관리
  3. CSV에서 각 SG별 규칙을 읽고 for_each + dynamic 블록으로 자동 관리

 

 aws_security_group_rule을 쓰지 않았는가?

 

Terraform에서는 보안 그룹 규칙을 aws_security_group_rule 리소스로도 관리할 수 있습니다. 하지만 이 방식은 Import를 할 경우 다음과 같은 불편한 점이 존재합니다.

 

aws_security_group_rule 리소스를 Terraform으로 import할 때는 리소스 이름과 규칙의 고유 식별자(ID)를 직접 구성해줘야 합니다. 

 

예시 1) Rule Source: CIDR 방식

 

  • 설명 : SG ID가 sg-6e616f6d69인 보안 그룹에서 ingress 방향, tcp, 포트 8000~8000, CIDR 10.0.3.0/24로 설정된 룰을 import
terraform import aws_security_group_rule.ingress sg-6e616f6d69_ingress_tcp_8000_8000_10.0.3.0/24

 

 

예시 2) Rule Source: Prefix List 방식

 

  • SG ID가 sg-62726f6479인 보안 그룹에서 egress 방향, tcp, 포트 8000~8000, Prefix List ID pl-6469726b로 설정된 룰을 import
terraform import aws_security_group_rule.egress sg-62726f6479_egress_tcp_8000_8000_pl-6469726b

 

 

예시 3) Rule Source: Security Group 참조 방식

 

  • SG ID가 sg-7472697374616e인 보안 그룹에서 ingress 방향, all protocols, 포트 범위 0~65536, 다른 SG (sg-6176657279)을 Source로 설정한 룰을 import 
terraform import aws_security_group_rule.ingress sg-7472697374616e_ingress_all_0_65536_sg-6176657279

 

 

예시 4) 일반 보안 그룹(Resource 자체)

  • test-bastion-dev 라는 이름으로 선언된 aws_security_group.this 리소스에 실제 AWS의 SG ID sg-0352db9635a15ee50를 import
terraform import aws_security_group.this test-bastion-dev sg-0352db9635a15ee50

 

 

위 예시에서처럼 aws_security_group_rule 리소스를 import하려면 단순히 SG ID만 적는 것이 아니라, 다음의 항목들을 모두 조합한 문자열을 정확히 구성해야 합니다.

  • 어떤 Security Group인지 (보안 그룹 ID)
  • Ingress인지 Egress인지 (방향)
  • 프로토콜 (예: tcp, udp, all)
  • 시작 포트 / 종료 포트
  • CIDR 블록, Prefix List ID, 혹은 참조 SG ID

각 import ID를 보면 알 수 있듯이, aws_security_group_rule은 단순한 SG ID 하나가 아니라 다양한 조건(방향, 포트, 프로토콜, 소스 종류 등)을 모두 조합한 ID를 직접 구성해야 하기 때문에 자동화가 어려운 단점이 있습니다.

 

그래서 aws_security_group_rule을 사용하는 방식 대신, 하나의 aws_security_group 블록 안에서 dynamic을 통해 ingress와 egress를 모두 관리하는 방법으로 작성하기로 설계하고 진행하였습니다.

 

 

먼저 CSV 구조부터 설계하자

보안 그룹을 코드로 추출하려고 할 때, 보통은 단순히 CIDR만 다루는 예제를 많이 봅니다. 하지만 실제 AWS 운영 환경에서는 다음과 같은 다양한 규칙 유형이 섞여 있습니다

  • IPv4 CIDR (0.0.0.0/0 등)
  • IPv6 CIDR (::/0 등)
  • Prefix List ID (예: S3, DynamoDB 접근 허용)
  • 다른 Security Group 참조 (SG to SG)
  • self 참조 (자기 자신에게 허용)
  • 설명 (description)

이런 다양한 경우를 모두 커버하기 위해 아래처럼 각 필드를 명확하게 나눌 수 있는 CSV 구조를 먼저 설계하였습니다.

필드명 설명
sg_name 보안 그룹 이름
direction ingress 또는 egress
from_port / to_port 시작 / 끝 포트 번호
protocol tcp  / udp / -1 (모두 허용) 등
cidr_blocks IPv4 CIDR 목록
ipv6_cidr_blocks IPv6 CIDR 목록
prefix_list_ids Prefix List ID (예: S3, RDS 등)
source_security_group_id 참조하는 SG ID
self "true"면 자기 자신 허용
description 규칙 설명

 

 

앞서 설계한 CSV 구조를 기반으로, 실제 AWS에 존재하는 보안 그룹(Security Group)들의 규칙을 추출하여 .csv 파일로 저장해야 합니다.

이를 위해 AWS CLI와 jq를 활용한 Bash 스크립트를 아래와 같이 작성해 사용하였습니다.

 

해당 스크립트는 macOS 환경에서 실행하였으며, 사전에 AWS CLI가 설치되어 있고 aws configure로 인증이 완료된 환경에서 실행해야 합니다.

#!/bin/bash

# 추출 대상 보안 그룹 이름 설정
TARGET_SG_NAMES=("test-bastion-dev" "test-nomad-dev" "test-cis_nomad-dev")

# CSV 헤더 작성
echo "sg_name,direction,from_port,to_port,protocol,cidr_blocks,ipv6_cidr_blocks,prefix_list_ids,source_security_group_id,self,description" > sg_rules_export.csv

# 보안 그룹 목록 가져오기
aws ec2 describe-security-groups --query "SecurityGroups[*]" | jq -c '.[]' | while read -r sg; do
  sg_name=$(echo "$sg" | jq -r '.GroupName')

  # 필터된 SG만 처리
  if printf '%s\n' "${TARGET_SG_NAMES[@]}" | grep -qx "$sg_name"; then
    echo "Processing: $sg_name"

    # Ingress
    echo "$sg" | jq -c '.IpPermissions[]?' | while read -r rule; do
      from_port=$(echo "$rule" | jq -r '.FromPort // 0')
      to_port=$(echo "$rule" | jq -r '.ToPort // 0')
      protocol=$(echo "$rule" | jq -r '.IpProtocol')

      # IPv4 CIDR
      echo "$rule" | jq -c '.IpRanges[]?' | while read -r cidr; do
        cidr_ip=$(echo "$cidr" | jq -r '.CidrIp')
        description=$(echo "$cidr" | jq -r '.Description // ""')
        echo "$sg_name,ingress,$from_port,$to_port,$protocol,$cidr_ip,,,,false,\"$description\"" >> sg_rules_export.csv
      done

      # IPv6 CIDR
      ...
      ...

      # Prefix List
      ...
      ...

      # Source SG
      ...
      ...
    done

    # Egress
    echo "$sg" | jq -c '.IpPermissionsEgress[]?' | while read -r rule; do
      ...
      ...
      ...
      ...
    done
  fi
done

 

코드가 길기 때문에 반복되는 구조는 생략하였지만, TARGET_SG_NAMES에 정의된 특정 보안 그룹만 필터링 해서 조회기본적으로 모든 방향(Ingress/Egress)과 모든 유형의 규칙을 순회하며, 각 항목을 CSV 한 줄로 출력하도록 구성되어 있습니다.

 

이 스크립트를 통해 추출된 sg_rules_export.csv 파일은 다음 단계에서 Terraform 코드에 직접 연동되어 사용됩니다.

 

위 스크립트를 돌린 후 생성된 CSV 파일 내용은 다음과 같이 생성됩니다.

 

 

Terraform Code - locals 블록 작성

먼저 Security Group과 Rule들을 CSV 파일로 추출을 해놓았고, 해당 데이터들을 사용하여 Security Group을 생성하기 위해 다음과 같은 local 변수로 Security Group을 구조화 하였습니다.

locals {
  sg_rules = csvdecode(file("${path.module}/sg_rules_export.csv"))
  sg_names = distinct([for rule in local.sg_rules : rule.sg_name])

  sg_grouped_rules = {
    for sg in local.sg_names :
    sg => [for rule in local.sg_rules : rule if rule.sg_name == sg]
  }
}

 

sg_rules: CSV 파싱 결과 전체 규칙 리스트

  • 이 변수는 CSV 파일을 파싱해서 하나의 list of object 형태로 변환한 결과
  • 모든 SG 룰을 flat list로 만들어주는 단계
sg_rules = tolist([
  {
    "cidr_blocks" = "10.2.0.0/16"
    "description" = "Allow full internal access (TCP)"
    "direction" = "ingress"
    "from_port" = "0"
    "ipv6_cidr_blocks" = ""
    "prefix_list_ids" = ""
    "protocol" = "tcp"
    "self" = "false"
    "sg_name" = "test-cis_nomad-dev"
    "source_security_group_id" = ""
    "to_port" = "65535"
  },
  {
    "cidr_blocks" = ""
    "description" = "Allowed in Nomad SG (UDP)"
    ...
    ...
  },
  ...
  ...
  ...
  {
    "cidr_blocks" = "0.0.0.0/0"
    "description" = "Allow all outbound"
    ...
    ...
  },
  {
    "cidr_blocks" = "0.0.0.0/0"
    "description" = "SSH Access"
    ...
    ...
  },
  {
    "cidr_blocks" = "0.0.0.0/0"
    "description" = "Allow all outbound"
    ...
    ...
  },
])

 

sg_names: SG 이름만 뽑아낸 고유 리스트

  • sg_rules에서 중복되지 않는 SG 이름만 추출한 리스트
  • 이 값은 이후 for_each 반복 시 key 값으로 활용
sg_names = tolist([
  "test-cis_nomad-dev",
  "test-nomad-dev",
  "test-bastion-dev",
])

 

sg_grouped_rules: SG 별 룰 목록으로 그룹핑된 Map

  • sg_rules를 sg_name 기준으로 그룹핑하여, 각 SG에 속한 룰만 따로 모은 구조
  • 이 값을 기반으로 각 보안 그룹마다 해당하는 ingress / egress 룰만 적용
  • 이 구조 덕분에 aws_security_group 블록 내부에서 동적으로 SG와 규칙을 매핑 가능
sg_grouped_rules = {
  "test-bastion-dev" = [
    {
      "cidr_blocks" = "0.0.0.0/0"
      "description" = "SSH Access"
      "direction" = "ingress"
      "from_port" = "22"
      "ipv6_cidr_blocks" = ""
      "prefix_list_ids" = ""
      "protocol" = "tcp"
      "self" = "false"
      "sg_name" = "test-bastion-dev"
      "source_security_group_id" = ""
      "to_port" = "22"
    },
    {
      "cidr_blocks" = "0.0.0.0/0"
      "description" = "Allow all outbound"
      "direction" = "egress"
      "from_port" = "0"
      "ipv6_cidr_blocks" = ""
      "prefix_list_ids" = ""
      "protocol" = "-1"
      "self" = "false"
      "sg_name" = "test-bastion-dev"
      "source_security_group_id" = ""
      "to_port" = "0"
    },
  ]
  "test-cis_nomad-dev" = [
    {
      "cidr_blocks" = "10.2.0.0/16"
      "description" = "Allow full internal access (TCP)"
      ...
      ...
    },
    {
      "cidr_blocks" = ""
      "description" = "Allowed in Nomad SG (UDP)"
      ...
      ...
    },
    ...
    ...
    ...
    ...
  ]
}

 

이렇게 세 가지 local 변수를 구성함으로써, 각 보안 그룹을 반복적으로 선언하고, 그 내부의 ingress / egress 룰을 동적으로 생성할 수 있게 됩니다.

 

 

 

Terraform Code - aws_security_group 리소스 블록 작성

앞에서 정의한 locals 블록의 데이터 구조를 기반으로, Security Group 리소스를 실제로 생성하는 Terraform 코드는 아래와 같습니다.

resource "aws_security_group" "sg" {
  for_each = local.sg_grouped_rules
  name     = each.key
  vpc_id   = "{VPC_ID 입력}"

  dynamic "ingress" {
    for_each = [for rule in each.value : rule if rule.direction == "ingress"]
    content {
      from_port        = tonumber(ingress.value.from_port)
      to_port          = tonumber(ingress.value.to_port)
      protocol         = ingress.value.protocol
      cidr_blocks      = length(trimspace(ingress.value.cidr_blocks)) > 0 ? split(";", ingress.value.cidr_blocks) : []
      ipv6_cidr_blocks = length(trimspace(ingress.value.ipv6_cidr_blocks)) > 0 ? split(";", ingress.value.ipv6_cidr_blocks) : []
      prefix_list_ids  = length(trimspace(ingress.value.prefix_list_ids)) > 0 ? split(";", ingress.value.prefix_list_ids) : []
      security_groups  = length(trimspace(ingress.value.source_security_group_id)) > 0 ? [trimspace(ingress.value.source_security_group_id)] : null
      self             = ingress.value.self == "true"
      description      = ingress.value.description
    }
  }

  dynamic "egress" {
    for_each = [for rule in each.value : rule if rule.direction == "egress"]
    content {
      from_port        = tonumber(egress.value.from_port)
      to_port          = tonumber(egress.value.to_port)
      protocol         = egress.value.protocol
      cidr_blocks      = length(trimspace(egress.value.cidr_blocks)) > 0 ? split(";", egress.value.cidr_blocks) : []
      ipv6_cidr_blocks = length(trimspace(egress.value.ipv6_cidr_blocks)) > 0 ? split(";", egress.value.ipv6_cidr_blocks) : []
      prefix_list_ids  = length(trimspace(egress.value.prefix_list_ids)) > 0 ? split(";", egress.value.prefix_list_ids) : []
      security_groups  = length(trimspace(egress.value.source_security_group_id)) > 0 ? [trimspace(egress.value.source_security_group_id)] : null
      self             = egress.value.self == "true"
      description      = egress.value.description
    }
  }

  tags = {
    Name = each.key
  }
}

 

  • 먼저 이 Security Group의 블록은 sg_grouped_rules하는 local 변수의 map을 기준으로 보안 그룹을 반복 생성합니다.
    • for_each = local.sg_grouped_rules
    • 즉, 각 key(SG 이름)에 해당하는 룰 목록을 기반으로 SG 하나를 만들고, 해당 룰들을 적용하게 됩니다.

 

  • dynamic 블록
dynamic "ingress" {
  for_each = [for rule in each.value : rule if rule.direction == "ingress"]
  content {
    ...
  }
}
  • 이 부분은 SG 안의 ingress, egress 룰을 동적으로 생성하기 위한 코드로 여기의 each.value 는 해당 SG에 속한 전체 Rule list 입니다.
  • 이 중 if 문을 통해  "ingress" 또는 "egress"에 해당하는 룰만 필터링해서 동적으로 적용하게 되고 dynamic 블록 자체가 for_each로 구성되어 있기 때문에 룰 개수만큼 ingress/egress 블록을 반복적으로 생성합니다.

 

  • 내부 필드
from_port        = tonumber(ingress.value.from_port)
to_port          = tonumber(ingress.value.to_port)
protocol         = ingress.value.protocol
cidr_blocks      = length(trimspace(...)) > 0 ? split(";", ...) : []
ipv6_cidr_blocks = ...
prefix_list_ids  = ...
security_groups  = ...
self             = ingress.value.self == "true"
description      = ingress.value.description
  • 모든 필드들은 CSV에서 받아온 값들에 대해 유효성 체크 및 타입 변환 후 설정됩니다.
  • cidr_blocks, prefix_list_ids, ipv6_cidr_blocks 등은 ;로 여러 개가 들어올 수 있어 split() 처리합니다.
  • security_groups는 값이 존재할 경우 리스트로 감싸고, 없으면 null 로 처리합니다.
  • self는 문자열 "true"인지 확인 후 boolean으로 변환합니다.

 

요약하자면 다음과 같습니다.

  • for_each로 SG를 반복 생성
  • dynamic 블록으로 각 SG별 룰을 반복 적용
  • CSV 기반 데이터만 잘 정리하면 몇 줄의 코드만으로 수십 개의 SG를 선언적으로 관리 가능

 

 

Terraform Import Block 작성 & Import TEST

locals {
  sg_import = {
    "test-bastion-dev"   = "sg-0352db9635a15ee50"
    "test-nomad-dev"     = "sg-08d5c53f60c72eea5"
    "test-cis_nomad-dev" = "sg-089402aa15fb6be79"
  }
}

import {
  for_each = local.sg_import
  to       = aws_security_group.this[each.key]
  id       = each.value
}
  • locals.sg_import
    • 이 변수는 SG 이름과 실제 AWS 보안 그룹 ID를 key-value 형태로 매핑한 것으로 이후 import 블록에서 for_each로 반복하여 사용하기 위한 변수입니다.
    • 스크립트를 통해 자동 추출해낼 수도 있지만, 이번에는 간단한 테스트 목적이었기 때문에 수동으로 입력하여 구성했습니다. 실무에서는 aws ec2 describe-security-groups + jq 등을 활용해 자동화하는 것도 좋은 방법입니다.
  • import 블록
    • for_each 구문을 사용하여 여러 개의 리소스를 한 번에 import 합니다.
      • to : 코드로 정의된 Terraform 리소스의 주소 → aws_security_group.this["test-bastion-dev"] 형태
      • id : 실제 AWS 리소스 ID → sg-xxxxxxxxxxxxxxxxx

 

terraform plan 명령어 시 결과 화면

 

terraform apply 명령어 시 결과 화면

 

CSV 파일의 내용 임의로 변경 후 terraform apply 명령어 시 결과 화면

 

위와 같이 제가 기존에 관리하고 있던 SG들을 Terraform Code를 통해 CSV 하나의 파일로 관리 가능한 모습을 확인 가능합니다.

 

 

 

 

개인 정리

앞서 작성했던 블로그에서도 언급했듯이, 개인적으로 기존에 운영 중인 AWS 리소스를 Terraform으로 관리하려면 Import 과정은 필수라고 생각됩니다.

 

특히 보안 그룹처럼 리소스 수가 많고, 각 SG마다 다수의 규칙이 얽혀 있는 경우에는 단순히 terraform import 명령어를 반복하는 방식으로는 유지보수나 협업 측면에서 한계가 있고, 향후 코드 리팩토링 시에도 머리를 쥐어뜯는 상황이 생길 수 있습니다.

 

그래서 저는 다음과 같은 접근 방식을 추천합니다.

리소스를 어떻게 관리할 것인지 먼저 설계하고, for_each, dynamic 블록 등을 활용해 코드 구조를 구성한 뒤, 마지막에 해당 리소스를 상태(state)에 연결하는 import를 수행하기!!!

 

물론 고객사의 요청이나 일정상 빠르게 리소스를 import해야 하는 경우도 존재합니다. 하지만 단순한 import만으로 끝나는 경우, 장기적으로 보았을 때 기업의 인프라 코드 자산으로 활용되기엔 부족할 수 있습니다.

즉, 설계 없이 import만 빠르게 해버리는 방식은 향후 해당 코드를 재사용 또는 다른 리소스에 참조시키는 등을 위해 반드시.. 리팩토링이 필요한 상황이 찾아오게 되며, 유지보수나 팀 간 협업에 있어 큰 리스크가 될 수 있습니다.

 

결국 인프라를 코드로 관리하는 목적은 단순히 Import를 통해 tfstate를 관리하는 데 있지 않고, 누구나 해당 코드를 읽고, 쉽게 변경하며, 재사용 가능한 구조로 만드는 데에 있습니다.

 

그래서 저는 “선 Terraform 코드 설계 → 후 Terraform import” 흐름을 기반으로 한 접근 방식이 가장 현실적이고, 동시에 가장 안전한 전략이라고 생각합니다.

 

 

728x90
반응형