Terraformの機能を使いこなす難しさ

TerraformでInfrastructure as Codeする際、module機能を使ってコードをDRYにしたり、count等を使ってループ相当のことを実現できる。また変数も多くのデータ型で表現できるので、大抵のことはTerraform純正の機能を使って実現できる。

しかし、これらを使いこなすのは意外と難しい。

moduleの難しさ

moduleに汎用的なコードのテンプレートを切り出しても、そのmoduleを呼び出すにはTerraformのソースを書かなくてはいけない。呼び出し元と呼び出される側の二つにTerraformのコードが散らばってしまい、コードの一覧性が損なわれる。

呼び出し元のec2.tf

module "ec2_api0001" {
  source = "./module/ec2"
  ami = "${var.ami}"
  instance_type = "m5.xlarge"
  key_name = "${var.key_name}"
  root_disk_type = "gp2"
  略
}

呼び出される側のmodule/ec2.tf

resource "aws_instance" "ec2" {
  ami                    = "${var.ami}"
  instance_type          = "${var.instance_type}"
  key_name               = "${var.key_name}"
  root_block_device {
    volume_type = "${var.root_disk_type}"
    delete_on_termination = true
  }
  略
  lifecycle {
    ignore_changes = [
      "ami",
      "key_name",
      "volume_tags",
    ]
  }
}

module/ec2.tfがどのような変数を受け付けるのかを把握するもの大変。(変数宣言を別途切り出すこともできるが、そうするとソースコード量が増える。)

またmoduleで作成した場合、作成したリソースを参照しようとすると、必ずoutput定義をしなければならない。

forループやif分岐の難しさ

countでループを実現することができるが、countと組み合わせてTagにつける値を生成したりなどすると、Terraformのソースコードが複雑化してしまう。他にifを使いたければ三項演算子しか使えないなど、複雑化しやすいポイントが多い。forループ、if分岐等の基本的なロジックはもう少し素直に書きたい。

変数定義の難しさ

Terraformの変数のデータ型は多彩なのだが、その定義した変数を自由に操ることが難しい。上にも書いたように、forifを書きづらいため、定義した変数を使ってロジックを組み立てるのが難しい。

そもそもAnsibleのような書きようによってはあたかもプログラミングしているかのようにロジックを書くこともできるツールと比較すると、Terraformはより宣言的なコーディングを前提としている感がある。

Jinja2でTerraformのソースコードを生成する

Terraformのversion自体がまだ 0.11.8 と1.0に達していないことも大きいとは思うが、Terraformの純正の機能に辛さを感じる。しかしTerraformのコア機能であるプロビジョニング機能自体は非常に利便性を感じている。

そこでJinja2を使ってTerraformのソースコードを生成することで、複雑性を排除してみたい。複雑性排除の方法は以下の通り。

  • for, if等のロジック要素は全てJinja2に譲る
  • TemplateエンジンであるJinja2を導入しているので、DRYはそれで実現し、module機能は使わない
  • 変数定義は全てYAMLに切り出す

この方式では、複雑性の排除だけでなく、PythonおよびJinja2によるプログラミングができるので、YAMLに切り出した変数をdictとしてかなり柔軟に扱うことができる。

実際にどのようなコードになるのか、VPCの作成からEC2の作成まで書いていく。

YAMLによる変数定義

全ての変数をYAMLに切り出す。all.ymlにはネットワークやセキュリティ周りなど全般にかかわることを記載し、resource.ymlにはEC2やAuroraなどを記載する。

all.yml

project: myproject
ami: ami-XXXXXXXXXXXXXXXXX
access_key: "XXXXXXXXXXXXXXXX"
secret_key: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
region: ap-northeast-1
vpc:
  name: vpc_1
  cidr_block: 10.1.0.0/16
  open_subnets:
    ap-northeast-1a: 10.1.0.0/19
    ap-northeast-1c: 10.1.64.0/19
    ap-northeast-1d: 10.1.128.0/19
  close_subnets:
    ap-northeast-1a: 10.1.32.0/19
    ap-northeast-1c: 10.1.96.0/19
    ap-northeast-1d: 10.1.160.0/19

