Skip to content

필사 모드: IaC 패턴 & 베스트 프랙티스 2025: Terraform 모듈, Pulumi, Crossplane, 상태 관리

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

목차

1. IaC 랜드스케이프 2025: 도구 비교와 선택 기준

Infrastructure as Code(IaC)는 인프라를 코드로 정의하고 버전 관리하는 핵심 DevOps 실천법이다. 2025년 현재 IaC 생태계는 다양한 도구가 공존하며, 각각 고유한 강점을 가지고 있다.

1.1 주요 IaC 도구 비교

| 도구 | 언어 | 상태 관리 | 클라우드 지원 | 특징 |

|------|------|-----------|-------------|------|

| Terraform/OpenTofu | HCL | 원격 상태 파일 | 멀티 클라우드 | 가장 큰 생태계, 프로바이더 풍부 |

| Pulumi | TS/Python/Go/C# | Pulumi Cloud/자체 백엔드 | 멀티 클라우드 | 범용 프로그래밍 언어 사용 |

| CDKTF | TS/Python/Go/C# | Terraform 백엔드 | 멀티 클라우드 | CDK 구문 + Terraform 프로바이더 |

| AWS CDK | TS/Python/Go/C# | CloudFormation | AWS 전용 | AWS 네이티브, L2/L3 컨스트럭트 |

| Crossplane | YAML(K8s CRD) | K8s etcd | 멀티 클라우드 | K8s 네이티브, GitOps 친화 |

| Ansible | YAML | 상태 없음(절차적) | 멀티 클라우드 | 구성 관리 + 프로비저닝 |

1.2 선언적 vs 명령적 IaC

선언적(Declarative) IaC:

┌─────────────────────────────────────────────┐

│ "나는 EC2 인스턴스 3대를 원한다" │

│ → 도구가 현재 상태와 비교하여 필요한 변경 계산 │

│ → Terraform, Pulumi, CloudFormation │

└─────────────────────────────────────────────┘

명령적(Imperative) IaC:

┌─────────────────────────────────────────────┐

│ "EC2 인스턴스를 생성하라, 보안그룹을 연결하라" │

│ → 순서대로 명령을 실행 │

│ → Ansible, Shell Scripts │

└─────────────────────────────────────────────┘

1.3 OpenTofu vs Terraform

2023년 HashiCorp의 라이센스 변경(BSL) 이후 OpenTofu가 Linux Foundation 프로젝트로 탄생했다.

OpenTofu와 Terraform은 동일한 HCL 문법 사용

opentofu init / terraform init 모두 동일하게 동작

terraform {

required_version = ">= 1.6.0"

required_providers {

aws = {

source = "hashicorp/aws"

version = "~> 5.0"

}

}

backend "s3" {

bucket = "my-terraform-state"

key = "prod/terraform.tfstate"

region = "ap-northeast-2"

dynamodb_table = "terraform-locks"

encrypt = true

}

}

2. Terraform 모듈 설계 패턴

Terraform 모듈은 재사용 가능한 인프라 코드의 핵심 단위다. 올바른 모듈 설계는 조직 전체의 인프라 일관성과 생산성을 결정한다.

2.1 플랫 모듈 vs 중첩 모듈

플랫 모듈 구조 (권장):

modules/

├── vpc/

│ ├── main.tf

│ ├── variables.tf

│ ├── outputs.tf

│ └── versions.tf

├── ecs-cluster/

│ ├── main.tf

│ ├── variables.tf

│ ├── outputs.tf

│ └── versions.tf

└── rds/

├── main.tf

├── variables.tf

├── outputs.tf

└── versions.tf

중첩 모듈 구조 (복잡성 증가):

modules/

└── platform/

├── main.tf # vpc, ecs, rds 모듈 호출

├── modules/

│ ├── vpc/

│ ├── ecs-cluster/

│ └── rds/

└── outputs.tf

2.2 컴포지션 패턴

모듈 컴포지션은 작은 모듈을 조합하여 더 큰 인프라를 구성하는 패턴이다.

environments/prod/main.tf

컴포지션 패턴: 작은 모듈을 조합

module "vpc" {

source = "../../modules/vpc"

name = "prod-vpc"

cidr_block = "10.0.0.0/16"

azs = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]

private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]

public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

enable_nat_gateway = true

single_nat_gateway = false # 프로덕션은 AZ별 NAT

}

module "ecs_cluster" {

source = "../../modules/ecs-cluster"

name = "prod-cluster"

vpc_id = module.vpc.vpc_id

subnet_ids = module.vpc.private_subnet_ids

capacity_providers = ["FARGATE", "FARGATE_SPOT"]

}

