Terraform 시작하기

인프라 관리를 시작하고 처음 든 생각은 이것이었습니다. 왜 배포 환경 (이하 스테이지)마다 인프라 형상이 다른가? 같은 형상으로 배포하는 것은 정말로 어려운 일인가?

원인은 크게 두 가지라고 생각합니다. 첫째는 인프라 구축을 담당하는 팀이 따로 있었는데, 능동적으로 서비스 개발에 참여하는 것이 아니라서 인프라를 잘 모르는 개발팀의 요건을 그대로 수용하였던 것입니다. 둘째는 이러한 상황에서 개별 이슈 처리를 위해 개발자가 인프라를 하나씩 만지다 보니까 모든 스테이지가 서로 다른 형상을 가지게 되었습니다.

이러한 인프라 형상 불일치를 해소하기 위해서 Terraform을 이용해보기로 합니다. EC2의 ECS 전환과 함께 Terraform을 도입하여 최소한 ECS로 배포되는 서비스들의 모든 형상은 동일하게 맞춰보고자 하였습니다. 그 과정을 살펴보도록 하겠습니다.

개발 환경 구축하기

원래 공부 잘 못하는 사람은 환경 탓부터 한다죠? 저도 그래서 개발 환경부터 만들었습니다. 로컬 PC에 Terraform을 설치하고 싶지는 않았습니다.

개발에는 VS Code를 이용할 것이므로 Container 개발 환경을 생성합니다. 다음의 세 파일을 프로젝트 워크스페이스 루트에 위치한 .devcontainer 폴더에 집어넣습니다. 그 후에 Remote - Containers 익스텐션을 설치하고, F1을 눌러 Remote-Containers: Open Workspace in Containers를 선택하면 컨테이너 환경으로 전환됩니다.

Dockerfile

FROM python:3.8-alpine RUN apk add git terraform jq tree
Code language: Dockerfile (dockerfile)

docker-compose.yml

version: '3' services: vscode: build: context: . dockerfile: Dockerfile volumes: - ..:/workspace:cached privileged: true command: /bin/sh -c "while sleep 1000; do :; done"
Code language: YAML (yaml)

devcontainer.json

{ "name": "infra", "dockerComposeFile": "docker-compose.yml", "service": "vscode", "workspaceFolder": "/workspace", "extensions": [ "hashicorp.terraform" ] }
Code language: JSON / JSON with Comments (json)

좌측 하단에 Dev Container라고 표시됩니다.

VS Code에서 터미널을 열어서 아래 명령을 입력해보면 잘 동작합니다.

/workspace # terraform version Terraform v0.14.4 Your version of Terraform is out of date! The latest version is 0.15.1. You can update by downloading from https://www.terraform.io/downloads.html
Code language: Bash (bash)

Terraform 코드 작성

자, 이제 컨테이너 개발 환경에서 코드를 작성해볼 차례입니다.

. └── api ├── cloud-watch.tf ├── code-deploy.tf ├── deploy.sh ├── ecr-repository.tf ├── ecs-service.tf ├── ecs-task-definition.tf ├── env │ └── dev.tfvars ├── iam.tf ├── load-balancer.tf ├── main.tf ├── security-group.tf └── variables.tf
Code language: CSS (css)

특정 서비스의 API 기능을 위한 인프라 작업을 가정해보도록 하겠습니다. 초기에 이 코드를 작성할 때는 Terraform으로 배포 및 관리되는 서비스가 거의 없었습니다. 그래서 모듈을 사용하지 않았습니다. 진작 사용했어야 하는데…

개별 서비스 코드보다는 주요 파일만 살펴보도록 하겠습니다.

main.tf

terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 3.27" } } } provider "aws" { region = var.region } data "aws_vpc" "default" { id = var.vpc_id } data "aws_ecs_cluster" "default" { cluster_name = "ECS-FIN-${var.stage}-OPSNOW" } data "aws_lb" "default" { name = var.alb_name } data "aws_iam_role" "ecs_exec" { name = "ecsTaskExecutionRole" } locals { application = "${var.service}-${var.stage}-${var.usage}" }
Code language: JavaScript (javascript)

variables.tf

