Provider, Resource Đầu Tiên, và Vòng Đời init plan apply destroy

K
Kai··10 min read

Bài trước ta đã cài Terraform và nắm được nó gồm core với provider tách rời nhau, vận hành qua vòng đời write → plan → apply. Giờ là lúc gõ lệnh thật. Ta sẽ khai báo provider AWS, tạo một bucket S3, rồi đi trọn vòng đời và dừng lại ở mỗi bước để xem chuyện gì thực sự xảy ra. Bucket là resource lý tưởng cho bài đầu: tạo nó miễn phí, không có gì chạy nền để tốn tiền, và destroy trong một giây.

Mục tiêu

Hiểu bốn lệnh init, plan, apply, destroy không phải bằng định nghĩa mà bằng cách quan sát đầu ra thật của từng lệnh. Đồng thời nhìn lần đầu vào file state để thấy Terraform nhớ những gì.

💰 Chi phí

Tạo bucket S3 không mất phí. Bạn chỉ trả tiền cho dữ liệu lưu trong đó và lượng request, mà bài này không bỏ gì vào bucket cả. Tổng chi phí thực tế gần như bằng không, và ta destroy ngay khi xong.

Khai báo provider và pin phiên bản

Tạo một thư mục rỗng, trong đó một file main.tf:

terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "ap-southeast-1"
}

Block terraform {} khai báo những ràng buộc về môi trường chạy. required_version nói cấu hình này cần Terraform từ 1.10 trở lên — ai dùng bản cũ hơn sẽ bị chặn ngay thay vì gặp lỗi khó hiểu giữa chừng. required_providers khai báo cần provider nào, lấy từ đâu (hashicorp/aws trên Terraform Registry), và phiên bản nào.

Ràng buộc ~> 6.0 đáng dừng lại một chút. Đây là toán tử "pessimistic", cho phép mọi bản 6.x mới nhất nhưng không nhảy sang 7.0. Ý đồ là: vá lỗi và tính năng nhỏ trong dòng 6 thì nhận, còn bản major mới (có thể đổi cú pháp gây vỡ) thì không tự động nhảy lên. Pin phiên bản provider là thói quen nên có từ ngày đầu, vì hạ tầng của bạn phải tái lập được kể cả sáu tháng sau.

Block provider "aws" cấu hình chính provider đó. Ở đây chỉ cần region. Ta không ghi credential vào file — đó là điều không bao giờ nên làm. Provider AWS tự tìm credential theo thứ tự ưu tiên: tham số trong block provider, rồi biến môi trường (AWS_ACCESS_KEY_ID...), rồi file ~/.aws/credentials~/.aws/config, rồi credential của container hoặc instance profile. Vì máy này đã có profile default cấu hình sẵn qua aws configure, provider dùng nó mà ta không cần khai báo gì thêm.

terraform init: chuẩn bị thư mục làm việc

$ terraform init
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.46.0...
- Installed hashicorp/aws v6.46.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

init đọc required_providers, tìm bản mới nhất khớp ~> 6.0 (là 6.46.0), tải binary provider về thư mục .terraform/ trong thư mục làm việc, và xác minh chữ ký của HashiCorp. Đó là tiến trình provider mà bài 1 đã nói: core sẽ khởi chạy nó như tiến trình con và nói chuyện qua gRPC.

Một sản phẩm phụ quan trọng: file .terraform.lock.hcl. Nó ghi chính xác phiên bản provider đã chọn cùng checksum. Lần init sau, kể cả khi đã có bản 6.47 mới hơn, Terraform vẫn dùng đúng bản ghi trong lock file. Đây là cơ chế khóa giống package-lock.json của npm — và đúng như dòng thông báo khuyên, bạn nên commit nó vào git để cả nhóm và CI dùng chung một phiên bản. Ngược lại, thư mục .terraform/ chứa binary tải về thì không commit (đã đưa vào .gitignore).

Trước khi đi tiếp, hai lệnh kiểm tra nhanh không tốn gì:

$ terraform fmt -check
$ terraform validate
Success! The configuration is valid.

fmt chuẩn hóa định dạng (thụt lề, canh lề dấu =); thêm -check để chỉ kiểm chứ không sửa. validate kiểm cú pháp HCL và tính nhất quán của tham số mà không gọi API nào — bắt lỗi gõ sai tên trường trước khi tốn một vòng gọi AWS.

Khai báo resource và đọc plan

Thêm vào main.tf một bucket S3:

resource "aws_s3_bucket" "first" {
  bucket_prefix = "tf-series-bai2-"
  force_destroy = true

  tags = {
    Project = "terraform-series"
    Bai     = "02"
  }
}

output "bucket_name" {
  value = aws_s3_bucket.first.id
}

output "bucket_arn" {
  value = aws_s3_bucket.first.arn
}

Một block resource có hai nhãn: kiểu (aws_s3_bucket) và tên local (first). Tên local là cách bạn tham chiếu resource này ở nơi khác trong cấu hình (aws_s3_bucket.first.arn); nó không phải tên bucket trên AWS. Tên bucket thật do bucket_prefix quyết định — ta để AWS thêm một hậu tố ngẫu nhiên vì tên bucket S3 phải là duy nhất trên toàn cầu, gõ tên cứng dễ trùng với người khác. force_destroy = true cho phép destroy bucket kể cả khi còn object bên trong; chỉ nên bật ở lab.

Giờ xem Terraform định làm gì:

$ terraform plan

Terraform will perform the following actions:

  # aws_s3_bucket.first will be created
  + resource "aws_s3_bucket" "first" {
      + arn                         = (known after apply)
      + bucket                      = (known after apply)
      + bucket_domain_name          = (known after apply)
      + bucket_prefix               = "tf-series-bai2-"
      + force_destroy               = true
      + id                          = (known after apply)
      + region                      = "ap-southeast-1"
      + tags                        = {
          + "Bai"     = "02"
          + "Project" = "terraform-series"
        }
      ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + bucket_arn  = (known after apply)
  + bucket_name = (known after apply)

Dấu + ở đầu mỗi dòng nghĩa là "sẽ tạo". Dòng tổng kết Plan: 1 to add, 0 to change, 0 to destroy là thứ bạn luôn đọc đầu tiên trên mọi plan — nó cho biết quy mô thay đổi trước khi soi chi tiết.

Để ý nhiều trường ghi (known after apply). Đó là những giá trị Terraform chưa biết lúc lập kế hoạch vì chúng do AWS sinh ra sau khi tạo: tên bucket cuối cùng (vì có hậu tố ngẫu nhiên), ARN, domain name. Terraform đánh dấu thẳng "biết sau khi apply" thay vì đoán bừa. Còn những gì bạn ghi rõ trong cấu hình (bucket_prefix, region, tags) thì hiện giá trị luôn. Phân biệt này quan trọng khi đọc plan của hạ tầng lớn: trường nào đã định, trường nào còn để ngỏ.

Lưu ý dòng cuối plan: vì không lưu kế hoạch ra file (-out), Terraform không đảm bảo lần apply sau làm y hệt. Ở lab thì không sao; trong CI ta sẽ lưu plan rồi apply đúng cái plan đó (Part VII).

terraform apply

$ terraform apply -auto-approve
...
aws_s3_bucket.first: Creating...
aws_s3_bucket.first: Creation complete after 2s [id=tf-series-bai2-20260525025042897800000001]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

bucket_arn = "arn:aws:s3:::tf-series-bai2-20260525025042897800000001"
bucket_name = "tf-series-bai2-20260525025042897800000001"

apply chạy lại plan rồi (nếu bạn không dùng -auto-approve thì) hỏi xác nhận, sau đó thực thi. Bên dưới, core gửi yêu cầu "tạo bucket" qua gRPC cho provider AWS, provider gọi API CreateBucket, AWS trả về tên bucket thật tf-series-bai2-20260525025042897800000001 (prefix cộng hậu tố sinh ra). Những trường lúc nãy là (known after apply) giờ đã có giá trị thật, và hai output in ra đúng tên với ARN.

State: Terraform nhớ những gì

Sau apply, thư mục có thêm file terraform.tfstate. Xem Terraform nắm những resource nào:

$ terraform state list
aws_s3_bucket.first

Xem chi tiết một resource theo cách đã được làm gọn:

$ terraform show
# aws_s3_bucket.first:
resource "aws_s3_bucket" "first" {
    arn                         = "arn:aws:s3:::tf-series-bai2-20260525025042897800000001"
    bucket                      = "tf-series-bai2-20260525025042897800000001"
    bucket_domain_name          = "tf-series-bai2-20260525025042897800000001.s3.amazonaws.com"
    bucket_regional_domain_name = "tf-series-bai2-...s3.ap-southeast-1.amazonaws.com"
    force_destroy               = true
    hosted_zone_id              = "Z3O0J2DXBE1FTB"
    id                          = "tf-series-bai2-20260525025042897800000001"
    region                      = "ap-southeast-1"
    tags                        = {
        "Bai"     = "02"
        "Project" = "terraform-series"
    }
    ...
}

Bản thân file state là JSON. Vài dòng đầu cho thấy cấu trúc:

{
  "version": 4,
  "terraform_version": "1.15.4",
  "serial": 2,
  "lineage": "d082edf2-018a-b768-b885-f1b2fe6be02e",
  "outputs": {
    "bucket_arn": { "value": "arn:aws:s3:::tf-series-bai2-2026...", "type": "string" },
    "bucket_name": { "value": "tf-series-bai2-2026...", "type": "string" }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "first",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [ { "schema_version": 0, "attributes": { ... } } ]
    }
  ]
}