module "rds" {

source = "../../modules/rds"

name = "prod-db"

engine = "aurora-postgresql"

engine_version = "15.4"

instance_class = "db.r6g.xlarge"

vpc_id = module.vpc.vpc_id

subnet_ids = module.vpc.database_subnet_ids

security_group_ids = [module.ecs_cluster.security_group_id]

}

2.3 팩토리 패턴

동일한 모듈을 여러 번 인스턴스화하는 패턴이다.

서비스 팩토리 패턴

variable "services" {

type = map(object({

cpu = number

memory = number

port = number

count = number

health_check_path = string

}))

default = {

"api-gateway" = {

cpu = 512

memory = 1024

port = 8080

count = 3

health_check_path = "/health"

}

"user-service" = {

cpu = 256

memory = 512

port = 8081

count = 2

health_check_path = "/actuator/health"

}

"order-service" = {

cpu = 512

memory = 1024

port = 8082

count = 2

health_check_path = "/health"

}

}

}

module "ecs_services" {

source = "../../modules/ecs-service"

for_each = var.services

name = each.key

cluster_id = module.ecs_cluster.cluster_id

cpu = each.value.cpu

memory = each.value.memory

container_port = each.value.port

desired_count = each.value.count

health_check_path = each.value.health_check_path

subnet_ids = module.vpc.private_subnet_ids

}

2.4 래퍼 모듈 패턴

커뮤니티 모듈을 감싸서 조직 표준을 강제하는 패턴이다.

modules/org-s3-bucket/main.tf

조직 표준을 강제하는 래퍼 모듈

module "s3_bucket" {

source = "terraform-aws-modules/s3-bucket/aws"

version = "~> 4.0"

bucket = var.bucket_name

조직 표준: 항상 암호화

server_side_encryption_configuration = {

rule = {

apply_server_side_encryption_by_default = {

sse_algorithm = "aws:kms"

kms_master_key_id = var.kms_key_id

}

}

}

조직 표준: 퍼블릭 접근 차단

block_public_acls = true

block_public_policy = true

ignore_public_acls = true

restrict_public_buckets = true

조직 표준: 버전 관리 활성화

versioning = {

enabled = true

}

조직 표준: 태그

tags = merge(var.tags, {

ManagedBy = "terraform"

Team = var.team

Environment = var.environment

CostCenter = var.cost_center

})

}

3. Terraform 베스트 프랙티스

3.1 원격 상태 관리

상태 관리 인프라 부트스트랩

bootstrap/main.tf

resource "aws_s3_bucket" "terraform_state" {

bucket = "myorg-terraform-state-${data.aws_caller_identity.current.account_id}"

lifecycle {

prevent_destroy = true

}

}

resource "aws_s3_bucket_versioning" "terraform_state" {

bucket = aws_s3_bucket.terraform_state.id

versioning_configuration {

status = "Enabled"

}

}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {

bucket = aws_s3_bucket.terraform_state.id

rule {

apply_server_side_encryption_by_default {

sse_algorithm = "aws:kms"

}

}

}

resource "aws_dynamodb_table" "terraform_locks" {

name = "terraform-locks"

billing_mode = "PAY_PER_REQUEST"

hash_key = "LockID"

attribute {

name = "LockID"

type = "S"

}

tags = {

Name = "Terraform State Lock Table"

ManagedBy = "terraform"

}

}

3.2 워크스페이스 vs 디렉토리 전략

디렉토리 전략 (권장):

infrastructure/

├── modules/ # 재사용 모듈

│ ├── vpc/

│ ├── ecs/

│ └── rds/

├── environments/

│ ├── dev/

│ │ ├── main.tf

│ │ ├── variables.tf

│ │ ├── terraform.tfvars

│ │ └── backend.tf # dev 전용 상태 파일

│ ├── staging/

│ │ ├── main.tf

│ │ ├── variables.tf

│ │ ├── terraform.tfvars

│ │ └── backend.tf

│ └── prod/

│ ├── main.tf

│ ├── variables.tf

│ ├── terraform.tfvars

│ └── backend.tf

└── global/ # 공유 리소스 (IAM, Route53)

├── iam/

└── dns/

워크스페이스 전략 (주의 필요):

- 동일 코드, 다른 상태 → 환경 간 차이가 커지면 관리 어려움

- terraform workspace select dev

- terraform workspace select prod

- 상태 파일이 같은 백엔드에 저장 → 권한 분리 어려움

3.3 프로바이더 버전 관리

versions.tf

terraform {

required_version = ">= 1.6.0, < 2.0.0"

required_providers {

aws = {

source = "hashicorp/aws"

version = "~> 5.30" # 5.30.x 허용, 6.0 불가

}

kubernetes = {

source = "hashicorp/kubernetes"

version = ">= 2.24, < 3.0"

}

helm = {

source = "hashicorp/helm"

version = "~> 2.12"

}

}

}

