
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):
- 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.
- 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/applykéo dài. Chia nhỏ state thì việc truyền dữ liệu qua lại giữa các module quaterraform_remote_statetrở nên cồng kềnh, dễ lỗi quyền truy cập. - 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
cdvào từng thư mục theo thứ tự (network->database->app) để chạyapply.
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ần | Terraform + Terragrunt |
| Cấu hình Backend | Khai báo thủ công tại từng thư mục module con | Khai báo 1 lần tại root.hcl, tự sinh động theo đường dẫn |
| Cấu hình Provider | Copy-paste file provider.tf ở khắp nơi | Tự động sinh qua block generate |
| Phụ thuộc giữa các Module | Dùng data "terraform_remote_state" thủ công | Dùng block dependency, tự phân giải outputs |
| Thực thi đa module | Phải chạy thủ công từng thư mục | Hỗ trợ terragrunt run --all apply/destroy |
| Quản lý biến theo tầng | Lặp lại terraform.tfvars | Kế 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.hcl4. 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 applyTerragrunt sẽ thực hiện:
- Quét đồ thị phụ thuộc (
network->database->app). - Sinh mã
backend.tfvàprovider.tfcho từng module. - Chạy
terraform initvàapplytheo đú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 destroy7. Đánh giá và Best Practices trong Production
- 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ánhmainhoặc đường dẫn local trong môi trường production thực tế. - Quản lý Mock Outputs: Luôn định nghĩa
mock_outputscùngmock_outputs_allowed_terraform_commands = ["validate", "plan"]trong blockdependencyđể CI/CD có thể chạyterragrunt planngay cả khi hạ tầng chưa từng được apply. - 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.

