Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản9 phút đọc

Terraform module nên để bên gọi quyết định đúng phần việc của mình

Xây dựng module tính địa chỉ mạng cục bộ để kiểm tra đầu vào, đầu ra và ranh giới giữa lựa chọn của bên gọi với quy tắc chia subnet cố định.

Mô hình dây chuyền băng tải với ba trạm và một nhánh vòng bên cạnh

Module có thể tái sử dụng không nhất thiết phải có biến cho mọi chi tiết triển khai. Nó cần một giao diện cho phép bên gọi đưa ra những quyết định thuộc trách nhiệm của bên gọi. Chẳng hạn, module mạng có thể nhận dải địa chỉ đã được phân bổ và các vị trí subnet có tên, đồng thời giữ cố định quy tắc về kích thước subnet. Hợp đồng gọn hơn như vậy thường dễ kiểm tra hơn một tập hợp cờ mà mỗi cờ âm thầm thay đổi nhiều trách nhiệm cùng lúc.

Bài này dành cho lập trình viên đọc được biến và khối resource của Terraform. Ví dụ đã được kiểm tra với Terraform 1.16.1 trên Linux. Nó tính địa chỉ rồi lưu một kết quả vào tài nguyên terraform_data cục bộ. Ví dụ không tạo mạng đám mây, không dùng provider bên ngoài và không chứng minh điều gì về định tuyến, quy tắc tường lửa hay địa chỉ còn trống trong hệ thống đang vận hành.

Nêu rõ ai quyết định điều gì

Bên gọi chọn một mạng IPv4 /16 ở dạng chuẩn và một map ánh xạ tên có ý nghĩa sang số thứ tự subnet. Module chia mạng đó thành các khối /24. Mỗi số phải là số nguyên riêng biệt từ không đến 255. Module trả về map các chuỗi CIDR tương ứng. Module gốc chọn kết quả có tên mà ứng dụng của nó cần.

Đó là những quyết định khác nhau. Module con không biết liệu mọi bên gọi có cần subnet tên web hay không. Ứng dụng trong ví dụ này thì cần. Vì vậy, module kiểm tra quy tắc phân bổ số, còn phép truy cập của ứng dụng thể hiện yêu cầu riêng về tên. Đưa yêu cầu tên đó vào module dùng chung sẽ buộc các bên gọi khác phải nhận thêm một giả định chỉ thuộc về ứng dụng này.

Hướng dẫn kết hợp module của HashiCorp khuyến nghị biểu đạt quan hệ phụ thuộc qua giao diện module và giữ cấu trúc kết hợp tương đối phẳng. Các ví dụ đảo ngược phụ thuộc trong tài liệu giúp làm rõ cách phân công ở đây: module gốc nối các phần với nhau, thay vì để module con tự tìm hiểu mọi phần khác được tạo ra bằng cách nào.

Mạng và tên do bên gọi chọn đi qua quy tắc /24 cố định; ứng dụng ở module gốc dùng khóa web để lấy kết quả từ map đầu ra.Mở ảnh kích thước đầy đủ ↗

Đầu ra là giao diện công khai của module, không phải ranh giới phân quyền. Quyền đọc cấu hình và state được quản lý riêng. Đặt một tên nội bộ phía sau output không mã hóa giá trị đó và không ngăn người vốn đã có quyền đọc các tệp bên dưới.

Viết module nhỏ nhất có ích

Chạy các khối sau trong cùng một phiên Bash. Khối đầu tạo thư mục tạm mới. Module con không có tài nguyên; công việc của nó là kiểm tra và tính toán. Tài nguyên tích hợp của module gốc giúp quan sát kết quả đã chọn trong state cục bộ.

tf_module_lab=$(mktemp -d)
cd "$tf_module_lab"
mkdir -p modules/network_contract
cat > modules/network_contract/main.tf <<'HCL'
variable "network_cidr" {
  type     = string
  nullable = false
  validation {
    condition = try(
      cidrnetmask(var.network_cidr) == "255.255.0.0" &&
      var.network_cidr == "${cidrhost(var.network_cidr, 0)}/16",
      false
    )
    error_message = "Use a canonical IPv4 /16 network."
  }
}

