0

Destroy Worlds với Terraform #3: Nested Object Variables

1. Giới thiệu về Nested Object Variables

Khi Terraform configuration ngày càng phức tạp, việc chỉ sử dụng các variable riêng lẻ (flat variables) sẽ không còn phù hợp.

Trong thực tế, Infrastructure thường có cấu trúc phân cấp (hierarchical relationships):

  • Một VPC chứa nhiều subnets.
  • Một application có nhiều services.
  • Mỗi service có configuration riêng. Nested object variables cho phép bạn mô hình hóa những mối quan hệ này trực tiếp trong Terraform bằng cách khai báo các object lồng nhau.

Điều này giúp:

  • Nhóm những configuration có liên quan vào cùng một cấu trúc.
  • Giúp code dễ đọc và dễ quản lý hơn.
  • Cấu trúc variable phản ánh đúng cấu trúc Infrastructure thực tế. Bài viết hướng dẫn cách khai báo, truyền giá trị và sử dụng nested object variables.

2. Basic Nested Objects

Một nested object đơn giản là một object chứa một object khác (hoặc list of objects, map of objects) bên trong một attribute.

Ví dụ: variables.tf

variable "vpc_config" {
  description = "VPC and network configuration"

  type = object({
    cidr_block = string

    dns = object({
      enable_hostnames = bool
      enable_support   = bool
    })

    tags = map(string)
  })

  default = {
    cidr_block = "10.0.0.0/16"

    dns = {
      enable_hostnames = true
      enable_support   = true
    }

    tags = {
      ManagedBy = "terraform"
    }
  }
}

Trong ví dụ trên:

  • vpc_config là variable chính.
  • cidr_block là attribute kiểu string.
  • dns là một object nằm bên trong vpc_config.
  • enable_hostnames và enable_support là các attribute của object dns.
  • tags là một map(string). Cách truy cập Nested Attributes

Bạn có thể truy cập các attribute bằng cách sử dụng dấu chấm (.) liên tiếp.

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_config.cidr_block
  enable_dns_hostnames = var.vpc_config.dns.enable_hostnames
  enable_dns_support   = var.vpc_config.dns.enable_support

  tags = var.vpc_config.tags
}

Ví dụ:

  • var.vpc_config.cidr_block: Truy cập CIDR của VPC.
  • var.vpc_config.dns.enable_hostnames: Truy cập cấu hình DNS Hostnames.
  • var.vpc_config.tags: Truy cập tags của VPC.

4. Lists of Nested Objects

Khi có nhiều items, mỗi item có cấu trúc riêng, bạn có thể sử dụng list(object({...})).

Ví dụ: Một application có nhiều services.

variable "services" {
  description = "Application services to deploy"

  type = list(object({
    name     = string
    port     = number
    replicas = number

    resources = object({
      cpu    = number
      memory = number
    })

    health_check = object({
      path     = string
      interval = number
      timeout  = number
    })

    environment_variables = map(string)
  }))

  default = []
}

Ở đây:

  • services là một list.
  • Mỗi phần tử trong list là một object.
  • Mỗi object có các nested object như resources và health_check.
  • environment_variables là một map. Truyền giá trị trong terraform.tfvars
services = [
  {
    name     = "api"
    port     = 8080
    replicas = 3

    resources = {
      cpu    = 512
      memory = 1024
    }

    health_check = {
      path     = "/health"
      interval = 30
      timeout  = 5
    }

    environment_variables = {
      LOG_LEVEL = "info"
      DB_POOL   = "10"
    }
  },

  {
    name     = "worker"
    port     = 9090
    replicas = 2

    resources = {
      cpu    = 1024
      memory = 2048
    }

    health_check = {
      path     = "/ready"
      interval = 60
      timeout  = 10
    }

    environment_variables = {
      CONCURRENCY = "5"
    }
  }
]

Cách sử dụng for_each với Nested Objects

Bài viết đề xuất chuyển list thành map để có các resource address ổn định hơn.

locals {
  services_map = {
    for svc in var.services : svc.name => svc
  }
}

Giải thích:

for svc in var.services: Duyệt qua từng service trong list.
svc.name: Lấy tên service.
svc.name => svc: Sử dụng tên service làm key và toàn bộ object làm value.

Kết quả sẽ tương tự:

services_map = {
  api    = { ... }
  worker = { ... }
}

Sau đó sử dụng for_each:

resource "aws_ecs_task_definition" "services" {
  for_each = local.services_map

  family = each.key

  cpu    = each.value.resources.cpu
  memory = each.value.resources.memory
}

Trong đó:

  • each.key: Tên service (api, worker).
  • each.value: Toàn bộ object configuration của service.
  • each.value.resources.cpu: CPU configuration của service. Tại sao cần chuyển List thành Map?