our_cidr_blocks:
  - xxx.xxx.xxx.xxx/32 # MY OFFICE
security:
  - name: bastion
    port: 22
    protocol: tcp
    cidr_blocks:
      - yyy.yyy.yyy.yyy/32 # additional cidr for bastion

resource.yml(踏み台サーバbastion0001の例)

ec2:
  - instances:
      - bastion0001
    type: t2.micro
    subnets:
      type: open
      use:
        - ap-northeast-1a
    security_groups:
      - bastion
    ebs:
      - type: gp2
        size: 100
    eip: true

all.ymlは開発環境/本番環境など各環境を通して使う(VPCはregionで一つ作成することを前提としている)ため、symlinkを使って各ディレクトリから参照する。resources.ymlは環境ごとに分割し、環境ごとにterraform applyしてプロビジョニングしていく。

ディレクトリ構造は以下の通り。

/terraform
|--ap-northeast-1
|  |--bastion
|  |  |--all.yml -> ../all.yml
|  |  |--generate.py -> ../../template/generate.py
|  |  |--resource.yml
|  |--development
|  |  |--all.yml -> ../all.yml
|  |  |--generate.py -> ../../template/generate.py
|  |  |--resource.yml
|  |--production
|  |  |--all.yml -> ../all.yml
|  |  |--generate.py -> ../../template/generate.py
|  |  |--resource.yml
|  |--all.yml
|  |--generate.py -> ../template/generate.py
|--template
|  |--generate.py
|  |--default_security.tf
|  |--ec2.tf
|  |--main.tf
|  |--network.tf
|  |--remote.tf
|  |--security.tf

Terraformのソースコードを生成するPythonプログラム

generate.py(Terraformのソースコードを生成するPythonプログラム)

#!/usr/bin/env python

import os
import sys
import yaml
import glob
from jinja2 import Environment, FileSystemLoader

def realdir():
  return os.path.dirname(os.path.realpath(__file__))

def deepupdate(dict_base, dict_over):
  for k, v in dict_over.items():
    if isinstance(v, dict) and k in dict_base:
      deepupdate(dict_base[k], v)
    else:
      dict_base[k] = v


ymls = glob.glob('./*.yml')

variables = {}
for yml in ymls:
  with open(yml, "r") as f:
    v = yaml.load(f)
    deepupdate(variables, v)

args = sys.argv
if len(args) > 1:
  filename = args[1]
else:
  filename = "*"

env = Environment(loader=FileSystemLoader(realdir()))
templates = map(os.path.basename, glob.glob(os.path.join(realdir(), filename)))
for template in templates:
  generated = env.get_template(template).render(variables)
  with open(template, "w") as f:
    f.write(generated)

カレントディレクトリのYAMLファイルの構造をdictに読み込み、templateディレクトリのテンプレートファイルに変数として渡して、カレントディレクトリにファイルを生成している。

Templateファイルを生成するためには、以下のようにスクリプトを実行する。

$ cd ap-northeast-1
$ ./generate.py main.tf
$ ./generate.py network.tf
$ ./generate.py security.tf
$ terraform init
$ terraform plan
$ terraform apply

$ cd bastion
$ ln -s ../main.tf .
$ ./generate.py remote.tf
$ ./generate.py ec2.tf
$ ./generate.py default_security.tf # 踏み台サーバ後、踏み台サーバのEIPをデフォルトセキュリティグループにつける
$ terraform init
$ terraform plan
$ terraform apply

Templateファイル

以下、Templateファイルがどのようなものかを書く。かなり長くなるが、実体としてはただのJinja2によるテンプレートファイルであり、{{ }}で変数が埋められるようになっていたり、{% if boolean %}, {% for var in vars %}で分岐、繰り返しをしているだけ。記載しているものも、Terraformのmodule等の難しい機能は使っていない。

実際にTemplateファイルから生成したファイルは、EC2を10個作るのであれば10個分の定義が愚直に出力され、読むときに頭を全く使わなくていい。