variable "subnet_numbers" {
  type     = map(number)
  nullable = false
  validation {
    condition = try(
      length(var.subnet_numbers) > 0 &&
      alltrue([for n in values(var.subnet_numbers) :
        floor(n) == n && n >= 0 && n < 256]) &&
      length(distinct(values(var.subnet_numbers))) == length(var.subnet_numbers),
      false
    )
    error_message = "Use nonempty, distinct integer subnet slots from 0 to 255."
  }
}

locals {
  subnet_cidrs = {
    for name, slot in var.subnet_numbers :
    name => cidrsubnet(var.network_cidr, 8, slot)
  }
}

output "subnet_cidrs" {
  value = local.subnet_cidrs
}
HCL
cat > main.tf <<'HCL'
terraform {
  required_version = ">= 1.4.0, < 2.0.0"
}
variable "network_cidr" {
  type    = string
  default = "10.42.0.0/16"
}
variable "subnet_numbers" {
  type    = map(number)
  default = { web = 1, data = 2 }
}
module "network" {
  source         = "./modules/network_contract"
  network_cidr   = var.network_cidr
  subnet_numbers = var.subnet_numbers
}
resource "terraform_data" "application_binding" {
  input = module.network.subnet_cidrs["web"]
}
output "subnet_cidrs" {
  value = module.network.subnet_cidrs
}
HCL
terraform init -input=false -no-color
terraform plan -input=false -no-color -out=initial.tfplan
terraform apply -input=false -no-color initial.tfplan
terraform show -json > before.json
terraform output -json subnet_cidrs > initial-subnets.json

Tài liệu biến đầu vào mô tả cách module cha truyền đầu vào cho module con. Ở đây module con không có giá trị mặc định: bên gọi phải cung cấp cả hai lựa chọn. Module gốc có các mặc định tiện cho việc chạy ví dụ. Khi ghi lại những giá trị mà người dùng module phải cung cấp, cần phân biệt hai cấp này.

Điều kiện đầu dùng cidrnetmask để yêu cầu IPv4 /16, rồi dùng cidrhost để so sánh chuỗi ban đầu với địa chỉ mạng của nó. Vì thế 10.42.1.0/16 bị từ chối thay vì tự động đổi về dạng chuẩn. Khi đối số của hàm không hợp lệ, try biến lỗi đó thành điều kiện false để module đưa ra thông báo đã định nghĩa. Quy tắc này cũng từ chối cách viết khác không ở dạng chuẩn.

Điều kiện thứ hai kiểm tra nhiều hơn kiểu của map. Chỉ dùng map(number) vẫn cho phép số thập phân và cùng một số dưới nhiều khóa khác nhau. Tài liệu khối variable cũng phân biệt một tập hợp khác null với các phần tử khác null bên trong nó. nullable = false loại trừ map null; biểu thức kiểm tra còn loại trừ giá trị vị trí null. Điều đó không có nghĩa mọi đầu vào bị từ chối đều cho cùng thông báo: lỗi kiểu có thể xuất hiện trước bước kiểm tra tùy chỉnh.

Tự suy ra kết quả thay vì tin một địa chỉ có vẻ đúng

Tài liệu cidrsubnet định nghĩa đối số thứ hai là số bit bổ sung cho độ dài tiền tố. Thêm tám bit vào /16 tạo /24; tám bit cho phép 256 số thứ tự. Do đó vị trí một là 10.42.1.0/24, không phải địa chỉ máy đầu tiên trong mạng cha.

Kiểm tra đầu ra bằng thư viện địa chỉ độc lập của Python rồi đọc giá trị đã lưu. Cách này xác nhận kết quả thực tế, thay vì chỉ xác nhận rằng Terraform chạy xong mà không báo lỗi.

python3 -I - <<'PY'
from pathlib import Path
import ipaddress, json
actual = json.loads(Path('initial-subnets.json').read_text())
subnets = list(ipaddress.ip_network('10.42.0.0/16').subnets(new_prefix=24))
expected = {name: str(subnets[slot]) for name, slot in {'web': 1, 'data': 2}.items()}
assert actual == expected
state = json.loads(Path('before.json').read_text())
resource, = state['values']['root_module']['resources']
assert resource['address'] == 'terraform_data.application_binding'
assert resource['values']['input'] == actual['web']
print(actual)
PY

