Mở đầu — vì sao bài này quan trọng
Trong các bài trước bạn đã học IaC là gì và tại sao Terraform trở thành công cụ chuẩn để mô tả hạ tầng bằng code. Nhưng có một khoảng cách rất lớn giữa "viết được một file main.tf để tạo một EC2 instance" và "vận hành hạ tầng AWS cho cả một công ty bằng Terraform mà không sợ đổ vỡ". Khoảng cách đó chính là pattern — những cách tổ chức code, quản lý state và phân tách môi trường đã được cộng đồng kiểm chứng qua hàng nghìn dự án thực tế.
Tôi đã chứng kiến không ít đội ở Việt Nam bắt đầu Terraform rất hào hứng: một repo, một file main.tf dài 2000 dòng, chạy ngon lành trong tháng đầu. Rồi đến khi có ba môi trường (dev, staging, prod), năm người cùng chạm vào code, và một buổi chiều ai đó vô tình terraform apply xóa nhầm database production — lúc đó họ mới hiểu vì sao pattern quan trọng.
Bài này tập trung riêng vào các pattern tổ chức Terraform cho AWS trong thực tế: cấu trúc thư mục module/env, quản lý remote state, cách tách môi trường an toàn, và những quy tắc giúp một đội nhiều người làm việc mà không giẫm chân nhau. Đây không phải bài dạy cú pháp Terraform cơ bản, mà là bài dạy bạn cách sống chung với Terraform ở quy mô doanh nghiệp.
Khái niệm cốt lõi
Vấn đề trung tâm: state và sự lặp lại
Mọi pattern Terraform cho AWS đều xoay quanh hai bài toán. Thứ nhất là state — Terraform lưu trạng thái hạ tầng thật trong một file terraform.tfstate; nếu để file này trên máy cá nhân thì nhiều người không thể cộng tác, và nếu mất file thì Terraform "quên" hết những gì nó đã tạo. Thứ hai là sự lặp lại — bạn cần tạo VPC, EKS, RDS cho cả dev lẫn prod với cấu hình gần giống nhau, và copy-paste code giữa các môi trường là con đường ngắn nhất dẫn tới lỗi.
Cấu trúc module — envs
Pattern nền tảng và phổ biến nhất là tách modules (khối tái sử dụng) khỏi envs (môi trường cụ thể):
infra/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── eks/
│ └── rds/
├── envs/
│ ├── prod/
│ │ ├── main.tf
│ │ ├── backend.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── dev/
modules/ chứa logic — "một VPC gồm những gì, subnet chia thế nào". Nó không biết mình đang chạy cho dev hay prod; nó chỉ nhận input qua variables.tf và trả kết quả qua outputs.tf. Còn envs/prod/main.tf chỉ đơn giản gọi các module với giá trị cụ thể của prod:
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
env = "prod"
azs = ["ap-southeast-1a", "ap-southeast-1b", "ap-southeast-1c"]
}
module "rds" {
source = "../../modules/rds"
vpc_id = module.vpc.vpc_id
instance_class = "db.r6g.large" # prod dùng máy mạnh
multi_az = true # prod bật Multi-AZ
env = "prod"
}
Bên envs/dev/main.tf gọi đúng module đó nhưng với db.t3.micro và multi_az = false. Logic viết một lần, khác biệt môi trường nằm gọn ở tầng env. Đây là nguyên tắc DRY (Don't Repeat Yourself) áp dụng cho hạ tầng.
Remote state với S3 và DynamoDB
Với AWS, pattern chuẩn để lưu state là dùng S3 bucket (lưu file state) kết hợp DynamoDB table (làm state lock — khóa để hai người không apply cùng lúc). Mỗi môi trường có file backend.tf riêng:
# envs/prod/backend.tf
terraform {
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "prod/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Điểm mấu chốt: mỗi môi trường một state file riêng biệt (key khác nhau, thậm chí bucket khác nhau cho prod). Nhờ vậy khi bạn thao tác trên dev, không có cách nào Terraform vô tình chạm vào tài nguyên prod — vì state prod nằm ở một chỗ hoàn toàn khác.
Workspaces — nên hay không?
Terraform có tính năng workspace cho phép nhiều môi trường dùng chung code với các state tách biệt. Nghe hấp dẫn, nhưng trong thực tế nhiều đội tránh dùng workspace để tách prod/dev, vì nó dễ khiến người ta gõ nhầm terraform workspace select prod rồi apply nhầm. Pattern thư mục riêng cho mỗi env (như trên) tường minh hơn, an toàn hơn: bạn phải cd envs/prod một cách có ý thức. Workspace vẫn hữu ích cho các trường hợp tạm thời như tạo môi trường review cho từng nhánh feature.
Provider version và pinning
Một pattern nhỏ nhưng cứu mạng: luôn ghim (pin) version của Terraform và provider AWS trong versions.tf. Nếu không, một ngày đẹp trời provider AWS lên version mới với breaking change, và cả đội bỗng nhiên không apply được — hoặc tệ hơn, apply ra kết quả khác.
terraform {
required_version = ">= 1.6.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
Tình huống thực tế
Ví dụ 1 — Startup fintech TP.HCM: từ một file thành module
Một startup ví điện tử ở TP.HCM (khoảng 30 kỹ sư) khởi đầu với đúng một file main.tf 1800 dòng cho toàn bộ hạ tầng AWS ở region ap-southeast-1 (Singapore). Trong sáu tháng đầu mọi thứ ổn. Vấn đề nổ ra khi họ cần môi trường staging để ngân hàng đối tác test tích hợp: kỹ sư copy toàn bộ file sang, đổi tên biến, và trong lúc đổi đã bỏ sót một chỗ khiến staging trỏ vào RDS của production. Một script test đã ghi dữ liệu giả vào database thật của khách hàng.
Sau sự cố, họ tái cấu trúc theo pattern module/env: VPC, RDS, ECS được đưa vào modules/, mỗi môi trường có thư mục và state S3 riêng (mycompany-tfstate-prod và mycompany-tfstate-staging). Họ còn thêm quy tắc: prod dùng AWS account riêng hoàn toàn với account staging, nên ngay cả khi gõ nhầm lệnh, credential cũng không cho phép chạm sang account kia.
Bài học: copy-paste giữa các môi trường là nguồn lỗi nghiêm trọng nhất. Tách module + tách state + tách account là ba lớp phòng thủ độc lập, và với dữ liệu tài chính bạn cần cả ba.
Ví dụ 2 — Công ty thương mại điện tử: chuẩn hóa module VPC
Một sàn TMĐT quy mô trung bình có tới 6 đội sản phẩm, mỗi đội tự dựng VPC theo cách riêng. Kết quả là 6 kiểu chia subnet khác nhau, đội security phát điên vì không biết luồng mạng nào là chuẩn, và có hai VPC trùng dải CIDR khiến không thể peering được với nhau (nội dung mạng bạn sẽ học sâu ở các bài networking).
Họ giải quyết bằng cách xây một module VPC nội bộ đặt trong Git repo dùng chung, phiên bản hóa bằng Git tag:
module "vpc" {
source = "git::https://git.mycompany.vn/infra/terraform-vpc.git?ref=v2.1.0"
cidr = "10.20.0.0/16"
env = "prod"
team = "checkout"
}
Module này ép mọi đội chia subnet theo chuẩn công ty và cấp phát CIDR không trùng. Muốn nâng cấp, đội chỉ đổi ref=v2.2.0 khi sẵn sàng — không bị ép nâng đồng loạt.
Bài học: module dùng chung có version là cách bạn áp đặt tiêu chuẩn mà vẫn cho các đội tự chủ nhịp độ. source trỏ tới Git tag chứ không phải nhánh main, để một thay đổi ở module không tự động phá hạ tầng của người khác.
Ví dụ 3 — Đội DevOps thuê ngoài: CI/CD và plan bắt buộc
Một công ty gia công phần mềm ở Đà Nẵng vận hành hạ tầng AWS cho khách hàng Nhật. Vì nhiều người cùng chạm vào code, họ áp dụng pattern không ai apply từ máy cá nhân. Mọi thay đổi phải qua pull request; CI (dùng GitLab CI) tự chạy terraform plan và đăng kết quả vào PR để reviewer thấy chính xác tài nguyên nào sẽ tạo/sửa/xóa. Chỉ khi PR được duyệt và merge vào main, pipeline mới chạy terraform apply với credential được quản lý tập trung qua IAM role.
Có lần một plan hiện dòng -/+ destroy and recreate aws_db_instance.main. Reviewer chặn ngay PR đó lại — hóa ra một thay đổi tưởng vô hại (đổi engine_version) lại buộc RDS phải tạo lại. Nhờ plan hiện rõ trong PR, họ tránh được việc mất database.
Bài học: terraform plan trong pipeline CI là "lưới an toàn" quan trọng nhất khi làm việc nhóm. Đọc kỹ output plan — đặc biệt các dòng có chữ destroy hoặc replace — trước khi cho apply.
Hướng dẫn từng bước
Giả sử bạn dựng hạ tầng mới cho môi trường dev trên AWS theo pattern chuẩn.
- Tạo backend trước. Tạo thủ công (hoặc bằng một Terraform "bootstrap" riêng) một S3 bucket để chứa state và một DynamoDB table để lock. Đây là con gà đẻ trứng: backend phải tồn tại trước khi Terraform dùng nó.
- Dựng khung thư mục. Tạo
modules/vàenvs/dev/. Đặtversions.tf,backend.tf,main.tf,variables.tf,terraform.tfvarstrongenvs/dev/.
- Viết module VPC. Trong
modules/vpc/, khai báo VPC, subnet public/private, internet gateway, NAT gateway. Nhận input quavariables.tf(cidr, azs, env) và exposevpc_id,private_subnet_idsquaoutputs.tf.
- Gọi module từ env. Trong
envs/dev/main.tf, gọimodule "vpc"vớisource = "../../modules/vpc"và truyền giá trị dev.
- Khởi tạo. Chạy
terraform inittrongenvs/dev/— lệnh này tải provider và kết nối backend S3.
- Xem trước. Chạy
terraform plan -out=dev.tfplan. Đọc kỹ: mọi dòng đều là+ createcho hạ tầng mới.
- Áp dụng. Chạy
terraform apply dev.tfplan. Dùng đúng file plan đã lưu để đảm bảo áp dụng đúng cái bạn vừa xem.
- Kiểm tra state. Chạy
terraform state listđể thấy các tài nguyên đã được quản lý, và xác nhận file state đã nằm trên S3.
- Lặp lại cho staging/prod bằng cách sao chép cấu trúc thư mục env (KHÔNG sao chép logic module), đổi
backend.tfvàterraform.tfvars.
Lỗi thường gặp & mẹo
Lưu state trên máy cá nhân hoặc commit vào Git. File terraform.tfstate chứa cả thông tin nhạy cảm (đôi khi cả mật khẩu). Không bao giờ commit nó; luôn dùng remote backend S3 với encrypt = true và thêm .tfstate vào .gitignore.
Không bật state locking. Nếu quên DynamoDB lock, hai người apply cùng lúc sẽ làm hỏng state — một trong những lỗi khó sửa nhất. Luôn khai báo dynamodb_table trong backend.
Dùng source trỏ vào nhánh main của module. Khi ai đó sửa module, hạ tầng của bạn thay đổi ngoài ý muốn ở lần init tiếp theo. Luôn pin bằng Git tag/version (?ref=v2.1.0).
Bỏ qua output của plan. Rất nhiều sự cố production đến từ việc apply mà không đọc plan. Rèn thói quen soi các dòng destroy và replace; hai từ này với database hay EBS volume nghĩa là mất dữ liệu.
Nhét mọi thứ vào một state khổng lồ. Khi hạ tầng lớn, một state file chứa hàng trăm tài nguyên khiến mỗi plan chậm và rủi ro (một lỗi ảnh hưởng tất cả). Mẹo: chia theo tầng — networking một state, database một state, ứng dụng một state — và nối bằng terraform_remote_state hoặc data source.
Hardcode giá trị vào module. Nếu bạn viết "ap-southeast-1a" cứng trong module, nó mất tính tái sử dụng. Mọi thứ khác nhau giữa các môi trường phải là biến.
Mẹo an toàn cho prod: đặt prevent_destroy = true trong lifecycle của các tài nguyên trọng yếu (RDS, S3 chứa dữ liệu) để Terraform từ chối xóa chúng ngay cả khi ai đó lỡ tay.
Bài tập thực hành
- Dựng khung project. Tạo cấu trúc
infra/vớimodules/vpc/vàenvs/dev/,envs/prod/. Viết một module VPC tối giản (chỉ VPC + 2 subnet) nhận biếncidr_blockvàenv, outputvpc_id.
- Tách môi trường. Cho dev dùng
10.10.0.0/16và prod dùng10.20.0.0/16, mỗi env mộtbackend.tfvớikeykhác nhau. Chạyterraform planở cả hai và xác nhận chúng độc lập.
- Đọc plan có chủ đích. Trong một module RDS thử, đổi
instance_classrồi chạyplan— quan sát Terraform báoupdate in-place. Sau đó đổienginevà quan sát nó báoreplace. Ghi lại sự khác biệt và giải thích vì sao.
- Version hóa module. Đưa module VPC lên một Git repo, gắn tag
v1.0.0, rồi tham chiếu nó từ env bằngsource = "git::...?ref=v1.0.0". Thử sửa module, tagv1.1.0, và nâng cấp env một cách có kiểm soát.
- Suy nghĩ: Nếu công ty bạn có 4 đội và 3 môi trường, bạn sẽ chia state thành bao nhiêu file và theo tiêu chí nào? Viết ra lập luận của bạn.
Tóm tắt
Terraform cho AWS ở quy mô thực tế không phải chuyện cú pháp, mà là chuyện tổ chức. Pattern nền tảng là tách modules/ (logic tái sử dụng) khỏi envs/ (cấu hình từng môi trường), để bạn viết logic một lần và chỉ thay đổi giá trị giữa dev/staging/prod. Remote state trên S3 kèm DynamoDB lock là điều kiện tiên quyết để làm việc nhóm, và mỗi môi trường phải có state riêng biệt — lý tưởng nhất là nằm trên các AWS account tách biệt để prod được cô lập hoàn toàn.
Ba lớp phòng thủ luôn nên có: module có version để áp đặt chuẩn, terraform plan trong CI để nhìn trước thay đổi, và prevent_destroy cho tài nguyên trọng yếu. Ba câu chuyện thực tế ở trên — từ startup fintech mất dữ liệu vì copy-paste, tới sàn TMĐT chuẩn hóa bằng module version, tới đội gia công chặn được sự cố nhờ đọc plan — đều dạy cùng một điều: pattern tồn tại để bảo vệ bạn khỏi chính sai lầm của con người. Nắm vững chúng, bạn sẽ vận hành hạ tầng AWS bằng Terraform một cách tự tin và ngủ ngon vào ban đêm.