entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Terraform運用事例書きました

こんにちは、インフラ部の id:sue445 です。

Terraformなにもわからないけどディレクトリ構成の実例を晒して人類に貢献したい - エムスリーテックブログTerraformのディレクトリ構成の模索 - Adwaysエンジニアブログ を読んで影響されたのでピクシブのTerraform運用事例を紹介しようと思います。

Terraformの採用理由

Terraformと同じようなプロビジョニングツールとしてAWSであればCloudFormation*1、GCPであればCloud Deployment Manager*2 がそれぞれクラウドベンダから提供されています。

しかし、

  • ピクシブではAWSとGCPを両方利用しているため同一ツールでメンテできた方が嬉しい
  • Terraformはサードパーティ製だけど活発にバージョンアップされていて信用できる

という理由でTerraformを採用しています。

GitLabでのリポジトリ構成

TerraformのリポジトリはGitLabで管理しています。

GitLabではグループが何階層でも作れるため、「infra/(AWSアカウント名|GCPプロジェクト名)/Terraformのリポジトリ」のような構成で運用しています。

f:id:pxvpxv:20200730150924p:plain

  • pixivグループ:全エンジニアがMaintainer権限(GitHubのAdminに相当)を持っているグループ。(通常はここにリポジトリを作る)
  • infraグループ:インフラ部メンバーのみがMaintainer権限を持っていて、他のエンジニアはDeveloper権限(GitHubのWriteに相当)を持っているグループ

このようにグループ毎に権限を分けている理由は下記です

  • GitLabの仕様上、Maintainer権限を持っているとリポジトリに設定された機微情報が普通に見れてしまうため
    • 特にTerraformはその性質上強い権限のアクセスキーやサービスアカウントを持っているため悪用されるのを防ぎたい
  • Terraform系のリポジトリは全てmasterブランチで terraform apply 、他のブランチで terraform plan を実行するようなCIを構築しているが(後述)、不用意にmasterブランチが更新されると下手したら本番環境が破滅するためインフラ部メンバーのみがMergeRequestをマージできるようにしたい

また、AWSのアカウント単位・GCPのプロジェクト単位でさらにサブグループを作り、その下にTerraformのリポジトリを置くようにしています。

最初はこのようなサブグループは作らずにフラットな構成で運用していたのですが、AWSのアカウント(GCPのプロジェクト)ではTerraform以外にも用途毎にリポジトリが必要になることが多いのでサブグループを作るようにしました。

例えばGitLab CI用のAWSアカウントを管理するサブグループではTerraform以外にも下記のようなリポジトリがあります。

f:id:pxvpxv:20200730150951p:plain

Terraformのファイル構成

例えば以前insideで紹介した rendertron で使っているTerraformだと下記のようなファイル構成になっています。

├── README.md
├── .envrc
├── .gitignore
├── .gitlab-ci.yml
├── .terraform-version
├── account.tf
├── backend.tf
├── cloud_audit_logging.tf
├── modules
│   └── rendertron_cluster
│       ├── cloud_load_balancing.tf
│       ├── cloud_nat.tf
│       ├── gke.tf
│       ├── inputs.tf
│       └── vpc.tf
├── rendertron_prestage.tf
├── rendertron_production.tf
├── service_account-datadog.tf
├── service_account-gcr_pusher.tf
├── service_account-gke_deploy.tf
└── variables.tf

エムスリーさんとは対称的にmoduleはほとんど作っていません。

僕は下記のような理由でTerraformのmoduleを多用すべきではないと思っているためです。*3

  • 一度module化するとmoduleの構成に合わないようなリファクタリングが必要になった時に terraform state mv が必要になってとたんにつらくなるため
  • Terraformのmoduleを細分化しすぎると variableoutput が大量に必要になって書きづらくなる

個人的にTerraformのmoduleは 再利用できる複数のリソースの単位(プログラミング言語でいうところの関数みたいなもの) で作るのがしっくりきています。

moduleがうまく使えたと思っている事例

1つ目は環境毎に全く同じリソース群(GCPだとVPC, GCLB, Cloud SQLなど)をセットで作る必要がある場合です。

開発環境と本番環境でVPC含めて完全に別にしたいというのはよくあります。

下記は rendertron_production.tf ですが、スペックやnode数など本番環境と開発環境で変わりうるものは全てmoduleに variable として渡すことによりいい感じに設定を共通化できたと思っています。

module "rendertron_production" {
  source      = "./modules/rendertron_cluster"
  environment = "production"

  region = local.region

  gke_node_version = "1.16.8-gke.15"

  gke_location = local.region

  gke_zones = local.gce_tokyo_n2_available_zones

  # NOTE:
  # * rendertronの場合n1-standardの1/4のメモリ(1 vCPU辺り960MB)が一番バランスがいい
  # * customの場合メモリは256MBの倍数で指定する必要がある
  #   https://cloud.google.com/dataproc/docs/concepts/compute/custom-machine-types

