理系学生日記

おまえはいつまで学生気分なのか

忍者TOOLS

Terraformに入門してサーバを構築してみるまで

VPS を何年も前に解約してから Linux の遊び場もなくなってしまっていたのですが、 AWS を勉強したこともあり、あー EC2 なり S3 なり使えば色々できるなぁと思い立ちました。

せっかくなので、楽に遊び場を作ったり消したりしたいなぁということで、 Terraform に入門するかと思い立ったのが昨日のことです。

Getting Started あたりを読んで tf ファイルを書いていったところ、 目標だった任意台数の Web サーバをデプロイするところまではいけたので、苦難の道のりをまとめようと思います。

なにせ短期間しか Terraform に触れていないので、間違ってるところとかあったら教えてくださいおねがいします。

あと上級者の方は id:minamijoyoTerraform職人入門: 日々の運用で学んだ知見を淡々とまとめる - Qiita でこれでもかってくらいのヤツをまとめてくれているので、読んだら良いはずです。

目次記法ができたの知らなかった

今日のゴール

以下を達成することをゴールとしました。

  1. 1 コマンド
  2. 任意台数の Web サーバを構築できる
  3. 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_subnetvpc に依存しているという暗黙の依存関係が定義されます。

この依存関係は、terraform graph コマンドで見ることができまして、Graphivzdot コマンドを使って、

$ dot -Tpng <(terraform graph) -o dependency.png

とかで画像化すれば、aws_subnet.global から aws_vpc.vpc への依存を示す矢印が存在していることがわかります。

f:id:kiririmode:20180418011904p:plain:w500

こういった依存関係と 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 - 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 番目のサブネットを返せ、っていう関数で、 ここでの iprange10.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 については、Configuring Resources - 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
}