まーぽんって誰がつけたの?

iOS→Scala→インフラなおじさん技術メモ

Terraform実践入門 #3

入門三部作ラスト

www.mpon.me www.mpon.me

ここまでで、簡単にTerraformの機能をおおざっぱに説明してきました。 今回は、そこそこ現実に即したインフラを作るまでの流れを追いながら、徐々にリファクタリングしていくことで実践的な考え方を身につけていきましょう。

すごく簡略化した一般的なAWSの構成を題材にします

本番に完全に即してる訳ではないけども簡略化した構成を例として、terraformでの構築、リファクタリングの過程をやっていきます。 ディレクトリ構成などに焦点を当てていきたいので、セキュリティグループなど細かいことは省いています。また、あくまでも例なのでそのまま動くコードにはなっていません。 下図のような構成です。

f:id:masato47744:20171110005014p:plain

ロードバランサーにEC2インスタンスがそれぞれ2つずつぶらさがってるような構成。各インスタンスにはnginxとRailsアプリケーションがいるようなすごく一般的な構成デス。 で、admin画面とapp画面があって、admin.example.comでアクセスするとadmin画面に、app.example.comにいくと通常のユーザー用のweb画面に行くみたいな構成があったとします。

これをTerraformで書いていきます。

第一形態

私の戦闘力は53万です

ディレクトリ構成

最初のTerraformはこんな構成で各ファイルの役割は以下のようなものが考えつきます。シンプルにディレクトリ配下に全てtfファイルがある状態です。

.
├── ec2.tf // EC2インスタンスに関するresourceを書く
├── elb.tf // ELBに関するresourceを書く
└── main.tf // provider情報などを書く

各ファイルはコンポーネントごとにある程度分けていきます。AWSコンソールと同じような考え方ですね。

main.tfというのは、別にcommon.tfとか、core.tfとかbase.tfとか基本となるやつみたいな意味であればまぁ名前は自由でいいかなと思います。main.tfとしてるのはプログラムのスタートってmain関数だからそれにならってるぐらいの感じです。

ファイルの中身

各ファイルの中身はこういう構成です。

main.tf

providerを書くだけです。

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

ec2, elb.tf

EC2instanceはcountを使って簡単に複数台立てられます。また、ELBでinstanceを指定する際も、*.idとすることでcountで作成したresourceはterraformがlistに展開してくれます。

instanceやelbを指定する際にどのsubnetに属するかも書いておきます。

ec2.tf
resource "aws_instance" "admin" {
  count         = 2
  ami           = "ami-999999"
  instance_type = "t2.micro"
  subnet_id     = "subnet1"
}

resource "aws_instance" "app" {
  count         = 2
  ami           = "ami-999999"
  instance_type = "t2.micro"
  subnet_id     = "subnet1"
}
elb.tf

こちらがELBのresource。instancesのところがポイントかと思います。

resource "aws_elb" "admin" {
  name               = "admin-elb"
  instances          = ["${aws_instance.admin.*.id}"]
  subnets            = ["subnet1"]
  
  // その他、lister、healthcheckの設定など
}

resource "aws_elb" "app" {
  name               = "app-elb"
  instances          = ["${aws_instance.app.*.id}"]
  subnets            = ["subnet1"]
  
  // その他、lister、healthcheckの設定など
}

ここまでは、単純なケースなので特に問題は起きていないように思えます。 では、次のようなケースはどうでしょうか。

ここまでの環境をterraformで作ったあなたに、 開発メンバーから同じ構成で開発環境とステージング環境を作って欲しいといわれました。 別VPCで全く同じ構成でたてて欲しいと。つまりこういう構成です。

f:id:masato47744:20171110012109p:plain

🤔 この場合、あなたならどのように追加するでしょうか?

環境ごとのファイルをコピペしてprefixをつける?

.
├── dev-ec2.tf
├── dev-elb.tf
├── stg-ec2.tf
├── stg-elb.tf
├── prod-ec2.tf
├── prod-elb.tf
└── main.tf

このやり方には問題点があります。

フラット構造の問題点

terraformは実行するディレクトリ配下の全てのtfファイルを読み込むので、開発環境、ステージング環境、本番環境を全て同じ状態ファイル(tfstate)で保存します。 そのため、開発環境だけの変更をapplyしたいだけなのに、もし本番の差分があれば本番への差分も変更が入ってしまうということです。

また、単に今はファイル数が少ないから見分けがつきますが、多くなってくると管理が難しくなってきます。

そこでディレクトリを分ける次の方法です。