Khi sử dụng for_each, mỗi resource sẽ có một key định danh rõ ràng.

Ví dụ:

aws_ecs_task_definition.services["api"]
aws_ecs_task_definition.services["worker"]

Điều này giúp resource address không phụ thuộc vào vị trí index của phần tử như khi sử dụng count.

5. Maps of Nested Objects

Nếu mỗi item có một unique identifier, bạn có thể sử dụng map(object({...})).

Ví dụ: Quản lý nhiều Database.

variable "databases" {
  description = "Database configurations keyed by name"

  type = map(object({
    engine = object({
      type    = string
      version = string
    })

    sizing = object({
      instance_class = string
      storage_gb     = number
    })

    availability = object({
      multi_az      = bool
      read_replicas = number
    })
  }))

  default = {}
}

Cấu trúc này chia Database configuration thành ba nhóm:

Object Chức năng
engine Database engine và version
sizing Instance class và storage
availability Multi-AZ và read replicas

Truyền giá trị

databases = {
  users = {
    engine = {
      type    = "postgres"
      version = "15.4"
    }

    sizing = {
      instance_class = "db.r6g.large"
      storage_gb     = 100
    }

    availability = {
      multi_az      = true
      read_replicas = 2
    }
  }

  analytics = {
    engine = {
      type    = "postgres"
      version = "15.4"
    }

    sizing = {
      instance_class = "db.r6g.xlarge"
      storage_gb     = 500
    }

    availability = {
      multi_az      = true
      read_replicas = 1
    }
  }
}

Sử dụng map với for_each

resource "aws_db_instance" "databases" {
  for_each = var.databases

  identifier     = "${var.project}-${each.key}"
  engine         = each.value.engine.type
  engine_version = each.value.engine.version

  instance_class = each.value.sizing.instance_class
  allocated_storage = each.value.sizing.storage_gb

  username                    = "app_admin"
  manage_master_user_password = true

  multi_az = each.value.availability.multi_az
}

Kết quả sẽ tạo hai Database:

aws_db_instance.databases["users"]
aws_db_instance.databases["analytics"]

Ưu điểm của cách này là bạn có thể thêm hoặc xóa Database bằng cách thay đổi map trong .tfvars.

6. Validation for Nested Objects

Bạn có thể sử dụng validation block để kiểm tra giá trị của nested attributes.

Ví dụ:

variable "application" {
  type = object({
    name = string

    database = object({
      engine     = string
      storage_gb = number
    })
  })

  validation {
    condition     = length(var.application.name) >= 3
    error_message = "Application name must be at least 3 characters."
  }

  validation {
    condition = contains(
      ["postgres", "mysql"],
      var.application.database.engine
    )

    error_message = "Database engine must be postgres or mysql."
  }

  validation {
    condition = var.application.database.storage_gb >= 20

    error_message = "Database storage must be at least 20 GB."
  }
}

Có ba điều kiện được kiểm tra:

Validation name Condition
Application name Tên application phải có ít nhất 3 ký tự
Database engine Chỉ chấp nhận postgres hoặc mysql
Database storage Storage phải tối thiểu 20 GB

Nếu một trong các điều kiện không thỏa mãn, Terraform sẽ trả về error tương ứng.

7. Flattening Nested Structures

Đôi khi bạn cần chuyển đổi nested structures thành một cấu trúc phẳng hơn để sử dụng với for_each.

Ví dụ:

variable "environments" {
  type = map(object({
    region = string

    services = map(object({
      replicas = number
      port     = number
    }))
  }))
}

Giả sử bạn có nhiều environments, mỗi environment có nhiều services.

Cấu trúc ban đầu:

environments
│
├── dev
│   ├── region
│   └── services
│       ├── api
│       └── worker
│
└── prod
    ├── region
    └── services
        ├── api
        └── worker

Bạn có thể sử dụng flatten hoặc kết hợp for với merge để chuyển đổi cấu trúc.

Ví dụ trong bài viết:

locals {
  all_services = merge([
    for env_name, env in var.environments : {
      for svc_name, svc in env.services :
      "${env_name}-${svc_name}" => {
        environment = env_name
        region      = env.region
        service     = svc_name
        replicas    = svc.replicas
        port        = svc.port
      }
    }
  ]...)
}

Giải thích:

  1. for env_name, env in var.environments: Duyệt qua từng environment.
  2. for svc_name, svc in env.services: Duyệt qua từng service bên trong environment.
  3. "${env_name}-${svc_name}": Tạo unique key cho từng service.
  4. merge(...): Kết hợp các map thành một map duy nhất.
  5. ...: Sử dụng argument expansion để truyền các phần tử của list vào merge. Kết quả sẽ có cấu trúc phẳng hơn:
all_services = {
  dev-api = {
    environment = "dev"
    region      = "eu-west-1"
    service     = "api"
    replicas    = 2
    port        = 8080
  }

  prod-api = {
    environment = "prod"
    region      = "eu-west-1"
    service     = "api"
    replicas    = 5
    port        = 8080
  }
}

Lợi ích: Có thể dễ dàng sử dụng for_each để tạo resources từ nhiều environments và services.

8. Real-World Example: Resource Stack

Phần này minh họa cách sử dụng nested object để quản lý toàn bộ configuration của một resource group.

Cụ thể, thư mục stacks/ chứa các stack như alerting, db, gcs, instances, kms, network. Mỗi stack chịu trách nhiệm quản lý một loại resource nhất định và có thể chạy terraform apply độc lập. Trong khi đó, module chỉ đóng vai trò cung cấp cấu hình có thể được gọi và tái sử dụng bởi các stack.

Ví dụ:

stacks/
├── alerting/
├── db/
├── instances/
│   ├── variables.tf
│   ├── locals.tf
│   ├── main.tf
│   ├── versions.tf
│   ├── demo-prod.tfvars
│   └── demo-test.tfvars
├── kms/
├── network/

8.1. Biến instances: Quản lý nhiều VM bằng map(object)

Stack instances sử dụng biến instances để khai báo các VM cần triển khai. Mỗi VM được định danh bằng tên và có một bộ configuration riêng, bao gồm machine type, zone, boot disk, network interfaces, service account và scheduling.

Thay vì khai báo từng VM bằng các biến riêng biệt, chúng ta sử dụng map(object({...})). Cách này cho phép quản lý nhiều VM trong cùng một biến, đồng thời mỗi VM vẫn có thể sử dụng các cấu hình khác nhau.

variable "instances" {
  description = "Virtual machine definitions, keyed by VM name."

  type = map(object({
    machine_type = string
    zone         = string
    tags         = optional(list(string), [])

    boot_disk = object({
      type  = string
      size  = optional(number, 20)
      image = optional(string)

      snapshot = optional(object({
        retention_days = optional(number, 7)
        schedule = object({
          daily_schedule = optional(object({
            days_in_cycle = number
            start_time    = string
          }))
        })
      }))
    })

    network_interfaces = list(object({
      network    = string
      network_ip = optional(string)
      public_ip = optional(object({
        enabled = bool
      }))
    }))

    service_account = object({
      email  = string
      scopes = list(string)
    })

    scheduling = optional(object({
      preemptible       = optional(bool, false)
      automatic_restart = optional(bool, true)
    }))
  }))

  default = {}
}

Cấu trúc trên thể hiện nhiều cấp độ nested objects khác nhau. Ở cấp ngoài cùng, instances là một map(object), trong đó mỗi key tương ứng với tên của một VM. Bên trong mỗi VM, boot_disk, service_account và các cấu hình khác được tổ chức thành những object riêng.

Riêng network_interfaces sử dụng list(object), vì một VM có thể được gắn nhiều network interfaces. Trong khi đó, boot_disk còn chứa snapshot, và snapshot tiếp tục chứa cấu hình schedule. Đây là ví dụ điển hình của việc kết hợp map, list và object để mô hình hóa một cấu trúc Infrastructure có nhiều cấp độ.

Một điểm quan trọng trong thiết kế này là sử dụng optional() cho những attributes không bắt buộc. Ví dụ, machine_type, zone và boot_disk.type phải được khai báo, trong khi tags, boot_disk.size và scheduling có thể được bỏ qua.

Khi không truyền giá trị cho một optional attribute có default, Terraform sẽ tự động sử dụng giá trị mặc định tương ứng. Chẳng hạn, nếu không khai báo boot_disk.size, Terraform sẽ sử dụng 20. Nếu không khai báo tags, giá trị mặc định sẽ là một list rỗng.

Đối với snapshot và scheduling, toàn bộ object cũng được khai báo là optional. Điều này có nghĩa là người dùng có thể không cần khai báo các cấu hình này nếu VM không sử dụng snapshot hoặc không cần scheduling configuration. Khi không được cung cấp, giá trị của các object này sẽ là null.

Cuối cùng, default = {} cho phép stack hoạt động ngay cả khi không có VM nào được khai báo. Trong trường hợp này, Terraform sẽ không tạo bất kỳ VM nào từ biến instances.

8.2. Biến lb_config: Điều khiển việc triển khai Load Balancer

Ngoài các VM độc lập, stack instances còn hỗ trợ triển khai Managed Instance Group (MIG) cùng với Internal Load Balancer (ILB).