.terraform.lock.hcl 파일은 반드시 Git에 커밋

terraform init -upgrade 로 프로바이더 업데이트

3.4 변수 검증과 타입 안전성

variable "environment" {

type = string

description = "배포 환경"

validation {

condition = contains(["dev", "staging", "prod"], var.environment)

error_message = "environment는 dev, staging, prod 중 하나여야 합니다."

}

}

variable "instance_type" {

type = string

description = "EC2 인스턴스 타입"

validation {

condition = can(regex("^(t3|m6i|c6i|r6i)\\.", var.instance_type))

error_message = "허용된 인스턴스 패밀리: t3, m6i, c6i, r6i"

}

}

variable "cidr_block" {

type = string

description = "VPC CIDR 블록"

validation {

condition = can(cidrhost(var.cidr_block, 0))

error_message = "유효한 CIDR 블록을 입력하세요."

}

}

복합 타입 변수

variable "scaling_config" {

type = object({

min_size = number

max_size = number

desired_size = number

})

validation {

condition = var.scaling_config.min_size <= var.scaling_config.desired_size

error_message = "min_size는 desired_size보다 작거나 같아야 합니다."

}

validation {

condition = var.scaling_config.desired_size <= var.scaling_config.max_size

error_message = "desired_size는 max_size보다 작거나 같아야 합니다."

}

}

4. Pulumi 딥 다이브: 프로그래밍 언어로 IaC

Pulumi는 TypeScript, Python, Go, C# 등 범용 프로그래밍 언어로 인프라를 정의한다. 조건문, 반복문, 추상화 등 언어의 모든 기능을 활용할 수 있다.

4.1 Pulumi TypeScript 기본 구조

// index.ts

const config = new pulumi.Config();

const environment = config.require("environment");

const vpcCidr = config.get("vpcCidr") || "10.0.0.0/16";

// VPC 생성

const vpc = new aws.ec2.Vpc("main-vpc", {

cidrBlock: vpcCidr,

enableDnsHostnames: true,

enableDnsSupport: true,

tags: {

Name: `${environment}-vpc`,

Environment: environment,

ManagedBy: "pulumi",

},

});

// 퍼블릭 서브넷 (프로그래밍 언어의 반복문 활용)

const azs = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"];

const publicSubnets = azs.map((az, index) => {

return new aws.ec2.Subnet(`public-subnet-${index}`, {

vpcId: vpc.id,

cidrBlock: `10.0.${index + 1}.0/24`,

availabilityZone: az,

mapPublicIpOnLaunch: true,

tags: {

Name: `${environment}-public-${az}`,

Type: "public",

},

});

});

// 출력값

export const vpcId = vpc.id;

export const publicSubnetIds = publicSubnets.map(s => s.id);

4.2 Pulumi 컴포넌트 리소스

// components/ecs-service.ts

interface EcsServiceArgs {

clusterArn: pulumi.Input<string>;

vpcId: pulumi.Input<string>;

subnetIds: pulumi.Input<string>[];

containerImage: string;

cpu: number;

memory: number;

port: number;

desiredCount: number;

healthCheckPath: string;

}

export class EcsService extends pulumi.ComponentResource {

public readonly serviceUrl: pulumi.Output<string>;

public readonly taskDefinitionArn: pulumi.Output<string>;

constructor(

name: string,

args: EcsServiceArgs,

opts?: pulumi.ComponentResourceOptions

) {

super("custom:app:EcsService", name, {}, opts);

const logGroup = new aws.cloudwatch.LogGroup(`${name}-logs`, {

retentionInDays: 30,

tags: { Service: name },

}, { parent: this });

const taskRole = new aws.iam.Role(`${name}-task-role`, {

assumeRolePolicy: JSON.stringify({

Version: "2012-10-17",

Statement: [{

Action: "sts:AssumeRole",

Effect: "Allow",

Principal: { Service: "ecs-tasks.amazonaws.com" },

}],

}),

}, { parent: this });

const taskDefinition = new aws.ecs.TaskDefinition(`${name}-task`, {

family: name,

cpu: args.cpu.toString(),

memory: args.memory.toString(),

networkMode: "awsvpc",

requiresCompatibilities: ["FARGATE"],

executionRoleArn: taskRole.arn,

taskRoleArn: taskRole.arn,

containerDefinitions: JSON.stringify([{

name: name,

image: args.containerImage,

cpu: args.cpu,

memory: args.memory,

portMappings: [{ containerPort: args.port }],

logConfiguration: {

logDriver: "awslogs",

options: {

"awslogs-group": logGroup.name,

"awslogs-region": "ap-northeast-2",

"awslogs-stream-prefix": "ecs",

},

},

}]),

}, { parent: this });

this.taskDefinitionArn = taskDefinition.arn;

this.registerOutputs({

taskDefinitionArn: this.taskDefinitionArn,

});

}

}

