じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Terraform Modules で再利用できるので最高ではないでしょうか?

概要

ModuleはTerraformの複数のリソースをまとめて再利用可能な単位として扱うことができます。Moduleを使うことで複雑なリソース構成を抽象化し、システムの構造の把握やリソース構成の再利用が可能になり、読みやすさや可読性が向上し、修正箇所が単一になるなどのメリットがあります。

ただし、理解には初期コストが必要です。Moduleの設計では、1つの機能を持つように小さくシンプルに保つことが重要で、それが難しい場合は大抵複雑と言えます。

また、公式のModuleを利用することで、自身で定義やドキュメントの整備、メンテナンスの手間を省きつつ、プロジェクトを超えて共通認識として扱えるため、Module理解のコストが減ります。

しかし、どのタイミングでModuleに組み込むかの正解は、個々のプロジェクトの特性や開発チームの状況により大いに変わるでしょう。

絶えず試行錯誤を繰り返しながら個々のプロジェクトごとに最適な解を見つけることが求められます。このブログではそれらの話の前にTerraform Modulesについて利用方法をまとめてみました。

Module を利用する

システムを構築するにあたって開発、検証、本番環境をそれぞれ用意することが多いですが、Terraformを環境ごと(例:開発環境、ステージング環境、本番環境)にシンプルなWebサーバーの構成を例にしてModuleを使わないときと使ったときの構成を比較してみましょう。

Terraform Configuration
|--- Development Environment
|   |--- VM Instances (Web servers)
|   |--- Firewall Rules (Allow HTTP/HTTPS traffic to the web servers)
|   |--- Load Balancer (Balance traffic among VM instances)
|   |--- Storage Bucket (Store static content)
|--- Staging Environment
|   |--- VM Instances (Web servers)
|   |--- Firewall Rules (Allow HTTP/HTTPS traffic to the web servers)
|   |--- Load Balancer (Balance traffic among VM instances)
|   |--- Storage Bucket (Store static content)
|--- Production Environment
|   |--- VM Instances (Web servers)
|   |--- Firewall Rules (Allow HTTP/HTTPS traffic to the web servers)
|   |--- Load Balancer (Balance traffic among VM instances)
|   |--- Storage Bucket (Store static content)

この構成では、 環境毎にVM Instances 、Firewall Rules 、 Load Balancer 、Storage Bucket などのリソースが定義されていて環境間で異なるリソース設定を利用します。

一方、moduleを使用した場合の構成は以下のようになります。

Terraform Configuration
|--- modules
|   |--- user_service_cluster
|       |--- main.tf
|       |   |--- VM Instances (Web servers)
|       |   |--- Firewall Rules (Allow HTTP/HTTPS traffic to the web servers)
|       |   |--- Load Balancer (Balance traffic among VM instances)
|       |   |--- Storage Bucket (Store static content)
|       |--- variables.tf
|       |--- output.tf
|--- Development Environment
|   |--- User-Service-Cluster  Module (source: ../modules/user_service_cluster)
|--- Staging Environment
|   |--- User-Service-Cluster Module (source: ../modules/user_service_cluster)
|--- Production Environment
|   |--- User-Service-Cluster  Module (source: ../modules/user_service_cluster)

この構成では、 user_service_cluster moduleのmain.tfファイル内にVM Instances 、Firewall Rules 、 Load Balancer 、Storage Bucket などのリソースが定義されています。各環境はこのuser_service_clustermoduleを参照しており、環境間で共通のリソース設定を再利用します。これによって再利用性、可読性が上がり維持管理性を高める事ができると思います。

moduleの使い方

Terraformの moduleは、リソース設定の再利用可能な部品で、コードの抽象化と組織化をサポートします。 moduleは一つ以上のリソースを定義し、それらをまとめて管理することができます。

moduleを使用するためには、 moduleブロックをmain.tf(またはその他の.tfファイル)に追加し、そこでmoduleのソースと任意の入力変数を指定します。以下に、user_service_cluster moduleを使用するための基本的なmodule ブロックの例を示します。

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

  instance_type  = "n1-standard-1"
  instance_count = 3
  firewall_rules = {
    allow_http  = true
    allow_https = true
  }
  load_balancer_config = {
    protocol = "HTTP"
    port     = 80
  }
  bucket_name = "dev-bucket"
}

source属性にmoduleのソースコードが存在するパスを指定しています。そして、user_service_cluster moduleが定義する各入力変数を設定しています。

moduleは、そのソース内でvariableブロックを使用して入力変数を定義します。これらの入力変数は、moduleの使用者が値を提供することでmoduleの振る舞いをカスタマイズできます。

また、moduleはoutputブロックを使用して出力値を定義します。出力値は、moduleの内部リソースの属性をmoduleの外部に公開するために使用されます。これにより、他のリソースやmoduleがmoduleから生成されるリソースを参照することが可能になります。

