테라폼 실전 강좌 #3 EC2와 보안 그룹: SG 체이닝 설계와 user_data 부팅
지난 편의 네트워크 위에 첫 컴퓨팅을 올립니다. 이번 글에서 만드는 단일 EC2 인스턴스는 최종 형태가 아니라 다음 편에서 Auto Scaling 그룹으로 승격되기 전의 검증 단계입니다. 그래도 이번 편에 실전의 핵심이 하나 들어 있습니다. 보안 그룹을 어떻게 설계하는가입니다. 콘솔 시절에 “일단 열고 나중에 정리하자"로 쌓인 보안 그룹이 어떤 모습이 되는지 겪어 본 분이라면, 코드로 처음부터 구조를 잡는 이번 편이 그 답이 될 것입니다.
보안 그룹 체이닝: CIDR가 아니라 SG를 참조합니다 #
원칙은 하나입니다. 트래픽이 흐르는 경로를 따라 보안 그룹이 보안 그룹을 참조하게 만드는 것입니다. 인터넷 → ALB → EC2 경로라면 이렇게 됩니다.
- ALB SG: 인바운드 80·443을 인터넷(0.0.0.0/0)에 개방
- EC2 SG: 인바운드 80을 ALB SG에서만 허용
EC2 쪽에 CIDR를 적지 않는 것이 핵심입니다. “웹 서버는 ALB에서 오는 트래픽만 받는다"라는 의도가 코드에 그대로 남고, 서브넷 대역이 바뀌어도 규칙은 수정할 필요가 없습니다. 다음 편에서 만들 ALB의 SG를 먼저 정의해 둡니다.
# security.tf (새 파일)
resource "aws_security_group" "alb" {
name_prefix = "myapp-${var.env}-alb-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_security_group" "web" {
name_prefix = "myapp-${var.env}-web-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # 체이닝
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle {
create_before_destroy = true
}
}기초 #7에서 배운 두 가지가 바로 실전에 나왔습니다. 보안 그룹은 이름이 유일해야 하고 규칙 변경이 교체를 일으키는 경우가 있어서, name_prefix(무작위 접미사)와 create_before_destroy를 짝으로 걸어 교체가 일어나도 공백이 없게 했습니다. 이 조합은 보안 그룹의 관용 패턴으로 기억해 둘 만합니다.
SSH 포트가 없는 이유 #
눈치챘겠지만 22번 포트를 여는 규칙이 없습니다. 실수가 아니라 설계입니다. 요즘 표준 접속 경로는 SSH 키와 열린 포트 대신 SSM Session Manager입니다. 포트를 하나도 열지 않고, 키 파일을 배포하지 않고, 접속 기록이 남습니다. 동작에는 인스턴스에 IAM 역할이 필요한데 그것이 #7의 주제이므로, 이번 편의 인스턴스는 일단 “포트가 닫힌 상태"로 두고 #7에서 접속까지 완성하겠습니다. 급하게 디버깅이 필요하면 그때 임시 규칙을 추가했다 지우면 되고, 그 변경조차 plan에 남는 것이 코드 관리의 장점입니다.
AMI 조회와 user_data #
기초 #4의 데이터 소스로 최신 AL2023 AMI를 조회하고, 부팅 스크립트로 nginx까지 올립니다.
# compute.tf (새 파일)
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023*-x86_64"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = aws_subnet.private["ap-northeast-2a"].id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
dnf install -y nginx
echo "myapp ${var.env} - $(hostname)" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
EOF
tags = { Name = "myapp-${var.env}-web" }
}짚어 둘 결정이 셋 있습니다.
- 프라이빗 서브넷에 놓았습니다. 웹 서버라도 인터넷과 직접 만나는 것은 다음 편의 ALB이고, 서버 자체는 뒤에 숨는 것이 2계층 구조의 목적입니다. 패키지 설치(dnf)가 되는 것은 지난 편의 NAT 덕분입니다.
- user_data는 첫 부팅에 한 번 실행됩니다. 그리고 기초 #7에서 본 대로 user_data 변경은 기본적으로 인스턴스 교체를 일으킵니다. “서버를 고치지 않고 갈아 끼운다"라는 불변 인프라 방식과 맞는 동작이고, 다음 편의 Auto Scaling에서 이 성질이 장점으로 바뀝니다.
- key_name이 없습니다. SSH를 안 쓰기로 했으니 키 페어도 만들지 않습니다.
검증: 아직 접속 경로가 없습니다 #
apply 하면 인스턴스가 뜨지만, 프라이빗 서브넷이라 브라우저로 확인할 방법이 아직 없습니다. 이 어색한 상태는 의도된 중간 단계입니다. 콘솔에서 인스턴스 상태 검사(2/2 checks)가 통과하는 것, 그리고 terraform state show aws_instance.web으로 프라이빗 IP가 잡힌 것까지 보면 이번 편의 확인은 충분합니다. 접속 확인은 ALB가 생기는 다음 편에서 브라우저로 하게 됩니다.
정리 #
이번 글에서 다룬 내용입니다.
- 보안 그룹은 CIDR 대신 보안 그룹 참조로 체이닝합니다. “ALB에서 오는 트래픽만"이라는 의도가 코드에 남습니다
- 보안 그룹에는 name_prefix와 create_before_destroy를 짝으로 겁니다. 교체가 일어나도 공백이 없습니다
- SSH 포트는 열지 않습니다. 접속은 #7에서 SSM Session Manager로 완성합니다
- 인스턴스는 프라이빗 서브넷에 두고, user_data로 부팅 시 웹 서버를 구성합니다. user_data 변경이 교체인 성질은 다음 편에서 장점이 됩니다
- 프라이빗 인스턴스는 아직 접속 경로가 없는 중간 상태이며, 다음 편의 ALB가 그 입구가 됩니다
다음 글(#4 ALB와 Auto Scaling)에서는 이 단일 인스턴스를 로드밸런서 뒤의 Auto Scaling 그룹으로 승격합니다. launch template으로 인스턴스 정의를 옮기고, 브라우저로 첫 응답을 확인하겠습니다.