4.3 Pulumi 스택 레퍼런스

// 스택 간 데이터 공유

// infrastructure/index.ts 에서 VPC 정보 export

export const vpcId = vpc.id;

export const privateSubnetIds = privateSubnets.map(s => s.id);

// application/index.ts 에서 참조

const infraStack = new pulumi.StackReference("org/infrastructure/prod");

const vpcId = infraStack.getOutput("vpcId");

const subnetIds = infraStack.getOutput("privateSubnetIds");

4.4 Pulumi Policy as Code (CrossGuard)

// policy-pack/index.ts

new PolicyPack("aws-security", {

policies: [

{

name: "s3-no-public-read",

description: "S3 버킷은 퍼블릭 읽기를 허용하면 안 됩니다",

enforcementLevel: "mandatory",

validateResource: validateResourceOfType(aws.s3.BucketAclV2, (acl, args, reportViolation) => {

if (acl.acl === "public-read" || acl.acl === "public-read-write") {

reportViolation("S3 버킷에 퍼블릭 ACL이 설정되었습니다.");

}

}),

},

{

name: "ec2-require-tags",

description: "EC2 인스턴스는 필수 태그를 가져야 합니다",

enforcementLevel: "mandatory",

validateResource: validateResourceOfType(aws.ec2.Instance, (instance, args, reportViolation) => {

const requiredTags = ["Name", "Environment", "Team", "CostCenter"];

const tags = instance.tags || {};

for (const tag of requiredTags) {

if (!(tag in tags)) {

reportViolation(`필수 태그 '${tag}'가 누락되었습니다.`);

}

}

}),

},

],

});

5. Crossplane: Kubernetes 네이티브 IaC

Crossplane은 Kubernetes CRD(Custom Resource Definition)를 사용하여 클라우드 리소스를 관리한다. kubectl로 AWS, GCP, Azure 리소스를 생성하고 관리할 수 있다.

5.1 Crossplane 아키텍처

Crossplane 아키텍처:

┌─────────────────────────────────────────────────┐

│ K8s Cluster │

│ ┌───────────────────────────────────────────┐ │

│ │ Crossplane Core │ │

│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ │ │

│ │ │Composite│ │Composition│ │ XRD │ │ │

│ │ │Resource │ │ │ │ │ │ │

│ │ └────┬────┘ └────┬─────┘ └────┬────┘ │ │

│ │ └─────────────┼─────────────┘ │ │

│ └───────────────────┬─┤─────────────────────┘ │

│ │ │ │

│ ┌───────────────────┴─┴─────────────────────┐ │

│ │ Providers │ │

│ │ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │

│ │ │AWS Prov. │ │GCP Prov. │ │Azure P. │ │ │

│ │ └────┬─────┘ └────┬─────┘ └────┬────┘ │ │

│ └───────┼──────────────┼─────────────┼──────┘ │

└──────────┼──────────────┼─────────────┼──────────┘

▼ ▼ ▼

AWS Cloud GCP Cloud Azure Cloud

5.2 XRD (Composite Resource Definition) 정의

xrd.yaml - 플랫폼 팀이 정의하는 API 스키마

apiVersion: apiextensions.crossplane.io/v1

kind: CompositeResourceDefinition

metadata:

name: xdatabases.platform.example.com

spec:

group: platform.example.com

names:

kind: XDatabase

plural: xdatabases

claimNames:

kind: Database

plural: databases

versions:

- name: v1alpha1

served: true

referenceable: true

schema:

openAPIV3Schema:

type: object

properties:

spec:

type: object

properties:

engine:

type: string

enum: ["postgresql", "mysql"]

description: "데이터베이스 엔진"

size:

type: string

enum: ["small", "medium", "large"]

description: "인스턴스 크기"

region:

type: string

default: "ap-northeast-2"

required:

- engine

- size

5.3 Composition 정의

composition.yaml - 실제 리소스 매핑

apiVersion: apiextensions.crossplane.io/v1

kind: Composition

metadata:

name: xdatabases.aws.platform.example.com

labels:

provider: aws

spec:

compositeTypeRef:

apiVersion: platform.example.com/v1alpha1

kind: XDatabase

resources:

- name: rds-instance

base:

apiVersion: rds.aws.upbound.io/v1beta1

kind: Instance

spec:

forProvider:

engine: postgresql

engineVersion: "15"

instanceClass: db.t3.medium

allocatedStorage: 20

publiclyAccessible: false

skipFinalSnapshot: true

patches:

- type: FromCompositeFieldPath

fromFieldPath: "spec.engine"

toFieldPath: "spec.forProvider.engine"

- type: FromCompositeFieldPath

fromFieldPath: "spec.size"

toFieldPath: "spec.forProvider.instanceClass"

transforms:

- type: map

map:

small: db.t3.medium

medium: db.r6g.large

large: db.r6g.xlarge

- type: FromCompositeFieldPath

fromFieldPath: "spec.region"

toFieldPath: "spec.forProvider.region"

- name: subnet-group

base:

apiVersion: rds.aws.upbound.io/v1beta1

kind: SubnetGroup

spec:

forProvider:

description: "Crossplane managed subnet group"

5.4 Claim 기반 프로비저닝

claim.yaml - 개발자가 요청하는 리소스

apiVersion: platform.example.com/v1alpha1

kind: Database

metadata:

name: orders-db

namespace: orders-team

spec:

engine: postgresql

size: medium

region: ap-northeast-2

개발자의 사용 경험

kubectl apply -f claim.yaml

kubectl get databases -n orders-team

NAME ENGINE SIZE READY AGE

orders-db postgresql medium True 5m

6. 상태 관리 심화

6.1 상태 파일 백엔드 비교

S3 + DynamoDB (AWS):

┌─────────────┐ ┌──────────────┐

│ S3 Bucket │ │ DynamoDB │

│ (상태 저장) │ │ (잠금 관리) │

│ 버전 관리 ON │ │ LockID 해시 │

│ 암호화 ON │ │ 키 테이블 │

└─────────────┘ └──────────────┘

GCS (GCP):

┌─────────────────────────────┐

│ GCS Bucket │

│ (상태 저장 + 내장 잠금) │

│ 버전 관리 ON, 암호화 ON │

└─────────────────────────────┘

Terraform Cloud / Spacelift:

┌─────────────────────────────┐

│ 관리형 상태 저장 │

│ 내장 잠금, 상태 히스토리 │

│ 접근 제어, 감사 로그 │

└─────────────────────────────┘

6.2 상태 잠금과 동시성

상태 잠금 강제 해제 (주의: 다른 작업이 실행 중이 아닌지 확인)

terraform force-unlock LOCK_ID

상태 파일 조회

terraform state list

terraform state show aws_instance.web

상태에서 리소스 제거 (삭제 없이)

terraform state rm aws_instance.legacy

기존 리소스를 상태로 가져오기

terraform import aws_instance.web i-1234567890abcdef0

6.3 상태 마이그레이션

moved 블록으로 리소스 이동 (Terraform 1.1+)

moved {

from = aws_instance.web

to = module.compute.aws_instance.web

}

moved {

from = aws_security_group.web_sg

to = module.networking.aws_security_group.web_sg

}

import 블록으로 기존 리소스 가져오기 (Terraform 1.5+)

to = aws_instance.legacy_server

id = "i-0abc123def456789"

}

to = aws_s3_bucket.existing_bucket

id = "my-existing-bucket-name"

}

7. IaC 테스트 전략

7.1 Terratest

// test/vpc_test.go

package test

"testing"

"github.com/gruntwork-io/terratest/modules/terraform"

"github.com/stretchr/testify/assert"

)

func TestVpcModule(t *testing.T) {

t.Parallel()

terraformOptions := &terraform.Options{

TerraformDir: "../modules/vpc",

Vars: map[string]interface{}{

"name": "test-vpc",

"cidr_block": "10.0.0.0/16",

"azs": []string{"us-east-1a", "us-east-1b"},

},

NoColor: true,

}

defer terraform.Destroy(t, terraformOptions)

terraform.InitAndApply(t, terraformOptions)

vpcId := terraform.Output(t, terraformOptions, "vpc_id")

assert.NotEmpty(t, vpcId)

privateSubnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")

assert.Equal(t, 2, len(privateSubnetIds))

}

7.2 Checkov 정적 분석

Checkov 실행

checkov -d . --framework terraform

특정 체크 건너뛰기

checkov -d . --skip-check CKV_AWS_18,CKV_AWS_21

JSON 리포트 생성

checkov -d . -o json > checkov-report.json

커스텀 정책 예시

custom_policy.yaml

metadata:

id: "CUSTOM_001"

name: "Ensure S3 bucket has lifecycle policy"

category: "general"

definition:

cond_type: "attribute"

resource_types:

- "aws_s3_bucket"

attribute: "lifecycle_rule"

operator: "exists"

7.3 OPA Conftest

policy/terraform.rego

package terraform

deny[msg] {

resource := input.resource_changes[_]

resource.type == "aws_instance"

not resource.change.after.tags.Environment

msg := sprintf("EC2 인스턴스 '%s'에 Environment 태그가 없습니다", [resource.address])

}

