Bắt đầu với một bài toán cơ bản, viết một module hỗ trợ việc tạo Commpute resource trong GCP. Khá đơn giản, code minh họa như sau:

resource "google_compute_instance" "this" {
  project             = var.project_id
  count               = var.enabled ? 1 : 0
  name                = "${local.name}-${count.index}"
  machine_type        = var.machine_type
  zone                = var.zone
  deletion_protection = var.deletion_protection
  min_cpu_platform    = var.min_cpu_platform
  description         = var.description

  allow_stopping_for_update = var.allow_stopping

  boot_disk {
    auto_delete = var.auto_delete
    initialize_params {
      image  = var.machine_image
      size   = var.disk_size_gb
      type   = var.disk_type
      labels = merge({ "managed_by" = "terraform" }, local.disk_labels)
    }
  }

  labels = merge({ "managed_by" = "terraform" }, local.default_labels)

  network_interface {
    subnetwork         = var.subnetwork
    subnetwork_project = var.subnetwork_project
    network_ip         = var.network_ip
  }

  lifecycle {
    create_before_destroy = "true"
    ignore_changes        = [attached_disk]
  }

  scheduling {
    preemptible         = var.preemptible
    automatic_restart   = !var.preemptible
    on_host_maintenance = local.on_host_maintenance
  }

  tags = var.tags

  service_account {
    email  = "[email protected]"
    scopes = ["cloud-platform"]
  }
}

variables.tf

#
# General
#
variable "project_id" {
  description = "The ID of the project in which the resource belongs"
}

variable "product" {
  description = "The product of this resource"
  type        = string
}

variable "env" {
  description = "Environment of this resource"
  type        = string
  default     = "prod"
}

# sql/kafka/postgres/redis
variable "service" {
  description = "The service name of this resource"
  type        = string
}

# eg:
# Redis is a service, but we can use redis as a caching or database
variable "role" {
  description = "The Role of service"
  type        = string
}

local.tf

resource "random_string" "this" {
  length           = 5
  special          = true
  override_special = "/@£$"
  lower            = true
  min_lower        = 5
}

locals {
  host_ip = element(split(".", var.network_ip), 3)
  net_ip  = element(split(".", var.private_ip), 2)
  name    = "${var.product}-${var.env}-${var.service}-${var.role}-${random_string.this.id}-${local.net_ip}-${local.host_ip}"

  on_host_maintenance = (
    var.preemptible ? "TERMINATE" : var.on_host_maintenance
  )

  default_labels = {
    project_id  = var.project_id
    env         = var.env
    gcp_service = "compute"
    product     = var.product
    service     = var.service
    role        = var.role
  }

  disk_labels = {
    project_id  = var.project_id
    env         = var.env
    gcp_service = "disk"
    product     = var.product
    service     = var.service
    role        = var.role
  }
}

Lưu ý code trên chỉ là code minh hoạ, nên có thể thiếu một số variables và có bug, tuy nhiên ý tưởng chính là tạo được GCE resource, enforce resource đó có đầy đủ labels, và naming của resource cũng được enforce theo một convention cố định. Chuyện này đem lại 2 lợi ích:

  • Quản lý chi phí dễ dàng dựa trên labeling
  • Dễ dàng phân biệt được resource thuộc loại gì, môi trường nào, chạy dịch vụ gì khi nhìn trên Google console

Sau khi tạo được GCE resource rồi, tui bắt đầu suy nghĩ đến việc làm thế nào để attach LocalSSD vào resource này hoặc tạo GCE hỗ trợ gắn LocalSSD. Lục trong google_compute_instance thì thấy có vài ý như sau:

Có một đoạn mã minh họa

// Local SSD disk
scratch_disk {
  interface = "SCSI"
}

Phần giải thích về argument này như sau:

  • scratch_disk - (Optional) Scratch disks to attach to the instance. This can be specified multiple times for multiple scratch disks. The scratch_disk block supports:
    • interface - (Required) The disk interface to use for attaching this disk; either SCSI or NVME.

Ngoài ví dụ minh họa, trong phần giải thích không nhắc gì tới việc đây là thuộc tính của LocalSSD. Nếu thử gắn code vào module, chạy terraform apply thì sẽ thấy compute được tạo có một LocalSSD với kích thước theo mô tả của GCP là 375GB.

Trong đoạn giải thích phía trên có nói có thể gắn nhiều LocalSSD bằng cách lặp lại nhiều lần, tức là nếu muốn có 2 LocalSSD, thì code sẽ như sau:

resource "google_compute_instance" "this" {
	...

  scratch_disk {
    interface = "NVME"
  }
  
  scratch_disk {
    interface = "NVME"
  }
  
  ...
}

Ở nhiều lần đọc document, tui đã bỏ qua chi tiết multiple times for multiple scratch disks và khá là loay hoay trong việc suy nghĩ làm sao để attach nhiều LocalSSD, mặc dù cách đơn giản là lặp lại nhiều lần scratch_disk { interface = "NVME" }

Tới đây, coi như giải quyết được chuyện làm thế nào để tạo GCE với LocalSSD, mặc dù cái docs thì hơi xu cà na tí.

