Giới thiệu

Khi triển khai hạ tầng dưới dạng mã nguồn (Infrastructure as Code – IaC) với Terraform, bài toán quản lý thường đơn giản ở quy mô 1 cụm và 1 môi trường. Tuy nhiên, khi hệ thống mở rộng lên hàng chục cụm microservices trải dài qua các môi trường Dev, Staging, Production (hoặc đa tenant), mã nguồn Terraform thuần (Vanilla Terraform) rất nhanh rơi vào tình trạng phân mảnh và trùng lặp.

Terragrunt – công cụ mã nguồn mở do Gruntwork phát triển – đóng vai trò là một lớp bọc mỏng (thin wrapper) phía trên Terraform nhằm giải quyết triệt để bài toán này theo nguyên lý DRY (Don’t Repeat Yourself).

1. Vấn đề khi scale Vanilla Terraform

Xét kiến trúc hạ tầng cơ bản gồm 3 tầng dịch vụ:

  • network: VPC, Subnet, Route Table, NAT Gateway.
  • database: RDS Instance, Subnet Group, Security Group.
  • app: Compute layer (EKS/ECS cluster, EC2 instance).

Cần deploy kiến trúc này trên 2 môi trường: dev và prod. Cấu trúc thư mục Terraform thuần thường thấy:

infrastructure/
├── dev/
│   ├── network/  (main.tf, backend.tf, provider.tf, terraform.tfvars)
│   ├── database/ (main.tf, backend.tf, provider.tf, terraform.tfvars)
│   └── app/      (main.tf, backend.tf, provider.tf, terraform.tfvars)
└── prod/
    ├── network/  (main.tf, backend.tf, provider.tf, terraform.tfvars)
    ├── database/ (main.tf, backend.tf, provider.tf, terraform.tfvars)
    └── app/      (main.tf, backend.tf, provider.tf, terraform.tfvars)

Các “điểm nghẽn” (Pain Points):

  1. Lặp lại Backend & Provider: Với 2 môi trường × 3 module = 6 lần khai báo cấu hình S3 bucket, DynamoDB lock table và HashiCorp Provider. Đổi cấu hình remote state đồng nghĩa với việc sửa hàng chục file thủ công.
  2. Blast Radius và Quản lý State: Gộp tất cả tài nguyên vào một file state duy nhất sẽ tăng bán kính ảnh hưởng khi có sự cố và khiến thời gian chạy terraform plan/apply kéo dài. Chia nhỏ state thì việc truyền dữ liệu qua lại giữa các module qua terraform_remote_state trở nên cồng kềnh, dễ lỗi quyền truy cập.
  3. Thao tác thủ công theo thứ tự: Khi dựng mới cả một môi trường, kỹ sư phải cd vào từng thư mục theo thứ tự (network -> database -> app) để chạy apply.

2. Terragrunt là gì? Cơ chế giải quyết vấn đề

Terragrunt không thay thế Terraform. Terragrunt điều phối (orchestrate) cấu hình Terraform, tách biệt hoàn toàn giữa:

  • Mã nguồn module (Terraform Modules): Chứa định nghĩa hạ tầng, độc lập môi trường.
  • Mã nguồn cấu hình triển khai (Terragrunt Live Layer): Chỉ truyền tham số (inputs) và khai báo dependency.
Tiêu chíTerraform thuầnTerraform + Terragrunt
Cấu hình BackendKhai báo thủ công tại từng thư mục module conKhai báo 1 lần tại root.hcl, tự sinh động theo đường dẫn
Cấu hình ProviderCopy-paste file provider.tf ở khắp nơiTự động sinh qua block generate
Phụ thuộc giữa các ModuleDùng data "terraform_remote_state" thủ côngDùng block dependency, tự phân giải outputs
Thực thi đa modulePhải chạy thủ công từng thư mụcHỗ trợ terragrunt run --all apply/destroy
Quản lý biến theo tầngLặp lại terraform.tfvarsKế thừa phân tầng qua file .hcl (root -> env -> module)

3. Kiến trúc Hands-on Lab (Chạy 100% Local)

Để thực hành đầy đủ cơ chế của Terragrunt mà không phát sinh chi phí Cloud (AWS/GCP), bài lab này sử dụng provider hashicorp/local để sinh file cấu hình JSON mô phỏng việc provisioning hạ tầng.

Sơ đồ phụ thuộc (Dependency Graph)

       ┌───────────┐
       │  network  │
       └─────┬─────┘
             │ (network_id)
             ▼
       ┌───────────┐
       │ database  │
       └─────┬─────┘
             │ (network_id + db_endpoint)
             ▼
       ┌───────────┐
       │    app    │
       └───────────┘

Cấu trúc dự án

