본 포스팅에서는 GCP에서 Terraform을 사용하는 다양한 Best Practice에 대해서 알아보겠습니다.
Terraform을 잘 사용하는 방법은 Naming convention같은 vendor specific하지 않은 분야도 있지만 서비스 어카운트의 위임이나 모듈 생성 부분같이 Google Cloud에서만 통용되는 분야도 존재하기 때문에 GCP에서 어떻게 Terraform을 사용해야 하는지 아는 것은 중요합니다.
그 외에 Google Cloud에서는 Cloud foundation 등 다양한 Terraform example 코드도 제공하고 있기 때문에, Terraform 이용자로써 Google Cloud에서 이용할 수 있는 것에는 무엇이 있는지 최대한 아는 것이 좋을 것입니다.
그러한 요지에서 이번 포스팅은 GCP 리소스를 Terraform으로 배포하고자 하는 Devops 엔지니어, 혹은 개발자가 어떻게 코드를 조직하고 개발해야 하는지 알아보는 주제가 되겠습니다.
1. Naming Convention
먼저 알아볼 것은 Terraform의 리소스, 모듈 등의 이름을 지정하는 Naming convention입니다.
Terraform 코드 또한 다른 프로그래밍 언어와 마찬가지로 재사용할 여지가 많고 조직 간 공유가 활발하게 이루어져야 하므로 명명 규칙을 통일해 이를 수월하게 할 수 있어야 합니다.
Terraform 공식 문서에서는 Naming convention에 대한 Best practice를 문서에 정의해놓았으므로 사용하는 Cloud vendor에 관계없이 이를 최대한 따르도록 합니다.
일반
- 이름에 - (dash) 대신 _ (underscore) 사용 (리소스, 데이터 소스, 변수, 아웃풋 등...)
1234567resource "google_compute_instance" "web_server" { # Oname = "web-server"}resource "google_compute_instance" "web-server" { # Xname = "web-server"}cs - 소문자와 숫자만 사용해서 작명 (대문자 및 특수문자 금지)
1234resource "google_storage_bucket_iam_binding" "admins" {} # Oresource "Google_Storage_Bucket_Iam_Binding" "admins" {} # Xcs
리소스 및 데이터 소스
- 리소스 이름에 리소스 타입을 포함하지 말 것 (중복 정보 방지)
123456`resource "aws_route_table" "public" {}` # O`resource "aws_route_table" "public_route_table" {}` # X`resource "aws_route_table" "public_aws_route_table" {}` # Xcs
- 리소스 모듈이 단일 타입의 리소스를 생성하거나, 리소스가 더 이상 설명할 수 없는 일반적인 리소스일 경우 이름을 "this"로 명명할 것.
1234resource "google_compute_health_check" "this" {} # Oresource "google_compute_health_check" "health_check" {} # Xcs
ex: GCP Network Module의 google_compute_health_check 타입은 단일 리소스만 가지기 때문에 이름으로 "this"를 사용. - 이름은 항상 단수형으로 명명할 것.
123resource "google_compute_subnetwork" "subnetwork" {} # Oresource "google_compute_subnetwork" "subnetworks" {} # Xcs - count / for_each 인자는 리소스나 데이터 소스 블록의 가장 첫번째 인자로 포함.
123456789101112131415161718192021222324resource "google_compute_route" "route" { # Oprovider = google-betacount = var.routes_countproject = var.project_idnetwork = var.network_name...depends_on = [var.module_depends_on]}resource "google_compute_route" "route" { # Xprovider = google-betaproject = var.project_idnetwork = var.network_name...depends_on = [var.module_depends_on]count = var.routes_count}cs - tags 인자는 depends_on과 lifecycle를 제외하고 가장 마지막 인자로 포함. 필요 시 빈 줄 하나로 나머지 인자들과 분리시킬 것.
12345678910111213141516171819resource "google_compute_network_peering" "peer_network_peering" { # Oprovider = google-betaname = local.peer_network_peering_namenetwork = var.peer_network...depends_on = [null_resource.module_depends_on, google_compute_network_peering.local_network_peering]}resource "google_compute_network_peering" "peer_network_peering" { # xprovider = google-betaname = local.peer_network_peering_namenetwork = var.peer_networkdepends_on = [null_resource.module_depends_on, google_compute_network_peering.local_network_peering]...}cs - count / for_each 인자에 condition 사용시 length나 다른 expression을 사용하기보다 boolean 값을 사용할 것.
변수
- 타입이 list(...)나 map(...)일 경우 변수 이름으로 복수형을 사용할 것.
1234567891011variable "internal_ranges" { # Odescription = "IP CIDR ranges for intra-VPC rules."type = list(string)default = []}variable "internal_range" { # Xdescription = "IP CIDR ranges for intra-VPC rules."type = list(string)default = []}cs - 변수 block은 다음과 같은 순서로 키를 배치할 것 : description , type , default, validation
12345678910111213141516171819202122232425262728variable "groups" { # Odescription = "Contain the details of the Groups to be created."type = object({...})default = {...}validation {condition = var.groups.create_groups == true ? (var.groups.billing_project != "" ? true : false) : trueerror_message = "A billing_project must be passed to use the automatic group creation."}}variable "groups" { # Xdefault = {...}type = object({...})description = "Contain the details of the Groups to be created."validation {condition = var.groups.create_groups == true ? (var.groups.billing_project != "" ? true : false) : trueerror_message = "A billing_project must be passed to use the automatic group creation."}}cs - 가능한 한 모든 변수에 description을 명시할 것.
12345678910variable "initial_group_config" { # Odescription = "Define the group configuration when it are initialized. Valid values are: WITH_INITIAL_OWNER, EMPTY and INITIAL_GROUP_CONFIG_UNSPECIFIED."type = stringdefault = "WITH_INITIAL_OWNER"}variable "initial_group_config" { # Xtype = stringdefault = "WITH_INITIAL_OWNER"}cs - 특별한 제약이 없는 한 복잡한 object() 타입보단 단순한 number, string, list(...),map(...),any 타입을 사용할 것.
1234567891011variable "peer_external_gateway" { # Object Type variabledescription = "Configuration of an external VPN gateway to which this VPN is connected."type = object({redundancy_type = stringinterfaces = list(object({id = numberip_address = string}))})default = null}cs - "{}"값은 map(...)으로도, object()로도 인식될 수 있기 때문에 map(...)을 만들고자 한다면 tomap(...)을 사용할 것.
1234567locals {restart_policy_enum = tomap({"onfailure" : "OnFailure""unlessstopped" : "UnlessStopped""always" : "Always""never" : "Never"})cs
아웃풋
- 아웃풋의 이름은 항상 포함하고 있는 속성을 명확히 설명할 수 있어야 함.
1234567891011output "password" { # Odescription = "Auto-generated password, if no password was set as a variable."sensitive = truevalue = local.use_kms && var.password == "" ? "" : local.password}output "sensitive_value" { # Xdescription = "Auto-generated password, if no password was set as a variable."sensitive = truevalue = local.use_kms && var.password == "" ? "" : local.password}cs - 아웃풋이 여러 리소스의 값을 반환한다면, 가능한 일반적인 이름을 명명할 것.
123456789101112131415161718192021output "additional_users" { # Odescription = "List of maps of additional users and passwords"value = [for r in google_sql_user.additional_users :{name = r.namepassword = r.password}]sensitive = true}output "additional_users_name_and_password" { # Xdescription = "List of maps of additional users and passwords"value = [for r in google_sql_user.additional_users :{name = r.namepassword = r.password}]sensitive = true}cs - 아웃풋이 list를 반환한다면 이름을 복수형으로 명명할 것.
12345678910111213output "names" { # Odescription = "Bucket names."value = { for name, bucket in google_storage_bucket.buckets :name => bucket.name}}output "name" { # Xdescription = "Bucket names."value = { for name, bucket in google_storage_bucket.buckets :name => bucket.name}}cs - 가능한 한 모든 아웃풋에 description을 명시할 것.
12345678output "bucket" { # Odescription = "Bucket resource (for single use)."value = local.first_bucket}output "bucket" { # Xvalue = local.first_bucket}cs - Output을 사용하는 모든 리소스에 권한을 가지고 있지 않은 한, 아웃풋에 sensitive는 지양할 것.
- argument의 에러 검증을 하는데 element(concat(...)보다는 try()를 사용할 것.
123456789output "bigquery_destination_name" { # Odescription = "The resource name for the destination BigQuery."value = try(module.destination_bigquery[0].resource_name, "")}output "bigquery_destination_name" { # Xdescription = "The resource name for the destination BigQuery."value = element(concat(module.destination_bigquery[0].resource_name, ""),0)}cs - 가능하면 Secret은 아웃풋의 반환값으로 정의하지 말 것. 반환하더라도 Sensitive를 True로 설정할 것.
12345678910output "generated_user_password" { # Odescription = "The auto generated default user password if not input password was provided"value = random_password.user-password.resultsensitive = true}output "generated_user_password" { # Xdescription = "The auto generated default user password if not input password was provided"value = random_password.user-password.result}cs
모듈
- 모듈 이름의 Best Practice : "terraform-{provider}-{name}"
- provider : gcp와 같은 프로바이더 명
- name : 모듈로 생성하는 리소스, 혹은 리소스를 대표할 수 있는 이름
ex: terraform-google-kubernetes-engine, terraform-google-network
- 모듈 버전은 Semantic Versioning을 따라야 함.
ex: v23.1.0, v1.1.0 ...
Semantic Versioning이란? : https://spoqa.github.io/2012/12/18/semantic-versioning.html - 모든 변수는 variables.tf 파일에 description과 type을 포함하여 넣을 것.
- 모든 아웃풋은 outputs.tf 파일에 description을 포함하여 넣을 것.
- 참조할때 항상 상대 경로(relative path)와 file()을 사용할 것.
12345provider "google-beta" { # file()credentials = file(var.credentials_path)region = var.region}
module "base_env" { # relative path
source = "../../modules/base_env"
...
}cs - 모든 모듈과 프로바이더의 버전은 특정한 버전으로 고정해서 사용할 것.
12345678910111213141516171819202122terraform { ##### Orequired_providers {google = {source = "hashicorp/google"version = "4.35.0"}provider_meta "google" {module_name = "blueprints/terraform/terraform-google-network/v5.2.0"}}terraform { ##### Xrequired_providers {google = {source = "hashicorp/google"version = "< 5.0, >= 3.83"}provider_meta "google" {module_name = "blueprints/terraform/terraform-google-network"}}cs - 모듈의 리소스를 너무 크게 정의하기보다는 작은 단위의 리소스로 정의할 것
ex:
aws_security_group 보다는 aws_security_group_rule - terraform.tfvars 보다는 variables.tf를 사용해 default값을 정의할 것.
스테이트
- 리모트 스테이트를 사용할 것.
- state locking을 위해 backend를 사용할 것.
- 스테이트를 저장하는 오브젝트 스토리지는 Versioning과 Encryption, IAM 폴리시를 사용할 것.
2. Service account Impersonate를 이용한 권한 분산
GCP환경에서 Terraform을 사용해 여러 프로젝트에 리소스를 배포하고자 할때, 그 주체는 Service account가 될 것입니다.
하지만 Service account가 여러 프로젝트에 Owner와 같은 강력한 권한을 가지게 될 시, 아래와 같은 문제가 발생할 수 있습니다.
1. 하나의 계정이 조직 전체에 강력한 권한을 행사할 수 있다는 문제 발생
2. Service account 권한이 만료 없이 영구적으로 유지되므로 계정 탈취 시 권한 행사를 막을 수 없음
3. Cicle CI, Gitlab CI 등의 CI 툴 사용 시 Repo 내부의 CI config file이 수정될 가능성 존재
이 문제를 해결할 수 있는 것이 Service account의 impersonate입니다.
Terraform의 배포를 맡은 Serviceaccount A가 각 프로젝트에 존재하는 Serviceaccount B, C ,D의 권한을 임시적으로 위임받아 리소스를 배포하는 방식으로 인프라를 구축합니다.
이때 Serviceaccount A는 Serviceaccount B, C, D에 impersonate할 수 있는 권한만을 가져야 하며, Serviceaccount B, C, D는 각 프로젝트에 리소스를 생성할 수 있는 권한을 가져야 합니다.
Service account의 impersonate는 impersonate를 요구한 계정에게 expiry date가 존재하는 토큰을 발급하기 때문에 임시적으로만 권한을 획득할 수 있어 계정 탈취의 위협에서도 안전합니다.
Least privilege를 위해 권한을 더 분산하고 싶다면 "terraform plan" 용도의 serviceaccount와 "terraform apply" 용도의 serviceaccount를 생성해 각각 viewer 권한과 owner 권한을 부여하는 구조도 가능합니다.
위 구조를 사용할 시 terraform plan 커맨드 실행 시에는 viewer 역할을 가진 serviceaccount에 impersonate해 불필요한 리스크를 줄일 수 있다는 장점이 있습니다.
GCP에서 Terraform을 통해 여러 프로젝트에 리소스를 배포하고자 한다면 impersonate를 활용한 위 아키텍쳐로 보안성을 강화하길 바랍니다.
3. Terraform 모듈 생성
Terraform에서 모듈은 재사용 가능한 코드 블럭의 뭉치라고 할 수 있습니다.
모듈을 사용하면 코드의 재사용성을 높임으로써 개발 속도를 높일 수 있고, 배포 주기를 짧게 가질 수 있다는 장점이 있기도 합니다.
이런 모듈을 생성하기 위해서는 두 가지 사항을 고려하고 있어야 합니다.
- 요구사항에 따라 모듈의 범위를 적절하게 지정
- 모듈을 MVP(minimum viable product)로 만들 것
요구사항에 따라 모듈의 범위를 지정
모듈에 어떤 인프라를 담을지 결정하는 것은 가장 어려운 작업 중 하나입니다.
모듈은 항상 한 가지 기능만을 잘 수행하도록 설계되어야 합니다. 만약 모듈의 기능을 설명하기 어렵다면, 그 모듈은 너무 복잡한 것입니다.
처음 모듈의 범위를 잡을때, 작고 간단하게 시작하는 것을 목표로 합니다.
모듈을 설계할때는 아래 사항들을 염두해야 합니다.
- 캡슐화 : 함께 배포해야 하는 인프라들을 그룹화해야 합니다.
- 권한 : 권한의 범위에 따라 모듈을 제한해야 합니다.
- 휘발성 : 수명이 긴 인프라와 짧은 인프라를 분리해야 합니다.
모듈을 MVP(Minimum Viable Product)로 만들 것
모듈도 코드와 마찬가지로 완전하지 않습니다. 항상 모듈에는 새로운 요구사항과 변경이 있을 수 있다는 것을 염두해 두어야 합니다.
아래 사항들은 MVP 기준을 지키기 위한 가이드라인입니다.
- 항상 Use-case의 80% 이상 동작하는 모듈을 제공하도록 합니다.
- 모듈은 희귀한 Edge-case를 고려하지 않아야 합니다. 모듈은 항상 재사용 가능한 코드 블럭이어야 합니다.
- MVP에서 조건식을 사용해선 안됩니다. MVP는 범위가 좁아야 하며 여러가지 일을 해서는 안됩니다.
- 모듈은 오직 자주 변경되는 argument만을 variable로 노출해야 합니다. 초기에는 가장 필요한 것만을 variable로 노출해야 합니다.
아래는 GCP architecture로 알아본 module 생성의 예시입니다.
모듈 생성 Example
위 다이어그램은 Cloud DNS, Cloud Load Balancer, MIG로 관리되고 있는 GCE, GCS 버켓과 Cloud SQL 인스턴스로 이루어진 아키텍쳐입니다.
위 아키텍쳐를 다음과 같은 모듈로 나눌 수 있습니다.
- Network
- Database
- Application
- Security
예시 아키텍쳐의 각 모듈에 어떤 리소스가 포함될 수 있을지 확인해보겠습니다.
Network 모듈
Network 모듈에는 VPC와 VPC에 포함되는 Subnet, 그리고 Firewall rule이 포함됩니다.
그 외에도 VPC Peering이나 Shared VPC, Private service connect가 포함될 수 있습니다.
이 모듈이 위의 리소스를 포함하는 이유는 높은 권한과 낮은 휘발성 때문입니다.
- 네트워크 리소스를 생성하거나 수정할 수 있는 권한이 있는 멤버만이 이 모듈을 사용할 수 있습니다.
- 이 모듈의 리소스는 자주 변치 않습니다. 그래서 이 리소스들을 모듈로 묶어 필요치 않은 위협을 받지 않게 할 수 있습니다.
Application 모듈
Application 모듈에는 Managed Instance Group, Cloud Load Balancer, GCS bucket이 포함되어 있습니다.
Managed Instance Group에는 GCE instance, autoscaler, compute health check 리소스가 포함되어 있으며, Load Balancer에는 forwarding rule, proxy, ssl cert, url map, health check 리소스가 포함되어 있습니다.
그 외에 인스턴스 이미지나 Load Balancer의 SSL cert가 포함될 수 있습니다.
이 모듈이 위의 리소스를 포함하는 이유는 높은 캡슐화와 높은 휘발성 때문입니다.
- 이 모듈의 리소스들은 애플리케이션이라는 범위에 강하게 연결되어 있습니다.
- 이 모듈의 리소스들은 코드 배포마다 변경되는 자주 변하는 리소스이므로, 불필요한 리스크를 줄이기 위해 다른 모듈과 분리합니다.
Database 모듈
Database 모듈에는 CloudSQL 인스턴스가 포함됩니다.
이 외에도 백업, 스토리지 등의 DB 관련 리소스가 포함될 수 있습니다.
이 모듈이 위의 리소스를 포함하는 이유는 높은 권한과 낮은 휘발성 때문입니다.
- DB 리소스를 생성하거나 수정할 수 있는 팀만이 이 모듈을 사용할 수 있습니다.
- 이 모듈은 자주 바뀔 확률이 적습니다. 그래서 다른 모듈과 분리해 불필요한 리스크를 줄입니다.
Security 모듈
Security 모듈에는 IAM 정책 리소스가 포함됩니다.
이 외에도 Cloud KMS나 Secret management와 같은 보안 관련 리소스가 포함될 수 있습니다.
이 모듈이 위의 리소스를 포함하는 이유는 높은 권한과 낮은 휘발성 때문입니다.
- Security 리소스를 생성하거나 수정할 수 있는 팀만이 이 모듈을 사용할 수 있습니다.
- 이 모듈은 자주 바뀔 확률이 적습니다. 그래서 다른 모듈과 분리해 불필요한 리스크를 줄입니다.
모듈 구조 Example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
.
├── .gitignore
├── .markdownlint.json
├── .pre-commit-config.yaml
├── LICENSE
├── README.md
├── VERSION
├── examples
│ ├── complete
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ └── minimal
│ ├── main.tf
│ ├── outputs.tf
│ ├── variables.tf
│ └── versions.tf
├── main.tf
├── outputs.tf
├── test
│ ├── go.mod
│ ├── go.sum
│ └── terraform_module_gcp_dns_test.go
├── variables.tf
└── versions.tf
|
cs |
4. 마무리
이번 포스팅에서는 GCP에서 Terraform을 활용하기 위한 Best practice들을 알아봤습니다.
Terraform의 Naming convention부터 Serviceaccount impersonate를 활용한 보안성 확보, 마지막으로 Terraform 모듈 생성까지 알 수 있었는데요.
Terraform을 활용한 IaC는 잘 이용하면 빠르고 신속한 인프라 배포가 가능하지만 그만큼 정책의 확립이나 보안 대책의 수립이 중요하기 때문에 이러한 Best practice들을 잘 알아놓는 것이 중요하겠습니다.
이번 포스팅으로 GCP 환경에서 Terraform을 이용하시는 분들이 도움을 받으셨으면 합니다.
'Devops' 카테고리의 다른 글
Tekton 사용해보기 (2) Tekton으로 인프라를 자동 배포하는 Terraform Pipeline을 만들어보자 (2) | 2022.11.06 |
---|---|
Tekton 사용해보기 (1) Tekton으로 쿠버네티스에서 CI/CD 파이프라인을 구성해보자 (1) | 2022.10.03 |
CKA(Certified Kubernetes Administrator) 자격증 시험 및 합격 후기 (2) | 2022.08.13 |
Kubernetes에 존재하는 Metrics Server란 무엇일까? 그리고 어떻게 해야 잘 사용할 수 있을까? (1) | 2022.06.26 |
Apache Kafka란? Apache Kafka를 Kubernetes에서 구성해보자 (1) | 2022.06.16 |