VPS を何年も前に解約してから Linux の遊び場もなくなってしまっていたのですが、 AWS を勉強したこともあり、あー EC2 なり S3 なり使えば色々できるなぁと思い立ちました。
せっかくなので、楽に遊び場を作ったり消したりしたいなぁということで、 Terraform に入門するかと思い立ったのが昨日のことです。
そろそろTerraformに入門しなければ…
— Yuichi Kiri (@kiririmode) 2018年4月16日
Getting Started あたりを読んで tf ファイルを書いていったところ、 目標だった任意台数の Web サーバをデプロイするところまではいけたので、苦難の道のりをまとめようと思います。
なにせ短期間しか Terraform に触れていないので、間違ってるところとかあったら教えてくださいおねがいします。
あと上級者の方は id:minamijoyo が Terraform職人入門: 日々の運用で学んだ知見を淡々とまとめる - Qiita でこれでもかってくらいのヤツをまとめてくれているので、読んだら良いはずです。
目次記法ができたの知らなかった
- 目次記法ができたの知らなかった
- 今日のゴール
- Terraform とは
- 開発環境 (Emacs)
- 開発環境 (Terraform)
- Terraform の重要概念の整理
- リソースに依存したリソースはどうやって書くの?
- 変数
- 便利関数
- サーバのプロビジョニング
- 任意個数のサーバ
- 必要な情報を簡単に手に入れたい
- というわけで
今日のゴール
以下を達成することをゴールとしました。
- 1 コマンド
- 任意台数の Web サーバを構築できる
- Web サーバには、インターネットから ssh/http でアクセス可能
Terraform とは
Terraform は、安全かつ効率的に、インフラの構築、変更、バージョン管理をするためのツールとされています。
Terraform 自体は、ぼくから見ると「IaaS だろうが PaaS だろうが SaaS だろうが、API さえ提供してくれたら 下回りは作ってやるぜ」というような感じで、
- AWS や Azure、GCS (このあたりはイメージつきやすいですね)
- Heroku (このあたりもなんかイメージできますね)
- Github や MySQL (??????????)
とか諸々下回りを作ってくれます。 Terraform の Github のドキュメントを見る限りリポジトリやチームメンバの登録とかできるようですし、 MySQL だと database やら grant やらの投入ができるっぽいです。
全体として見ると Ansible とかと領域が被っている気もするんですが、 Provisioner のドキュメントに目を通すかんじ、Ansible とか Chef よりも もう少し下位レイヤが主戦場なんだろうと読み取っています。EC2 とかの上で Web/App サーバとかを立てようとすると、Chef とかコマンド列を呼び出すことになりますし。
開発環境 (Emacs)
ぼくは Emacs 派ということもあり、terraform-mode をインストールしました。
terraform-mode は、パスを通すだけで、シンタックスハイライトはもちろん、
ファイル保存をフックにした terraform fmt
の実行 (go fmt
と同じようにコードフォーマットしてくれる) や
auto-mode-alist
への設定追加もしてくれるので、インストールしただけで何もしておりません。。。
開発環境 (Terraform)
Terraform 自体は、ver.up がわりと早いので、Terraform 本体ではなく、tfenv を入れました。 rbenv とか plenv と同種の、Terraform 用のバージョンマネージャです。
Terraform の最新バージョンをインストールしておきました。
$ tfenv list-remote | head -3 0.11.7 0.11.6 0.11.5 $ tfenv install 0.11.7
Terraform の重要概念の整理
とりあえず重要なのは、プロバイダとリソースだと思います。
リソースっていうのが、物理マシンとか VM とか Github リポジトリ上のメンバーとかです。
一方、プロバイダっていうのが、それらを API 経由で提供してくれる輩です。具体的には AWS とか GCP とか Github とかです。
これを理解した上で、とりあえず以下のファイルを main.tf
として保存して、terraform
を実行すれば、AWS "プロバイダ"の上で VPC "リソース"が構築されます。
各属性はともかく、AWS を使って、東京リージョン (ap-northeast-1
) に 10.2.0.0/16 のネットワークを持つ VPC を作っていることが直感的に分かります。
terraform { required_version = "= 0.11.7" } provider "aws" { region = "${var.aws_region}" } resource "aws_vpc" "vpc" { cidr_block = "10.2.0.0/16" enable_dns_support = true enable_dns_hostnames = true } variable "aws_region" { default = "ap-northeast-1" }
プロバイダは複数書くこともできて、1 つの tf ファイル上で GCP と AWS のリソースを一度に作り連携させる、なんてことも可能です。
リソースに依存したリソースはどうやって書くの?
リソースには依存関係がある場合があって、例えば VPC の中でサブネットを切る場合なんてのがその例です。 サブネットは VPC なしには構築できません。
サブネットリソースの定義も色々書けますが、ぼくはとりあえずこんなかんじにしました。
resource "aws_subnet" "global" { vpc_id = "${aws_vpc.vpc.id}" cidr_block = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 4)}" map_public_ip_on_launch = true }
ミソは vpc_id
です。
ここでの定義は、「aws_subnet
というサブネットを表現するリソースは、aws_vpc
というリソースの vpc
という名前の ID に紐付けられている」ということを示しています。
こういう紐付けをすると、aws_subnet
は vpc
に依存しているという暗黙の依存関係が定義されます。
この依存関係は、terraform graph
コマンドで見ることができまして、Graphivz の dot
コマンドを使って、
$ dot -Tpng <(terraform graph) -o dependency.png
とかで画像化すれば、aws_subnet.global
から aws_vpc.vpc
への依存を示す矢印が存在していることがわかります。
こういった依存関係と resource
を駆使して、必要なリソースを配置していけば、インフラの定義ファイルが完成します。
変数
サーバの数だったりリージョンだったり、可変にしておきたいところは変数にしておきます。
変数は variable
で宣言できます。以下では、aws_region
っていう、デフォルト値 ap-northeast-1
の変数を宣言してます。
variable "aws_region" { default = "ap-northeast-1" }
この値は、terraform
コマンドでも、環境変数でも上書きできます。
簡単に上書くのであれば、
$ terraform plan -var 'aws_region=us-east-1'
といった形で上書きできますし、変数値をまとめたファイルをコマンドに渡すこともできます。 環境変数でも上書きできて、
$ export TF_VAR_AWS_REGION=us-east-1
ってかんじで TF_VAR_
を接頭語につけた環境変数を宣言しちゃえば良いです。
便利関数
tf ファイルには、色々関数を使うことができて、わりと便利だったりします。 このあたりにまとまってる。 - Interpolation Syntax - 0.11 Configuration Language | Terraform by HashiCorp
サブネットの例を再掲しますが、ここでの cidrsubnet なんてのはまさに便利関数です。
resource "aws_subnet" "global" { vpc_id = "${aws_vpc.vpc.id}" cidr_block = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 4)}" map_public_ip_on_launch = true }
cidrsubnet(iprange, newbits, netnum)
は、CIDR で示された iprange
のネットワーク帯のうち、さらに newbits
分だけサブネットを切って、そのうちの netnum
番目のサブネットを返せ、っていう関数で、
ここでの iprange
が 10.2.0.0/16
だったとすると、返却値は 10.2.4.0/24
になります。
他にも、file(path)
は、パスで示されたファイルの中身を返してくれますし、色々できます。はい。
偉そうに書いていますが、あんまり把握できていません。はい。
サーバのプロビジョニング
リソースの作成、破棄時にスクリプトとかを実行することができて、その仕組みを provisioners と呼んでいます。
たとえば、想像しやすい例だと、remote-exec
とうプロビジョナは、デプロイされるサーバ側で任意のコマンドを実行できます。がんばれば、ansible
とかも実行できるはず。
ぼくは今回、httpd をインストールして起動するにあたり、この remote-exec
を使いました。
見た方がイメージできると思うので、EC2 のサーバリソースの宣言を以下に示します。
resource "aws_instance" "svr" { # Amazon Linux AMI 2017.09.1 (HVM), SSD Volume Type ami = "ami-a77c30c1" count = "${var.server_num}" instance_type = "t2.micro" associate_public_ip_address = true subnet_id = "${aws_subnet.global.id}" vpc_security_group_ids = ["${aws_security_group.allow-ssh.id}"] key_name = "${var.key_name}" connection { host = "${self.public_ip}" type = "ssh" user = "ec2-user" private_key = "${file(var.key_file)}" } provisioner "remote-exec" { inline = [ "sudo yum -y install httpd", "echo hello | sudo tee /var/www/html/index.html", "sudo service httpd start", "sudo chkconfig httpd on" ] } }
こんな風に何でもできるといえばできるのですが、AWS 的には、予め httpd とかを組み込んだ AMI を作っておくほうが捗るように思います。サーバ作成時にプロビジョニングするの、時間かかるし。
任意個数のサーバ
同一構成のサーバを任意の個数並べたいなんてときは、count
を使います。count
については、Resources Landing Page - Configuration Language | Terraform by HashiCorp のあたりにシレっと説明がある。
上に貼り付けた EC2 サーバリソースにも使ってます。ただ、count = 5
とか書くだけで、5 台ほどサーバができるってかんじになります。
必要な情報を簡単に手に入れたい
サーバつくったら ssh したいんですけど、ssh しようと思ってもホスト名が分からん。 Elastic IP を付与したりすれば良いし、もちろんそれも Terraform でできるんですが、ここでは Elastic IP を付けてなかった。
terraform show
で全量出しても良いんだけど、
こういうときに便利なのが output
ってヤツです。便利なかんじで出力してよーという定義です。
output "address" { value = "${aws_instance.svr.*.public_dns}" }
上の例では、「EC2 サーバの台数分、インターネットから見えるホスト名を出力してよー」という定義をしています。
これを加えて実行すると、terraform apply
の最後に、
$ terraform apply -var 'server_num=2' (snip) Outputs: address = [ ec2-18-182-32-112.ap-northeast-1.compute.amazonaws.com, ec2-13-231-199-50.ap-northeast-1.compute.amazonaws.com ]
というようなホスト名が出力されるようになりますし、terraform output
でも同様の出力が得られます。
json 形式でも出力可能で、スクリプトとかで捗りそうです。
$ terraform output -json | jq '.address.value' [ "ec2-18-182-32-112.ap-northeast-1.compute.amazonaws.com", "ec2-13-231-199-50.ap-northeast-1.compute.amazonaws.com" ]
というわけで
だいたい以下のようなかんじになりました。 変数はファイルを別にしているんですが、ここではマージしちゃっています。
コマンドちょっと打つだけで、自分の遊べるインフラが瞬時にでき、また、ぶっ壊せるというのはなかなかに新しい体験で、 正直かなりおもしろいです。まだ Getting Started を読んだ程度ですし、AWS にも疎いところなので、 Terraform、AWS ともにまだまだ勉強していきたいなと思っています。
terraform { required_version = "= 0.11.7" } provider "aws" { region = "${var.aws_region}" } resource "aws_vpc" "vpc" { cidr_block = "10.2.0.0/16" enable_dns_support = true enable_dns_hostnames = true } resource "aws_internet_gateway" "igw" { vpc_id = "${aws_vpc.vpc.id}" } resource "aws_route" "internet_access" { route_table_id = "${aws_vpc.vpc.main_route_table_id}" destination_cidr_block = "0.0.0.0/0" gateway_id = "${aws_internet_gateway.igw.id}" } resource "aws_subnet" "global" { vpc_id = "${aws_vpc.vpc.id}" cidr_block = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 4)}" map_public_ip_on_launch = true } resource "aws_security_group" "allow-ssh" { name = "dev-sg-allow-ssh" description = "Allow ssh for each server" vpc_id = "${aws_vpc.vpc.id}" ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { protocol = "-1" from_port = 0 to_port = 0 cidr_blocks = ["0.0.0.0/0"] } } resource "aws_instance" "svr" { # Amazon Linux AMI 2017.09.1 (HVM), SSD Volume Type ami = "ami-a77c30c1" count = "${var.server_num}" instance_type = "t2.micro" associate_public_ip_address = true subnet_id = "${aws_subnet.global.id}" vpc_security_group_ids = ["${aws_security_group.allow-ssh.id}"] key_name = "${var.key_name}" connection { host = "${self.public_ip}" type = "ssh" user = "ec2-user" private_key = "${file(var.key_file)}" } provisioner "remote-exec" { inline = [ "sudo yum -y install httpd", "echo hello | sudo tee /var/www/html/index.html", "sudo service httpd start", "sudo chkconfig httpd on" ] } } output "address" { value = "${aws_instance.svr.*.public_dns}" } variable "aws_region" { default = "ap-northeast-1" } variable "key_name" { default = "testkey" } variable "key_file" { default = "~/key/testkey.pem" } variable "server_num" { default = 1 }