챕터 12 CSV 파일로 관리하는 보안 그룹 모듈 만들기
// INDEX
TFAS (TerraForm Addicted School)의 내용을 정리한 글입니다.
심각한 테라폼 중독입니다 - 책으로 스터디를 진행합니다.

보안 그룹 모듈을 만들어보자. 보안 그룹의 경우에는 보안 그룹 생성하는 것뿐만 아니라, 보안 그룹에 속한 규칙도 하나하나 관리할 수 있어야 한다. 보안 그룹은 다른 인프라 리소스에 비해 많은 수의 리소스를 만들게 되며 하나의 보안 그룹 안에 속하는 규칙도 수십 개가 넘게 관리할 수 있다.
이를 yaml 로 관리하는 것이 아닌 csv를 이용하여 관리해 보자. 보안그룹은 테이블 형식의 데이터로 표현하기 적합하기 때문에 csv로 관리하는 보안 그룹 모듈을 만들어보자.
입력값 정하기
- 보안 그룹마다 하나의 csv 파일을 갖는다.
- 아웃바운드는 전부 열어 제어하지 않는다. 인바운드 규칙만 제어한다.
- csv 파일의 하나의 행이 하나의 인바운드 규칙이 되며, 입력이 필요한 열은 프로토콜, 시작 포트, 끝 포트, 소스, 설명이다.
- 소스로 사용할 수 있는 값은 CIDR, 보안 그룹 아이디, 접두사 목록 아이디, “self” 문자열이다.
- 보안 그룹이 존재하는 VPC 의 CIDR이나 현재 사용자의 IP를 특정 키워드로 입력할 수 있게 한다.
이를 만족하는 보안 그룹 모듈의 입력값은 다음과 같다.
protocol,from_port,to_port,source,desc
tcp,3306,3306,self,
tcp,3306,3306,my-ip,
tcp,3306,3306,10.0.0.0/16,production vpc
입력값을 모듈에 전달할 방법 정하기
보안 그룹은 각각의 VPC 마다 구성하고 싶은 모안 그룹만큼 만들어야 한다.
만약 vpc 별로 별개의 보안 그룹 모듈을 구성한다면 리소스 주소는 다음과 같을 것이다.
# production vpc
module.production_sg["mysql"].aws_security_group
# develop vpc
module.develop_sg["mysql"].aws_security_group
이는 직관적이지만, vpc 별로 모듈을 선언해야하기 때문에 유지 보수성이 나빠지고, 다른 리소스에서 각 보안 그룹 모듈을 참조할 때도 어려움을 겪게 된다. 이유는 module.${vpc_name}_sg 형태의 참조는 테라폼에서 불가능하기 때문이다.
문제 1: 유지보수성이 나빠진다.
VPC가 추가될 때마다 모듈 블록을 복사해서 추가해야 한다.
# VPC 10개면 → 모듈 선언도 10개 필요
module "production_sg" { ... }
module "develop_sg" { ... }
module "staging_sg" { ... }
module "qa_sg" { ... }
module "test_sg" { ... }
문제 2: 동적 참조가 불가능하다
다른 리소스에서 보안 그룹을 참조할 때 문제가 생긴다.
# ❌ 이런 식의 동적 참조는 Terraform에서 불가능하다.
resource "aws_instance" "example" {
for_each = var.instances
# vpc_name 변수로 모듈 이름을 동적으로 만들 수 없음
security_groups = module.${each.value.vpc_name}_sg["mysql"].id
# ↑ 문법 오류! 불가능!
}
맵 변수로 vpc 와 보안 그룹을 엮어서 모든 vpc의 보안 그룹을 한꺼번에 순회하는 경우 다음과 같은 리소스 주소를 가진다.
# production vpc
module.sg["production_mysql"].aws_security_group
# develop vpc
module.sg["develop_mysql"].aws_security_group
vpc 마다 모듈을 선언할 필요가 없고 다른 리소스가 보안 그룹을 참조하는 것도 쉬워졌다. 그러나 문자열 조작이 필요하고 vpc와 보안 그룹의 단순한 순회로 참조가 바로 가능하지 않다.
문제 1: 문자열 조작이 필요함
# VPC와 보안 그룹을 참조하려면 문자열을 합쳐야 함
module.sg["${vpc_name}_${sg_name}"].id
# 키를 만들 때도 조합 필요
"production" + "_" + "mysql" → "production_mysql"
문제 2: 이중 순회가 직관적이지 않음
# ❌ 이런 직관적인 접근이 안 됨
module.sg["production"]["mysql"]
# ⭕ 대신 이렇게 해야 함
module.sg["production_mysql"]
보안 그룹 모듈 자체는 vpc 별로 반복하고, 모듈 안에서 보안 그룹별로 반복적으로 참조하는 경우 리소스 주소는 다음과 같다.
# production vpc
module.sg["production"].aws_security_group["mysql"]
# develop vpc
module.sg["develop"].aws_security_group["mysql"]
모듈을 참조하는 코드는 복잡해질 수 있지만 리소스 주소가 직관적이다. vpc를 많이 만들 수 있는 상황에서는 모듈 사용자 관점에서 직관적으로 사용할 수 있다.
보안 그룹 모듈을 호출하는 테라폼 코드는 다음과 같이 작성 가능하다. 이는 앞서 vpc 모듈을 호출하는 테라폼 코드에 이어서 작성한다.
module "sg" {
for_each = local.vpc_set
source = "../modules/sg"
name = each.key
vpc_id = module.vpc[each.key].vpc_id
sg_set {
for sgfile in fileset(local.info_files, "${each.key}/sg/*.csv") : trimsuffix(basename(sgfile), ".csv") => csvdecode(file("${local.info_files}/${sgfile}"))
}
tags = local.env_tags
}
vpc의 디렉터리에 있는 모든 sg 하위의 csv 파일을 전부 가지고 온다. 이는 production/sg/mysql.csv 와 같은 파일 이름을 가지고 이는 변수 sgfile로 지정한다. 보안 그룹의 이름을 얻어내기 위해 sgfile의 basename을 구하고 trimsuffix 함수로 확장자를 제거한다. 이를 sg_set의 맵 키로 삼는다. 그리고 csvdecode 함수를 사용하여 sgfile 경로에 존재하는 csv 파일을 읽고 맵의 값으로 사용한다.
모듈 만들기
로컬 변수를 정의했으니, 보안 그룹 모듈을 만들어보자. 먼저 변수를 임시로 설정하고 시작한다.
variable "vpc_name" {}
variable "vpc_id" {}
variable "sg_set" {}
variable "tags" {
default = {}
}
모듈 내부에서 사용할 공통 태그를 로컬 변수의 형태로 선언하고, vpc 자체의 태그도 상속받아 사용한다.
modules/sg/main.tf
locals {
vpc_name = var.vpc_name
vpc_id = var.vpc_id
vpc_tags = data.aws_vpc.this.tags
module_tag = merge(
var.tags,
local.vpc_tags,
{
tf_module = "sg"
}
)
}
data "aws_vpc" "this" {
id = local.vpc_id
}
locals {
tf_desc = "Managed By Terraform"
}
resource "aws_security_group" "this" {
for_each = var.sg_set
name = "${local.vpc_name}-sg-${each.key}"
description = local.tf_desc
vpc_id = local.vpc_id
tags = local.module_tag
}
resource "aws_security_group_rule" "this" {
for_each = var.sg_set
security_group_id = aws_security_group.this[each.key].id
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = local.tf_desc
}
보안 그룹의 규칙은 aws_security_group 의 리소스 안에 egress와 ingress 중첩 블록으로 동일한 작동을 정의할 수 있다. 하지만, 별도 리소스 블록이 존재한다면 해당 리소스 블록을 사용하는 것이 권장 사항으로 별도 리소스 블록을 사용하여 생성한다.
인바운드 규칙 생성
인바운드 규칙은 vpc 반복 호출 구조에 더해 인바운드 규칙별 반복까지 돌아야 한다. 이 때문에 2차원 반복을 수행하기 위한 구조로 테라폼 코드를 작성해 보자.
# ingress rule =========================================#
resource "aws_security_group_rule" "this" {
for_each = local.merged_inbound_rule_set
security_group_id = aws_security_group.this[each.value.sg].id
type = "ingress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
self = each.value.source == "self" ? true : null
cidr_blocks = length(regexall("[a-z]", each.value.source)) == 0 ? [each.value.source] : null
prefix_list_ids = startswith(each.value.source, "pl-") ? [each.value.source] : null
source_security_group_id = startswith(each.value.source, "sg-") ? each.value.source : null
description = each.value.desc == "" ? "tf/${each.value.source}" : "tf/${each.value.desc}"
}
보안 그룹의 인바운드 규칙을 작성할 때 규칙의 소스로 네 가지를 넣을 수 있다.
1) CIDR 2) 보안그룹 아이디 3) 접두사 목록 아이디 4) 보안그룹 자신의 아이디
이렇게 네 가지 종류의 대상에 따라 다른 리소스 블록 속성값을 사용하여 생성한다.
규칙의 보안 그룹 아이디를 소스로 재사용: self
self = each.value.source == "self" ? true : null
source 가 self 가 아닌 경우, null을 입력해 주어서 self라는 설정값을 애초에 사용하지 않도록 한다. self 가 아닌 경우는 null로 지정해야 다른 설정값을 정상적으로 사용할 수 있다. 하나의 보안 그룹 규칙에 cidr 값과 보안 그룹 아이디를 동시에 넣을 수는 없다.
CIDR 블록 입력 (IPv4)
영문자 포함 여부를 기준으로 cidr 블록 여부를 체크했다. cidr 가 아닌 경우 문자열이 반드시 영문자를 포함하기 때문이다. 정규 표현식 검증을 통해 문자열이 영문자를 포함하는지 검증하고, 영문자를 포함하지 않는다면 CIDR이라고 간주한다.
cidr_blocks = length(regexall("[a-z]", each.value.source)) == 0 ? [each.value.source] : null
보안 그룹 아이디
보안 그룹의 아이디를 소스로 입력받는 경우 “sg-” 접두사를 가지는 문자열인지 검증이 필요하다.
source_security_group_id = startswith(each.value.source, "sg-") ? each.value.source : null
접두사 목록 아이디
접두사 목록의 아이디를 소스로 받는 경우 prefix_list_ids 설정값을 사용해야 하며, “pl-” 문자열을 접두사로 가지는 문자열인지 검사하면 된다. 접두사 목록 아이디를 리스트로 입력받기 때문에 리스트의 형태로 넣어준다.
prefix_list_ids = startswith(each.value.source, "pl-") ? [each.value.source] : null
변수 타입 유효성
모듈 구성을 완료했으니 변수 유효성 검사를 구현하자. precondition, postcondition을 이용한 lifecycle 블록에서의 검증이나, validation 블록을 이용한 유효성 검증은 필요하지 않고, 변수 타입 유효성 검사를 진행한다.
variable "vpc_name" {
description = "보안 그룹이 존재할 VPC 의 이름"
type = string
}
variable "vpc_id" {
type = string
description = "보안 그룹이 존재할 VPC 의 ID"
}
variable "sg_set" {
description = "보안 그룹별 인바운드 ruleset"
type = map(object({
desc = optional(string, "")
protocol = string
from_port = number
to_port = number
source = string
}))
}
variable "tags" {
description = "모든 리소스에 적용될 태그 (map)"
type = map(string)
default = {}
}
모듈 출력값 설정
보안 그룹 모듈에서도 다른 리소스에서 참조할 수 있는 값을 출력 블록으로 설정할 수 있다. 보안 그룹 모듈에서는 출력값으로 사용가능한 것은 보안 그룹의 아이디이다. 생성한 모든 보안 그룹의 아이디를 맵 형식으로 내보내도록 정의한다.
output "sg_id" {
description = "보안 그룹 id (map)"
value = {
for k, v in aws_security_group.this : k => v.id
}
}