State là ánh xạ giữa tên local trong cấu hình (aws_s3_bucket.first) và resource thật trên AWS, kèm toàn bộ thuộc tính đã đọc về. serial tăng mỗi lần state đổi; lineage định danh dòng đời của state để Terraform biết hai file state có cùng gốc hay không. Đây là cuốn sổ Terraform tra mỗi lần chạy để biết "cái tôi quản lý hiện ra sao".

Lưu ý quan trọng: file này chứa mọi thuộc tính của resource, kể cả các giá trị nhạy cảm (mật khẩu RDS, private key) ở dạng plaintext. Đó là lý do nó tuyệt đối không được commit vào git, và là lý do Part II dành hẳn nhiều bài cho việc cất state ở nơi an toàn (remote state trên S3) và xử lý secret.

Có thể kiểm chứng bucket tồn tại thật, độc lập với Terraform, bằng AWS CLI:

$ aws s3api head-bucket --bucket tf-series-bai2-20260525025042897800000001
{
    "BucketArn": "arn:aws:s3:::tf-series-bai2-20260525025042897800000001",
    "BucketRegion": "ap-southeast-1",
    "AccessPointAlias": false
}

Vì sao apply lần hai không tạo thêm bucket

Đây là tính chất idempotent của mô hình khai báo. Chạy plan lần nữa mà không sửa gì:

$ terraform plan
aws_s3_bucket.first: Refreshing state... [id=tf-series-bai2-20260525025042897800000001]

No changes. Your infrastructure matches the configuration.

Terraform không tạo bucket thứ hai. Cơ chế: trước khi tính plan, nó refresh — đọc state để lấy id tf-series-bai2-2026..., gọi AWS hỏi lại bucket đó hiện ra sao, rồi so ba thứ với nhau: cấu hình bạn viết (mong muốn), state (lần cuối Terraform biết), và thực tế trên AWS. Cả ba khớp nên diff rỗng, kết quả là "No changes". Đây là tính idempotent: chạy bao nhiêu lần với cùng cấu hình cũng cho ra cùng một hiện trạng. Cũng chính cơ chế so sánh này phát hiện drift — nếu ai đó sửa tay bucket trên console, lần plan sau Terraform sẽ thấy thực tế lệch khỏi cấu hình và đề xuất kéo nó về.

🧹 Dọn dẹp

$ terraform destroy -auto-approve
...
Plan: 0 to add, 0 to change, 1 to destroy.

aws_s3_bucket.first: Destroying... [id=tf-series-bai2-20260525025042897800000001]
aws_s3_bucket.first: Destruction complete after 1s

Destroy complete! Resources: 1 destroyed.

destroy đọc state, gọi API xóa từng resource nó quản lý, rồi cập nhật state. Output cũng chuyển về null vì resource sinh ra chúng không còn. Luôn chạy destroy sau khi thực hành xong để không để lại rác trên tài khoản.

Tổng kết

Bốn lệnh giờ đã có hình hài cụ thể: init tải provider và tạo lock file, plan so cấu hình với state để xem trước diff (với (known after apply) cho giá trị AWS sinh sau), apply thực thi qua gRPC tới provider rồi ghi kết quả vào state, destroy dỡ bỏ. State là cuốn sổ ánh xạ cấu hình với resource thật, chứa cả giá trị nhạy cảm nên phải giữ cẩn thận. Tính idempotent đến từ việc Terraform refresh rồi so ba chiều trước mỗi plan.

Ta đã viết HCL theo kiểu bắt chước mà chưa thực sự hiểu cú pháp. Bài tới mổ xẻ HCL cho ra ngô ra khoai: block, argument, kiểu dữ liệu, biểu thức, và những gì block terraform {} còn khai báo được ngoài required_providers.