IaC 도입 검토 및 구현 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki
v1 IaC 조사 및 검토 요약
최종 결정 : v1에서는 IaC 도입을 보류한다.
- 지금 v1 규모에서는 VM 1대만 운영서버로 사용하고 있고 DB도 1대이기 때문에 이점들을 체감하기 어렵다.
- 반대로 단점 중 학습 곡선과 복잡한 파이프라인 디버깅은 지금 조건에서 그대로 리스크가 된다. 인프라 담당 인원이 2명뿐인 상황에서 Terraform 같은 새 도구를 익히는 시간은 v1의 핵심 가치인 속도를 깎아먹는 시간이다.
- 지금까지 설계한 GCP 리소스는 gcloud 명령어 형태로 문서에 남겨뒀다. 완전한 IaC는 아니지만, "설정을 코드로 남겨 재현 가능하게 한다"는 IaC의 핵심 목적 상당 부분은 이미 이 방식으로 충족하고 있다.
재검토 시점
아래 중 하나라도 해당되면 그 시점에 Terraform 등 IaC 도구 도입을 다시 검토한다.
-
VM 인스턴스 총 대수가 5대를 넘어가는 경우 (Backend, AI Server Scale-Out이 되는 시점)
-
스테이징 등 새로운 환경을 반복적으로 복제해야 하는 상황이 생기는 경우
-
인프라 담당 인원이 늘어나서 누가 무엇을 바꿨는지에 대한 추적이 실제 문제로 나타나는 경우
-
참고자료 : https://www.redhat.com/ko/topics/automation/what-is-infrastructure-as-code-iac
-
참고자료 : https://www.ibm.com/kr-ko/think/topics/infrastructure-as-code
현재 V2 IaC가 필요한 이유
- V1은 Blue-Green VM 2대에서 Docker Compose로 서비스를 함께 운영하는 구조다.
- V2에서는 Chat, BE, AI, FE를 분리하고 도메인별 MIG와 롤링 업데이트를 도입하면서 관리할 리소스와 설정 간 의존성이 증가한다. 이에 따라 VM별 설정을 수동으로 관리하는 방식에서 벗어나, 인프라 구성을 코드로 정의하고 일관되게 적용할 필요가 있다.
1. 도메인별 인프라 변경을 팀의 표준 절차로 관리해야 한다
- V1에서 VM 사양, 방화벽, 접근 권한 등을 수동으로 변경하면 작업자의 판단과 확인에 따라 적용 방식이 달라질 수 있다.
- V2에서는 도메인별 MIG, 인스턴스 템플릿, 헬스체크, 롤링 업데이트 정책을 함께 관리해야 한다. 설정 하나의 변경이 인스턴스 생성이나 트래픽 처리에 영향을 줄 수 있으므로, IaC와 Git 리뷰를 통해
- 변경 계획 확인 → 리뷰 → 적용의 절차를 표준화해야 한다.
2. 애플리케이션 배포 이력과 인프라 변경 이력을 함께 추적해야 한다
- V1의 Compose와 배포 스크립트는 컨테이너 실행 및 배포 방식을 보여주지만, VM·네트워크·방화벽·IAM 설정까지 모두 설명하지는 못한다. 장애 발생 시 애플리케이션 변경과 인프라 설정 변경을 여러 곳에서 확인해야 한다.
- V2에서는 도메인마다 사용하는 템플릿과 연결 리소스가 달라진다. IaC로 변경 대상과 의도를 기록하면 어느 도메인의 어떤 설정이 변경되었는지 확인하기 쉬워지고, 장애 원인 분석과 설정 검토의 기준을 확보할 수 있다.
3. 늘어나는 운영 지식이 특정 담당자에게 집중되지 않도록 해야 한다
- V1의 운영에는 VM 설정, Nginx 트래픽 전환, Secret 반영, 수동 롤백 등 여러 절차에 대한 이해가 필요하다. 이러한 정보가 코드·문서·담당자의 경험에 분산되어 있으면 인수인계 부담이 커진다.
- V2에서는 도메인별 실행 환경과 배포 정책까지 파악해야 한다. IaC 모듈과 환경별 설정을 운영 문서와 함께 관리하면 신규 담당자가 리소스 구성과 연결 관계를 코드 기준으로 이해할 수 있어 협업과 인수인계 부담을 줄일 수 있다.
4. 서비스별 설정과 새 인스턴스 구성을 일관되게 적용해야 한다
-
V1에서는 Frontend, Backend, AI를 같은 VM에서 실행해 서버 단위로 관리했다. V2에서는 도메인별로 VM 사양, 방화벽 규칙, 서비스 계정, Secret 접근 권한 등을 구분해야 하므로 설정 항목이 늘어난다.
-
예를 들어
- FE의 DB 직접 접근은 제한하고, BE에는 필요한 DB 대상과 포트의 통신을 허용하도록 구성해야 한다. MIG의 롤링 업데이트로 새 VM이 생성될 때도 이러한 설정이 동일하게 적용되어야 한다. IaC로 템플릿과 권한을 정의하면 반복 설정과 누락을 줄이고, 교체되는 인스턴스에도 동일한 실행 환경을 제공할 수 있다.
-
FE의 DB 직접 접근은 제한하고, BE에는 필요한 DB 대상과 포트의 통신을 허용하도록 구성해야 한다. MIG의 롤링 업데이트로 새 VM이 생성될 때도 이러한 설정이 동일하게 적용되어야 한다. IaC로 템플릿과 권한을 정의하면 반복 설정과 누락을 줄이고, 교체되는 인스턴스에도 동일한 실행 환경을 제공할 수 있다.
5. 스테이징에서 검증한 인프라 구조를 운영 환경에 일관되게 적용해야 한다
- V1의 구성을 수동으로 복제하면 VM, 방화벽, 권한 등을 하나씩 다시 설정해야 하며, 스테이징과 운영 환경 사이에 설정 차이가 생길 수 있다.
- V2에서는 도메인별 MIG와 로드밸런서 연결, 헬스체크, 업데이트 정책까지 재현해야 한다. 공통 IaC 모듈을 재사용하고 프로젝트 ID, 네트워크, VM 사양, 인스턴스 수, Secret 참조를 환경별 변수로 분리하면 스테이징에서 검증한 구조를 운영 환경의 요구사항에 맞춰 일관되게 적용할 수 있다.
참고 자료 : https://www.sentinelone.com/ko/cybersecurity-101/cloud-security/infrastructure-as-code/
참고 자료 : https://cloud.google.com/discover/what-is-infrastructure-as-code?hl=ko
참고 자료 : https://fivecloud.co.kr/insight/cloud-iac
IaC 시작 가이드
1. gcloud auth application-default login
- 로컬 개발 환경에서 애플리케이션과 Google Cloud 서비스가 상호작용할 때 사용하는 명령어입니다.
- 여기서 GCP 계정으로 로그인히면 로컬 환경에 인증 정보가 저장됩니다.
gcloud auth login을 사용하지 않는 이유- gcloud auth application-default login은 로컬에서 실행하는 Terraform이 Google Cloud API에 접근할 때 사용할 ADC(Application Default Credentials)를 설정합니다.
- 예를 들면 terraform plan이나 terraform apply를 실행하면 Google provider가 이 인증 정보를 찾아 사용합니다. 반면 gcloud auth login은 주로 gcloud CLI 명령을 위한 로그인으로, 이것만 실행해서는 Terraform용 ADC가 설정되지 않습니다.
- 따라서 로컬에서 Terraform을 실행할 때는 gcloud auth application-default login을 사용합니다.
gcloud auth login을 사용하지 않는 이유- gcloud auth application-default login은 로컬에서 실행하는 Terraform이 Google Cloud API에 접근할 때 사용할 ADC(Application Default Credentials)를 설정합니다.
- 예를 들면 terraform plan이나 terraform apply를 실행하면 Google provider가 이 인증 정보를 찾아 사용합니다. 반면 gcloud auth login은 주로 gcloud CLI 명령을 위한 로그인으로, 이것만 실행해서는 Terraform용 ADC가 설정되지 않습니다.
- 따라서 로컬에서 Terraform을 실행할 때는 gcloud auth application-default login을 사용합니다.
- gcloud auth application-default login은 로컬에서 실행하는 Terraform이 Google Cloud API에 접근할 때 사용할 ADC(Application Default Credentials)를 설정합니다.
- 예를 들면 terraform plan이나 terraform apply를 실행하면 Google provider가 이 인증 정보를 찾아 사용합니다. 반면 gcloud auth login은 주로 gcloud CLI 명령을 위한 로그인으로, 이것만 실행해서는 Terraform용 ADC가 설정되지 않습니다.
- 따라서 로컬에서 Terraform을 실행할 때는 gcloud auth application-default login을 사용합니다.
참고 자료 : https://wooono.tistory.com/750
2. Terraform 설치 (MacOS)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
참고 자료 : https://developer.hashicorp.com/terraform/install
3. 프로젝트 디렉터리 및 코드 작성
- 작업할 폴더를 생성하고 이동합니다.
main.tf파일을 생성하고 GCP 프로바이더와 생성할 리소스를 정의합니다.
// 기본 예시
provider "google" {
project = "your-gcp-project-id" # 본인의 GCP 프로젝트 ID
region = "asia-northeast3" # 리전 (예: 서울)
zone = "asia-northeast3-a" # 존
}
# 예시: 간단한 VPC 네트워크 생성
resource "google_compute_network" "vpc_network" {
name = "my-custom-network"
}
4. Terraform 실행 단계 (Workflow)
terraform init // 초기화
terraform validate // 구문 검증
terraform plan // 어떤 인프라가 생성될지 미리 확인
terraform apply // 실제로 인프라 생성 확인되면 yes 입력
terraform apply -auto-approve // 확인되면 yes 입력 하고 엔터치는게 아니라 바로 yes
쳤다치고 넘어감
terraform destroy // 현재 디렉토리 인프라 그대로 삭제
참고 자료 : https://yooloo.tistory.com/181
참고 자료 : https://www.youtube.com/watch?v=57FJoXsbf2s
참고 자료 : https://somaz.tistory.com/458
인스턴스 생성 및 그룹 만들기 (compute.tf)
// 인스턴스 생성
resource "google_service_account" "vm" {
for_each = local.services
account_id = "${var.name_prefix}-${each.key}"
display_name = "${var.name_prefix} ${each.key} VM"
depends_on = [google_project_service.required]
}
resource "google_compute_instance" "vm" {
for_each = local.services
name = "${var.name_prefix}-${each.key}"
zone = var.zone
machine_type = var.vm_machine_type
allow_stopping_for_update = true
tags = ["${var.name_prefix}-app"]
metadata = { enable-oslogin = "TRUE" }
metadata_startup_script = lookup(var.startup_scripts, each.key, null)
boot_disk {
initialize_params {
image = var.vm_image
size = 30
type = "pd-balanced"
}
}
network_interface {
subnetwork = google_compute_subnetwork.app.id
}
service_account {
email = google_service_account.vm[each.key].email
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
depends_on = [google_compute_router_nat.app]
}
resource "google_compute_instance_group" "service" {
for_each = local.services
name = "${var.name_prefix}-${each.key}-ig"
zone = var.zone
instances = [google_compute_instance.vm[each.key].self_link]
network = google_compute_network.main.id
named_port {
name = "http"
port = each.value.port
}
}
resource "google_compute_instance" "vm" {
google_compute_instance- 무엇을 만들지를 정의하는 코드 GCP Compute Engine VM
- 무엇을 만들지를 정의하는 코드 GCP Compute Engine VM
vm- Terraform 코드에서 이 리소스를 부를 이름 (별칭)
- Terraform 코드에서 이 리소스를 부를 이름 (별칭)
- Terraform을 사용해 이 객체를 부르기 위해선
google_compute_instance.vm로 가져올 수 있다.
google_compute_instance.vm로 가져올 수 있다.
${each.key}
- 간단히 말하면 지금 만들고 있는 항목의 이름을 여기에 넣어라고 생각하면 되는데
- 해당 객체 안에
for_each = local.services를 지정하면 each.key를 사용할 수 있다. - each.key는 현재 지정하고 있는 local.service 즉 main.tf 에 정의되어 있는 locals 안에 서비스 이름을 가져올 수 있다.
- 해당 객체 안에
- 해당 객체 안에
for_each = local.services를 지정하면 each.key를 사용할 수 있다. - each.key는 현재 지정하고 있는 local.service 즉 main.tf 에 정의되어 있는 locals 안에 서비스 이름을 가져올 수 있다.
subnetwork = google_compute_subnetwork.app.id
- network.tf를 보면
google_compute_subnetwork가 있는데, 이걸 식별자로 가져온다는 뜻이다. - 좀 풀어서 말하면
app이라고 선언한 서브넷의 ID를 가져와 이 VM에 연결한다는 뜻이다.google_compute_subnetwork.app.id는 VM의 네트워크 인터페이스를 프로젝트의 asia-northeast3 리전에 있는 mulo-v2-app 서브넷에 연결한다는 뜻이다. VM은 그 서브넷의 IP 범위에서 Private IP를 할당받는다.
google_compute_subnetwork.app.id는 VM의 네트워크 인터페이스를 프로젝트의 asia-northeast3 리전에 있는 mulo-v2-app 서브넷에 연결한다는 뜻이다. VM은 그 서브넷의 IP 범위에서 Private IP를 할당받는다.
Variables.tf과 *.tfvars
-
위 코드에서 ${var ~ } 라고 적어둔 곳이 있다. 이 설명은 아래에서 설명한다.
-
variables.tf- 이 파일은 Terraform 모듈 내에서 사용할 변수를 정의하는 데 사용된다.
- 모듈 내에서 공통으로 사용되는 변수를 정의하는데 주로 사용한다
-
이 파일은 Terraform 모듈 내에서 사용할 변수를 정의하는 데 사용된다.
-
모듈 내에서 공통으로 사용되는 변수를 정의하는데 주로 사용한다
-
variables.tf 예시
variable "instance_type" { description = "EC2 instance type" type = string } variable "region" { description = "AWS region" type = string } variable "availability_zone" { description = "AWS availability zone" type = string }
variable "instance_type" {
description = "EC2 instance type"
type = string
}
variable "region" {
description = "AWS region"
type = string
}
variable "availability_zone" {
description = "AWS availability zone"
type = string
}
-
( ).tfvars-
실제 Terraform 변수에 값을 할당하는 데 사용된다. 주로 환경별로 다른 값을 변수에 할당하기 위해 사용한다.
-
예를들면
// 개발 환경 dev.tfvars instance_type = "t2.micro" region = "us-west-2" availability_zone = "us-west-2a" // 스테이지 환경 stag.tfvars instance_type = "t2.micro" region = "us-west-2" availability_zone = "us-west-2a" // 프로덕션 환경 prod.tfvars instance_type = "t2.large" region = "eu-central-1" availability_zone = "eu-central-1a"
-
-
실제 Terraform 변수에 값을 할당하는 데 사용된다. 주로 환경별로 다른 값을 변수에 할당하기 위해 사용한다.
-
예를들면
// 개발 환경 dev.tfvars instance_type = "t2.micro" region = "us-west-2" availability_zone = "us-west-2a" // 스테이지 환경 stag.tfvars instance_type = "t2.micro" region = "us-west-2" availability_zone = "us-west-2a" // 프로덕션 환경 prod.tfvars instance_type = "t2.large" region = "eu-central-1" availability_zone = "eu-central-1a"
// 개발 환경 dev.tfvars
instance_type = "t2.micro"
region = "us-west-2"
availability_zone = "us-west-2a"
// 스테이지 환경 stag.tfvars
instance_type = "t2.micro"
region = "us-west-2"
availability_zone = "us-west-2a"
// 프로덕션 환경 prod.tfvars
instance_type = "t2.large"
region = "eu-central-1"
availability_zone = "eu-central-1a"
- Terraform을 실행할때 각 환경에 맞게
.tfvar파일을 사용하여 실행할 수 있다. 예를 들면
terraform apply -var-file=dev.tfvars
terraform apply -var-file=stag.tfvars
terraform apply -var-file=prod.tfvars
- 꼭
*.tfvars이 없더라도 variables.tf 파일로만 변수 값을 지정해 사용할 수 있다.- 그러면 variables.tf가 왜 필요한 것인가?
- variables.tf는 변수의 정의를 담당하고, tfvars는 그 변수에 넣을 값을 담당합니다. variables.tf에 default를 쓰면 별도 tfvars 없이도 실행할 수 있습니다.
- 하지만 환경마다 값이 달라진다면 변수의 타입, 검증 규칙은 variables.tf에 공통으로 두고, 실제 값만 tfvars로 분리하는 게 편합니다. 반대로 값이 항상 같고 바꿀 일도 없다면 굳이 변수로 만들지 않고 리소스에 직접 적어도 됩니다.
- 그러면 variables.tf가 왜 필요한 것인가?
- 그러면 variables.tf가 왜 필요한 것인가?
- variables.tf는 변수의 정의를 담당하고, tfvars는 그 변수에 넣을 값을 담당합니다. variables.tf에 default를 쓰면 별도 tfvars 없이도 실행할 수 있습니다.
- 하지만 환경마다 값이 달라진다면 변수의 타입, 검증 규칙은 variables.tf에 공통으로 두고, 실제 값만 tfvars로 분리하는 게 편합니다. 반대로 값이 항상 같고 바꿀 일도 없다면 굳이 변수로 만들지 않고 리소스에 직접 적어도 됩니다.
- variables.tf는 변수의 정의를 담당하고, tfvars는 그 변수에 넣을 값을 담당합니다. variables.tf에 default를 쓰면 별도 tfvars 없이도 실행할 수 있습니다.
- 하지만 환경마다 값이 달라진다면 변수의 타입, 검증 규칙은 variables.tf에 공통으로 두고, 실제 값만 tfvars로 분리하는 게 편합니다. 반대로 값이 항상 같고 바꿀 일도 없다면 굳이 변수로 만들지 않고 리소스에 직접 적어도 됩니다.
참고 자료 : https://kimalarm.tistory.com/13
참고 자료 : https://kschoi728.tistory.com/26
참고 자료 : https://terraform-docs.io/
self_link
- GCP 리소스 자기 자신을 가리키는 전체 URL이다.
- 다음과 같이 사용한다면
instances = [google_compute_instance.vm["ai"].self_link]
- ai VM의 주소를 가져와 이 그룹에 등록하라는 뜻으로 얘기할 수 있습니다
- name은 짧은 이름이고, self_link는 프로젝트, 존까지 포함해 해당 VM을 가리키는 GCP 전용 문법 입니다.
database.tf(Cloud SQL)
resource "google_sql_database_instance" "mysql" {
for_each = toset(["BE", "chat"])
name = "${var.name_prefix}-mysql-${each.key}"
region = var.region
database_version = var.mysql_version
deletion_protection = var.sql_deletion_protection
settings {
tier = var.sql_tier
edition = "ENTERPRISE"
availability_type = "ZONAL"
disk_type = "PD_SSD"
disk_size = 20
disk_autoresize = true
deletion_protection_enabled = var.sql_deletion_protection
backup_configuration {
enabled = true
binary_log_enabled = true
}
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.main.id
}
}
depends_on = [google_service_networking_connection.sql]
}
resource "google_sql_database" "mysql" {
for_each = {
primary = "mulo"
chat = "mulo_chat"
}
name = each.value
instance = google_sql_database_instance.mysql[each.key].name
}
resource "google_sql_database_instance" "postgres" {
name = "${var.name_prefix}-postgres-vector"
region = var.region
database_version = var.postgres_version
deletion_protection = var.sql_deletion_protection
settings {
tier = var.sql_tier
edition = "ENTERPRISE"
availability_type = "ZONAL"
disk_type = "PD_SSD"
disk_size = 20
disk_autoresize = true
deletion_protection_enabled = var.sql_deletion_protection
backup_configuration {
enabled = true
point_in_time_recovery_enabled = true
}
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.main.id
}
}
depends_on = [google_service_networking_connection.sql]
}
resource "google_sql_database" "vector" {
name = "mulo_vector"
instance = google_sql_database_instance.postgres.name
}
toset(["BE", "chat"])
- toset 은 인수를 집합 값으로 변환합니다.
- 이를 통해 Chat DB와 BE DB 2개가 생성됩니다.
- 이를 통해 Chat DB와 BE DB 2개가 생성됩니다.
참고 자료 : https://developer.hashicorp.com/terraform/language/functions/toset
"google_sql_database_instance" "mysql" vs "google_sql_database" "mysql"
google_sql_database_instance- MySQL이 실행되는 Cloud SQL 인스턴스를 만드는 것
- MySQL이 실행되는 Cloud SQL 인스턴스를 만드는 것
google_sql_database- 그 서버 안에 애플리케이션이 사용할 DB를 만듭니다. 서버만 만들었다고 mulo DB가 자동으로 생기지는 않습니다.
- 그 서버 안에 애플리케이션이 사용할 DB를 만듭니다. 서버만 만들었다고 mulo DB가 자동으로 생기지는 않습니다.
load_balancer.tf
resource "google_compute_health_check" "service" {
for_each = local.services
name = "${var.name_prefix}-${each.key}-health-checks"
check_interval_sec = 10
timeout_sec = 5
http_health_check {
port = each.value.port
request_path = each.value.health_path
}
}
// 로드밸런서가 요청을 전달할 VM 그룹 설정
resource "google_compute_backend_service" "service" {
for_each = local.services
name = "${var.name_prefix}-${each.key}-service"
protocol = "HTTP"
port_name = "http"
load_balancing_scheme = "EXTERNAL_MANAGED" // 글로벌 외부 Application Load Balancer
timeout_sec = each.key == "chat" ? 3600 : (each.key == "ai" ? 300 : 30)
// chat이면 timeout = 3600, ai는 300, 나머지 30
health_checks = [google_compute_health_check.service[each.key].id] // 서비스 헬스체크 연결
backend {
group = google_compute_instance_group.service[each.key].self_link
balancing_mode = "UTILIZATION"
capacity_scaler = 1.0
}
// LB 로그 활성화 및 샘플링 비율 100%
log_config {
enable = true
sample_rate = 1.0
}
}
// L7 분기 결정
resource "google_compute_url_map" "https" {
name = "${var.name_prefix}-routes"
default_service = google_compute_backend_service.service["frontend"].id
// domain으로 들어오면 mulo 규칙을 적용하기 위해 설정
host_rule {
hosts = [var.domain]
path_matcher = "mulo"
}
// FE는 도메인은 일치 했지만 아래 경로 규칙은 필요 없다. 따로 설정
path_matcher {
name = "mulo"
default_service = google_compute_backend_service.service["frontend"].id
path_rule {
paths = ["/image", "/image/*"]
service = google_compute_backend_bucket.images.id
}
path_rule {
paths = ["/ai/search", "/ai/search/*", "/internal/tasks", "/internal/tasks/*"]
service = google_compute_backend_service.service["ai"].id
}
path_rule {
paths = ["/api", "/api/*"]
service = google_compute_backend_service.service["backend"].id
}
path_rule {
paths = ["/ws", "/ws/*"]
service = google_compute_backend_service.service["chat"].id
}
}
}
// GCP TLS 인증서 만들기
resource "google_compute_managed_ssl_certificate" "site" {
name = "${var.name_prefix}-cert"
managed {
domains = [var.domain]
}
}
// TSL 연결 처리 및 요청 url_amp으로 넘기위한 HTTPS_proxy
resource "google_compute_target_https_proxy" "site" {
name = "${var.name_prefix}-https-proxy"
url_map = google_compute_url_map.https.id
ssl_certificates = [google_compute_managed_ssl_certificate.site.id]
}
// 사용자가 접속할 IP 예약
resource "google_compute_global_address" "site" {
name = "${var.name_prefix}-lb-ip"
address_type = "EXTERNAL"
ip_version = "IPV4"
}
// HTTPS_proxy 전달 rule
resource "google_compute_global_forwarding_rule" "https" {
name = "${var.name_prefix}-https"
target = google_compute_target_https_proxy.site.id
ip_address = google_compute_global_address.site.address
port_range = "443"
load_balancing_scheme = "EXTERNAL_MANAGED"
}
// HTTP redirect 설정
resource "google_compute_url_map" "http_redirect" {
name = "${var.name_prefix}-redirect"
default_url_redirect {
https_redirect = true
strip_query = false
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
}
}
// 위 HTTPS와 동일
resource "google_compute_target_http_proxy" "redirect" {
name = "${var.name_prefix}-http-proxy"
url_map = google_compute_url_map.http_redirect.id
}
resource "google_compute_global_forwarding_rule" "http" {
name = "${var.name_prefix}-http"
target = google_compute_target_http_proxy.redirect.id
ip_address = google_compute_global_address.site.address
port_range = "80"
load_balancing_scheme = "EXTERNAL_MANAGED"
}
main.tf
locals {
services = {
frontend = { port = var.service_ports.frontend, health_path = var.health_paths.frontend }
ai = { port = var.service_ports.ai, health_path = var.health_paths.ai }
backend = { port = var.service_ports.backend, health_path = var.health_paths.backend }
chat = { port = var.service_ports.chat, health_path = var.health_paths.chat }
}
// VM 이름별 서비스, AI만 두 대이며 두 VM은 같은 AI 서비스 계정을 사용
vm_instances = {
frontend = "frontend"
ai = "ai"
ai-2 = "ai"
backend = "backend"
chat = "chat"
}
required_apis = toset([
"cloudtasks.googleapis.com",
"compute.googleapis.com",
"iam.googleapis.com",
"servicenetworking.googleapis.com",
"sqladmin.googleapis.com",
"storage.googleapis.com",
])
}
resource "google_project_service" "required" {
for_each = local.required_apis
service = each.key
disable_on_destroy = false
// Terraform이 이 API 활성화 리소스를 제거하더라도, GCP API는 비활성화 하지 않는다
}
network.tf
// VPC 생성
resource "google_compute_network" "main" {
name = "${var.name_prefix}-vpc"
auto_create_subnetworks = false
depends_on = [google_project_service.required]
}
// Compute Engine이 들어갈 서브넷 정의
resource "google_compute_subnetwork" "app" {
name = "${var.name_prefix}-app"
region = var.region
network = google_compute_network.main.id // VPC에 속하게 된다.
ip_cidr_range = var.subnet_cidr // cidr 설정 해야함
private_ip_google_access = true // GCP API에 접근할 수 있게 한다.
}
// Public IP가 없는 VM의 OS 업데이트 및 외부 AI API 호출을 위한 NAT Gateway(Cloud NAT)
resource "google_compute_router" "nat" {
name = "${var.name_prefix}-router"
region = var.region
network = google_compute_network.main.id // VPC 연결
}
resource "google_compute_router_nat" "app" {
name = "${var.name_prefix}-nat"
router = google_compute_router.nat.name // Cloud Router에 NAT 설정 연결
region = var.region
nat_ip_allocate_option = "AUTO_ONLY" // NAT에 사용할 외부 IP 자동 할당 설정
source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS" // 모든 서브넷이 아니라, 아래 subnetwork 블록에 지정한 서브넷에만 NAT 적용
subnetwork {
name = google_compute_subnetwork.app.id
source_ip_ranges_to_nat = ["ALL_IP_RANGES"] // 해당 서브넷이나 네트워크 내의 모든 Private 주소가 외부 인터넷과 통신할때 NAT Gateway를 거치게 한다.
}
}
# Cloud SQL private IP용 peering 설정
resource "google_compute_global_address" "sql_peering" {
name = "${var.name_prefix}-sql-peering"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = google_compute_network.main.id
depends_on = [google_project_service.required]
}
// 추가 설명 필요
resource "google_service_networking_connection" "sql" {
network = google_compute_network.main.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.sql_peering.name]
depends_on = [google_project_service.required]
}
// Google Frontends와 헬스체크가 서비스 포트로만 접근할 수 있게 하는 방화벽 설정
resource "google_compute_firewall" "load_balancer" {
name = "${var.name_prefix}-allow-lb"
network = google_compute_network.main.name
direction = "INGRESS"
source_ranges = ["35.191.0.0/16", "130.211.0.0/22"] // 로드밸런서 프록시와 상태 검사가 VM에 접속할때 사용하는 구글 IP 대역
target_tags = ["${var.name_prefix}-app"] // 네트워크 태그에 mulo-v2-app인 경우 적용한다. label 아님
allow {
protocol = "tcp"
ports = [for port in distinct(values(var.service_ports)) : tostring(port)]
}
}
// 1. values(var.service_ports): 서비스 포트 값들을 꺼내고
// 2. distinct(..): 같은 번호가 두 번 있으면 중복 제거
// 3. tostring(port): 각 번호를 방화벽 설정용 문자열로 변경
// IAP 권한이 있는 사람만 VM에 SSH 접속할 수 있게하는 firewall 설정
resource "google_compute_firewall" "iap_ssh" {
name = "${var.name_prefix}-allow-iap-ssh"
network = google_compute_network.main.name
direction = "INGRESS"
source_ranges = ["35.235.240.0/20"]
target_tags = ["${var.name_prefix}-app"]
allow {
protocol = "tcp"
ports = ["22"]
}
}
VPC Peering
- VPC Peering은 두 개의 VPC 간에 직접적인 네트워크 연결을 구성하는 방식입니다. 말 그대로, 서로 "피어(Peer, 동등한 관계)"로 묶어서 마치 하나의 네트워크처럼 통신할 수 있게 만드는 기능이다.
- 그렇다면 VPC 안에 Cloud SQL도 넣으면 그게 가능하지 않나라고 생각할 수 있는데 이는 AWS에서는 가능하지만 GCP에서는 다르다
- GCP에서는 Cloud SQL에 비공개 IP를 설정하면 자동으로 Google 관리 인스턴스로 들어가게 되어 다른 VPC로 나뉘어지게 된다.
- 그렇기 때문에 비공개 IP밖에 없는 Cloud SQL에 접속하기 위해선 반드시 Peering이 필요한 것이다.
- GCP에서는 Cloud SQL에 비공개 IP를 설정하면 자동으로 Google 관리 인스턴스로 들어가게 되어 다른 VPC로 나뉘어지게 된다.
- 그렇기 때문에 비공개 IP밖에 없는 Cloud SQL에 접속하기 위해선 반드시 Peering이 필요한 것이다.
output.tf
- 이 코드는 Terraform이 만든 리소스의 값을 실행 결과로 보여주는
output선언 output.tf자체가 GCP 리소스를 새로 만드는 것이 아니라,terraform apply후terraform output으로 GCP 리소스를 확인하기 위해 만들었다.
output "load_balancer_ip" {
description = "도메인의 A 레코드에 등록할 Global IP"
value = google_compute_global_address.site.address
}
output "site_url" {
value = "https://${var.domain}"
}
// URL 설정 개발서버, 스테이징 서버, 운영 서버 도메인이 각각 다르기 때문에 실수 방지를 위해
output "vm_private_ips" {
value = { for key, vm in google_compute_instance.vm : key => vm.network_interface[0].network_ip }
}
// VM private 확인
output "mysql_private_ips" {
value = { for key, instance in google_sql_database_instance.mysql : key => instance.private_ip_address }
}
// mysql DB private ip 확인
output "postgres_private_ip" {
value = google_sql_database_instance.postgres.private_ip_address
}
// postgres_private_ip 확인
output "image_bucket_name" {
value = google_storage_bucket.images.name
}
// bucket unique name
output "ai_tasks_queue_name" {
value = google_cloud_tasks_queue.ai.id
}
// cloud Task name
output "task_invoker_service_account" {
value = google_service_account.task_invoker.email
}
output "ai_task_target_url" {
value = "https://${var.domain}/internal/tasks/*"
}
storage.tf
// 이미지 객체는 비공개로 보관하고, backend VM이 인증 후 조회와 업로드를 처리
resource "google_storage_bucket" "images" {
name = var.image_bucket_name
location = var.region
storage_class = "STANDARD"
uniform_bucket_level_access = true
public_access_prevention = "enforced"
force_destroy = false
depends_on = [google_project_service.required]
}
// backend VM 서비스 계정에 이미지 읽기, 업로드, 삭제 권한을 부여
resource "google_storage_bucket_iam_member" "backend_image_admin" {
bucket = google_storage_bucket.images.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.vm["backend"].email}"
}
task.tf 이부분 얘기가 필요함
resource "google_cloud_tasks_queue" "ai" {
name = "${var.name_prefix}-ai-search"
location = var.region
rate_limits {
max_dispatches_per_second = 10
max_concurrent_dispatches = 10
}
retry_config {
max_attempts = 5
min_backoff = "5s"
max_backoff = "300s"
}
depends_on = [google_project_service.required]
}
resource "google_project_iam_member" "api_tasks_enqueuer" {
project = var.project_id
role = "roles/cloudtasks.enqueuer"
member = "serviceAccount:${google_service_account.vm["backend"].email}"
}
# API가 HTTP Task를 생성할 때 이 계정으로 OIDC 토큰을 붙이도록 합니다.
resource "google_service_account" "task_invoker" {
account_id = "${var.name_prefix}-task-invoker"
display_name = "${var.name_prefix} Cloud Tasks OIDC identity"
depends_on = [google_project_service.required]
}
resource "google_service_account_iam_member" "api_can_act_as_task_invoker" {
service_account_id = google_service_account.task_invoker.name
role = "roles/iam.serviceAccountUser"
member = "serviceAccount:${google_service_account.vm["backend"].email}"
}
Variable.tf
variable "project_id" {
description = "이미 생성된 GCP 프로젝트 ID"
type = string
}
variable "domain" {
description = "로드밸런서의 HTTPS 도메인. 적용 후 A 레코드를 출력된 IP로 연결해야 인증서가 발급됩니다."
type = string
}
variable "image_bucket_name" {
description = "전역에서 유일한 이미지 버킷 이름"
type = string
}
variable "name_prefix" {
description = "GCP 리소스 이름"
type = string
default = "mulo-v2"
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,15}[a-z0-9]$", var.name_prefix))
error_message = "name_prefix는 소문자/숫자/하이픈으로 된 2~17자여야 합니다."
}
}
variable "region" {
type = string
default = "asia-northeast3"
}
variable "zone" {
type = string
default = "asia-northeast3-a"
}
variable "subnet_cidr" {
type = string
default = "10.20.0.0/24"
}
variable "vm_machine_type" {
type = string
default = "e2-medium"
}
variable "vm_image" {
type = string
default = "debian-cloud/debian-12"
}
variable "service_ports" {
description = "각 VM에서 애플리케이션이 수신할 HTTP 포트"
type = object({
frontend = number
ai = number
backend = number
chat = number
})
default = {
frontend = 3000
ai = 8000
backend = 8080
chat = 8081
}
}
variable "health_paths" {
description = "로드밸런서가 각 애플리케이션에서 호출할 200 응답 경로"
type = object({
frontend = string
ai = string
backend = string
chat = string
})
default = {
frontend = "/"
ai = "/health"
backend = "/actuator/health"
chat = "/health"
}
}
// 뺄지 고민중 너무 길어서 파일로 하는게 오히려 전략적 선택
variable "startup_scripts" {
description = "서비스별 선택적 배포/부트스트랩 스크립트 AI 스크립트는 AI VM 두 대에 모두 적용됩니다. 키: frontend, ai, backend, chat"
type = map(string)
default = {}
}
variable "mysql_version" {
type = string
default = "MYSQL_8_4"
}
variable "postgres_version" {
type = string
default = "POSTGRES_16"
}
variable "sql_tier" {
type = string
default = "db-g1-small"
}
variable "sql_deletion_protection" {
description = "Terraform 및 GCP의 Cloud SQL 삭제 보호"
type = bool
default = true
}