모듈 구성
모듈 구성 (Module Composition)
모듈 구성은 여러 개의 구성 가능한(composable) 빌딩 블록 모듈을 가져다가 함께 조립해 더 큰 시스템을 만들어 내는 평평한 모듈 사용 방식이에요. 루트 모듈이 모듈 간의 관계를 표현식으로 연결해서, 같은 모듈들을 서로 다른 방식으로 조합해 서로 다른 결과를 만들 수 있어요.
출처: 문서
본문
루트 모듈 하나만 있는 단순한 Terraform 구성에서는 평평한 리소스 집합을 만들고 Terraform의 표현식 구문을 사용해 리소스 간 관계를 설명해요:
resource "aws_vpc" "example" {
cidr_block = "10.1.0.0/16"
}
resource "aws_subnet" "example" {
vpc_id = aws_vpc.example.id
availability_zone = "us-west-2b"
cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 4, 1)
}
module 블록을 도입하면 구성이 평평한 구조에서 계층 구조로 바뀌어요: 각 모듈은 자신의 리소스 집합을 담고, 어쩌면 자신의 하위 모듈도 담아서, 잠재적으로 깊고 복잡한 리소스 구성 트리를 만들 수 있어요.
하지만 대부분의 경우 모듈 트리를 평평하게, 하위 모듈은 딱 한 단계만 두는 것을 강력히 권장하며, 위와 비슷하게 표현식을 사용해 모듈 간의 관계를 설명해요:
module "network" {
source = "./modules/aws-network"
base_cidr_block = "10.0.0.0/8"
}
module "consul_cluster" {
source = "./modules/aws-consul-cluster"
vpc_id = module.network.vpc_id
subnet_ids = module.network.subnet_ids
}
이런 평평한 모듈 사용 방식을 모듈 구성(module composition)이라고 불러요. 여러 개의 구성 가능한(composable) 빌딩 블록 모듈을 가져다가 함께 조립해 더 큰 시스템을 만들기 때문이에요. 모듈이 자신의 의존성을 내장하고 자신의 복사본을 만들고 관리하는 대신, 모듈은 의존성을 루트 모듈로부터 받아요. 따라서 루트 모듈은 같은 모듈들을 서로 다른 방식으로 연결해서 서로 다른 결과를 만들 수 있어요.
이 페이지의 나머지 부분에서는 Terraform으로 더 큰 시스템을 설명할 때 유용할 수 있는 몇 가지 더 구체적인 구성 패턴을 다룰게요.
의존성 역전 (Dependency Inversion)
위 예시에서 AWS VPC 네트워크에서 실행 중인 HashiCorp Consul 서버 클러스터를 설명하는 것으로 추정되는 consul_cluster 모듈을 봤어요. 그래서 VPC 자체와 그 VPC 안의 서브넷 둘 다의 식별자를 인자로 요구해요.
대안 설계는 consul_cluster 모듈이 자신의 네트워크 리소스를 설명하게 하는 것이지만, 그렇게 하면 Consul 클러스터가 같은 네트워크의 다른 인프라와 공존하기 어려워져요. 그래서 가능하면 모듈을 상대적으로 작게 유지하고 의존성을 전달하는 쪽을 선호해요.
이런 의존성 역전 접근은 미래 리팩터링에 대한 유연성도 높여줘요. consul_cluster 모듈은 호출 모듈이 그 식별자들을 어떻게 얻는지 알지도 신경 쓰지 않기 때문이에요. 미래의 리팩터링에서 네트워크 생성을 자체 구성으로 분리하고, 그 값들을 데이터 소스에서 모듈로 전달할 수도 있겠죠:
data "aws_vpc" "main" {
tags = {
Environment = "production"
}
}
data "aws_subnet_ids" "main" {
vpc_id = data.aws_vpc.main.id
}
module "consul_cluster" {
source = "./modules/aws-consul-cluster"
vpc_id = data.aws_vpc.main.id
subnet_ids = data.aws_subnet_ids.main.ids
}
객체의 조건부 생성 (Conditional Creation of Objects)
같은 모듈을 여러 환경에서 사용하는 상황에서는, 일부 필수 객체가 어떤 환경에는 이미 존재하지만 다른 환경에서는 생성해야 하는 경우가 흔해요.
예를 들어 개발 환경 시나리오에서 이런 일이 발생할 수 있어요: 비용 때문에 특정 인프라를 여러 개발 환경에서 공유할 수 있지만, 프로덕션에서는 인프라가 고유하고 프로덕션 구성이 직접 관리해요.
무언가가 존재하는지 스스로 감지하고 없으면 생성하는 모듈을 작성하려고 하기보다, 의존성 역전 접근을 적용하는 것을 권장해요: 입력 변수를 통해 모듈이 필요한 객체를 인자로 받도록 만드는 거예요.
예를 들어 Terraform 모듈이 디스크 이미지를 바탕으로 컴퓨팅 인스턴스를 배포하는 상황을 생각해 보세요. 어떤 환경에는 전문화된 디스크 이미지가 있고 다른 환경은 공통 기본 디스크 이미지를 공유해요. 모듈 자체가 두 시나리오를 모두 처리하게 하는 대신, 디스크 이미지를 나타내는 객체에 대한 입력 변수를 선언할 수 있어요. AWS EC2를 예로 들면 aws_ami 리소스 타입과 데이터 소스 스키마의 공통 하위 타입을 선언할 수 있어요:
variable "ami" {
type = object({
# Declare an object using only the subset of attributes the module
# needs. Terraform will allow any object that has at least these
# attributes.
id = string
architecture = string
})
}
이 모듈의 호출자는 이제 인라인으로 생성할 AMI인지 다른 곳에서 가져올 AMI인지 직접 나타낼 수 있어요:
# In situations where the AMI will be directly managed:
resource "aws_ami_copy" "example" {
name = "local-copy-of-ami"
source_ami_id = "ami-abc123"
source_ami_region = "eu-west-1"
}
module "example" {
source = "./modules/example"
ami = aws_ami_copy.example
}
# Or, in situations where the AMI already exists:
data "aws_ami" "example" {
owner = "9999933333"
tags = {
application = "example-app"
environment = "dev"
}
}
module "example" {
source = "./modules/example"
ami = data.aws_ami.example
}
이것은 Terraform의 선언적 스타일과 일관돼요: 복잡한 조건 분기를 가진 모듈을 만들기보다, 무엇이 이미 존재해야 하는지와 Terraform이 직접 관리하기를 원하는 것을 직접 설명해요.
이 패턴을 따르면 어떤 상황에서 AMI가 이미 존재할 것을 기대하는지, 어떤 상황에서 그렇지 않은지 명시적으로 밝힐 수 있어요. 구성의 미래 독자는 원격 시스템의 상태를 먼저 검사하지 않고도 의도하는 바를 직접 이해할 수 있어요.
위 예시에서는 생성하거나 읽을 객체가 단일 리소스로 인라인으로 주기에 충분히 단순하지만, 의존성 자체가 추상화의 이점을 누릴 만큼 복잡한 상황에서는 이 페이지의 다른 곳에서 설명한 것처럼 여러 모듈을 함께 구성할 수도 있어요.
가정과 보장 (Assumptions and Guarantees)
모든 모듈에는 소비자에게 무엇을 기대하고 무엇을 만들어 내는지 정의하는 암시적 가정(assumption)과 보장(guarantee)이 있어요.
- 가정(Assumption): 특정 리소스의 구성이 사용 가능하려면 반드시 참이어야 하는 조건. 예를 들어
aws_instance구성은 주어진 AMI가 항상x86_64CPU 아키텍처로 구성될 것이라는 가정을 가질 수 있어요. - 보장(Guarantee): 나머지 구성이 의존할 수 있어야 하는 객체의 특성 또는 동작. 예를 들어
aws_instance구성은 EC2 인스턴스가 개인 DNS 레코드를 할당하는 네트워크에서 실행될 것이라는 보장을 가질 수 있어요.
구성 검증(validation)을 사용해 가정과 보장을 포착하고 테스트하는 것을 권장해요. 이는 미래의 유지 관리자가 구성 설계와 의도를 이해하는 데 도움이 돼요. 구성 검증은 오류에 대한 유용한 정보를 더 일찍, 더 문맥에 맞게 반환해서 소비자가 구성의 문제를 더 쉽게 진단할 수 있게 해줘요.
다음 예시는 EC2 인스턴스에 암호화된 루트 볼륨이 있는지 확인하는 사전 조건(precondition)을 만들어요.
output "api_base_url" {
value = "https://${aws_instance.example.private_dns}:8433/"
# The EC2 instance must have an encrypted root volume.
precondition {
condition = data.aws_ebs_volume.example.encrypted
error_message = "The server's root volume is not encrypted."
}
}
멀티 클라우드 추상화 (Multi-cloud Abstractions)
Terraform 자체는 의도적으로 다른 벤더가 제공하는 유사한 서비스를 추상화하려 하지 않아요. 각 제공물의 전체 기능을 노출하고 싶지만, 여러 제공물을 단일 인터페이스 뒤로 통합하려면 "최소 공통 분모" 접근이 필요하기 쉬워서예요. 하지만 Terraform 모듈의 구성을 통해, 어떤 플랫폼 기능이 자신에게 중요한지에 대한 자체 트레이드오프를 정해서 가벼운(lightweight) 멀티 클라우드 추상화를 만들 수 있어요.
이런 추상화의 기회는 여러 벤더가 같은 개념, 프로토콜 또는 개방형 표준을 구현하는 모든 상황에서 생겨나요. 예를 들어 도메인 이름 시스템의 기본 기능은 모든 벤더에 공통이고, 일부 벤더는 지리적 위치(geolocation)와 스마트 로드 밸런싱 같은 고유 기능으로 차별화하지만, 당신의 사용 사례에서는 그런 기능을 포기하고 여러 벤더에 걸쳐 공통 DNS 개념을 추상화하는 모듈을 만드는 대가로 받아들일 수 있다고 판단할 수 있어요:
module "webserver" {
source = "./modules/webserver"
}
locals {
fixed_recordsets = [
{
name = "www"
type = "CNAME"
ttl = 3600
records = [
"webserver01",
"webserver02",
"webserver03",
]
},
]
server_recordsets = [
for i, addr in module.webserver.public_ip_addrs : {
name = format("webserver%02d", i)
type = "A"
records = [addr]
}
]
}
module "dns_records" {
source = "./modules/route53-dns-records"
route53_zone_id = var.route53_zone_id
recordsets = concat(local.fixed_recordsets, local.server_recordsets)
}
위 예시에서 우리는 "recordset" 객체 형태의 가벼운 추상화를 만들었어요. 이것은 어떤 DNS 제공사에도 매핑할 수 있어야 하는 DNS recordset의 일반적인 개념을 설명하는 속성들을 담고 있어요. 그런 다음 그 추상화의 한 구체적 구현을 모듈로 인스턴스화하는데, 이 경우에는 recordset을 Amazon Route53에 배포해요.
나중에 다른 DNS 제공사로 전환하고 싶다면, 그 제공사를 대상으로 하는 새 구현으로 dns_records 모듈만 교체하면 되고, recordset 정의를 만들어 내는 모든 구성은 변경하지 않아도 돼요.
이런 가벼운 추상화를 만들려면 관련 개념을 나타내는 Terraform 객체 타입을 정의하고 그 객체 타입을 모듈 입력 변수에 사용하면 돼요. 이 경우 모든 "DNS records" 구현은 다음 변수를 선언할 거예요:
variable "recordsets" {
type = list(object({
name = string
type = string
ttl = number
records = list(string)
}))
}
DNS가 단순한 예시로 쓰이지만, 벤더 간 공통 요소를 활용할 기회는 훨씬 더 많아요. 더 복잡한 예시는 Kubernetes인데, 지금은 호스팅 Kubernetes 클러스터를 제공하는 벤더가 많고 직접 Kubernetes를 실행하는 방법은 더 많아요.
이 모든 구현에서 공통 기능이 당신의 요구에 충분하다면, 특정 Kubernetes 클러스터 구현을 설명하고 모두 클러스터의 호스트 이름을 출력 값으로 내보낸다는 공통 특성을 가진 서로 다른 모듈 집합을 구현할 수 있어요:
output "hostname" {
value = azurerm_kubernetes_cluster.main.fqdn
}
그런 다음 입력으로 Kubernetes 클러스터 호스트 이름만 기대하는 다른 모듈을 작성해서 Kubernetes 클러스터 모듈 중 아무거나와 상호 교환적으로 사용할 수 있어요:
module "k8s_cluster" {
source = "modules/azurerm-k8s-cluster"
# (Azure-specific configuration arguments)
}
module "monitoring_tools" {
source = "modules/monitoring_tools"
cluster_hostname = module.k8s_cluster.hostname
}
데이터 전용 모듈 (Data-only Modules)
대부분의 모듈은 resource 블록을 담고 있어서 생성하고 관리할 인프라를 설명해요. 데이터 소스로 다른 곳에서 생성된 기존 인프라에 대한 정보를 검색만 하고, 어떤 새 인프라도 전혀 설명하지 않는 모듈을 작성하는 것이 유용할 때가 있어요.
일반 모듈과 마찬가지로, 이 기법은 모듈이 어떤 방식으로든 추상화 수준을 높일 때만 사용하는 것을 권장해요. 이 경우에는 데이터를 검색하는 정확한 방법을 캡슐화하는 방식으로요.
이 기법의 일반적인 사용 사례는 시스템을 여러 하위 시스템 구성으로 분해했지만, 모든 하위 시스템에 공유되는 특정 인프라(예: 공통 IP 네트워크)가 있을 때예요. 이 상황에서 AWS에 배포할 때 공유 네트워크에 대한 정보가 필요한 모든 구성이 호출할 수 있는 join-network-aws라는 공유 모듈을 작성할 수 있어요:
module "network" {
source = "./modules/join-network-aws"
environment = "production"
}
module "k8s_cluster" {
source = "./modules/aws-k8s-cluster"
subnet_ids = module.network.aws_subnet_ids
}
network 모듈 자체는 이 데이터를 여러 방식으로 검색할 수 있어요: aws_vpc와 aws_subnet_ids 데이터 소스로 AWS API를 직접 쿼리하거나, consul_keys로 Consul 클러스터에서 저장된 정보를 읽거나, terraform_remote_state로 네트워크를 관리하는 구성의 스테이트에서 출력을 직접 읽을 수 있어요.
이 접근의 핵심 이점은 이 정보의 출처가 그에 의존하는 모든 구성을 업데이트하지 않고도 시간에 따라 바뀔 수 있다는 점이에요. 게다가 데이터 전용 모듈을 해당 관리 모듈과 비슷한 출력 집합으로 설계하면, 리팩터링할 때 둘 사이를 비교적 쉽게 교체할 수 있어요.