deny[msg] {

resource := input.resource_changes[_]

resource.type == "aws_s3_bucket"

resource.change.after.acl == "public-read"

msg := sprintf("S3 버킷 '%s'이 퍼블릭 읽기로 설정되었습니다", [resource.address])

}

deny[msg] {

resource := input.resource_changes[_]

resource.type == "aws_security_group_rule"

resource.change.after.cidr_blocks[_] == "0.0.0.0/0"

resource.change.after.from_port == 22

msg := "SSH(22) 포트를 0.0.0.0/0에 개방하면 안 됩니다"

}

Conftest 실행

terraform plan -out=tfplan

terraform show -json tfplan > tfplan.json

conftest test tfplan.json -p policy/

7.4 tfsec 보안 스캐닝

tfsec 실행

tfsec .

결과 예시

Result: CRITICAL - aws_security_group.web

Description: Security group rule allows ingress from 0.0.0.0/0 to port 22

Impact: Unrestricted SSH access

Resolution: Restrict SSH access to known IP ranges

tfsec 인라인 무시

resource "aws_security_group_rule" "allow_ssh" {

#tfsec:ignore:aws-vpc-no-public-ingress-sgr

type = "ingress"

from_port = 22

to_port = 22

protocol = "tcp"

cidr_blocks = ["10.0.0.0/8"] # 내부 네트워크만 허용

}

8. 모노레포 vs 폴리레포 전략

8.1 모노레포 구조

infrastructure/ # 모노레포

├── .github/

│ └── workflows/

│ ├── terraform-plan.yml # PR에서 plan

│ └── terraform-apply.yml # merge 후 apply

├── modules/ # 공유 모듈

│ ├── vpc/

│ ├── ecs/

│ ├── rds/

│ └── monitoring/

├── environments/

│ ├── shared/ # 공유 리소스 (IAM, DNS)

│ │ ├── iam/

│ │ └── route53/

│ ├── dev/

│ │ ├── main.tf

│ │ └── terragrunt.hcl

│ ├── staging/

│ │ ├── main.tf

│ │ └── terragrunt.hcl

│ └── prod/

│ ├── main.tf

│ └── terragrunt.hcl

├── terragrunt.hcl # 루트 설정

└── Makefile

8.2 Terragrunt으로 DRY 달성

environments/prod/terragrunt.hcl

include "root" {

path = find_in_parent_folders()

}

terraform {

source = "../../modules/vpc"

}

inputs = {

environment = "prod"

cidr_block = "10.0.0.0/16"

azs = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]

enable_nat_gateway = true

single_nat_gateway = false

}

루트 terragrunt.hcl

remote_state {

backend = "s3"

config = {

bucket = "myorg-terraform-state"

key = "${path_relative_to_include()}/terraform.tfstate"

region = "ap-northeast-2"

encrypt = true

dynamodb_table = "terraform-locks"

}

}

generate "provider" {

path = "provider.tf"

if_exists = "overwrite_terragrunt"

contents = <<EOF

provider "aws" {

region = "ap-northeast-2"

default_tags {

tags = {

ManagedBy = "terragrunt"

Environment = "${basename(get_terragrunt_dir())}"

}

}

}

EOF

}

8.3 CI/CD 파이프라인

.github/workflows/terraform-plan.yml

name: Terraform Plan

on:

pull_request:

paths:

- 'environments/**'

- 'modules/**'

jobs:

detect-changes:

runs-on: ubuntu-latest

outputs:

directories: "steps.changes.outputs.directories"

steps:

- uses: actions/checkout@v4

- id: changes

uses: dorny/paths-filter@v3

with:

filters: |

dev:

- 'environments/dev/**'

- 'modules/**'

staging:

- 'environments/staging/**'

- 'modules/**'

prod:

- 'environments/prod/**'

- 'modules/**'

plan:

needs: detect-changes

runs-on: ubuntu-latest

strategy:

matrix:

directory: [dev, staging, prod]

steps:

- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3

with:

terraform_version: "1.7.0"

- name: Terraform Init

working-directory: environments/${{ matrix.directory }}

run: terraform init -no-color

- name: Terraform Plan

working-directory: environments/${{ matrix.directory }}

run: terraform plan -no-color -out=tfplan

- name: Checkov Scan

uses: bridgecrewio/checkov-action@v12

with:

directory: environments/${{ matrix.directory }}

framework: terraform

- name: Infracost

uses: infracost/actions/setup@v3

with:

api-key: "${{ secrets.INFRACOST_API_KEY }}"

- run: |

infracost breakdown \

--path environments/${{ matrix.directory }} \

--format json \

--out-file /tmp/infracost.json

