11
4

More than 5 years have passed since last update.

Terraform で(無理やり!)独自の関数を定義する

Posted at

Infrastructure as Code に欠かせない存在である Terraform。宣言的な記法、安心の plan 出力、豊富な対応プロバイダなど嬉しいポイントはいっぱいです。

でも、独自の設定記述言語 HCL/HIL や Terraform 自体の制約上「こういう処理が書きたいのに書けない!」「無理やり書くとカオスになる!」と嘆くこともしばしば(それでも、昔よりはマシですが……)。

今回はそんな悩みをちょっぴり和らげてくれる「独自の関数を定義する」 hack を紹介します。

※ お断り: 本記事はあくまで「こうやったらできるよ」と方法を紹介するもので、それが good practice であるかどうかは大いに議論の余地があります

Terraform バージョン

下記バージョンの Terraform で実行を確認しました。

$ terraform -v
Terraform v0.11.7

とはいえ、それほど新しい機能は使っていないので、もう少し古いバージョンでも動くはずです(ただし local value は v0.10.3 で導入された ので、それ以前の方は若干の書き換えが必要です)。

どんなやり方があるのか

現在の Terraform で関数を定義する方法としては、以下の3つの選択肢が思いつきます(他にもありそうだったら教えて下さい)。

  1. External data source から外部スクリプトを呼ぶ
  2. Module 内に interpolation function をまとめる
  3. 独自 interpolation function をコンパイルする

それぞれ、下表のような特徴があります:

External data source から外部スクリプトを呼ぶ Module 内に interpolation function をまとめる 独自 interpolation function をコンパイルする
依存関係 :broken_heart: スクリプト実行環境も必要 :white_check_mark: Terraform のみ :white_check_mark: (独自の)Terraform のみ
できること :white_check_mark: 何でも :broken_heart: interpolation でできることだけ :white_check_mark: 何でも
ブロックの記述 :broken_heart: :broken_heart: :white_check_mark: 不要(インラインですぐ使える)
ソースビルド :white_check_mark: 不要 :white_check_mark: 不要 :broken_heart:

3つ目の方法は Terraform 自体のコンパイルが必要で話が大きくなってしまうため、今回は深入りしません。興味がある方向けに、ソースコードの場所だけご案内しておきます:

:octocat: https://github.com/hashicorp/terraform/blob/v0.11.7/config/interpolate_funcs.go

もし自分が欲しい関数が汎用的なものであれば、実装して本家に pull request を送ってみるのも良いですね :muscle:

やってみよう

今回実装したものは、Github にも上げてありますので、必要に応じてクローンしてご活用ください。

:octocat: https://github.com/tmshn/terraform-how-to/define-custom-functions

実装してみる処理内容

今回はサンプルとして、引数として URL を受け取り、それをスキーム・ホスト・ポート番号・パス・クエリ文字列・ハッシュにバラして返す関数を実装してみます。

下記のようなイメージです。

>>> parse_url('https://blog.example.com/articles?category=bigdata')
{'scheme': 'https',
 'host':   'blog.example.com',
 'port':   '',
 'path':   '/articles',
 'query':  'category=bigdata',
 'hash':   ''}

準備

Terraform の設定ファイルに、先にパース対象の URL を変数で宣言しておきましょう。

main.tf
variable "url" {
  type        = "string"
  description = "URL to parse."
  default     = "https://blog.example.com/articles?category=bigdata"
}

方法 1. External data source から外部スクリプトを呼ぶ

使い方

実行対象のプログラムを実装し、それを external data source の program に指定して呼び出します。入力データは、 query に map で指定します。

これが無事評価できれば、プログラムの出力結果が data.external.url_parts.result で参照できるようになります。

main.tf
data "external" "url_parts" {
  program = ["python", "parse_url.py"]

  # input to the program
  query = {
    url = "${var.url}"
  }

  # result = (...result of the program...)
}

解説

External data source とは、ざっくり言うと「external プロバイダが提供する」「data source」です。それぞれを説明すると……

この external data source を使うと、任意のプログラムを実行してデータを取得することができます。

通常は aws コマンドや gcloud コマンドを実行して、Terraform に未実装のリソースの情報を取得するなどの用途で使われますが、純粋な関数として利用することも可能ですね。

Terraform と外部プログラム間では、下記のようなプロトコルでやりとりが行われます:

  • queryブロックで文字列 1 のキーバリューを渡すことができ、それは外部プログラムの標準入力に JSON 形式で渡される
  • 外部プログラムの戻り値は JSON 形式で標準出力に出力することができ、それは Terraform 側 data source の result attribute に設定される
  • 成功時はゼロ、エラー時は非ゼロの実行ステータスを返すこと

(入力としてはコマンドライン引数も渡せますが、既存のコマンドを呼ぶとき以外は入力はすべて標準入力に集約した方が混乱が少なくなると思います)

実装例

今回は、前述の処理をするためのスクリプトを Python で書いてみました。

