Skip to main content

第 9 章:使用 Terraform 模組

前面練習過程中,我們寫了不少的 Terraform 組態檔,但都同樣是要建立網頁伺服器的,我想應該有辦法製作可重複使用的組態,減少一些重工的行為。

接下來要認識的 Terraform 模組 (Modules) 就可以解決這些問題。

Terraform 模組 Modules

模組 (Modules) 是在一個資料夾下的多個組態檔組合,可以被多次的呼叫,以達到重複使用組態資源的目的。

所有的 Terraform 組態都是以模組的型式在運作的,平常執行 terraform 指令所在的工作資料夾,叫做「根模組」(root module)。

模組的好處

如同其他程式語言一樣,模組化帶來很多的好處:

  • 更容易整理組態
  • 封裝組態
  • 不用重複造輪子
  • 提供一致的組態設計方式

呼叫模組

要呼叫模組時,可以在你的組態檔裡使用模組區塊 (module block) 來呼叫模組。Terraform 在遇到模組區塊時會自行載入該模組的組態檔。

module "name" {
source = "module_path"
}

模組區塊一定要有的引數 (argument) 是 source,填入的值是要使用的模組路徑。

模組的來源可以是在本地的檔案,也可以是遠端的資源。

Terraform Registry 網址: https://registry.terraform.io/

HashiCorp 官方維運的服務,提供各種的供應商外掛跟模組,也可以上傳分享自己製作的模組。

今天我們不一點一點的打造基礎架構,而是要練習用現成的模組完成基礎架構。

你可以在 Terraform Registry 上搜尋適合的模組,Terraform 官方也有準備很多好用的模組,例如 AWS VPC 模組: https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/

打開網頁你會看到詳細的模組說明,右側會有一個快速使用範例。

接下來我們要來試著建立一個完整的基礎架構。完整檔案可以參考 Github 上的範例

建立 main.tf 檔案

main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}

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

module "vpc" {
source = "terraform-aws-modules/vpc/aws"

name = "example-vpc"
cidr = "10.0.0.0/16"

azs = ["ap-northeast-1a", "ap-northeast-1d", "ap-northeast-1d"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

enable_nat_gateway = true

tags = {
Terraform = "true"
Environment = "dev"
}
}

module "web_server_sg" {
source = "terraform-aws-modules/security-group/aws//modules/http-80"

name = "web-server"
description = "sg for http ingress"
vpc_id = module.vpc.vpc_id

ingress_cidr_blocks = ["0.0.0.0/0"]
}

data "aws_ami" "ubuntu-focal" {
most_recent = true

filter {
name = "name"
values = ["ubuntu/images/*ubuntu-focal-20.04-amd64-server-*"]
}

owners = ["099720109477"]
}

data "template_file" "user_data" {
template = file("user_data.yaml")
}

module "ec2_instances" {
source = "terraform-aws-modules/ec2-instance/aws"

name = "example-ec2-cluster"
instance_count = 2

ami = data.aws_ami.ubuntu-focal.id
instance_type = "t2.micro"
vpc_security_group_ids = [module.vpc.default_security_group_id, module.web_server_sg.this_security_group_id]
subnet_ids = module.vpc.public_subnets
#subnet_id = module.vpc.public_subnets[0]

user_data = data.template_file.user_data.rendered

tags = {
Terraform = "true"
Environment = "dev"
}
}

這次我們使用了三個模組:

  • module "vpc": 使用 terraform-aws-modules/vpc/aws 模組,建立 aws VPC
  • module "web_server_sg": 使用 terraform-aws-modules/security-group/aws//modules/http-80,建立 http 的安全群組
  • module "ec2_instances": 使用 terraform-aws-modules/ec2-instance/aws,建立兩台虛擬伺服器

建立 root output.tf 檔案

想要取得模組的輸出資料,格式是 module.<MODULE NAME>.<OUTPUT NAME>

我們試著把 public subnet 跟 public ip 輸出,範例如下:

output.tf
output "vpc_public_subnets" {
description = "IDs of the VPC's public subnets"
value = module.vpc.public_subnets
}

output "ec2_instance_public_ips" {
description = "Public IP addresses of EC2 instances"
value = module.ec2_instances.public_ip
}

執行 init

每當使用到新的模組是就要執行指令 terraform init,Terraform 會把模組下載到 .terraform/modules 資料夾裡。

terraform init
Initializing modules...
Downloading terraform-aws-modules/ec2-instance/aws 2.15.0 for ec2_instances...
- ec2_instances in .terraform/modules/ec2_instances
Downloading terraform-aws-modules/vpc/aws 2.51.0 for vpc...
- vpc in .terraform/modules/vpc
Downloading terraform-aws-modules/security-group/aws 3.16.0 for web_server_sg...
- web_server_sg in .terraform/modules/web_server_sg/modules/http-80
- web_server_sg.sg in .terraform/modules/web_server_sg

...

Terraform has been successfully initialized!

.terraform/modules 資料夾裡面大概的長相:

.terraform/modules folder
.terraform/modules
├── ec2_instances
├── modules.json
├── vpc
└── web_server_sg

執行 apply

執行指令 terraform apply 的方法是一樣的,確認執行計畫後輸入 yes

可以看到這些模組把我們要的 vpc, security-group, instance 都設定好,其他依賴的資源也一起準備好了。

terraform apply
Apply complete! Resources: 34 added, 0 changed, 0 destroyed.

Outputs:

ec2_instance_public_ips = [
"54.238.87.143",
"3.112.40.26",
]
vpc_public_subnets = [
"subnet-0f1fdfe4cf3d3bda0",
"subnet-09410b2bbd87c5876",
"subnet-0f89ceed33412208b",
]

執行 destroy

結束這一輪的練習,輸入指令 terraform destroy,並輸入 yes 刪除剛剛建立的基礎架構。

嘗試過如果使用現成的模組後,接下來我們要試著製作自己的模組。

一個基本的模組 (module) 大概會有以下幾個檔案:

  • README.md 說明模組用途的文件
  • main.tf 主要的組態設定檔
  • variables.tf 定義模組的變數,變數會成為 module 區塊的引數 (arguments)
  • outputs.tf 定義模組的輸出,輸出會成為可以從模組外取得的資訊,可以用來傳遞資訊給其他組態

這次的自製模組,我們要在 AWS S3 建立託管的靜態網站。

建立模組資料夾

我們要建立一個名稱為 static-bucket 的模組。

在工作資料夾裡建立 modules 資料夾,再放入 static-bucket 資料夾

一個指令完成這件事:

mkdir -p modules/static-bucket

建立 README

建立 README.md 放入模組的說明

# Static S3 Bucket
This module will create aws s3 bucket

建立組態檔 main.tf