Tuy nhiên, nó lại phát sinh ra một bài toán khác. Vì đây là code module, nên khi xài tui sẽ xài kiểu như vầy:

module "gh-postgresql" {
  source = "./modules/gce/"

  env         = "tst"
  product     = "gh"
  service     = "postgresql"
  role        = "primary"
  description = "Self manage PostgreSQL service for gh product"

  enable          = true
  machine_image   = "ubuntu-os-cloud/ubuntu-2004-lts"
  machine_type    = "n2-highcpu-2"
  tags            = ["postgresql", "ssh", "gh"]
}

module "gh-web" {
  source = "./modules/gce/"

  env         = "tst"
  product     = "gh"
  service     = "web"
  role        = "http"
  description = "HTTP web for gh product"

  enable          = true
  machine_image   = "ubuntu-os-cloud/ubuntu-2004-lts"
  machine_type    = "n2-highcpu-2"
  tags            = ["web", "ssh", "gh"]
}

Vấn đề ở đây là không phải GCE resource nào tui cũng muốn có LocalSSD, và không phải GCE nào tui cũng muốn 2 LocalSSD. Tui muốn bật tắt được nó và input vào số lượng tui muốn dựa vào nhu cầu. Nếu hardcode số lượng scratch_disk trong code module thì sẽ không thể nào truyền argument ở module được.

Suy nghĩ đầu tiên là hay thử với cú pháp for_each, tuy nhiên thì for_each phù hợp với việc tạo nhiều resource, ví dụ tạo nhiều GCE thì có thể dùng for_each thay cho count => bỏ, không dùng được

Suy nghĩ tiếp theo là dynamic block, đọc thử qua docs thì nghĩ mãi và thử một vài ví dụ vẫn không chạy được.

Do không nghĩ ra cách, tui quyết định đọc issues của tất cả các module tương tự như terraform-google-vm và đọc luôn provider repo (lưu ý là đọc tất cả những gì thấy liên quan bao gồm cả closed issue, các comment, link comment). Và search luôn trên cả Github với từ khóa scratch_disk xem có gì liên quan không (nhưng search của Github không ngon lắm), sau đó search tiếp Sourcegraph và cuối cùng cũng ra kết quả.

Đầu tiên, từ issue 7293, code minh họa như sau:

  dynamic "scratch_disk" {
    for_each = range(var.local_disk_count)
    content {
      interface = "NVME"
    }
  }

Khai báo biến như sau:

variable "local_ssd_count" {
  description = "Number of LocalSSD attach to this instance"
  type        = number
  default     = 1
}

Bằng việc khai báo local_ssd_count, tui có thể bật tắt hoặc tùy chọn số lượng LocalSSD mong muốn, tới đây coi như giải quyết được bài toán rồi

Tuy nhiên có thể thấy interface sẽ luôn luôn là NVME, thực ra cũng chẳng ảnh hưởng lắm, vì SCSI hay NVME thì giá cả không thay đổi, và nếu giá không thay đổi thì tội gì chọn SCSI. Nhưng vẫn có thể làm tốt hơn từ một ví dụ tui lấy được trên Sourcegraph. Code minh họa như sau:

  dynamic "scratch_disk" {
    for_each = [
      for i in range(0, var.scratch_disks.count) : var.scratch_disks.interface
    ]
    iterator = config
    content {
      interface = config.value
    }
  }

Khai báo biến như sau:

variable "scratch_disks" {
  description = "Scratch disks configuration."
  type = object({
    count     = number
    interface = string
  })
  default = {
    count     = 0
    interface = "NVME"
  }
}

Khi sử dụng trong code module, chỉ cần pass giá trị như sau:

module "gh-postgresql" {
  source = "./modules/gce/"

  env         = "tst"
  product     = "gh"
  service     = "postgresql"
  role        = "primary"
  description = "Self manage PostgreSQL service for gh product"

  enable          = true
  machine_image   = "ubuntu-os-cloud/ubuntu-2004-lts"
  machine_type    = "n2-highcpu-2"
  tags            = ["postgresql", "ssh", "gh"]
  
  scratch_disks = { count = 2, interface = "NVME" }
}

module "gh-web" {
  source = "./modules/gce/"

  env         = "tst"
  product     = "gh"
  service     = "web"
  role        = "http"
  description = "HTTP web for gh product"

  enable          = true
  machine_image   = "ubuntu-os-cloud/ubuntu-2004-lts"
  machine_type    = "n2-highcpu-2"
  tags            = ["web", "ssh", "gh"]
  
  scratch_disks = { count = 0, interface = "NVME" }
}

Vậy là xong, mất 2 ngày để giải quyết vấn đề này 🥺, trong đầu chỉ có một suy nghĩ là syntax của Terraform thật “kỳ lạ”, và khá khó để biết là nó phù hợp với vấn đề gì hoặc khi gặp một vấn đề, không biết bắt đầu với keyword gì. Thực ra, tui vẫn chưa hiểu là cú pháp đó nó làm gì đâu =)) nhưng thôi, để coi sau, cứ biết là nó chạy đúng nhu cầu đã. :v