  # GKE
  gke_primary_node_machine_type = "n2-custom-8-7680" # 8 vCPU, 7.5GB RAM
  gke_primary_node_disk_size_gb = 128
  gke_primary_node_count        = 1

  gke_autoscale_node_machine_type = "n2-custom-32-30720" # 32 vCPU, 30GB RAM
  gke_autoscale_node_disk_size_gb = 128
  gke_autoscale_node_min_count    = 0
  gke_autoscale_node_max_count    = 7
  gke_autoscale_node_preemptible  = true
}

2つ目はサービス固有でない汎用的な機能です。

Terraformは別のリポジトリからもmoduleを読み込むことができるため、他のTerraformでも使いそうになる汎用性のある機能を予めmoduleで作っておいて、実際に他のTerraformリポジトリで使いたくなったタイミングでmodule専用のリポジトリに抽出することはよくやっています。

GitLab CIでTerraformをいい感じにCIする

ピクシブにはAWSとGCP合わせて計10個以上のTerraformリポジトリがあります。

AWSアカウントやGCPプロジェクト作成の度にCIの初期設定したり複数リポジトリで設定修正をするのは大変なので、冒頭のGitLabのスクショにも出てきた pixiv/ci-templates/terraform-template でCIのテンプレートファイルを一元管理し、各Terraformのリポジトリからincludeするようにしています。

テンプレートの使い方

各Terraformのリポジトリでは下記のような必要最低限の .gitlab-ci.yml を書いていい感じにCIのワークフローを実現しています。

include:
  - project: "pixiv/ci-templates/terraform-template"
    file:    "/template.yml"

variables:
  TERRAFORM_VERSION: "0.12.16"
  TFLINT_VERSION:    "0.9.2"
  TFNOTIFY_VERSION:  "0.7.0"

Terraformはバージョン間の差異が激しいので利用側でバージョンを指定し、Terraformのバージョンアップ時は TERRAFORM_VERSION のみ書き換える運用をしています。

ピクシブで実際に使っているテンプレートファイル

テンプレートファイルの中身と解説です。

一部項目を削っていますが下記が実際に使ってるTerraformのCIで使ってるテンプレートファイルです。

variables:
  TERRAFORM_VERSION: ""
  TFLINT_VERSION:    ""
  TFNOTIFY_VERSION:  "0.4.0"

.terraform:
  image:
    name: hashicorp/terraform:${TERRAFORM_VERSION}
    entrypoint: [""]

stages:
  - test
  - deploy

cache:
  key: "${CI_JOB_NAME}"
  untracked: true
  paths:
    - .terraform/plugins/

.setup_terraform: &setup_terraform
  # moduleのキャッシュが悪さして存在するはずのrefが見つからないことがあるのでinit前に削除する
  - rm -rf .terraform/modules/

  - terraform init -input=false

.setup_tfnotify: &setup_tfnotify
  # Setup tfnotify
  - |
    if [[ ! -f tfnotify_${TFNOTIFY_VERSION}_linux_amd64.tar.gz ]]; then
      set +e
      wget https://github.com/mercari/tfnotify/releases/download/v${TFNOTIFY_VERSION}/tfnotify_linux_amd64.tar.gz -O tfnotify_${TFNOTIFY_VERSION}_linux_amd64.tar.gz;
      ret=$?
      set -e

      # v0.6.0以降のファイル名でマッチしなければそれ以前のファイル名にフォールバックする
      if [[ $ret -ne 0 ]]; then
        wget https://github.com/mercari/tfnotify/releases/download/v${TFNOTIFY_VERSION}/tfnotify_${TFNOTIFY_VERSION}_linux_amd64.tar.gz
      fi
    fi

  - tar zxf tfnotify_${TFNOTIFY_VERSION}_linux_amd64.tar.gz

  # tfnotifyはyamlのフィールドに自由に変数を入れられないのでシェルの変数からyamlを動的生成する
  - |
    cat > tfnotify.yml << YAML
    ---
    ci: gitlabci
    notifier:
      gitlab:
        token: $TFNOTIFY_GITLAB_TOKEN
        base_url: https://gitlab.example.com/api/v4
        repository:
          owner: $CI_PROJECT_NAMESPACE
          name: $CI_PROJECT_NAME
    terraform:
      fmt:
        template: |
          {{ .Title }}

          {{ .Message }}

          {{ .Result }}

          {{ .Body }}
      plan:
        template: |
          {{ .Title }} <sup>[CI link]( {{ .Link }} )</sup>
          {{ .Message }}
          {{if .Result}}
          <pre><code> {{ .Result }}
          </pre></code>
          {{end}}
          <details><summary>Details (Click me)</summary>
          <pre><code> {{ .Body }}
          </pre></code></details>
      apply:
        template: |
          {{ .Title }}
          {{ .Message }}
          {{if .Result}}
          <pre><code> {{ .Result }}
          </pre></code>
          {{end}}
          <details><summary>Details (Click me)</summary>
          <pre><code> {{ .Body }}
          </pre></code></details>
    YAML