第二形態

くっくっく・・・ちなみに戦闘力にしたら100万以上は確実か・・・

先ほどのようにフラットではなく、環境ごとにディレクトリに分けて、同じようにresourceを書いていきます。 たいていのモデルケースではインフラコードはディレクトリごとに分かれて作られていると思います。

なぜ環境ごとに分けているかというと、見やすさもありますが、terraformのtfstateを分けたいというのが一番のポイントでした。

ディレクトリ構成

では、実際にディレクトリ構成を見てみましょう。

.
├── dev
│   ├── ec2.tf
│   ├── elb.tf
│   └── main.tf
├── prod
│   ├── ec2.tf
│   ├── elb.tf
│   └── main.tf
└── stg
    ├── ec2.tf
    ├── elb.tf
    └── main.tf

このように、ディレクトリに分けることによってtfstateが環境ごとに作られるようになります。そのため、各環境ごとに変更を適用できて管理しやすくなります。また、それぞれのリソースファイルも見つけやすくなると思います。

パラメータ化する

ディレクトリを分けるのは理解しましたが、この場合にファイルの中身はどう書くのが正しいのでしょうか?

先ほどのresourceの設定をそのままコピペしただけだと、subnetを指定していたsubnet1の部分が各環境のVPCごとに別のものになるので、そこだけを切り替えないといけません。

ここで登場するのがterraformのvariablesという変数の仕組みです。変数の宣言を書くために各環境にvariables.tfというファイルを用意します。

Tips: このvariables.tfという名前はTerraformのコミュニティモジュールと呼ばれる有志が作っているモジュールのベストプラクティス的なところで紹介されている名前の付け方です。terraform-community-modules

variables.tfを追加した実際のディレクトリ構成です。

├── dev
│   ├── ec2.tf
│   ├── elb.tf
│   ├── main.tf
│   └── variables.tf
├── prod
│   ├── ec2.tf
│   ├── elb.tf
│   ├── main.tf
│   └── variables.tf
└── stg
    ├── ec2.tf
    ├── elb.tf
    ├── main.tf
    └── variables.tf

各variables.tfでは、以下のように宣言して値は環境ごとに変更しておきます。

variable "subnet_id" {
  default = "subnet1" // stgだったらsubnet2にするみたいな感じ
}

これを宣言しておくと、先ほどaws_instanceaws_elbのresource内で指定していたsubnetの部分は変数で書くことができるます。そのため、各環境の記述内容は同じままで、variables.tfの中身だけ書き換えればよくなります。

例としてadminのinstanceで指定していた部分です。このように直すことができます。

resource "aws_instance" "admin" {
  count         = 2
  ami           = "ami-999999"
  instance_type = "t2.micro"
-  subnet_id     = "subnet1"
+  subnet_id     = "${var.subnet_id}"
}

また、今回の例では触れていませんが、インスタンスの台数やインスタンスタイプなども環境ごとに変えたくなる値だと思います。そういったものも変数化しておくと対応しやすくなりますね。

これでたいがいのことはクリアできたような気がしますが、もう一歩先のリファクタリングがあります。例として次のようなケースはどうでしょうか?

企画担当者からランディングページ用のサーバーも作ってくれと言われました。 今まではadminとappだけでしたが、追加でadminとかappと同じ構成でLP用のサーバーも作って欲しいと言われました。 🤔この場合にあなたならどうするでしょうか?

resourceを増やす

これぐらいの規模なら、各環境のec2.tf、elb.tfにadminやappをパクってresourceをコピペすればいいだけだ🙋

そう思ってこんな風に各環境のファイルにコピペして回ります。

resource "aws_instance" "lp" {
  count         = 2
  ami           = "ami-999999"
  instance_type = "t2.micro"
  subnet_id     = "${var.subnet_id}"
}

これでも問題ないといえば問題ありませんが、これからLP以外にも違う役割のサーバーを増やして欲しいとかQA環境も欲しいなどの要望が来るかもしれません。 また、新しい役割のサーバーを増やすには今現在のあなたはelbとinstanceを追加すればいいと分かっていますが、1ヶ月後に自分自身がこのことを覚えていられるでしょうか?

第三形態

たいした自信だねベジータ。

こういったときに使えるのがterraformのmoduleという機能です。

module化

今回の構成の場合、LBがあって、EC2がぶらさがっているという部分はどのサーバー(admin、app、lp)でも同じということに気づいたと思います。moduleを使うことでLBとEC2をぶら下げるという部分をグループ化することができます。