parse_url.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""Small script to parse URL (intended to use from Terraform)."""

import json
import re
import sys


URL_REGEX = re.compile('^(?:(?P<scheme>\\w+):\\/\\/)?(?P<host>[^:\\/]+)(?::(?P<port>\\d+))?(?P<path>[^\\?#]*)(?:\\?(?P<query>[^#]*))?(?:#(?P<hash>.*))?$')


def parse_url(url):
    """Parse URL and returns its parts as dict."""
    try:
        return URL_REGEX.search(url).groupdict(default='')
    except AttributeError:  # If regex did not match
        raise ValueError('invalid url')


def main():
    """Read URL from stdin, parse it, and write result into stdout."""
    input_data = json.load(sys.stdin)

    url = input_data['url']
    result = parse_url(url)

    json.dump(result, sys.stdout)


if __name__ == '__main__':
    main()

方法 2. Module 内に interpolation function をまとめる

使い方

呼び出したい module を定義してそのパスを source に指定し、他の入力データ(今回は URL)も引数として指定すれば OK です。

今回は(方法 1 に合わせて) result という単一の output を定義するので、module.url_parts.result で値が取得できます。

main.tf
module "url_parts" {
  source = "./parse_url"
  url    = "${var.url}"
}

解説

「関数」というものを「一連の処理をまとめたもの」だと捉えれば、それを module で実現するのは理にかなっていますね。ただし「1つもリソースを定義しない」という、本来の目的とは少しだけずれた形ではありますが。

さて、module はそれぞれの名前空間が完全に分離されており、module 同士のデータのやり取りが制限されています。仕様として下記のことを理解しておく必要があります(ここでは、module を呼び出す側を「親」、呼び出される側を「子」と表現しています):

  • 子 module で variable として定義したものだけが、親 module から値を指定できる
  • 子 module で output として定義したものだけが、親 module から値を取得できる
  • それ以外は、ある module から 別の module の情報を見ることは一切できない(親子 module 間であっても)

ちなみに、通常の設定が書かれているのは実は root module という最上位の module です。Terraform 外から variable が指定できたり outputが取得できたりするのは、この仕組によるものなのです。

実装例

では、これを使って「関数」を実現してみましょう。

前掲の Python スクリプトと同じ処理をすればいいだけですね。Terraform では replace 関数 で正規表現が使えるので、Python で用いたものと全く同じ正規表現をそのまま使って実装してみます。

parse_url/variables.tf
variable "url" {
  type        = "string"
  description = "[Required] URL to parse."
}
parse_url/main.tf
locals {
  url_regex = "/^(?:(?P<scheme>\\w+):\\/\\/)?(?P<host>[^:\\/]+)(?::(?P<port>\\d+))?(?P<path>[^\\?#]*)(?:\\?(?P<query>[^#]*))?(?:#(?P<hash>.*))?$/"
}

locals {
  scheme = "${replace(var.url, local.url_regex, "$scheme")}"
  host   = "${replace(var.url, local.url_regex, "$host")}"
  port   = "${replace(var.url, local.url_regex, "$port")}"
  path   = "${replace(var.url, local.url_regex, "$path")}"
  query  = "${replace(var.url, local.url_regex, "$query")}"
  hash   = "${replace(var.url, local.url_regex, "$hash")}"
}
parse_url/outputs.tf
output "result" {
  value = {
    scheme = "${local.scheme}"
    host   = "${local.host}"
    port   = "${local.port}"
    path   = "${local.path}"
    query  = "${local.query}"
    hash   = "${local.hash}"
  }
}

Module を定義する時は variablevariables.tf に、outputoutputs.tf にまとめることが多いので、今回もそれに則りました。ただ使う時はディレクトリ名を指定するだけなので、どのようにファイルを分割するかはお好みで調整して下さい。

実行してみる

いよいよこれらを実行してみます。

$ terraform init  # 初期化処理
$ terraform apply # 実行!

"Apply complete!" と表示されれば実行は完了です。結果を確認してみましょう。

$ echo 'data.external.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "blog.example.com"
  "path" = "/articles"
  "port" = ""
  "query" = "category=bigdata"
  "scheme" = "https"
}
$ echo 'module.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "blog.example.com"
  "path" = "/articles"
  "port" = ""
  "query" = "category=bigdata"
  "scheme" = "https"
}

いずれも思ったとおりの結果が出ていますね。

他の URL でもやってみます。

$ terraform apply -var url='tcp://redis.example.com:6379'
$ echo 'data.external.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "redis.example.com"
  "path" = ""
  "port" = "6379"
  "query" = ""
  "scheme" = "tcp"
}
$ echo 'module.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "redis.example.com"
  "path" = ""
  "port" = "6379"
  "query" = ""
  "scheme" = "tcp"
}

うまくいっていますね!

おわりに

本記事では、Terraform で独自の関数を定義する方法として

  • External data source から外部スクリプトを呼ぶ
  • Module 内に interpolation function をまとめる

という2つを紹介しました。

:octocat: https://github.com/tmshn/terraform-how-to/define-custom-functions

記事内ではそれを terraform console コマンドで確認するにとどまりましたが、実際にはこれをリソースの argument に指定したりすることになるでしょう。

:coffee: :cake: :coffee: :cake:

今回紹介したようなやり方を「イケてる practice」と言えるかは微妙なところです。どちらかというと、無理のある黒魔術がかったやり方ですよね。

正直、個人的には Terraform 自体のイケてなさに「これが現代の構成管理ツールのあるべき姿なのか」と辟易することすらあります。

でも決して悪いツールではないし、スピード感のある開発チームと活発なコミュニティに恵まれているのも事実です。そのコミュニティの一員として、Terraform がより良いツールになるよう、これからも積極的に貢献していきたいと思います :muscle:

それでは、皆さんも Happy terraforming! :wave:


  1. Terraform で「文字列型」と呼ばれるのは、数値型や bool 型を含むスカラー型のことです 

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4