Giá trị output cho phép module cha sử dụng một giá trị mà module con chọn công bố. Module gốc tham chiếu module.network.subnet_cidrs, không tham chiếu biến local bên trong module con. Output ở cấp gốc còn cho phép lấy kết quả qua CLI. Bỏ output cấp gốc không xóa output cấp con, cũng không xóa quan hệ phụ thuộc của ứng dụng vào nó.

Thêm lựa chọn mà không đổi giá trị đang được dùng

Thêm vị trí app và giữ nguyên các phân bổ hiện có. Kế hoạch thay đổi map đầu ra ở cấp gốc, nhưng tài nguyên vẫn nhận cùng CIDR của web. Hành động của tài nguyên phải là no-op, và danh tính đã được sinh ra cho nó phải được giữ lại.

python3 -I - <<'PY'
from pathlib import Path
import json
Path('choices.auto.tfvars.json').write_text(json.dumps({
    'subnet_numbers': {'web': 1, 'data': 2, 'app': 3}
}))
PY
terraform plan -input=false -no-color -out=expanded.tfplan
terraform show -json expanded.tfplan > expanded.json
python3 -I - <<'PY'
from pathlib import Path
import json
plan = json.loads(Path('expanded.json').read_text())
change, = plan['resource_changes']
assert change['address'] == 'terraform_data.application_binding'
assert change['change']['actions'] == ['no-op']
assert plan['output_changes']['subnet_cidrs']['after']['app'] == '10.42.3.0/24'
PY
terraform apply -input=false -no-color expanded.tfplan
terraform show -json > after.json
python3 -I - <<'PY'
from pathlib import Path
import json
def binding(path):
    state = json.loads(Path(path).read_text())
    resource, = state['values']['root_module']['resources']
    return resource['values']
assert binding('before.json') == binding('after.json')
print('app added; existing web binding and ID preserved')
PY

Tính chất này đến từ việc gán số một cách tường minh, không phải từ mọi cách thêm phần tử vào danh sách. Một giao diện lấy số từ vị trí trong danh sách có thể đổi số của các phần tử phía sau khi chèn tên mới. Map có tên giúp kiểm tra những lựa chọn ấy dễ hơn, nhưng bên gọi vẫn có thể chủ động đổi số. Module không thể cam kết rằng mọi chỉnh sửa đều giữ nguyên mọi tài nguyên phía sau.

Kiểm tra đúng nơi chịu trách nhiệm cho từng lỗi

Các trường hợp sau cố ý làm bước lập kế hoạch thất bại. Chúng lưu và khôi phục tệp đầu vào đang hoạt động, đồng thời so sánh từng byte của state sau mỗi lần thất bại. Không kế hoạch bị từ chối nào được áp dụng.

python3 -I - <<'PY'
from pathlib import Path
import json, subprocess
path = Path('choices.auto.tfvars.json')
saved, state = path.read_bytes(), Path('terraform.tfstate').read_bytes()
cases = [
    ('duplicate', {'subnet_numbers': {'web': 1, 'data': 1}}, 'distinct integer'),
    ('fraction', {'subnet_numbers': {'web': 1.5}}, 'distinct integer'),
    ('outside', {'subnet_numbers': {'web': 256}}, 'distinct integer'),
    ('host-bits', {'network_cidr': '10.42.1.0/16'}, 'canonical IPv4 /16'),
    ('missing-web', {'subnet_numbers': {'data': 2}}, 'Invalid index'),
]
try:
    for name, values, diagnostic in cases:
        path.write_text(json.dumps(values))
        result = subprocess.run(
            ['terraform', 'plan', '-input=false', '-no-color'],
            capture_output=True, text=True, timeout=30)
        assert result.returncode == 1, (name, result.stdout, result.stderr)
        assert diagnostic in result.stdout + result.stderr, name
        assert Path('terraform.tfstate').read_bytes() == state
        print(name, 'rejected; state unchanged')