tflint:
  extends: .terraform

  stage: test

  script:
    - *setup_terraform

    # tflintのDockerイメージを使うとterraform initできなくてmoduleが読めないためバイナリをDLする
    - cd /tmp
    - wget https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip
    - unzip tflint_linux_amd64.zip

    - ./tflint

  only:
    variables:
      - $TFLINT_VERSION != ""

  tags:
    - docker

terraform-fmt:
  extends: .terraform

  stage: test

  script:
    - *setup_terraform

    - |
      set +e
      terraform fmt -recursive -check
      ret=$?
      set -e

      if [ $ret -ne 0 ]; then
        echo '[ERROR] terraform fmt -recursive をするか下記の修正を行ってpushを行ってください'
        terraform fmt -recursive
        git --no-pager diff
      fi
      exit $ret

  tags:
    - docker

terraform-plan:
  extends: .terraform

  stage: test

  script:
    - *setup_terraform
    - *setup_tfnotify

    - terraform plan -input=false | ./tfnotify plan

  except:
    - master

  resource_group: terraform-tfstate

terraform-apply:
  extends: .terraform

  stage: deploy

  script:
    - *setup_terraform
    - *setup_tfnotify
    - terraform plan -input=false | ./tfnotify plan
    - terraform apply -input=false -auto-approve | ./tfnotify apply

  only:
    - master

  resource_group: terraform-tfstate

terraform-apply-manual:
  extends: .terraform

  stage: deploy

  script:
    - *setup_terraform
    - *setup_tfnotify
    - terraform plan -input=false | ./tfnotify plan
    - terraform apply -input=false -auto-approve | ./tfnotify apply

  except:
    - master

  when: manual

  resource_group: terraform-tfstate

実はGitLabだとTerraformの汎用的なCIのテンプレートが提供されているのですが*4、このテンプレートが作られる前に既に自分でテンプレートを作ったためGitLabのテンプレートを使っていません。

このテンプレートでできること

masterブランチ以外

  • terraform plan , terraform fmt , https://github.com/terraform-linters/tflint を自動実行
  • terraform apply を手動実行できるようになる
    • MergeRequest上で試行錯誤したい場合によく使っています

f:id:pxvpxv:20200730151031p:plain

masterブランチ

terraform apply を自動実行

f:id:pxvpxv:20200730151040p:plain

このテンプレートファイルのポイント

  • https://github.com/mercari/tfnotify を使い terraform planterraform apply の結果をMergeRequestにコメントで出せるようにしている
    • GitLabにコメントつけるために必要なGitLabのAccess TokenはGitLab Runnerの設定に入れることにより、Terraformの各リポジトリで機微情報の設定を不要にしている
  • tfnotifyに自由に変数を入れることができないため、 .gitlab-ci.yml の中でtfnotify用のYAMLファイルを動的生成する という かなり黒魔術な ハックを行っている
  • DynamoDBやGCSで terraform.tfstate の排他ロックを行っていると terraform planterraform apply を同時に1つだけしか実行したくないということがよくあるが、GitLab CIだと resource_group を使うことでこのように「通常はCIのqueueが空いていれば可能な限り並列実行してほしいが特定のジョブだけは1つずつ実行してほしい」という同時実行数の制御ができるようになる
    • terraform.tfstate を安全に管理するには前述の排他制御が必須なのだが、同時実行数の制御ができないとトピックブランチでforce pushしたような場合に後からpushした方のビルドが排他ロックのエラーで失敗してちょっとイラッとする
    • 余談ですがこのようなジョブの同時実行数の制御は現時点ではTravis CI, CircleCI, Wercker, GitHub Actionsには実装されておらず、GitLab CI独自の機能になります。(CIマニア特有の早口)
    • さらに余談ですが resource_group 登場前は limit = 1 のRunnerを作るしかなかったので割と面倒でした... *5

最後に

簡単ではありますがピクシブのTerraformの運用を紹介させていただきました。

Terraformは各会社の文化によって運用が変わってくると思うので全部真似できないかもしれないですが、もし運用方法が定まっていなくて迷っているなら参考になれば幸いです。

20191219021925
sue445
2018年7月に中途入社。CIおじさん。好きな言葉は「全ての手作業を自動化」。 好きなアニメは女児アニメ全般(プリキュア・プリティーシリーズ・アイカツ)とサザエさん。 多数のgemをOSSで公開 https://rubygems.org/profiles/sue445 。代表作はプリキュアのRuby実装のrubicure (https://github.com/sue445/rubicure)