  • 用 aws_s3_bucket 區塊建立 S3 的儲存貯體 (bucket) 設定成託管網站
  • 模組內不需要 provider 區塊
module main.tf
resource "aws_s3_bucket" "s3_bucket" {
bucket = var.bucket_name

acl = "public-read"
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::${var.bucket_name}/*"
]
}
]
}
EOF

website {
index_document = "index.html"
error_document = "error.html"
}

tags = var.tags
}

建立組態檔 variables.tf

  • 沒的預設值的變數會是模組的必填的變數
  • 我們需要從模組外提供 bucket_name,宣告 bucket_name 不設定預設值
module variables.tf
variable "bucket_name" {
description = "the s3 bucket name."
type = string
}

variable "tags" {
type = map(string)
default = {}
}

建立組態檔 outputs.tf

  • 輸出一些模組外需要的資料
module outputs.tf
output "arn" {
description = "ARN of the bucket"
value = aws_s3_bucket.s3_bucket.arn
}

output "name" {
description = "Name (id) of the bucket"
value = aws_s3_bucket.s3_bucket.id
}

output "website_endpoint" {
description = "Domain name of the bucket"
value = aws_s3_bucket.s3_bucket.website_endpoint
}

到目前我們完成了自製的模組,檔案清單如下:

tree modules folder
tree modules/
modules/
└── static-s3-bucket
├── README.md
├── main.tf
├── outputs.tf
└── variables.tf

但是現在還沒辦法實際的運作。

要在根模組 (root module) 載入自製的模組,確定它是可以正常使用的。

Terraform 模組

到目前為止我們介紹了一些程式碼書寫的知識,但我們創建的所有資源和資料來源的程式碼都是我們在程式碼檔案中編寫出來的。我們有沒有辦法不透過複製貼上程式碼而直接使用別人寫的 Terraform 程式碼來創建一組資源呢?

Terraform 對此給的答案就是模組( Module )。簡單來講模組就是包含一組 Terraform 程式碼的資料夾,我們之前篇章中寫的程式碼其實也是在模組中。要真正理解模組的功能,我們需要去體驗模組的使用。

Terraform 模組是編寫高品質 Terraform 程式碼,提升程式碼多用性的重要手段,可以說,一個成熟的生產環境應該是由數個可信成熟的模組組裝而成的。我們將在本章介紹關於模組的知識。

建立模組

實際上所有包含 Terraform 程式碼檔案的資料夾都是一個 Terraform 模組。我們如果直接在一個資料夾內執行 terraform apply 或 terraform plan 命令,那麼目前所在的資料夾就被稱為根模組(root module)。我們也可以在執行 Terraform 指令時透過命令列參數指定根模組的路徑。

模組結構

旨在被重複使用的模組與我們編寫的根模組使用的是相同的 Terraform 程式碼和程式碼風格規格。一般來講,在一個模組中,會有:

  • 一個 README 文件,用來描述模組的用途。檔案名稱可以是 README 或者 README.md,後者應採用 Markdown 語法編寫。可以考慮在 README 中用可視化的圖形來描繪創建的基礎設施資源以及它們之間的關係。 README 中不需要描述模組的輸入輸出,因為工具會自動收集相關資訊。如果在 README 中引用了外部文件或圖片,請確保使用的是帶有特定版本號的絕對 URL 路徑以防止未來指向錯誤的版本
  • 一個 LICENSE 描述模組使用的授權協議。如果你想要公開發布一個模組,最好考慮包含一個明確的許可證協議文件,許多組織不會使用沒有明確許可證協議的模組
  • 一個 examples 資料夾用來給一個呼叫樣例(可選)
  • 一個 variables.tf 文件,包含模組所有的輸入變數。輸入變數應有明確的描述說明用途
  • 一個 outputs.tf 文件,包含模組所有的輸出值。輸出值應該有明確的描述說明用途
  • 嵌入模組資料夾,出於封裝複雜性或複用程式碼的目的,我們可以在 modules 子目錄下建立一些嵌入模組。所有包含 README 文件的嵌入模組都可以被外部用戶使用;不含 README 文件的模組被認為是僅在當前模組內使用的(可選)
  • 一個 main.tf,它是模組主要的入口點。對於一個簡單的模組來說,可以把所有資源都定義在裡面;如果是一個比較複雜的模組,我們可以把創建的資源分佈到不同的程式碼檔案中,但引用嵌入模組的程式碼還是要保留在 main.tf 裡
  • 其他定義了各種基礎設施物件的程式碼檔案(可選)

如果模組含有多個嵌入模組,那麼應避免它們彼此之間的引用,由根模組負責組合它們。

由於 examples/ 中的程式碼經常會被拷貝到其他項目中進行修改,所有在 examples/ 程式碼中引用本模組時使用的引用路徑應使用外部呼叫者可以使用的路徑,而非相對路徑。

一個最小化模組推薦的結構是這樣的:

tree minimal-module folder
tree minimal-module/
.
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf

一個更完整一些的模組結構可以是這樣的:

tree complete-module folder
tree complete-module/
.
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── ...
├── modules/
│ ├── nestedA/
│ │ ├── README.md
│ │ ├── variables.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ ├── nestedB/
│ ├── .../
├── examples/
│ ├── exampleA/
│ │ ├── main.tf
│ ├── exampleB/
│ ├── .../

避免過深的模組結構

我們剛才提到可以在 modules/ 子目錄下建立嵌入模組。Terraform 倡導"扁平"的模組結構,只應保持一層嵌入模組,防止在嵌入模組中繼續建立嵌入模組。嵌入模組應設計成易於組合的結構,使得在根模組中可以透過組合各個嵌入模組來創建複雜的基礎設施。

引用模組

在 Terraform 程式碼中引用一個模組,使用的是 module 區塊。

每當在程式碼中新增、刪除或修改一個 module 區塊之後,都要執行 terraform init 或是 terraform get 指令來取得模組程式碼並安裝到本機磁碟上。

模組源

module 區塊定義了一個 source 參數,指定了模組的來源;Terraform 目前支援如下模組來源:

  • 本地路徑
  • Terraform Registry
  • GitHub
  • Bitbucket
  • 通用Git、Mercurial倉庫
  • HTTP位址
  • S3 buckets
  • GCS buckets

我們後面會一一講解這些模組源的使用。source 使用的是 URL 風格的參數,但某些來源支援在 source 參數中透過額外參數指定模組版本。

出於消除重複程式碼的目的我們可以重構我們的根模組程式碼,將一些擁有重複模式的程式碼重構為可重複呼叫的嵌入模組,透過本地路徑來引用。

許多的模組來源類型都支援從目前系統環境中讀取認證訊息,例如環境變數或系統設定檔。我們在介紹模組來源的時候會介紹到這方面的資訊。

我們建議每個模組把期待被重複使用的基礎設施聲明在各自的根模組位置上,但是直接引用其他模組的嵌入模組也是可行的。

本地路徑

使用本地路徑可以使我們引用同一專案內定義的子模組:

module "consul" {
source = "./consul"
}

一個本機路徑必須以 ./ 或 ../ 為前綴來標示要使用的本機路徑,以區別於使用 Terraform Registry 路徑。

本機路徑引用模組和其他來源類型有一個區別,本地路徑引用的模組不需要下載相關原始碼,程式碼已經存在於本地相關路徑的磁碟上了。

Terraform Registry

Registry 目前是 Terraform 官方力推的模組倉庫方案,採用了 Terraform 客製化的協議,支援版本化管理和使用模組。

官方提供的公共倉庫保存和索引了大量公共模組,在這裡可以輕鬆搜尋到各種官方和社區提供的高品質模組。

讀者也可以透過 Terraform Cloud 服務維護一個私有模組倉庫,或是透過實作 Terraform 模組註冊協定來實現一個私有倉庫。

公共倉庫的的模組可以用 <NAMESPACE>/<NAME>/<PROVIDER> 形式的來源位址來引用,在公共倉庫上的模組介紹頁面上都包含了確切的來源位址,例如:

module "consul" {
source = "hashicorp/consul/aws"
version = "0.1.0"
}

對於那些託管在其他倉庫的模組,在來源位址頭部新增 <HOSTNAME>/ 部分,指定私有倉庫的主機名稱:

module "consul" {
source = "app.terraform.io/example-corp/k8s-cluster/azurerm"
version = "1.1.0"
}

如果你使用的是 SaaS 版本的 Terraform Cloud,那麼託管在上面的私有倉庫的主機名稱是 app.terraform.io。如果使用的是私有部署的 Terraform 企業版,那麼託管在上面的私有倉庫的主機名稱就是 Terraform 企業版服務的主機名稱。

模組倉庫支援版本化。你可以在 module 區塊中指定模組的版本約束。

如果要引用私有倉庫的模組,你需要先透過設定命令列工具設定檔來設定存取憑證。

GitHub

Terraform 發現 source 當參數的值如果是以 github.com 為前綴時,會將其自動辨識為一個 GitHub 來源:

module "consul" {
source = "github.com/hashicorp/example"
}

上面的例子會自動使用 HTTPS 協定來複製倉庫。如果要使用 SSH 協議,那麼請使用如下的位址:

module "consul" {
source = "git@github.com:hashicorp/example.git"
}

GitHub 來源的處理與後面要介紹的通用 Git 倉庫是一樣的,所以他們取得 git 憑證和透過 ref 參數引用特定版本的方式都是一樣的。如果要存取私有倉庫,你需要額外設定 git 憑證。

Bitbucket

Terraform 發現 source 當參數的值如果是以 bitbucket.org 為前綴時,會將其自動辨識為一個 Bitbucket 來源:

module "consul" {
source = "bitbucket.org/hashicorp/terraform-consul-aws"
}

這種捷徑方法只針對公共倉庫有效,因為 Terraform 必須存取 ButBucket API 來了解倉庫使用的是 Git 還是 Mercurial 協定。

Terraform 根據倉庫的類型來決定將它作為一個 Git 倉庫還是 Mercurial 倉庫來處理。後面的章節會介紹如何為存取倉庫設定存取憑證以及指定要使用的版本號。

通用 Git 倉庫

可以透過在位址開頭加上特殊的 git:: 前綴來指定使用任意的 Git 倉庫。在前綴後面跟隨的是一個合法的 Git URL

使用 HTTPS 和SSH 協定的範例:

module "vpc" {
source = "git::https://example.com/vpc.git"
}

module "storage" {
source = "git::ssh://username@example.com/storage.git"
}

Terraform 使用 git clone 指令安裝模組程式碼,所以 Terraform 會使用本機 Git 系統配置,包括存取憑證。若要存取私有 Git 倉庫,必須先配置對應的憑證。

如果使用了 SSH 協議,那麼會自動使用系統設定的 SSH 證書。通常情況下我們透過這種方法存取私有倉庫,因為這樣可以不需要互動式提示就可以存取私有倉庫。

如果使用 HTTP/HTTPS 協議,或其他需要使用者名稱、密碼作為憑證,你需要設定 Git 憑證儲存來選擇一個合適的憑證來源。

預設情況下,Terraform 會克隆預設分支。可以透過 ref 參數來指定版本:

module "vpc" {
source = "git::https://example.com/vpc.git?ref=v1.2.0"
}

ref 參數會被用作 git checkout 指令的參數,可以是分支名或是 tag 名。

使用 SSH 協定時,我們更推薦 ssh:// 的位址。你也可以選擇 scp 風格的語法,故意忽略 ssh:// 的部分,只留 git::,例如:

module "storage" {
source = "git::username@example.com:storage.git"
}

通用 Mercurial 倉庫

可以透過在位址開頭加上特殊的 hg:: 前綴來指定使用任意的 Mercurial 倉庫。在前綴後面跟隨的是一個合法的 Mercurial URL

module "vpc" {
source = "hg::http://example.com/vpc.hg"
}

Terraform 會透過執行 hg clone 指令從 Mercurial 倉庫安裝模組程式碼,所以 Terraform 會使用本地 Mercurial 系統配置,包括存取憑證。若要存取私有 Mercurial 倉庫,必須先配置對應的憑證。

如果使用了 SSH 協議,那麼會自動使用系統設定的 SSH 證書。通常情況下我們透過這種方法存取私有倉庫,因為這樣可以不需要互動式提示就可以存取私有倉庫。

類似 Git 來源,我們可以透過 ref 參數指定非預設的分支或標籤來選擇特定版本:

module "vpc" {
source = "hg::http://example.com/vpc.hg?ref=v1.2.0"
}

HTTP 位址

當我們使用 HTTP 或 HTTPS 位址時,Terraform 會向指定 URL 發送 GET 請求,期待返回另一個來源位址。這種間接的方法使得 HTTP 可以成為一個更複雜的模組來源位址的指標。

然後 Terraform 會再發送 GET 請求到先前回應的位址上,並附加一個查詢參數 terraform-get=1,這樣伺服器可以選擇當 Terraform 來查詢時可以傳回一個不一樣的位址。

如果對應的狀態碼是成功的(200 範圍的成功狀態碼),Terraform 就會透過以下位置來取得下一個存取位址:

  • 響應頭部的 X-Terraform-Get 值
  • 如果回應內容是一個 HTML 頁面,那麼會檢查名為 terraform-get 的 html meta 元素:
<meta name="terraform-get"
content="github.com/hashicorp/example" />

不管用哪一種方式回傳的位址,Terraform 都會像本章提到的其他的來源位址一樣處理它。

如果 HTTP/HTTPS 位址需要認證憑證,可以在 HOME 資料夾下設定一個 .netrc 文件,詳見相關文件

也有一種特殊情況,如果 Terraform 發現位址有著常見的存檔檔案的後綴名,那麼 Terraform 會跳過 terraform-get=1 重定向的步驟,直接將回應內容當作模組程式碼使用。

module "vpc" {
source = "https://example.com/vpc-module.zip"
}

目前支援的後綴名有:

  • zip
  • tar.bz2 和 tbz2
  • tar.gz 和 tgz
  • tar.xz 和 txz

如果 HTTP 位址不以這些文件名結尾,但又的確指向模組存檔文件,那麼可以使用 archive 參數來強制依照這種行為處理位址:

module "vpc" {
source = "https://example.com/vpc-module?archive=zip"
}

S3 Bucket

你可以把模組存檔保存在 AWS S3 桶裡,使用 s3:: 為地址前綴,後面跟著一個 S3 物件存取地址

module "consul" {
source = "s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"
}

Terraform 辨識到 s3:: 前綴後會使用 AWS 風格的認證機制存取給定位址。這使得這種來源位址也可以搭配其他提供了 S3 協定相容的物件儲存服務使用,只要他們的認證方式與 AWS 相同即可。

儲存在 S3 桶內的模組存檔檔案格式必須與上面 HTTP 來源提到的支援的格式相同,Terraform 會下載並解壓縮模組程式碼。

模組安裝器會從下列位置尋找 AWS 憑證,並依照優先順序排列:

  • AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY 環境變數
  • HOME 目錄下 .aws/credentials 檔案內的預設 profile
  • 如果是在 AWS EC2 主機內執行的,那麼會嘗試使用搭載的 IAM 主機執行個體配置。

GCS Bucket

你可以把模組存檔保存在 Google 雲端 GCS 儲存桶裡,使用 gcs:: 作為位址前綴,後面跟著一個 GCS 物件存取位址

module "consul" {
source = "gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip"
}

模組安裝器會使用 Google 雲端 SDK 的憑證來存取 GCS。要設定憑證,你可以:

  • 透過 GOOGLE_APPLICATION_CREDENTIALS 環境變數配置服務帳號的金鑰文件
  • 如果是在 Google 雲端主機上執行的 Terraform,可以使用預設憑證。訪問相關文件以獲取完整信息
  • 可以使用命令列 gcloud auth application-default login 設定

直接引用子資料夾中的模組

引用版本控制系統或是物件儲存服務中的模組時,模組本身可能存在於存檔檔案的子資料夾內。我們可以使用特殊的 // 語法來指定 Terraform 使用檔案內特定路徑作為模組程式碼所在位置,例如:

  • hashicorp/consul/aws//modules/consul-cluster
  • git::https://example.com/network.git//modules/vpc
  • https://example.com/network-module.zip//modules/vpc
  • s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/network.zip//modules/vpc

如果來源位址中包含又參數,例如指定特定版本號的 ref 參數,那麼把子資料夾路徑放在參數之前:

  • git::https://example.com/network.git//modules/vpc?ref=v1.2.0

Terraform 會解壓縮整個存檔檔案後,讀取特定子資料夾。所以,對於一個存在於子資料夾中的模組來說,透過本地路徑引用同一個存檔內的另一個模組是安全的。

使用模組

我們剛剛介紹如何用 source 指定模組來源,以下我們繼續講解如何在程式碼中使用一個模組。

我們可以把模組理解成類似函數,如同函數有輸入參數表和輸出值一樣,我們之前介紹過 Terraform 程式碼有輸入變數和輸出值。我們在 module 區塊的區塊體內除了 source 參數,還可以對該模組的輸入變數賦值:

module "servers" {
source = "./app-cluster"

servers = 5
}

在這個例子裡,我們將會建立 ./app-cluster 資料夾下 Terraform 宣告的一系列資源,該模組的 servers 輸入變數的值被我們設定成了 5。

在程式碼中新增、刪除或是修改一個某塊的 source,都需要重新執行 terraform init 指令。預設情況下,該指令不會升級已安裝的模組(例如 source 未指定版本,過去安裝了舊版本模組程式碼,那麼執行 terraform init 不會自動更新到新版本);可以執行 terraform init -upgrade 來強制更新到最新版本模組。

存取模組輸出值

在模組中定義的資源和資料來源都是封裝的,所以模組呼叫者無法直接存取它們的輸出屬性。然而,模組可以聲明一系列輸出值,來選擇性地輸出特定的資料供模組呼叫者使用。

舉例來說,如果 ./app-cluster 模組定義了名為 instance_ids 的輸出值,那麼模組的呼叫者可以像這樣引用它:

resource "aws_elb" "example" {
# ...

instances = module.servers.instance_ids
}

其他的模組元參數

除了 source 以外,目前 Terraform 還支援在 module 區塊上聲明其他一些可選元參數:

  • version:指定引用的模組版本,在後面的部分會詳細介紹
  • count 和 for_each:這是 Terraform 0.13 開始支援的特性,類似 resource 與 data,我們可以建立多個 module 實例
  • providers:透過傳入一個 map 我們可以指定模組中的 Provider 配置,我們將在後面詳細介紹
  • depends_on:建立整個模組和其他資源之間的明確依賴。直到依賴項創建完畢,否則聲明了依賴的模組內部所有的資源及內嵌的模組資源都會被延遲處理。模組的依賴行為與資源的依賴行為相同

除了上述元參數以外,lifecycle 參數目前還不能用於模組,但關鍵字被保留以便將來實現。

模組版本約束

使用 registry 作為模組來源時,可以使用 version 元參數約束使用的模組版本:

module "consul" {
source = "hashicorp/consul/aws"
version = "0.0.5"

servers = 3
}

version 元參數的格式與 Provider 版本約束的格式一致。在滿足版本限制的前提下,Terraform 會使用目前已安裝的最新版本的模組實例。如果目前沒有滿足約束的版本被安裝過,那麼會下載符合約束的最新的版本。

version 元參數只能配合 registry 使用,公共的或私有的模組倉庫都可以。其他類型的模組來源可能支援版本化,也可能不支援。本地路徑模組不支援版本化。

多實例模組

可以透過在 module 區塊上聲明 for_each 或 count 來創造多實例模組。在使用上 module 上的 for_each 和 count 與資源、資料來源區塊上的使用是一樣的。

multiple module
# my_buckets.tf
module "bucket" {
for_each = toset(["assets", "media"])
source = "./publish_bucket"
name = "${each.key}_bucket"
}
# publish_bucket/bucket-and-cloudfront.tf
variable "name" {} # this is the input parameter of the module

resource "aws_s3_bucket" "example" {
# Because var.name includes each.key in the calling
# module block, its value will be different for
# each instance of this module.
bucket = var.name

# ...
}

resource "aws_iam_user" "deploy_user" {
# ...
}

這個範例定義了一個位於 ./publish_bucket 目錄下的本機子模組,模組建立了一個 S3 儲存桶,封裝了桶的資訊以及其他實作細節。

我們透過 for_each 參數聲明了模組的多個實例,傳入一個 map 或是 set 作為參數值。另外,因為我們使用了 for_each,所以在 module 區塊裡可以使用 each 對象,例子裡我們使用了 each.key。如果我們使用的是 count 參數,那麼我們可以使用 count.index

子模組裡所建立的資源在執行計畫或 UI 中的名稱會以 作為 module.module_name[module index] 前綴。如果一個模組沒有宣告 count 或者 for_each,那麼資源位址將不包含 module index。

在上面的例子裡,./publish_bucket 模組包含了 aws_s3_bucket.example 資源,所以兩個 S3 桶實例的名字分別是 module.bucket["assets"].aws_s3_bucket.example 以及 module.bucket["media"].aws_s3_bucket.example

模組內的 Provider

當程式碼中聲明了多個模組時,資源如何與 Provider 實例關聯就需要特殊考慮。

每一個資源都必須關聯一個 Provider 配置。不像 Terraform 其他的概念,Provider 配置在 Terraform 專案中是全域的,可以跨模組共用。Provider 配置聲明只能放在根模組中。

Provider 有兩種方式傳遞給子模組:隱式繼承,或是明確透過 module 區塊的 providers 參數傳遞。

一個旨在被重複使用的模組不允許宣告任何 provider 區塊,只有使用"代理 Provider"模式的情況除外,我們後面會介紹這種模式。

出於向前相容 Terraform 0.10 及更早版本的考慮,Terraform 目前在模組程式碼中只用到了 Terraform 0.10 及更早版本的功能時,不會針對模組程式碼中聲明 provider 區塊報錯,但這是一個不被推薦的遺留模式。一個含有自己的 provider 塊定義的遺留模組與 for_eachcount 和 depends_on 等 0.13 引入的新特性是不相容的。

Provider 配置被用於相關資源的所有操作,包括銷毀遠端資源物件以及更新狀態資訊等。Terraform 會在狀態檔案中儲存最近用來執行所有資源變更的 Provider 配置的參考。當一個 resource 區塊被刪除時,狀態檔案中的相關記錄會被用來定位到對應的配置,因為原來包含 provider 參數(如果宣告了的話)的 resource 區塊已經不存在了。

這導致了,你必須確保刪除所有相關的資源配置定義以後才能刪除一個 Provider 配置。如果 Terraform 發現狀態檔案中記錄的某個資源對應的 Provider 設定已經不存在了會報錯,要求你重新給予相關的 Provider 設定。

模組內的 Provider 版本限制

雖然 Provider 設定資訊在模組間共享,每個模組還是得聲明各自的模組需求,這樣 Terraform 才能決定一個適用於所有模組配置的 Provider 版本。

為了定義這樣的版本約束要求,可以在 terraform 區塊中使用 required_providers 區塊:

provider version
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 2.7.0"
}
}
}

有關 Provider 的 source 和版本約束的資訊我們已經在前文中有所記述,在此不再贅述。

隱式 Provider 繼承

為了方便,在一些簡單的程式碼中,一個子模組會從呼叫者自動繼承預設的 Provider 配置。這表示明確 provider 區塊聲明僅位於根模組中,且下游子模組可以簡單地聲明使用該類型 Provider 的資源,這些資源會自動關聯到根模組的 Provider 配置上。

例如,根模組可能只含有一個 provider 塊和一個 module 塊:

隱式 Provider 繼承
provider "aws" {
region = "us-west-1"
}

module "child" {
source = "./child"
}

子模組可以聲明任意關聯 aws 類型 Provider 的資源而無需額外聲明 Provider 配置:

child module
resource "aws_s3_bucket" "example" {
bucket = "provider-inherit-example"
}

當每種類型的 Provider 都只有一個實例時我們建議使用這種方式。

要注意的是,只有 Provider 配置會被子模組繼承,Provider 的 source 或是版本約束條件則不會被繼承。每一個模組都必須聲明各自的 Provider 需求條件,這在使用非 HashiCorp 的 Provider 時尤其重要。

明確傳遞 Provider

當不同的子模組需要不同的 Provider 實例,或子模組需要的 Provider 實例與呼叫者自己使用的不同時,我們需要在 module 區塊上宣告 providers 參數來傳遞子模組要使用的 Provider 實例。例如:

明確傳遞 Provider
# The default "aws" configuration is used for AWS resources in the root
# module where no explicit provider instance is selected.
provider "aws" {
region = "us-west-1"
}

# An alternate configuration is also defined for a different
# region, using the alias "usw2".
provider "aws" {
alias = "usw2"
region = "us-west-2"
}

# An example child module is instantiated with the alternate configuration,
# so any AWS resources it defines will use the us-west-2 region.
module "example" {
source = "./example"
providers = {
aws = aws.usw2
}
}

module 區塊裡的 providers 參數類似 resource 區塊裡的 provider 參數,差異是前者接收的是一個 map 而不是單個 string,因為一個模組可能含有多個不同的 Provider。

providers 的 map 的鍵就是子模組中宣告的 Provider 需求中的名字,值就是目前模組中對應的 Provider 配置的名字。

如果 module 區塊內宣告了 providers 參數,那麼它將重載所有預設的繼承行為,所以你需要確保給定的 map 覆寫了子模組所需的所有 Provider。這避免了顯式賦值與隱式繼承混用時所帶來的混亂與意外。

額外的 Provider 配置(使用 alias 參數的)將永遠不會被子模組隱式繼承,所以必須明確通過 providers 傳遞。例如,一個模組配置了兩個 AWS 區域之間的網路打通,所以需要配置一個來源區域 Provider 和目標區域Provider。在這種情況下,根模組程式碼看起來是這樣的:

mutiple aws provider
provider "aws" {
alias = "usw1"
region = "us-west-1"
}

provider "aws" {
alias = "usw2"
region = "us-west-2"
}

module "tunnel" {
source = "./tunnel"
providers = {
aws.src = aws.usw1
aws.dst = aws.usw2
}
}

子目錄 ./tunnel 必須包含像下面的範例那樣聲明"Provider 代理",聲明模組呼叫者必須用這些名字傳遞的Provider 配置:

provider "aws" {
alias = "src"
}

provider "aws" {
alias = "dst"
}

./tunnel模組中的每一種資源都應該透過 provider 參數聲明它使用的是 aws.src 還是 aws.dst

Provider 代理程式配置區塊

一個 Provider 代理配置只包含 alias 參數,它就是一個模組間傳遞 Provider 配置的佔位符,聲明了模組期待明確傳遞的額外(帶有 alias 的)Provider 配置。

要注意的是,一個完全為空的 Provider 配置區塊也是合法的,但沒有必要。只有在模組內需要帶 alias 的Provider 時才需要代理程式配置區塊。如果模組中只是用預設 Provider 時請不要宣告代理程式配置區塊,也不要僅為了宣告 Provider 版本約束而使用代理程式配置區塊。

模組元參數

在 Terraform 0.13 之前,模組在使用上有一些限制。例如我們透過模組來建立 EC2 主機,可以這樣:

normal module
module "ec2_instance" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

如果我們要建立兩台這樣的主機怎麼辦?在 Terraform 0.13 之前的版本中,由於 Module 不支援元參數,所以我們只能手動拷貝模組程式碼:

hard copy
module "ec2_instance_0" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance-0"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

module "ec2_instance_1" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance-1"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

自從 Terraform 0.13 開始,模組也像資源一樣,支援 countfor_eachdepends_on 三種元參數。比如我們可以這樣:

元參數
module "ec2_instance" {
count = 2
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance-${count.index}"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

要注意的是 Terraform 0.13 之後在模組上聲明 depends_on,列表中也可以傳入另一個模組。聲明 depends_on 的模組中的所有資源的創建都會發生在被依賴的模組中所有資源創建完成之後。

重構

請注意,本節介紹的透過 moved 區塊進行模組重構的功能是從 Terraform v1.1 開始被引入的。如果要在先前的版本進行這樣的操作,必須透過 terraform state mv 指令來完成。

對一些旨在被人復用的老模組來說,最初的模組結構和資源名稱可能會逐漸變得不再合適。例如,我們可能會發現將先前的一個子模組分割成兩個單獨的模組會更合理,這需要將現有資源的子集移到新的模組中。

Terraform 將先前的狀態與新程式碼進行比較,資源與每個模組或資源的唯一位址相關聯。因此,預設情況下,移動或重新命名物件會被 Terraform 理解為銷毀舊地址的物件並在新地址建立新的物件。

當我們在程式碼中新增 moved 區塊以記錄我們移動或重新命名物件過去的地址時,Terraform 會將舊地址的現有物件視為現在屬於新地址。

moved 區塊語法

moved 區塊只包含 from 和 to 參數,沒有名稱:

moved {
from = aws_instance.a
to = aws_instance.b
}

上面的例子演示了模組先前版本中的 aws_instance.a 如今以 aws_instance.b 的名字存在。

在為 aws_instance.b 建立新的變更計畫之前,Terraform 會先檢查目前狀態中是否存在位址為 aws_instance.a 的記錄。如果存在該記錄,Terraform 會將其重命名為 aws_instance.b 然後繼續建立變更計劃。最終產生的變更計劃中該物件就好像一開始就是以 aws_instance.b 的名字被創建的,防止它在執行變更時被刪除。

from 和 to 的位址使用特殊的位址語法,該語法允許選定模組、資源以及子模組中的資源。以下是幾種不同的重構場景中所需要的位址語法:

重新命名一個資源

考慮模組程式碼中這樣一個資源:

resource "aws_instance" "a" {
count = 2

# (resource-type-specific configuration)
}

第一次套用該程式碼時 Terraform 會建立 aws_instance.a[0] 以及 aws_instance.a[1]

如果隨後我們修改了該資源的名稱,並且把舊名字記錄在一個 moved 區塊裡:

resource "aws_instance" "b" {
count = 2

# (resource-type-specific configuration)
}

moved {
from = aws_instance.a
to = aws_instance.b

當下一次應用程式使用了該模組的程式碼時,Terraform 會把所有位址為 aws_instance.a 的物件看作是一開始就以 aws_instance.b 的名字創建的:aws_instance.a[0] 會被看作是 aws_instance.b[0]aws_instance.a[1] 會被看作是 aws_instance.b[1]

新建立的模組實例中,因為從來就不存在 aws_instance.a,於是會忽略 moved 區塊而像通常那樣直接建立 aws_instance.b[0] 以及 aws_instance.b[1]。

為資源新增 count 或 for_each 聲明

一開始程式碼中有這樣一個單一實例資源:

resource "aws_instance" "a" {
# (resource-type-specific configuration)
}

應用程式碼會使得 Terraform 建立了一個位址為 aws_instance.a 的資源物件。

隨後我們想要在該資源上新增 for_each 來建立多個實例。為了保持先前關聯到 aws_instance.a 的資源物件不受影響,我們必須新增一個 moved 區塊來指定新程式碼中原先的物件實例所關聯的鍵是什麼:

moved
locals {
instances = tomap({
big = {
instance_type = "m3.large"
}
small = {
instance_type = "t2.medium"
}
})
}

resource "aws_instance" "a" {
for_each = local.instances

instance_type = each.value.instance_type
# (other resource-type-specific configuration)
}

moved {
from = aws_instance.a
to = aws_instance.a["small"]
}

上面的程式碼會防止 Terraform 在變更計畫中銷毀已經存在的 aws_instance.a 對象,並且將其視為以 aws_instance.a["small"] 的位址建立的。

當 moved 區塊的兩個位址中的至少一個包含實例鍵時,如上例中的 ["small"],Terraform 將這兩個位址理解為引用資源的特定實例而不是整個資源。這表示您可以使用 moved 在按鍵之間切換以及在 countfor_each 之間切換時新增和刪除鍵。

下面的範例示範了幾種其他類似的記錄了資源實例鍵變更的合法 moved 區塊:

moved
# Both old and new configuration used "for_each", but the
# "small" element was renamed to "tiny".
moved {
from = aws_instance.b["small"]
to = aws_instance.b["tiny"]
}

# The old configuration used "count" and the new configuration
# uses "for_each", with the following mappings from
# index to key:
moved {
from = aws_instance.c[0]
to = aws_instance.c["small"]
}
moved {
from = aws_instance.c[1]
to = aws_instance.c["tiny"]
}

# The old configuration used "count", and the new configuration
# uses neither "count" nor "for_each", and you want to keep
# only the object at index 2.
moved {
from = aws_instance.d[2]
to = aws_instance.d
}

注意:當我們在原先沒有宣告 count 的資源上新增 count 時,Terraform 會自動將原先的物件移到第 0 個位置,除非我們透過一個 moved 區塊明確宣告該資源。然而,我們建議使用 moved 區塊明確聲明資源的移動,使得讀者在未來閱讀模組的程式碼時更清楚地了解這些變更。

重新命名對模組的調用

我們可以用類似重命名資源的方式來重新命名對模組的呼叫。假設我們開始用以下程式碼呼叫一個模組:

module "a" {
source = "../modules/example"

# (module arguments)
}

當套用程式碼時,Terraform 會在模組內宣告的資源路徑前面加上一個模組路徑前綴 module.a。比方說,模組內的 aws_instance.example 的完整位址為 module.a.aws_instance.example

如果我們隨後打算修改模組名稱,我們可以直接修改 module 區塊的標籤,並且在一個 moved 區塊內部記錄該變更:

module "b" {
source = "../modules/example"

# (module arguments)
}

moved {
from = module.a
to = module.b
}

當下一次套用包含該模組呼叫的程式碼時,Terraform 會將所有路徑前綴為 module.a 的物件視為從一開始就是以 module.b 為前綴建立的。module.a.aws_instance.example 會被看作是 module.b.aws_instance.example

此範例中的 moved 區塊中的兩個位址都代表對模組的調用,而 Terraform 識別出將原始模組位址中所有的資源移至新的模組位址中。如果該模組聲明時使用了 count 或是 for_each,那麼該移動也將被應用於所有的實例上,不需要逐個指定。

為模組呼叫新增 count 或 for_each 聲明

考慮一下單一實例的模組:

module "a" {
source = "../modules/example"q

# (module arguments)
}

應用該段程式碼會導致 Terraform 建立的資源位址都擁有 module.a 的前綴。

隨後如果我們可能需要再透過新增 count 來建立多個資源實例。為了保留先前的 aws_instance.a 實例不受影響,我們可以新增一個 moved 區塊來設定在新程式碼中該實例的對應的鍵。

module "a" {
source = "../modules/example"
count = 3

# (module arguments)
}

moved {
from = module.a
to = module.a[2]
}

上面的程式碼引導 Terraform 將所有 module.a 中的資源視為從一開始就是以 module.a[2] 的前綴建立的。結果就就是,Terraform 產生的變更計畫中只會創建 module.a[0] 以及 module.a[1]

當 moved 區塊的兩個位址中的至少一個包含實例鍵時,例如上面範例中的 [2] 那樣,Terraform 會理解將這兩個位址理解為對模組的特定實例的呼叫而非對模組所有實例的呼叫。這意味著我們可以使用 moved 區塊在不同鍵之間切換來新增或是刪除鍵,該機制可用於 count 和 for_each,或刪除模組上的這種聲明。

將一個模組分割成多個模組

隨著模組提供的功能越來越多,最終模組可能變得過大而不得不將之拆分成兩個獨立的模組。

我們來看看下面的這個例子:

resource "aws_instance" "a" {
# (other resource-type-specific configuration)
}

resource "aws_instance" "b" {
# (other resource-type-specific configuration)
}

resource "aws_instance" "c" {
# (other resource-type-specific configuration)
}

我們可以將該模組分割為三個部分:

  • aws_instance.a 現在歸屬於模組 "x"。
  • aws_instance.b 也屬於模組 "x"。
  • aws_instance.c 現在歸屬於模組 "y"。要在不替換綁定到舊資源位址的現有物件的情況下實現此重構,我們需要:
  • 編寫模組 "x",將屬於它的兩個資源拷貝過去。
  • 編寫模組 "y",將屬於它的一個資源拷貝過去。
  • 編輯原有模組程式碼,刪除這些資源,只包含有關遷移現有資源的非常簡單的設定程式碼。新的模組 "x" 和 "y" 應該只包含 resource 塊:
module with resource
# module "x"

resource "aws_instance" "a" {
# (other resource-type-specific configuration)
}

resource "aws_instance" "b" {
# (other resource-type-specific configuration)

# module "y"

resource "aws_instance" "c" {
# (other resource-type-specific configuration)
}

而原有模組則被修改成只包含有向下相容邏輯的墊片,呼叫兩個新模組,並使用 moved 塊定義哪些資源被移到新模組中去了:

module with moved
module "x" {
source = "../modules/x"

# ...
}

module "y" {
source = "../modules/y"

# ...
}

moved {
from = aws_instance.a
to = module.x.aws_instance.a
}

moved {
from = aws_instance.b
to = module.x.aws_instance.b
}

moved {
from = aws_instance.c
to = module.y.aws_instance.c
}

當一個原始模組的呼叫者升級模組版本到這個「墊片」版本時,Terraform 會注意到這些 moved 區塊,並將那些關聯到舊地址的資源物件看作是從一開始就是由新模組創建的那樣。

這個模組的新用戶可以選擇使用這個墊片模組,或是獨立呼叫兩個新模組。我們需要通知舊模組的現有用戶舊模組已被廢棄,他們將來的開發中需要獨立使用這兩個新模組。

多模組重構的場景是不多見的,因為它違反了父模組將其子模組視為黑盒的典型規則,不知道在其中聲明了哪些資源。這種妥協的前提是假設所有這三個模組都由同一個人維護並分佈在一個模組包中。

為避免獨立模組之間的耦合,Terraform 只允許宣告在同一個目錄下的模組間的移動。換句話說,Terraform 不允許將資源移到一個 source 位址不是本機路徑的模組。

Terraform 使用定義 moved 區塊的模組實例的位址的位址來解析 moved 區塊中的相對位址。例如,如果上面的原始模組已經是名為 module.original 的子模組,則原模組中對 module.x.aws_instance.a 的引用在根模組中將被解析為 module.original.module.x.aws_instance.a。一個模組只能針對它本身或是它的子模組中的資源聲明 moved 區塊。

如果需要引用帶有 count 或 for_each 元參數的模組中的資源,則必須指定要使用的特定實例鍵以符合資源配置的新位置:

moved {
from = aws_instance.example
to = module.new[2].aws_instance.example
}

刪除 moved 區塊

隨著時間的推移,一些舊模組可能會累積大量 moved 塊。

刪除 moved 區塊通常是一種破壞性變更,因為刪除後所有使用舊地址引用的物件都將被刪除而不是被移動。我們強烈建議保留歷史上所有的 moved 區塊來保存使用者從任意版本升級到目前版本的升級路徑資訊。

如果我們決定要刪除 moved 塊,需要謹慎行事。對於組織內部的私有模組來說刪除 moved 區塊可能是安全的,因為我們可以確認所有使用者都已經使用新版本模組程式碼運行過 terraform apply 了。

如果我們需要多次重命名或是移動一個對象,我們建議使用串聯的 moved 區塊來記錄完整的變更訊息,新的區塊引用已有的區塊:

moved {
from = aws_instance.a
to = aws_instance.b
}

moved {
from = aws_instance.b
to = aws_instance.c
}

像這樣記錄下移動的序列可以使 aws_instance.a 以及 aws_instance.b 兩種地址的資源都成功更新,Terraform 會將他們視為從一開始就是以 aws_instance.c 的地址創建的。

Terraform 模組是獨立的基礎架構即程式碼片段,抽象化了基礎架構部署的底層複雜性。Terraform 使用者透過使用預置的設定碼加速採用 IaC,並降低了使用門檻。所以,模組的作者應盡量遵循諸如清晰的程式碼結構以及 DRY("Dont't Repeat Yourself")原則的程式碼最佳實踐。

本篇指導討論了模組架構的原則,用以幫助讀者編寫易於組合、易於分享及重用的基礎設施模組。這些架構建議對使用任意版本 Terraform 的企業都有好處,某些諸如「私有模組註冊表(Registry)」的模式僅在Terraform Cloud 以及企業版中才能使用。(本文不對相關內容進行翻譯)

本文是 Terraform 模組文件的補充和擴充。

透過閱讀文本,讀者可以:

  1. 學習有關Terraform 模組創建的典型工作流程和基本原則。
  2. 探索遵循這些原則的範例場景。
  3. 學習如何透過協作改進Terraform 模組
  4. 了解如何建立一套使用模組的工作流程。

模組創建的工作流程

要創建一個新模組,第一步是尋找一個早期採納者團隊,收集他們的需求。

與這支早期採納團隊一起工作使我們可以透過使用輸入變數以及輸出值來確保模組足夠靈活,從而打磨模組的功能。此外,還可以用最小的程式碼變更代價吸收其他有類似需求的團隊加入進來。這消除了程式碼重複,並縮短了交付時間。

完成以上任務後,需要謹記兩點:

  1. 將需求範圍劃分成合適的模組。
  2. 創建模組的最小可行產品(Minimum Viable Product, MVP)

將需求範圍劃分成適當的模組

創建新 Terraform 模組時最具挑戰的方面之一是決定要包含哪些基礎設施資源。

模組設計應該是有主見的,並且被設計成能很好地完成一個目標。如果一個模組的功能或目的很難解釋,那麼這個模組可能太複雜了。在最初確定模組的範圍時,目標應足夠小且簡單,易於開始編寫。

當建構一個模組時,需要考慮以下三個方面:

  • 封裝:一組始終被一起部署的基礎設施資源在模組中包含更多的基礎設施資源簡化了終端用戶部署基礎設施的工作,但會使得模組的目的與需求變得更難理解。
  • 職責:限制模組職責的邊界如果模組中的基礎設施資源由多個群組來負責,使用該模組可能會意外違反職責分離原則。模組中僅包含職責邊界內的一組資源可以提升基礎設施資源的隔離性,並保護我們的基礎設施。
  • 變化頻率:隔離長短生命週期基礎架構資源舉例來說,資料庫基礎架構資源相對來說較為靜態,而團隊可能在一天內多次部署更新應用程式伺服器。在同一個模組中同時管理資料庫與應用程式伺服器使得保存狀態資料的重要基礎架構沒有必要地暴露在資料遺失的風險之中。

創建模組的最小可行產品

如同所有類型的程式碼一樣,模組的開發永遠不會完成,永遠會有新的模組需求以及變更。擁抱變化,最初的模組版本應致力於滿足最小可行產品(MVP)的標準。以下是在設計最小可行產品時需要謹記的指導清單:

  • 永遠致力於交付至少可以滿足 80% 場景的模組
  • 模組中永遠不要處理邊緣場景。邊緣場景是很少見的。一個模組應該是一組可重複使用的程式碼。
  • 在最小可行產品中避免使用條件表達式。最小可行產品應縮小範圍,不應該同時完成多種任務。
  • 模組應該只將最常被修改的參數公開為輸入變數。一開始時,模組應該只提供最可能需要的輸入變數。

盡可能多輸出

在最小可行產品中輸出盡可能多的信息,即使目前沒有使用者需要這些資訊。這使得那些通常使用多個模組的終端使用者在使用該模組時更加輕鬆,可以使用一個模組的輸出作為下一個模組的輸入。

請記得在模組的README 文件中記錄輸出值的文件。

模組的協作

隨著團隊模組的開發工作,簡化我們的協作。

  1. 為每個模組建立路線圖
  2. 從使用者收集需求信息,並按受歡迎程度進行優先排序。
    • 不使用模組的最常見原因是「它不符合我的要求」。收集這些需求並將它們新增至路線圖或對使用者的工作流程提出建議。
    • 檢查每一項需求以確認它所引用的用例是否正確。
    • 公佈和維護需求清單。分享該清單並讓使用者參與清單管理過程。
    • 不要為邊緣用例排期。
  3. 將每一個決策記錄進文件。
  4. 在公司內部採用開源社群原則。有些用戶希望盡可能有效率地使用這些模組,而有些用戶則希望協助創建這些模組。
    • 創建一個社區
    • 維護一份清晰和公開的貢獻指引
    • 最終,我們將允許可信的社區成員獲得某些模組的所有權

使用原始碼控制系統追蹤模組

一個 Terraform 模組應遵守所有良好的程式碼實踐:

  • 將模組置於原始碼控制中以管理版本發布、協作、變更的審計追蹤。
  • 為所有 main 分支的發布版本建立版本標籤,記錄文件(最起碼在 CHANGELOG 及 README 中記錄)。
  • 對 main 分支的所有變更進行程式碼審查
  • 鼓勵模組的使用者透過版本標籤引用模組
  • 為每一個模組指派一位負責人
  • 一個代碼倉庫只負責一個模組
    • 這對於模組的冪等性和作為函式庫的功能至關重要。
    • 我們應該對模組打上版本標籤或是版本化控制。打上版本標籤或是版本化的模組應該是不可變的。
    • 發佈到私有模組註冊表的模組必須要有版本標籤。

開發一套模組消費工作流程

定義並宣傳一套消費者團隊使用模組時應遵循的可重複工作流程。這個工作流程,就像模組本身一樣,應該考慮到使用者的需求。

闡明團隊應該如何使用模組

  • 分散的安全性:如果每個模組都在自己的儲存庫中進行版本控制,則可以使用儲存庫 RBAC 來管理誰擁有寫入存取權限,從而允許相關團隊管理相關的基礎設施(例如網路團隊擁有對網路模組的寫入存取權限)。
  • 培育程式碼社群:鑑於上述建議,模組開發的最佳實踐是允許對儲存在私人模組儲存庫中的模組的所有模組儲存庫提出 Pull Request。這促進了組織內的程式碼社區,保持模組內容的相關性和最大的靈活性,並有助於保持模組註冊表的長期有效性。

資源地址

在編碼時我們有時會需要引用一些資源的輸出屬性或是一些模組的輸出值,這都涉及如何在程式碼中引用特定模組或是資源。另外在執行某些命令列操作時也需要我們明確指定一些目標資源,這時我們要掌握 Terraform 的資源路徑規則。

一個資源位址是用以在一個龐大的基礎設施中精確引用一個特定資源物件的字串。一個位址由兩個部分組成:[module path][resource spec]

模組路徑

一個模組路徑在模組樹上定位了一個特定模組。它的形式是這樣的:module.module_name[module index]

  • modulemodule 關鍵字標記了這時一個子模組而非根模組。在路徑中可以包含多個 module 關鍵字
  • module_name:使用者定義的模組名
  • [module index]:(可選)存取多個子模組中特定實例的索引,由方括號包圍

一個不包含具體資源的位址,例如 module.foo 代表了模組內所有的資源(如果只是單一模組而不是多實例模組),或者是多實例模組的所有實例。要指涉特定模組實例的所有資源,需要在位址中附帶下標,例如 module.foo[0]

如果位址中模組部分被省略,那麼位址就指根模組資源。

一個多 module 關鍵字應用於多實例模組的範例:module.foo[0].module.bar["a"]

要注意的是,由於模組的 count 和 for_each 元參數是 Terraform 0.13 開始引進的,所以多實例模組位址也只能在 0.13 及之後的版本使用。

資源地址形式

一個資源位址定位了程式碼中特定資源對象,它的形式是這樣的:resource_type.resource_name[resource index]

  • resource_type:資源類型
  • resource_name:使用者定義的資源名稱
  • [resource index]:(可選)存取多實例資源中特定資源實例的索引,由方括號包圍

多實例模組與資源的存取索引

以下規約適用於存取多實例模組及資源時所使用的索引值:

  • [N]:當使用 count 元參數時 N 是一個自然數。如果省略,且 count > 1,那麼指涉所有的實例
  • ["INDEX"]:當使用 for_each 元參數時 INDEX 是一個字母數字混合的字串

count 的例子

給定一個程式碼定義:

resource "aws_instance" "web" {
# ...
count = 4
}

給定一個位址:aws_instance.web[3],它指涉的是最後一個名為 web 的 aws_instance 實例;給定位址 aws_instance.web,指涉的是所有名為 web 的 aws_instance 實例。

for_each 的例子

resource "aws_instance" "web" {
# ...
for_each = {
"terraform": "value1",
"resource": "value2",
"indexing": "value3",
"example": "value4",
}
}

位址 aws_instance.web["example"] 引用的是 aws_instance.web 中鍵為 "example" 的實例。