main.tf

provider "aws" {
  access_key = "{{ access_key }}"
  secret_key = "{{ secret_key }}"
  region     = "{{ region }}"
}

network.tf

resource "aws_vpc" "{{ vpc.name }}" {
  cidr_block           = "{{ vpc.cidr_block }}"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags {
    Name = "{{ vpc.name }}"
  }
}
output "aws_vpc-{{ vpc.name }}-id" {
  value = "${aws_vpc.{{ vpc.name }}.id}"
}

resource "aws_internet_gateway" "{{ vpc.name }}_igw" {
  vpc_id = "${aws_vpc.{{ vpc.name }}.id}"
  tags {
    Name = "{{ vpc.name }}_igw"
  }
}

{% for k, v in vpc.open_subnets.items() %}
resource "aws_subnet" "open-{{ k }}" {
  vpc_id            = "${aws_vpc.{{ vpc.name }}.id}"
  cidr_block        = "{{ v }}"
  availability_zone = "{{ k }}"
  tags {
    Name = "open-{{ k }}"
  }
}
output "aws_subnet-open-{{ k }}-id" {
  value = "${aws_subnet.open-{{ k }}.id}"
}

resource "aws_route_table" "open-{{ k }}" {
  vpc_id = "${aws_vpc.{{ vpc.name }}.id}"

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = "${aws_internet_gateway.{{ vpc.name }}_igw.id}"
  }

  tags {
    Name = "open-{{ k }}"
  }
}

resource "aws_route_table_association" "open-{{ k }}" {
  subnet_id      = "${aws_subnet.open-{{ k }}.id}"
  route_table_id = "${aws_route_table.open-{{ k }}.id}"
}
{% endfor %}



{% for k, v in vpc.close_subnets.items() %}
resource "aws_subnet" "close-{{ k }}" {
  vpc_id            = "${aws_vpc.{{ vpc.name }}.id}"
  cidr_block        = "{{ v }}"
  availability_zone = "{{ k }}"
  tags {
    Name = "close-{{ k }}"
  }
}
output "aws_subnet-close-{{ k }}-id" {
  value = "${aws_subnet.close-{{ k }}.id}"
}

resource "aws_eip" "nat-open-{{ k }}" {
  vpc = true
  tags {
    Name = "nat-open-{{ k }}"
  }
}

resource "aws_nat_gateway" "nat-open-{{ k }}" {
  allocation_id = "${aws_eip.nat-open-{{ k }}.id}"
  subnet_id     = "${aws_subnet.open-{{ k }}.id}"
  tags {
    Name = "nat-open-{{ k }}"
  }
}

resource "aws_route_table" "close-{{ k }}" {
  vpc_id = "${aws_vpc.{{ vpc.name }}.id}"

  route {
    cidr_block = "0.0.0.0/0"
    nat_gateway_id = "${aws_nat_gateway.nat-open-{{ k }}.id}"
  }

  tags {
    Name = "close_{{ k }}"
  }
}

resource "aws_route_table_association" "close-{{ k }}" {
  subnet_id      = "${aws_subnet.close-{{ k }}.id}"
  route_table_id = "${aws_route_table.close-{{ k }}.id}"
}
{% endfor %}

security.tf

data "aws_security_group" "{{ vpc.name }}_default" {
  vpc_id = "${aws_vpc.{{ vpc.name }}.id}"
  name   = "default"
}
output "aws_security_group-{{ vpc.name }}_default-id" {
  value = "${data.aws_security_group.{{ vpc.name }}_default.id}"
}