terragrunt-lab/
├── terraform-modules/             # Layer 1: Terraform modules chuẩn
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── database/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── app/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── live/                          # Layer 2: Terragrunt orchestration
    ├── root.hcl                   # Cấu hình gốc: Backend, Provider, Global Inputs
    ├── dev/
    │   ├── env.hcl                # Biến môi trường DEV
    │   ├── network/terragrunt.hcl
    │   ├── database/terragrunt.hcl
    │   └── app/terragrunt.hcl
    └── prod/
        ├── env.hcl                # Biến môi trường PROD
        ├── network/terragrunt.hcl
        ├── database/terragrunt.hcl
        └── app/terragrunt.hcl

4. Xây dựng Module Terraform thuần

Các module này hoàn toàn không chứa bất kỳ cú pháp nào của Terragrunt.

4.1. Module Network (terraform-modules/network/)

terraform-modules/network/main.tf

resource "local_file" "network_config" {
  filename        = "${var.output_path}/network.json"
  file_permission = "0644"
  content = jsonencode({
    network_id  = "${var.app_name}-${var.environment}-vpc"
    cidr_block  = var.cidr_block
    subnets     = var.subnets
    environment = var.environment
  })
}

terraform-modules/network/variables.tf

variable "app_name" { type = string }
variable "environment" { type = string }
variable "cidr_block" { 
  type    = string 
  default = "10.0.0.0/16" 
}
variable "subnets" { 
  type    = list(string) 
  default = ["10.0.1.0/24", "10.0.2.0/24"] 
}
variable "output_path" { type = string }

terraform-modules/network/outputs.tf

output "network_id" {
  value = "${var.app_name}-${var.environment}-vpc"
}

output "cidr_block" {
  value = var.cidr_block
}

4.2. Module Database (terraform-modules/database/)

terraform-modules/database/main.tf

resource "local_file" "database_config" {
  filename        = "${var.output_path}/database.json"
  file_permission = "0644"
  content = jsonencode({
    db_id       = "${var.app_name}-${var.environment}-db"
    db_name     = var.db_name
    db_engine   = var.db_engine
    db_size     = var.db_size
    network_id  = var.network_id
    environment = var.environment
  })
}

terraform-modules/database/variables.tf

variable "app_name" { type = string }
variable "environment" { type = string }
variable "db_name" { type = string }
variable "db_engine" { type = string }
variable "db_size" { type = string }
variable "network_id" { type = string }
variable "output_path" { type = string }

terraform-modules/database/outputs.tf

output "db_id" {
  value = "${var.app_name}-${var.environment}-db"
}

output "db_endpoint" {
  value = "${var.app_name}-${var.environment}-db.internal:5432"
}

4.3. Module App (terraform-modules/app/)

terraform-modules/app/main.tf

resource "local_file" "app_config" {
  filename        = "${var.output_path}/app.json"
  file_permission = "0644"
  content = jsonencode({
    app_id        = "${var.app_name}-${var.environment}"
    app_version   = var.app_version
    instance_type = var.instance_type
    replicas      = var.replicas
    network_id    = var.network_id
    db_id         = var.db_id
    db_endpoint   = var.db_endpoint
    environment   = var.environment
  })
}

terraform-modules/app/variables.tf

variable "app_name" { type = string }
variable "environment" { type = string }
variable "app_version" { type = string }
variable "instance_type" { type = string }
variable "replicas" { type = number }
variable "network_id" { type = string }
variable "db_id" { type = string }
variable "db_endpoint" { type = string }
variable "output_path" { type = string }

5. Thiết lập Lớp Điều Phối Terragrunt

5.1. File cấu hình gốc (live/root.hcl)

root.hcl chứa các quy ước toàn cục: Backend state, Provider generation và inputs mặc định.

live/root.hcl

locals {
  app_name = "crf-system"
}

# 1. Tự động sinh backend cục bộ (trên Cloud sẽ dùng s3/gcs)
remote_state {
  backend = "local"
  config = {
    path = "${get_parent_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
  }
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
}

# 2. Tự động sinh file provider.tf cho tất cả sub-modules
generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}
EOF
}

# 3. Biến dùng chung cho toàn bộ module con
inputs = {
  app_name = local.app_name
}

Các hàm built-in cần lưu ý:

  • get_parent_terragrunt_dir(): Trả về đường dẫn tuyệt đối đến thư mục chứa file cấu hình cha (live/).
  • path_relative_to_include(): Trả về đường dẫn tương đối từ vị trí file root đến module hiện tại (ví dụ: dev/network).
  • find_in_parent_folders(): Tìm kiếm đệ quy ngược lên thư mục cha để nạp file cấu hình mong muốn.

5.2. Biến theo môi trường (live/dev/env.hcl và live/prod/env.hcl)

live/dev/env.hcl

locals {
  environment   = "dev"
  cidr_block    = "10.10.0.0/16"
  instance_type = "t3.micro"
  db_size       = "db.t3.micro"
  replicas      = 1
}