finally:
    path.write_bytes(saved)
PY
terraform plan -input=false -no-color -detailed-exitcode

Kế hoạch cuối không có thay đổi và thoát với mã 0. Vị trí trùng, vị trí không nguyên và vị trí vượt giới hạn vi phạm quy tắc phân bổ của module con. Địa chỉ có bit host khác 0 vi phạm quy tắc viết địa chỉ mạng chuẩn. Thiếu web lại khác: module con chấp nhận map hợp lệ chứa data, nhưng phép truy cập của ứng dụng ở cấp gốc thất bại. Thông báo tốt cần giúp người đọc nhận ra ranh giới này, thay vì quy mọi cách kết hợp sai cho module con.

Năm đầu vào bị từ chối gồm bốn lỗi quy tắc ở module con và một lỗi thiếu khóa của bên dùng ở module gốc; thêm app ở vị trí ba giữ nguyên kết quả web.Mở ảnh kích thước đầy đủ ↗

Để luyện tập, hãy thử map rỗng, vị trí âm và vị trí có giá trị null. Sau đó thử các vị trí 0 và 255. Nhóm đầu phải thất bại; hai đầu mút hợp lệ theo quy tắc của module này. Hãy kiểm tra CIDR của chúng bằng cách độc lập. Những kiểm tra đó không chứng minh nền tảng đám mây sẽ cho phép cấp mọi địa chỉ trong các khối kết quả cho máy chủ.

Giữ cam kết đúng với những gì đã kiểm tra

Module không kiểm tra xem nhóm khác đã sở hữu dải mạng cha hay chưa. Nó không yêu cầu dải địa chỉ riêng, không tìm subnet hiện có, không giữ lại các địa chỉ đặc thù của nền tảng đám mây và không cấu hình cách ly. Những yêu cầu đó cần đầu vào riêng, danh mục phân bổ đáng tin cậy hoặc kiểm tra phù hợp với provider. Gọi một giá trị trả về là “subnet riêng tư” sẽ hứa hẹn hành vi mà phép tính chưa hề xác lập.

Giữ cố định /24 có đánh đổi: giao diện nhỏ hơn và quy tắc nhất quán hơn, nhưng ít linh hoạt hơn. Hãy thêm lựa chọn kích thước subnet khi bên gọi thực sự cần chịu trách nhiệm cho quyết định đó, rồi sửa phần kiểm tra và ví dụ cùng nhau. Âm thầm đổi kích thước cố định trong bản phát hành sau có thể làm thay đổi mọi đầu ra, dù tên biến vẫn giữ nguyên.

Tương tự, thêm cả cờ create_network và use_existing_network có thể buộc module điều phối các trạng thái mâu thuẫn. Để module gốc chọn hoặc tạo mạng rồi truyền giá trị cần thiết sang một phần sử dụng riêng thường làm ranh giới trách nhiệm rõ hơn. Đây là lựa chọn thiết kế, không phải quy tắc bắt mọi module đều không có tài nguyên như ví dụ giảng giải này.

Cuối cùng, đọc và áp dụng kế hoạch dọn dẹp cục bộ. Chỉ tài nguyên tích hợp của module gốc có vòng đời được quản lý trong ví dụ.

terraform plan -destroy -input=false -no-color -out=cleanup.tfplan
terraform apply -input=false -no-color cleanup.tfplan
terraform state list

Danh sách rỗng kết thúc bài thực hành. Kết quả có ích là một giao diện mà đầu vào được chấp nhận, cấu trúc đầu ra, nơi chịu trách nhiệm cho lỗi và giới hạn đều có thể giải thích và kiểm tra độc lập với một lần triển khai đám mây cụ thể.

Nguồn tham khảo & đọc thêm

  1. HashiCorp: Module composition
  2. HashiCorp: Input variables
  3. HashiCorp: variable block reference
  4. HashiCorp: Output values
  5. HashiCorp: cidrsubnet
  6. HashiCorp: cidrnetmask
  7. HashiCorp: cidrhost
← Về trang bài viết

Hình minh họa

100%