module化はTerraformのコードベースを組織化し、再利用可能なコードを作成するための重要な手段です。これにより、一貫性が保たれ、メンテナンスが容易になり、エラーの可能性も低減します。

moduleの入力

Terraformのmoduleは再利用可能なコードブロックで、入力変数(input variables)を使用してカスタマイズできます。これらの入力変数は、moduleブロックで設定します。

以下に、user_service_cluster moduleで使用する入力変数の例を示します。

まず、module自体のvariables.tfファイルには以下のように入力変数を定義します

variable "instance_type" {
  description = "The type of instance to start"
  type        = string
  default     = "n1-standard-1"
}

variable "instance_count" {
  description = "Number of instances to create"
  type        = number
  default     = 1
}

variable "firewall_rules" {
  description = "Firewall rules for instances"
  type        = map(any)
  default     = {}
}

variable "load_balancer_config" {
  description = "Configuration for load balancer"
  type        = map(any)
  default     = {}
}

variable "bucket_name" {
  description = "Name of the storage bucket"
  type        = string
  default     = "default-bucket"
}

そして、このmodule を呼び出す際に、具体的な値を設定します:

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

  instance_type  = "n1-standard-1"
  instance_count = 3
  firewall_rules = {
    allow_http  = true
    allow_https = true
  }
  load_balancer_config = {
    protocol = "HTTP"
    port     = 80
  }
  bucket_name = "dev-bucket"
}

上記の例では、user_service_cluster moduleはsourceで指定されたソースからロードされ、instance_typeinstance_countfirewall_rulesload_balancer_configbucket_nameという入力変数を設定しています。

module に入力変数を提供することで、module の動作をカスタマイズし、異なる環境や条件で再利用することが可能になります。

ローカルをうまく利用する

Terraformのlocalsブロックを使用すると、再利用可能な内部変数をmodule内で定義することができます。localsはmodule内で共有され、module外からは参照できません。

以下に、user_service_cluster module のlocalsの例を示します。この例では、HTTPポート、任意のポート、任意のプロトコルTCPプロトコル、そして全てのIPアドレスをローカル変数として定義しています。

locals {
  http_port    = 80
  any_port     = 0
  any_protocol = "-1"
  tcp_protocol = "tcp"
  all_ips      = ["0.0.0.0/0"]
}

ローカル変数はlocal.<NAME>の形式で参照します。以下のリソース定義では、ロードバランサーリスナーとセキュリティグループの設定にローカル変数を使用しています。

resource "google_compute_instance" "http" {
  name         = "web-instance"
  machine_type = "n1-standard-1"

  network_interface {
    network = "default"
    access_config {
      // Assign an ephemeral IP to the instance
    }
  }
  
  // Other configuration...
}

resource "google_compute_firewall" "default" {
  name    = "default-firewall"
  network = "default"

  allow {
    protocol = local.tcp_protocol
    ports    = [local.http_port]
  }

  source_ranges = local.all_ips
}

上記の例では、ロードバランサーリスナーとセキュリティグループでlocalsブロックに定義したローカル変数を参照しています。local.http_portlocal.tcp_protocollocal.all_ipsを各リソースブロックで参照することで、コードがDRYに保たれ、より読みやすく、メンテナンスがしやすくなります。

localsブロックを使用することで、コードの冗長性を減らし、module全体の一貫性を保つことができます。また、ローカル変数を使用することで、moduleの一部で使用する変数をmodule全体で共有することが可能になります。

moduleの出力

Terraformのmoduleは、出力変数(outputs)を提供できます。出力変数はmoduleの値を外部に公開するための手段で、moduleを使用しているコードからアクセスできます。また、Terraformがapplyコマンドを実行した後にこれらの値を表示することもできます。

以下に、user_service_cluster moduleの出力変数の例を示します。この例では、output.tf にクラスタのURLとインスタンスのIDを出力しています。

output "cluster_url" {
  description = "The URL of the load balancer for the cluster"
  value       = "http://${google_compute_global_address.default.address}"
}

output "instance_ids" {
  description = "The IDs of the instances in the cluster"
  value       = google_compute_instance.default.*.id
}

これらの出力をmodule の使用側でアクセスするためには、moduleの名前と出力の名前を組み合わせて参照します。

output "user_service_cluster_url" {
  description = "The URL of the load balancer for the user service cluster"
  value       = module.user_service_cluster.cluster_url
}

output "user_service_cluster_instance_ids" {
  description = "The IDs of the instances in the user service cluster"
  value       = module.user_service_cluster.instance_ids
}

このようにして、moduleの出力変数を使用することで、moduleの内部データをmodule外部に公開し、他のTerraformコードがそのデータを参照できるようにします。出力変数はmodule間の情報共有を可能にし、moduleの再利用性を向上させます。Terraformはファイル名に特別な意味を持たせません。すなわち、variables.tfやoutputs.tfという名前は慣習にすぎないので、入力変数と出力変数を1つのファイルにまとめることも技術的には可能です。