{% for s in security %}
resource "aws_security_group" "{{ s.name }}" {
  name   = "{{ s.name }}"
  vpc_id = "${aws_vpc.{{ vpc.name }}.id}"

  {%- for p in (s.port|string).split(',') %}
    {%- if '-' in p %}
      {%- set from_port = p.split('-')[0] %}
      {%- set to_port   = p.split('-')[1] %}
    {%- else %}
      {%- set from_port = p %}
      {%- set to_port   = p %}
    {%- endif %}
    ingress {
      from_port   = {{ from_port }}
      to_port     = {{ to_port }}
      protocol    = "{{ s.protocol }}"
      cidr_blocks = [
        {%- for c in our_cidr_blocks %}
          "{{ c }}",
        {%- endfor %}
        {%- for c in s.cidr_blocks | default() %}
          "{{ c }}",
        {%- endfor %}
      ]
    }
  {%- endfor %}

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags {
    Name = "{{ s.name }}"
  }
}
output "aws_security_group-{{ s.name }}-id" {
  value = "${aws_security_group.{{ s.name }}.id}"
}
{% endfor %}

remote.tf

data "terraform_remote_state" "all" {
  backend = "local"
  config {
    path = "../terraform.tfstate"
  }
}

ec2.tf

{% for ec2grp in ec2 %}
{% for instance in ec2grp.instances %}
resource "aws_instance" "{{ instance }}" {
  ami                    = "{{ ami }}"
  instance_type          = "{{ ec2grp.type }}"
  key_name               = "{{ project }}SSHKEY01.pem"
  subnet_id              = "${ data.terraform_remote_state.all.aws_subnet-{{ ec2grp.subnets.type }}-{{ ec2grp.subnets.use[loop.index0 % ec2grp.subnets.use|length] }}-id }"
  vpc_security_group_ids = [
      "${data.terraform_remote_state.all.aws_security_group-{{ vpc.name }}_default-id}",
    {%- for s in ec2grp.security_groups | default() %}
      "${data.terraform_remote_state.all.aws_security_group-{{ s }}-id}",
    {%- endfor %}
  ]

  tags {
    Name = "{{ instance }}"
  }

{% if ec2grp.public_ip | default(false) %}
  associate_public_ip_address = true
{% endif %}

  root_block_device {
    volume_type = "{{ (instance.root_block_device | default({})).type | default('gp2') }}"
    volume_size = {{ (instance.root_block_device | default({})).size | default(30) }}
    delete_on_termination = true
  }

  volume_tags {
    Name = "{{ instance }}_rootdisk"
  }

  lifecycle {
    ignore_changes = [
      "ami",
      "key_name",

      # This prevents clobbering the tags of attached EBS volumes. See
      # [this bug][1] in the AWS provider upstream.
      #
      # [1]: https://github.com/terraform-providers/terraform-provider-aws/issues/770
      "volume_tags",
    ]
  }
}

{% for ebs in ec2grp.ebs %}
resource "aws_ebs_volume" "{{ instance }}-ebs{{ loop.index0 }}" {
  availability_zone = "{{ ec2grp.subnets.use[loop.index0 % ec2grp.subnets.use|length] }}"
  type = "{{ ebs.type }}"
  size = {{ ebs.size }}
  tags {
    Name = "{{ instance }}_ebs{{ loop.index0 }}"
  }
}

resource "aws_volume_attachment" "{{ instance }}-ebs{{ loop.index0 }}" {
  device_name = "/dev/sdb"
  volume_id   = "${aws_ebs_volume.{{ instance }}-ebs{{ loop.index0 }}.id}"
  instance_id = "${aws_instance.{{ instance }}.id}"
}
{% endfor %}

{% if ec2grp.eip | default(false) %}
resource "aws_eip" "{{ instance }}" {
  instance = "${aws_instance.{{ instance }}.id}"
  vpc      = true
  tags {
    Name = "{{ instance }}-eip"
  }
}
{% endif %}
{% endfor %}

{% if ec2grp.alb is defined %}
略
{% endif %}

{% endfor %}

default_security.tf

resource "aws_default_security_group" "{{ vpc.name }}" {
  vpc_id = "${data.terraform_remote_state.all.aws_vpc-{{ vpc.name }}-id}"

  ingress {
    from_port = 0
    to_port   = 0
    protocol  = "-1"
    self      = true
  }

  ingress {
    from_port = 0
    to_port   = 0
    protocol  = "-1"
    cidr_blocks = ["${aws_eip.{{ ec2[0].instances[0] | default('bastion0001') }}.public_ip}/32"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}