module、moduleと言いましたがやり方は簡単で、単にこのように別ディレクトリにまとめるだけです。ここでは、module/web_appという名前でまとめました。 そして、各環境にあったec2.tfとelb.tfはweb_app.tfという一つのファイルにまとめています。

├── dev
│   ├── web_app.tf
│   ├── main.tf
│   └── variables.tf
├── module
│   └── web_app
│       ├── ec2.tf
│       └── elb.tf
├── prod
│   ├── web_app.tf
│   ├── main.tf
│   └── variables.tf
└── stg
    ├── web_app.tf
    ├── main.tf
    └── variables.tf

このようにmoduleにまとめたら、利用する側のweb_app.tfでは、以下のようにsourceとしてmoduleのディレクトリを指定するだけです。

module "admin" {
  source = "../module/web_app"
}
module "app" {
  source = "../module/web_app"
}
module "lp" {
  source = "../module/web_app"
}

そして、moduleを利用する場合は、terraform planを打つ前に、terraform getというコマンドを打つ必要があります。 getは単にmoduleのファイルを実行ディレクトリ配下にコピーをしてきているだけです。 普通は意識する必要はありませんが、.terraform/modulesというディレクトリが自動的に作成されています。

moduleのスコープ

ここまでで、moduleの利用はできているのですが、この状態でterraform planをすると、実は失敗してしまいます。 terraformからは、moduleの中に書いてあるvar.subnet_idなんてものはない!!って怒られます。

なぜかというと、terraformの第1原則である実行ディレクトリ配下に存在するtfファイルのみ同じtfstateで管理されるからです。 dev、stg、prodと、分けたら別のスコープになったのと同じで、moduleに対しても同じことが言えるのです。 dev/variables.tfに変数が宣言されていたとしても、moduleから見るとスコープ外なので読み取れないのです。

では、どうやってmoduleに変数を渡せばいいのでしょうか?

moduleへ変数を渡す

moduleに変数を渡す方法はきちんと用意されているので安心してください。 まず、module/web_app/variables.tfというファイルを用意して以下のように書きます。

variable "web_app_subnet_id" {}

ここでは、web_app_subnet_idという値を宣言しました。

そして、module内でvar.subnet_idとしていたところをvar.web_app_subnet_idとします。

moduleの変更が終わったら、次は呼び出し側で変数を渡してあげます。 先ほどのmoduleをimportしているところで、dev/variables.tfで宣言しているsubnet_idを渡してあげるように変更します。

module "admin" {
  source = "../module/web_app"
+ web_app_subnet_id = "${var.subnet_id}"
}
module "app" {
  source = "../module/web_app"
+ web_app_subnet_id = "${var.subnet_id}"
}
module "lp" {
  source = "../module/web_app"
+ web_app_subnet_id = "${var.subnet_id}"
}

これでweb_appモジュールがたった4行で増やせるようになりました。 最終的なディレクトリ構成がこちら。

├── dev
│   ├── web_app.tf
│   ├── main.tf
│   └── variables.tf
├── module
│   └── web_app
│       ├── variables.tf
│       ├── ec2.tf
│       └── elb.tf
├── prod
│   ├── web_app.tf
│   ├── main.tf
│   └── variables.tf
└── stg
    ├── web_app.tf
    ├── main.tf
    └── variables.tf

module内のresourceを調整

今までは各resourceで、aws_instance "admin"のadminの部分のように、resourceの識別子がかぶらないように調整をしていました。 moduleにしたことで、もうadminだけのものではないのでinstanceなどのような一般的な識別子に変えてしまいます。moduleにしたことでスコープがかぶらなくなるので、どれだけimportされようと識別子がかぶることがなくなるのです!

このように、moduleの中はscopeが閉じるので、外からは変数を与えたり、今回は触れていませんが逆に値を取り出すにはoutputっていう機能を使ったりして取り出します。

エンジニアのみなさんが大好きなカプセル化ができる訳です😍

outputはこちら: https://www.terraform.io/intro/getting-started/outputs.html

とはいえ、やりすぎ注意

ここまでくるともっと共通化できるじゃん、全部moduleにしちゃえばいいじゃんという発想もでてきます。 ただ現実は厳しいので、例えば開発にはLPいらないよとかそういったことが起きてきます。 なので、やり過ぎは禁物で適度に柔軟性を持たせつつmodule化することが重要です。

まとめ

Terraform入門を通してある程度理解したあなたは、既存プロジェクトのソースコードが読めるようになっているはずです!!自分のプロジェクトのインフラコードを見てみましょう。