variable "stage" { default="DEV" } variable "service" { default="SERVICE" } variable "usage" { default="API" } variable "department" { default="FinOps" } variable "region" { default="" } variable "partition" { default="" } variable "account" { default="" } variable "alb_name" { default="" } variable "alb_health_path" { default="/" } variable "task_cpu" { default="" } variable "task_memory" { default="" } variable "task_port" { default="" } variable "service_count" { default="" } variable "sg_backend" { default="" } variable "sg_to_asset_db" { default="" } variable "sg_to_cost_db" { default="" } variable "sg_to_portal_db" { default="" } variable "vpc_id" { default="" } variable "subnets" { default="" }
Code language: JavaScript (javascript)

env/dev.tfvars

stage = "DEV" service = "SERVICE" usage = "API" region = "ap-northeast-2" partition = "aws" account = "123456789012" alb_name = "ALB" task_cpu = 256 task_memory = 1024 task_port = 8080 service_count = 1 sg_backend = "sg-111" sg_to_db_1 = "sg-222" sg_to_db_2 = "sg-333" sg_to_db_3 = "sg-444" vpc_id = "vpc-111" subnets = [ "subnet-111", # ap-northeast-2a "subnet-222" # ap-northeast-2c ]
Code language: PHP (php)

여기서 deploy.sh 파일은 .tfvars 파일을 손쉽게 담아서 배포하기 위한 스크립트입니다. 매번 커맨드 옵션으로 해당 파일을 포함시키기 번거로워서 작성하게 된 것입니다.

terraform $2 \ -var stage=${STAGE_UPPER} \ -var service=${SERVICE} \ -var usage=${USAGE_UPPER} \ -var-file=./env/${STAGE_LOWER}.tfvars \ -state=./states/${STAGE_LOWER}.tfstate;
Code language: Bash (bash)

대체로 이런 느낌의 코드입니다. 기왕 스크립트를 이용하다 보니까 .tfvars 파일에서 서브넷이나 보안 그룹 등의 공통된 리소스 파라미터들을 반복적으로 사용하는 것이 귀찮아집니다. 그래서 스테이지별로 사전에 정의된 공통 리소스를 별도의 .tfvars 파일로 생성하면 더 낫겠다는 생각이 들었습니다.

Terraform 코드 개선

프로젝트 구조를 바꿉니다.

. ├── _env │ ├── common_chn.tfvars │ ├── common_dev.tfvars │ └── common_prd.tfvars └── service_group └── service └── api ├── _env │ ├── chn.tfvars │ ├── dev.tfvars ├── cloud-watch.tf ├── code-deploy.tf ├── deploy.sh ├── ecr-repository.tf ├── ecs-service.tf ├── ecs-task-definition.tf ├── iam.tf ├── load-balancer.tf ├── main.tf ├── security-group.tf ├── states │ ├── chn.tfstate │ ├── dev.tfstate └── variables.tf
Code language: CSS (css)

공통 리소스는 루트 레벨의 _env 폴더 안에 있는 각 스테이지별 .tfvars 파일로 관리합니다. 물론, 이러한 리소스도 모두 Terraform으로 import를 하면 좋겠지만, 레거시와 함께 운영하려면 리소스 아이디만 받아오는 것이 현재로서는 가장 간편합니다. 그 후에 서비스 작업 단위로 필요한 변수는 서비스 내의 _env 폴더에 추가로 정의합니다.

terraform $2 \ -var stage=${STAGE_UPPER} \ -var-file=./_env/${STAGE_LOWER}.tfvars \ -var-file=/workspace/_env/common_${STAGE_LOWER}.tfvars \ -state=./states/${STAGE_LOWER}.tfstate;
Code language: JavaScript (javascript)

스크립트도 루트 레벨의 공통 .tfvars 파일과 서비스 레벨의 .tfvars 파일을 모두 포함하게 합니다. 그러면 일일히 서브넷이나 ECS 관련 변수를 정의할 필요가 없습니다. 서비스가 점점 늘어나면서 아래와 같이 폴더가 점점 늘어납니다.

. ├── service_group_1 │ ├── service_11 │ │ ├── app │ │ ├── batch │ │ └── web │ └── service_12 │ ├── app │ └── web └── service_group_2 ├── service_21 │ ├── app │ ├── batch │ └── web ├── service_22 │ ├── app │ └── web └── service_23 ├── app └── web

여전히 개별 폴더 안에는 비슷한 .tf 파일이 다수 위치하고 있습니다. 가만히 지켜보니까 app 서비스와 web 서비스 배포는 거의 대부분이 대동소이한 면이 있습니다. 역시 다음 숙제는 모듈화가 되겠군요.

Terraform 모듈화

처음에 서비스가 몇 개 되지 않았을 때에는 크게 문제가 없었는데, 이제는 40여개의 마이크로 서비스들이 존재하고 점점 늘어나는 추세입니다. 비슷비슷한 소스들을 하나로 묶어서 모듈로 만들면 소스 규모가 많이 줄어들 것 같습니다. 아직 진행하지는 않았지만, 진행하면 어떻게 될지 예상해보겠습니다.

resource "aws_cloudwatch_log_group" "new" { name = local.name_log_group retention_in_days = 90 tags = { OpsNowService = var.service Department = var.department } }
Code language: JavaScript (javascript)

매 프로젝트마다 들어가는 CloudWatch 로그 설정입니다. 해당 파일을 모듈화하여 루트에 위치하는 module 폴더에 넣고 기존 서비스의 main.tf에서 모듈을 추가할 수 있을 겁니다.

... module "cloudwatch_log_group" { source = "/workspace/module/cloudwatch" name = local.name_log_group service = var.service department = var.department }
Code language: JavaScript (javascript)

운이 나쁘게도 라인 수는 거의 차이가 없네요. 이번에는 ECR을 살펴봅니다.

resource "aws_ecr_repository" "new" { name = local.name_ecr tags = { OpsNowService = var.service Department = var.department } } resource "aws_ecr_lifecycle_policy" "new" { repository = aws_ecr_repository.new.name policy = <<EOF { "rules": [ { "rulePriority": 1, "description": "Keep last 2 images", "selection": { "tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 2 }, "action": { "type": "expire" } } ] } EOF }
Code language: PHP (php)

오! 이건 라인 수가 많이 줄어들 것 같습니다. 마찬가지로 module 폴더에 넣고 불러다가 사용하면 다음과 같이 코드를 줄일 수 있습니다.

... module "ecr_repository" { source = "/workspace/module/ecr-repository" name = local.name_ecr service = var.service department = var.department }
Code language: JavaScript (javascript)

ECS 서비스는 서비스의 형태에 따라서 여러 경우의 수를 살펴봐야 하지만 대체로 3가지 유형을 벗어나지 않습니다. 그러면 모듈도 3개로 나누면 되겠습니다. 작업 정의도 대부분 동일한 형태이므로 이런 모듈화의 끝에 각각의 서비스는 아래와 같이 소스가 줄어들게 될 것입니다.

. ├── _env │ └── common_dev.tfvars ├── module │ ├── cloudwatch │ ├── code-deploy │ ├── ecr-repository │ ├── ecs-service-api │ ├── ecs-service-web │ ├── ecs-service-batch │ ├── ecs-task-definition │ └── load-balancer └── service_group └── service └── api ├── deploy.sh ├── _env │ └── dev.tfvars ├── iam.tf ├── main.tf ├── security-group.tf └── variables.tf
Code language: CSS (css)

좋습니다. 점점 반복 작성하는 코드가 줄어들 것 같은 기분입니다. 얼른 적용해보고 싶네요.

그 이후에는?

점점 Terraform으로 관리하는 인프라 규모가 커지면서 단일 프로젝트로 관리하는 것은 보안 측면에서도 위험하고 관리 측면에서도 분할하여 여러 명이 동시에 작업하는 것이 유리합니다. 그래서 Terraform의 상태를 정의하는 .tfstate 파일을 공용 공간으로 업로드하는 작업이 필요합니다. 공용 공간의 예를 들자면 S3가 되겠습니다.

그 외에도 Terraform 내부의 Workspace 기능과 현재 스테이지별로 나눠놓은 파일 구조 중에서 어떤 것이 유리한지, 좀 더 안정적으로 소스를 유지할 방안을 꾸준히 강구해야 하겠지요. 그래도 조금씩 발전하고 있는 소스를 바라보고 있으면 뿌듯합니다.

Leave a Reply

Your email address will not be published. Required fields are marked *