Để quản lý phần cấu hình này, chúng ta sử dụng một biến object riêng có tên lb_config.

variable "lb_config" {
  description = "When set, creates a managed instance group and an internal load balancer instead of static VMs. The load balancer IP is the stable endpoint for internal clients."

  type = object({
    project_id    = string
    region        = string
    network       = string
    subnetwork    = string
    machine_type  = optional(string, "e2-small")
    target_size   = optional(number, 2)
    network_tags  = optional(list(string), ["demo-proxy"])
    upstream_host = string
    ilb_address   = optional(string)
  })

  default = null
}

Ở đây, lb_config được thiết kế như một công tắc điều khiển việc triển khai load balancer.

Nếu biến này được khai báo trong file .tfvars, Terraform sẽ sử dụng các giá trị được cung cấp để tạo MIG và ILB. Ngược lại, nếu không khai báo, giá trị mặc định sẽ là null và các resource liên quan đến load balancer sẽ không được tạo.

Cách thiết kế này giúp người sử dụng stack không cần phải khai báo thêm một biến boolean như enable_load_balancer. Chỉ cần cung cấp lb_config là đủ để thể hiện nhu cầu triển khai.

Một số attributes như project_id, region, network, subnetwork và upstream_host là bắt buộc khi bật cấu hình này. Các attributes còn lại như machine_type, target_size, network_tags và ilb_address có thể sử dụng giá trị mặc định hoặc được ghi đè khi cần thiết.

Việc sử dụng default = null cũng giúp phân biệt rõ hai trạng thái: không triển khai load balancer và triển khai load balancer với một bộ cấu hình cụ thể.

8.3. File .tfvars: Tách configuration theo môi trường

Sau khi định nghĩa các variables, mỗi môi trường sẽ cung cấp giá trị tương ứng thông qua file .tfvars.

Ví dụ, stack instances có thể sử dụng hai file:

  • demo-prod.tfvars: Chứa configuration dành cho production.
  • demo-test.tfvars: Chứa configuration dành cho test.

Với production, chúng ta có thể khai báo hai VM cùng với cấu hình load balancer:

instances = {
  "app-primary" = {
    machine_type = "e2-medium"
    zone         = "region-a-b"
    tags         = ["demo-app"]

    boot_disk = {
      type = "pd-balanced"
      size = 25

      snapshot = {
        retention_days = 14

        schedule = {
          daily_schedule = {
            days_in_cycle = 1
            start_time    = "03:00"
          }
        }
      }
    }

    network_interfaces = [
      {
        network    = "projects/demo-net/global/networks/demo-vpc"
        network_ip = "192.0.2.10"
        public_ip  = { enabled = false }
      }
    ]

    service_account = {
      email  = "demo-app@demo-prod.example.iam"
      scopes = ["cloud-platform"]
    }

    scheduling = {
      automatic_restart = true
    }
  }

  "app-secondary" = {
    machine_type = "e2-medium"
    zone         = "region-a-c"

    boot_disk = {
      type = "pd-balanced"
    }

    network_interfaces = [
      {
        network = "projects/demo-net/global/networks/demo-vpc"
      }
    ]

    service_account = {
      email  = "demo-app@demo-prod.example.iam"
      scopes = ["cloud-platform"]
    }
  }
}

lb_config = {
  project_id    = "demo-prod-proj"
  region        = "region-a"
  network       = "projects/demo-net/global/networks/demo-vpc"
  subnetwork    = "projects/demo-net/regions/region-a/subnetworks/demo-subnet"
  upstream_host = "198.51.100.20"
  ilb_address   = "192.0.2.30"
}

Nhờ sử dụng cùng một variable definition nhưng cung cấp các file .tfvars khác nhau, mỗi môi trường có thể triển khai một cấu hình Infrastructure riêng mà không cần duy trì nhiều phiên bản Terraform code.

9. Những điểm quan trọng cần nhớ

Đây là bảng tổng hợp để bạn dễ ghi nhớ khi thiết kế Terraform modules:

Kiểu dữ liệu Khi nào sử dụng Ví dụ
object({...}) Một cấu hình có nhiều attributes VPC configuration
object lồng object Configuration có cấu trúc phân cấp Database và backup
list(object({...})) Nhiều items có cùng cấu trúc Danh sách services
map(object({...})) Nhiều items có unique keys Danh sách databases
for expression Duyệt và chuyển đổi dữ liệu Chuyển list thành map
merge() Kết hợp nhiều maps Gom services từ nhiều environments
locals Tạo giá trị trung gian, đơn giản hóa expressions Rút gọn nested access
validation Kết hợp nhiều maps Gom services từ nhiều environments
merge() Kiểm tra dữ liệu đầu vào Kiểm tra database engine

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.