9. GitOps for IaC

9.1 Atlantis

atlantis.yaml

version: 3

automerge: false

parallel_plan: true

parallel_apply: true

projects:

- name: dev-vpc

dir: environments/dev

workspace: default

terraform_version: v1.7.0

autoplan:

when_modified:

- "*.tf"

- "../../modules/vpc/**"

enabled: true

apply_requirements:

- approved

- mergeable

- name: prod-vpc

dir: environments/prod

workspace: default

terraform_version: v1.7.0

autoplan:

when_modified:

- "*.tf"

- "../../modules/vpc/**"

enabled: true

apply_requirements:

- approved

- mergeable

- undiverged

Atlantis 서버 설정

atlantis server \

--atlantis-url="https://atlantis.example.com" \

--gh-user="atlantis-bot" \

--gh-token="ghp_xxx" \

--repo-allowlist="github.com/myorg/*"

9.2 Spacelift 설정

.spacelift/config.yml

version: "1"

stacks:

prod-infra:

space: production

project_root: environments/prod

terraform_version: "1.7.0"

autodeploy: false

administrative: false

labels:

- "env:prod"

- "team:platform"

policies:

- name: plan-approval

type: APPROVAL

body: |

package spacelift

approve {

count(input.reviews.current.approvals) >= 2

}

- name: drift-detection

type: TRIGGER

body: |

package spacelift

trigger["drift-check"] {

input.run.type == "DRIFT_DETECTION"

input.run.drift == true

}

drift_detection:

enabled: true

schedule:

- "0 */6 * * *" # 6시간마다 드리프트 감지

reconcile: false # 자동 수정하지 않음

9.3 Env0 설정

env0.yml

version: 2

deploy:

steps:

terraformVersion: "1.7.0"

init:

commands:

- terraform init -no-color

plan:

commands:

- terraform plan -no-color -out=tfplan

- checkov -d . --framework terraform --output json > checkov.json || true

- infracost breakdown --path . --format json --out-file infracost.json || true

apply:

commands:

- terraform apply -no-color tfplan

10. 드리프트 감지와 복원

10.1 드리프트 감지 전략

Terraform 내장 드리프트 감지

terraform plan -detailed-exitcode

종료 코드:

0 - 변경 없음

1 - 오류

2 - 변경 있음 (드리프트 감지됨)

자동 드리프트 감지 스크립트

#!/bin/bash

set -e

ENVIRONMENTS=("dev" "staging" "prod")

for env in "${ENVIRONMENTS[@]}"; do

echo "=== Checking drift for $env ==="

cd "environments/$env"

terraform init -no-color > /dev/null

if ! terraform plan -detailed-exitcode -no-color > "/tmp/drift-${env}.txt" 2>&1; then

EXIT_CODE=$?

if [ $EXIT_CODE -eq 2 ]; then

echo "DRIFT DETECTED in $env"

Slack 알림 전송

curl -X POST "$SLACK_WEBHOOK" \

-H 'Content-Type: application/json' \

-d "{\"text\": \"Drift detected in ${env} environment\"}"

fi

fi

cd ../..

done

10.2 Infracost 비용 추정

Infracost 기본 사용

infracost breakdown --path .

PR에서 비용 차이 표시

infracost diff \

--path . \

--compare-to infracost-base.json \

--format json \

--out-file infracost-diff.json

비용 정책 설정

infracost.yml

version: 0.1

projects:

- path: environments/prod

terraform_var_files:

- terraform.tfvars

usage_file: infracost-usage.yml

11. IaC 시크릿 관리

11.1 HashiCorp Vault 연동

Vault 프로바이더

provider "vault" {

address = "https://vault.example.com"

}

Vault에서 시크릿 읽기

data "vault_generic_secret" "db_credentials" {

path = "secret/data/prod/database"

}

resource "aws_db_instance" "main" {

engine = "postgres"

engine_version = "15"

instance_class = "db.r6g.large"

username = data.vault_generic_secret.db_credentials.data["username"]

password = data.vault_generic_secret.db_credentials.data["password"]

}

11.2 SOPS (Secrets OPerationS)

SOPS로 암호화

sops --encrypt --age age1xxxxx secrets.yaml > secrets.enc.yaml

SOPS 설정

.sops.yaml

creation_rules:

- path_regex: environments/prod/.*\.enc\.yaml

age: >-

age1xxx,age2xxx

encrypted_regex: "^(password|secret|key|token)$"

- path_regex: environments/dev/.*\.enc\.yaml

age: >-

age3xxx

encrypted_regex: "^(password|secret|key|token)$"

Terraform에서 SOPS 사용

data "sops_file" "secrets" {

source_file = "secrets.enc.yaml"

}

