読者です 読者をやめる 読者になる 読者になる

SideCI TechBlog

SideCIを作っているアクトキャットのエンジニアによる技術ブログです。


TerraformとPackerを使ったインフラ構築の効率化

Infra Other

はじめまして。4月にアクトキャットにjoinしたwata727です。主にサーバサイドの開発やAWSをはじめとしたインフラ周りを担当しています。よろしくお願いします。
今回は直近行ったSideCIインフラのAWS完全移行に、TerraformやPackerを採用した話について書いてみます。

SideCIのインフラ構成

SideCIではサーバの役割をフロント側でユーザの操作やリクエストを受け付けるweb群と、Rubocopなどのツールを実行するnode群に分けています。役割を分離することにより、関心事を分けることができ、必要に応じてスケールアウトやスケールアップがしやすくなるメリットがあります。

SideCIのインフラ構成

もともとはAWSとGCPのハイブリットクラウド構成をとっていたのですが、さまざまな問題があり、今回の再構築の段階でAWS側に完全に寄せる形になりました。

Infrastructure as Codeの実現

弊社には専属のインフラエンジニアがいないため、サーバサイドエンジニアでもある程度分かりやすく扱えるようにし、かつインフラ側のコードも全てGitHubで管理を行うようにしました。

また、これによりコードレビューと履歴化ができるようになります。過去のインフラ変更やパラメータチューニングの履歴を残すことで、変更の意図を伝えることができ、レビューによって他のエンジニアの現状に対する理解、インフラそのものに対する理解を深める教育的な側面も期待できます。

Terraformの採用

AWSのオーケストレーションツールにはTerraformを採用しました。他には、AWSのCloud Formationなどがありましたが、当時はDryRunに対応していない、JSON書きたくないなどの理由から採用を見送っています。最近はDryRunにも対応したようですね。

Terraformでは、インフラの構成をコード管理することはもちろん、手間のかかるインフラの変更作業などを自動化することができます。
例えば、ELB配下に紐づくEC2インスタンスにEIPが付与されている状態で、対象のインスタンスのAMIを新しいものに交換する作業を手作業で行う場合には、

  • 既存のインスタンスを削除
  • 新しいAMIから以前の設定値通りにインスタンスを起動
  • 新しいインスタンスにセキュリティグループを割り当て
  • 以前のインスタンスに紐付いていたEIPを割り当て
  • ELB配下にアタッチ

…というような手順が必要になります。これをTerraformで行う場合、変更したい部分(今回の場合はAMIのID)を書き換えて、terraform applyすると上記の手順を自動で行ってくれます。これはTerraformで各リソースの対応関係を設定しているために実現できます。具体的には、以下のようなリソースの定義で、対応関係を記述できます。

variable "web_ami_id" {
    type        = "string"
    default     = "ami-1234abcd"
}

resource "aws_instance" "web" {
    ami                    = "${var.web_ami_id}"
    instance_type          = "t2.nano"
    availability_zone      = "us-east-1b"
    subnet_id              = "subnet-1234abcd"
    vpc_security_group_ids = ["sg-1234abcd"]
    key_name               = "web-key-pair"
    root_block_device = {
        volume_type = "gp2"
        volume_size = "16"
    }
    tags {
        Name = "Web"
    }
}

resource "aws_elb" "web" {
    name            = "web"
    subnets         = ["subnet-1235abcd", "subnet-abcd1234"]
    security_groups = ["sg-abcd1234"]
    instances       = ["${aws_instance.web.id}"]
    
    listener {
        instance_port      = 80
        instance_protocol  = "http"
        lb_port            = 443
        lb_protocol        = "https"
        ssl_certificate_id = "arn:aws:acm:us-east-1:hogehoge:certificate/fugafuga"
    }
}

resource "aws_eip" "web" {
    instance = ${aws_instance.web.id}
    vpc      = true
}

${aws_instance.web.id}のように書くことで、特定のインスタンスではなく、Terraformで設定中の名前のリソースが指定されるため、再作成されてインスタンスIDが変更されても、関係を維持するようにTerraformがAPIを叩きます。
AMIを変更する場合には変数として分離したweb_ami_idの値を変更することですべての変更が完了します。terraform planで実行計画の内容を確認します。

$ terraform plan
Refreshing Terraform state prior to plan...

<skip...>

~ aws_eip.web
    instance: "i-1234abcd" => "${aws_instance.web.id}"

~ aws_elb.web
    instances.#: "" => "<computed>"

-/+ aws_instance.web
    ami:                                       "ami-abcd1234" => "ami-1234abcd" (forces new resource)
    availability_zone:                         "us-east-1b" => "us-east-1b"
    ebs_block_device.#:                        "0" => "<computed>"
    ephemeral_block_device.#:                  "0" => "<computed>"
    instance_state:                            "running" => "<computed>"
    instance_type:                             "t2.nano" => "t2.nano"
    key_name:                                  "web-key-pair" => "web-key-pair"
    placement_group:                           "" => "<computed>"
    private_dns:                               "ip-10-0-XXX-XXX.ec2.internal" => "<computed>"
    private_ip:                                "10.0.XXX.XXX" => "<computed>"
    public_dns:                                "ec2-XXX-XXX-XXX-XXX.compute-1.amazonaws.com" => "<computed>"
    public_ip:                                 "XXX.XXX.XXX.XXX" => "<computed>"
    root_block_device.#:                       "1" => "1"
    root_block_device.0.delete_on_termination: "true" => "1"
    root_block_device.0.iops:                  "48" => "<computed>"
    root_block_device.0.volume_size:           "16" => "16"
    root_block_device.0.volume_type:           "gp2" => "gp2"
    security_groups.#:                         "0" => "<computed>"
    source_dest_check:                         "true" => "1"
    subnet_id:                                 "subnet-1234abcd" => "subnet-1234abcd"
    tags.#:                                    "1" => "1"
    tags.Name:                                 "Web" => "Web"
    tenancy:                                   "default" => "<computed>"
    vpc_security_group_ids.#:                  "1" => "2"
    vpc_security_group_ids.hogehoge:           "sg-1234abcd" => "sg-1234abcd"