live/prod/env.hcl

locals {
  environment   = "prod"
  cidr_block    = "10.20.0.0/16"
  instance_type = "c6i.xlarge"
  db_size       = "db.r6g.large"
  replicas      = 3
}

5.3. Định nghĩa module con (live/dev/)

Network (live/dev/network/terragrunt.hcl)

include "root" {
  path = find_in_parent_folders("root.hcl")
}

locals {
  env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}

terraform {
  source = "../../../terraform-modules/network"
}

inputs = {
  environment = local.env_vars.locals.environment
  cidr_block  = local.env_vars.locals.cidr_block
  output_path = get_terragrunt_dir()
}

Database (live/dev/database/terragrunt.hcl)

include "root" {
  path = find_in_parent_folders("root.hcl")
}

locals {
  env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}

terraform {
  source = "../../../terraform-modules/database"
}

# Khai báo dependency đến network
dependency "network" {
  config_path = "../network"
  
  # Giá trị mock dùng khi chạy 'terragrunt plan' mà network chưa apply
  mock_outputs = {
    network_id = "mock-vpc-id"
  }
}

inputs = {
  environment = local.env_vars.locals.environment
  db_name     = "appdb"
  db_engine   = "postgres"
  db_size     = local.env_vars.locals.db_size
  network_id  = dependency.network.outputs.network_id
  output_path = get_terragrunt_dir()
}

App (live/dev/app/terragrunt.hcl)

include "root" {
  path = find_in_parent_folders("root.hcl")
}

locals {
  env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}

terraform {
  source = "../../../terraform-modules/app"
}

dependency "network" {
  config_path = "../network"
  mock_outputs = {
    network_id = "mock-vpc-id"
  }
}

dependency "database" {
  config_path = "../database"
  mock_outputs = {
    db_id       = "mock-db-id"
    db_endpoint = "mock-db-endpoint"
  }
}

inputs = {
  environment   = local.env_vars.locals.environment
  app_version   = "v1.2.0"
  instance_type = local.env_vars.locals.instance_type
  replicas      = local.env_vars.locals.replicas
  network_id    = dependency.network.outputs.network_id
  db_id         = dependency.database.outputs.db_id
  db_endpoint   = dependency.database.outputs.db_endpoint
  output_path   = get_terragrunt_dir()
}

(Đối với live/prod/, cấu trúc các file terragrunt.hcl tương tự hoàn toàn, chỉ khác là nạp file live/prod/env.hcl).

6. Vận hành và Thực thi

6.1. Triển khai toàn bộ môi trường DEV bằng một câu lệnh

Chuyển vào thư mục live/dev:

cd live/dev
terragrunt run --all apply

Terragrunt sẽ thực hiện:

  1. Quét đồ thị phụ thuộc (network -> database -> app).
  2. Sinh mã backend.tf và provider.tf cho từng module.
  3. Chạy terraform init và apply theo đúng thứ tự.

6.2. Kiểm tra kết quả

Sau khi lệnh hoàn tất, kiểm tra file sinh ra tại từng thư mục:

# Kiểm tra app.json
cat app/app.json | jq .

Output nhận được:

{
  "app_id": "crf-system-dev",
  "app_version": "v1.2.0",
  "db_endpoint": "crf-system-dev-db.internal:5432",
  "db_id": "crf-system-dev-db",
  "environment": "dev",
  "instance_type": "t3.micro",
  "network_id": "crf-system-dev-vpc",
  "replicas": 1
}

Các giá trị network_id và db_id đã được trích xuất trực tiếp từ state output của module cha và gán chính xác vào module con mà không cần bất kỳ cấu hình terraform_remote_state thủ công nào.

6.3. Dọn dẹp tài nguyên

Khi cần hủy môi trường, Terragrunt tự động đảo ngược thứ tự DAG (app -> database -> network):

terragrunt run --all destroy

7. Đánh giá và Best Practices trong Production

  1. Version Locking: Luôn gắn tag phiên bản module Git trong block terraform { source = "git::git@github.com:org/repo.git//modules/app?ref=v1.0.0" }. Tránh dùng nhánh main hoặc đường dẫn local trong môi trường production thực tế.
  2. Quản lý Mock Outputs: Luôn định nghĩa mock_outputs cùng mock_outputs_allowed_terraform_commands = ["validate", "plan"] trong block dependency để CI/CD có thể chạy terragrunt plan ngay cả khi hạ tầng chưa từng được apply.
  3. Phân tách State theo Tenant: Kết hợp path_relative_to_include() giúp phân tách ranh giới blast radius tuyệt đối. Nếu một module hỏng state, toàn bộ các thành phần khác trong cụm vẫn an toàn.

Leave a Reply

This site uses cookies to offer you a better browsing experience. By browsing this website, you agree to our use of cookies.