module を使ったときの失敗について

module を作る時に注意する点について実際にハマったことをベースに3つ紹介します。

バージョン

Moduleのバージョンが異なると意図しない挙動やエラーが引き起こされる可能性があるので、バージョンを固定し実行環境を統一しましょう。Providerやパッケージにしても同じでバージョンを指定して再利用性を高めろ!!!

状態の差分は可能な限り小さくすべき

いつでもアップグレードを状態差分なしで行うことはできません。依存するリソースの変更やセキュリティ問題ができるだけ早くパッチを適用する必要があるなど、破壊的な変更を導入する必要がある場合があります。その場合、コストをどのように減らすかについて考える必要があります。状態の差分が少なければ、アップグレードのコストは少なくなります。破壊的な変更を導入するときは、それを文書化できるCHANGELOGやユーザーガイドを通じてユーザーに伝える必要があります

アップグレードは自動されるべき

アップグレードは長期的に開発されるソフトウェアの最も重要なタスクの一つです。ただし、一般的に使用され、広く使用されているTerraform Moduleの場合、これは大きな問題でもあります。また、Moduleを頻繁に更新する場合、自動アップデートの機能を準備する必要があります。ユーザーにアップグレードを依頼しても、通常、彼らはより重要なタスクを行うためにそれを行うことはありません。そのため、代わりに、彼らのためにPRを作成します。PRがTerraformの差分がない場合に自動的にマージされるメカニズムを持っています。これと後方互換性の維持の組み合わせにより、最新バージョンのModuleを使用するユーザーの率を増やすことができます

ファイルパス

Terraformのtemplatefile関数を使用する際、ファイルパスは絶対パスではなく相対パスを使用する必要があります。しかし、これはどのパスに対して相対的なのでしょうか?

デフォルトでは、Terraformはパスを現在の作業ディレクトリに対して相対的に解釈します。そのため、terraform applyを実行しているディレクトリと同じディレクトリにTerraform設定ファイルがある場合、これはうまく動作します。しかし、別のフォルダに定義されたmodule内でtemplatefileを使用する場合、これは問題となります。

この問題を解決するためには、path.moduleなどのパス参照を使用します。これを使用すると、module自体に対する相対パスが得られます。

インラインブロック

Terraformリソースの一部の設定は、インラインブロックか別のリソースとして定義することができます。インラインブロックとは、リソース内で設定する引数のことで、次の形式を持っています。

resource "xxx" "yyy" {
  <NAME> {
    [CONFIG...]
  }
}

ここでNAMEはインラインブロックの名前(例えば、ingress)、CONFIGはそのインラインブロックに特有の一つ以上の引数(例えば、from_portやto_port)です。

しかし、インラインブロックと別のリソースを混在して使用すると、Terraformの設計上、設定が衝突し互いに上書きされてエラーが発生します。したがって、どちらか一方を使用する必要があります。moduleを作成する際には、別のリソースを使用することを常に推奨します。

これらの注意点を理解しておくことで、Terraformのmoduleをより効果的に利用することができます。

いい感じのデフォルトの変数

完全にカスタマイズできるModuleには魅力がないです。Moduleの変数には、80%のユーザーをカバーするスマートデフォルト値を持つべきです。ただし、同時に、通常のユーザーとは異なる方法でModuleを使用するパワーユーザーのための設定も用意するべきです。変数を変更したときに何が起こるかは、ユーザーにとって明白で予測可能でなければなりません。この設定は適切に設計され、安易に浅いインターフェースを持つべきではありません

最後に

moduleを活用することで、インフラストラクチャの再利用性と効率性が大幅に向上します。開発者は証明済み、テスト済み、文書化済みのインフラストラクチャの一部を再利用できるようになるため、迅速かつ確実にシステムを構築できます。例えば、マイクロサービスのデプロイメントを定義するmoduleを作成し、各チームが数行のコードで自身のマイクロサービスを管理できるようにすることが可能です。

しかし、このようなmoduleを複数のチームで活用するためには、module内のTerraformコードは柔軟性と設定可能性が必要です。異なるチームや状況に応じて、ロードバランサーなしの単一インスタンスロードバランサー付きの複数インスタンスといった、さまざまなデプロイメント要件を満たすことができます。Terraformの柔軟な構文を活用することで、より多機能なmoduleを設計し、インフラストラクチャの構築を一層楽しく効果的に行うことができます。また、どれぐらいの規模からmodule化するのかなど迷う場面が多いと思いますがこの辺は経験としか言えずにみんな雰囲気でやっているなぁって思いました。

このブログが伸びたらもっと実装に基づいた話をしていこうと思います。ちなみにベストプラクティスなんかは俺にはわからない。自分を信じても…信頼に足る仲間を信じても…誰にもわからない…今の構成が一番変更しやすくて誇れるものならそれが正解なんだとおもう。

参考