Plan: 1 to add, 2 to change, 1 to destroy.

このように、該当の変更によって、Terraformがインスタンスの削除、新規作成、EIPのアタッチ、ELB配下へのアタッチを実行することがわかります。作業の実行順序などもエラーが生じないように適切に処理されます。
Terraformで可能な限り、各リソースの対応関係を記述することで、手間のかかる手順を自動化することができ、人的ミスを軽減することができます。

なお、Terraformはステートファイルという仕組みを使用してリソースの状態管理をしています。つまり、そのままではすでに作成されてしまっているリソースは管理下に置けないのですが、Terraformingという有志が作られたステートファイルやテンプレートを既存のリソースから生成するgemがありますので、これを使ってテンプレートに落とし込みます。

これで現状のAWS上のリソースをコードに落とし込み、可視化することができました。

Packerの採用

また、別の問題として既存のサーバの構成管理がされていないという問題がありましたので、同時に構成管理ツールとしてのPackerを採用しました。
Packerを構成管理ツール、というと少し違和感があるかもしれませんが、サーバのプロビジョニングをコードに落とし込んで管理できることから、ここでは構成管理ツールとして扱っていきます。
他候補としては、ChefやItamae、Ansibleなどありましたが、最終的にはシンプルであることと、AutoScaling対応を見越してAMIを作ることに注視することなどが採用の決め手となりました。

{
  "builders": [{
    "type": "amazon-ebs",
    "region": "{{user `aws_region`}}",
    "vpc_id": "{{user `aws_vpc_id`}}",
    "subnet_id": "{{user `aws_vpc_subnet_id`}}",
    "source_ami": "{{user `aws_source_ami_id`}}",
    "instance_type": "{{user `aws_instance_type`}}",
    "ssh_username": "ubuntu",
    "ssh_pty": "true",
    "ami_name": "{{user `environment`}}_web_{{timestamp}}"
  }],
  "provisioners": [
    {
      "type": "shell",
      "execute_command": "{{ .Vars }} sudo -E bash -e '{{ .Path }}' {{user `environment`}}",
      "scripts": [
        "scripts/root_install.sh",
        "scripts/root_setup.sh"
      ]
    },
    {
      "type": "shell",
      "execute_command": "{{ .Vars }} sudo -u {{user `appuser`}} -i -H bash -e '{{ .Path }}' {{user `environment`}}",
      "script": [
        "scripts/appuser_setup.sh",
        "scripts/appuser_serverspec.sh"
      ]
    }
  ]
}

上記のようなテンプレートを作成することで、プロビジョニングを自動化できます。また、各種変数を外部から与える形式を取ることで、環境別のプロビジョニングを行うこともできます。

$ packer build -var-file=staging.json web.json

PackerはChefやAnsibleをプロビジョナーとして採用することもできますが、今回は安直にシェルスクリプトを使用しています。現状、そこまで複雑でないので、秘伝のタレ化はしていませんが、バッドプラクティス気味でもありますので、将来的には別のプロビジョナーに切り替えるかもしれません。
また、サービスインを担保するための検証にはServerSpecを採用しています。テストに落ちるとAMIがビルドされないので、ビルドされているAMIはすべてテストに通過しているという安心感があります。

今後の課題

Packerのビルドに時間がかかりすぎる

Packerのビルドタイムログイメージ

いわゆるImmutable Infrastructureのゴールデンイメージ問題と同じですが、現状、Packerのビルドを走らせて、AMIを取得するまでに約30分程度かかります。仮に、常に新しいアプリケーションのコードを内包するとなると、デプロイのたびに毎回30分以上のタイムロスが発生してしまい、開発効率やリリース頻度の低下が懸念されます。速度を重視するスタートアップでは致命的です。

また、ServerSpecのテストはビルドの最後に走るので、最後の最後に要件を満たせてなくてAMIがビルドできず、また30分待つ、という状況が発生することがあります。現状は、アプリケーションのデプロイは従来通りCapistranoを使い、何か新しいものをインストールしないといけないような場合だけPackerでAMIをビルドし直す方針をとっています。今後はPackerのレイヤを分けて、毎回Rubyのビルドを走らせなくてもよくするなど、別の方法を模索していく予定です。

Terraformが属人化する

現状、Terraform周りの環境を自分のローカル環境に構築して、そこからapplyをやってきたので、他のメンバーが参加するためには自前の環境を構築したり、ステートファイルを共有しないといけない問題がありました。
この辺りは現在取り組んでいる最中で、具体的にはChatOpsで解決を図ろうとしています。弊社では、Slackを活用しており、デプロイもChatOps的に自動化しているので、同様にインフラ変更もChatOpsでやっていく予定です。

Terraformのchatpos化イメージ

まだ完全には導入しきれていない状態ですが、Packerのビルドなども常にチャット経由で行えるような環境を構築していきます。

We are Hiring!

こんな感じでSideCIを開発するアクトキャットでは、エンジニアの生産性を高めるサービスを作るために、自らの生産性向上のための取り組みも積極的に行っています。
新しい技術を採用しつつ、メンバーでよりよい形を追求するために議論を重ねることができる環境です。AWSが好き、開発効率化が好き、新しい技術が好きな方は、現在積極採用中ですので、ぜひご応募ください!お待ちしております!