resource "aws_secretsmanager_secret_version" "db_password" {

secret_id = aws_secretsmanager_secret.db.id

secret_string = data.sops_file.secrets.data["db_password"]

}

12. 퀴즈

다음 퀴즈로 이 글에서 다룬 IaC 패턴에 대한 이해를 점검해 보자.

Q1. Terraform 모듈 설계

**정답: 커뮤니티 모듈을 감싸서 조직 표준(암호화, 태깅, 접근 제어)을 강제하는 것이다.**

래퍼 모듈은 커뮤니티 모듈을 내부적으로 호출하면서, 조직에서 필수로 요구하는 설정(S3 암호화, 퍼블릭 접근 차단, 태그 정책)을 기본값으로 적용한다. 개발자는 래퍼 모듈만 사용하면 자동으로 보안 및 거버넌스 정책을 준수하게 된다.

Q2. Pulumi vs Terraform

**정답: TypeScript, Python, Go 등 범용 프로그래밍 언어를 사용하므로 조건문, 반복문, 추상화 등 언어의 모든 기능을 인프라 코드에 활용할 수 있다.**

HCL은 선언적 DSL이므로 복잡한 로직 구현에 한계가 있다. Pulumi는 기존 IDE, 디버거, 테스트 프레임워크, 패키지 매니저를 그대로 사용할 수 있어 개발자 생산성이 높다.

Q3. Crossplane 아키텍처

**정답:**

- XRD(Composite Resource Definition): 플랫폼 팀이 정의하는 API 스키마. 개발자에게 노출할 필드와 타입을 정의한다.

- Composition: XRD에 대한 실제 리소스 매핑. 하나의 XRD에 여러 Composition(AWS용, GCP용 등)을 연결할 수 있다.

- Claim: 개발자가 네임스페이스 수준에서 요청하는 리소스. XRD 스키마에 맞춰 간단한 YAML을 작성하면 Composition이 실제 클라우드 리소스를 생성한다.

Q4. 상태 관리

**정답:**

- `moved` 블록: 이미 상태에 있는 리소스의 주소를 변경한다. 모듈 리팩토링 시 리소스를 삭제/재생성하지 않고 이동할 수 있다.

- `import` 블록: 상태 파일에 없지만 실제 클라우드에 존재하는 리소스를 Terraform 관리하에 가져온다. Terraform 1.5부터 코드에서 선언적으로 import할 수 있다.

Q5. GitOps for IaC

**정답:**

- Atlantis: PR 기반 워크플로에 초점. 드리프트 감지 기능이 내장되어 있지 않으며, 별도의 cron 스크립트나 CI 파이프라인으로 `terraform plan -detailed-exitcode`를 실행해야 한다.

- Spacelift: 내장 드리프트 감지 기능 제공. 스케줄(예: 6시간마다)에 따라 자동으로 plan을 실행하고, 드리프트가 발견되면 알림을 보내거나 자동 복원(reconcile) 옵션을 제공한다. 정책으로 드리프트 대응을 코드화할 수 있다.

13. 참고 자료

1. HashiCorp Terraform Documentation - https://developer.hashicorp.com/terraform/docs

2. Pulumi Documentation - https://www.pulumi.com/docs/

3. Crossplane Documentation - https://docs.crossplane.io/

4. OpenTofu Documentation - https://opentofu.org/docs/

5. Terragrunt Documentation - https://terragrunt.gruntwork.io/docs/

6. Terratest - https://terratest.gruntwork.io/

7. Checkov by Bridgecrew - https://www.checkov.io/

8. Infracost Documentation - https://www.infracost.io/docs/

9. Atlantis Documentation - https://www.runatlantis.io/docs/

10. Spacelift Documentation - https://docs.spacelift.io/

11. SOPS (Secrets OPerationS) - https://github.com/getsops/sops

12. OPA Conftest - https://www.conftest.dev/

13. tfsec by Aqua Security - https://aquasecurity.github.io/tfsec/

이 글에서는 IaC의 주요 도구(Terraform, Pulumi, Crossplane)와 설계 패턴(컴포지션, 팩토리, 래퍼), 상태 관리, 테스트, GitOps, 드리프트 감지까지 포괄적으로 다루었다. 조직의 규모와 요구사항에 맞는 도구와 패턴을 선택하고, 테스트와 보안 스캐닝을 CI/CD에 통합하는 것이 성공적인 IaC 운영의 핵심이다.

현재 단락 (1/927)

Infrastructure as Code(IaC)는 인프라를 코드로 정의하고 버전 관리하는 핵심 DevOps 실천법이다. 2025년 현재 IaC 생태계는 다양한 도구가 공존하며, ...

작성 글자: 0원문 글자: 23,581작성 단락: 0/927