エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

GitLab上でよしなに自動実行してくれるTerraformのCIを目指して

こんにちは、エムスリー エンジニアリンググループ の鳥山 (@to_lz1)です。製薬企業向けプラットフォームチームでチームSREとして活動しています。

この記事はエムスリー Advent Calendar 2021 22日目の記事です。

Googleの実践から生まれたSREという職種は決してインフラ "だけ" を見る存在ではありませんが、インフラの構築・維持管理は依然として主要な仕事の1つです。

ここ数年のエムスリーのインフラの変遷やその全体像については14日目の記事「エムスリーの IaC 3年史」に譲りますが、私の所属する製薬企業向けプラットフォームチームも、

  • 多くの本番プロダクトがAWS上で稼働
  • データ基盤はBigQuery上に整備
  • 他のチームが開発するGCP上の機械学習システムとも連携

...といった具合で日々クラウド上での開発と運用をしています。また、AWS上の構成はほぼ全て Terraform によってコードとして記述されている状態です。

さて、IaCが実現されてくると、今度は 「その変更を如何にスムーズに検証環境・本番環境に適用するか?」 という課題が挙がってきます。

エムスリーではソースコードの管理にGitLabを用いていますが、今回はGitLab CIを用いてTerraformの適用自動化を推し進めた事例をご紹介します。

インフラ変更の最後のワンステップである「適用」は意外に手作業が残ってしまいがちな領域の1つでもあると思います。類似する課題意識をお持ちの方、お持ちのチームに、この事例が少しでも参考になりましたら幸いです。

f:id:to_lz1:20211220234639p:plain

ディレクトリ構成と開発フロー

現在、私の所属する製薬企業向けプラットフォームチーム(Unit1と呼ばれています)のTerraformのディレクトリ構成は以下のようになっており、monorepo的なリポジトリの中で複数サービスのインフラを管理しています*1

unit1-infra
├── service1/
│   ├── prod/
│   ├── qa/
│   └── .gitlab-ci.yml
├── service2/
│   ├── prod/
│   ├── qa/
│   └── .gitlab-ci.yml
├── service3/
│   ├── prod/
│   ├── qa/
│   └── .gitlab-ci.yml
...
└── .gitlab-ci.yml

また、ブランチ戦略はGitHub Flowに近く、各featureブランチをmasterにマージしていくだけです。

開発フローに合わせて、以下の図のようにCI Jobが動いていきます。

f:id:to_lz1:20211221000112p:plain
開発フローとCI Job

Merge Requestを出したとき

terraform fmt を用いてファイルのフォーマットチェックを行います。これが通れば、QA(検証環境)、Production(本番環境)のそれぞれに対して terraform plan を行います。

Mergeされたとき

Mergeをトリガーとして、まず検証環境への terraform apply を自動で行います。本番への適用は手動でトリガーします。

レビュアがレビューするとき

Merge Request が出されると、plan結果と差分は tfnotify を用いて通知されます。

f:id:to_lz1:20211221000624p:plain
tfnotifyによるplan結果の自動通知

従って、レビュアはコードの差分とplan結果の双方を見てレビューができます。

また、本番applyの実施タイミングについては、MRを出す時にレビュイーがテンプレートに沿って記載するようにしました。

f:id:to_lz1:20211221000834p:plain
Merge Requestテンプレート

これはTerraformを実運用で使ってみて得た気付きなのですが、IaCの変更には

  • 「比較的軽微で、すぐにapplyしてしまいたい」変更
  • 「比較的大きく、事前確認や時間の調整が必要な」変更

があります。従って本番applyを全面的に自動化してしまうのは現実的でなく、一方で毎回レビュイーに対して「LGTMです! いつapplyして良いですか?」と尋ねる、というのもいまいち効率が良くありません。上記の運用を取ることで、案件ごとに異なる様々な事情を共有しやすくする狙いがあります。

フローを実現するためのGitLab CIの諸機能

このフローを実現するために利用したGitLab CIの諸機能をご紹介します。

include

Keyword reference for the `.gitlab-ci.yml` file | GitLab

You can split one long .gitlab-ci.yml file into multiple files to increase readability, or reduce duplication of the same configuration in multiple places.

先のディレクトリ構成でわかる通り、プロダクトごとに別個の tfstate を管理しているため、そのプロダクトの数だけ CI Job の定義が必要です。これを1つの .gitlab-ci.yml ファイルに書いてしまうと、yamlファイルがとてつもなく長く読みづらくなってしまいます。

ファイルを各プロダクトのディレクトリ配下に置き、プロジェクトルートからそれらを include すれば全体の構成がシンプルになります。また、CI定義の変更がconflictするような心配もなくなります。

extends

Keyword reference for the `.gitlab-ci.yml` file | GitLab

Use extends to reuse configuration sections.

ジョブを分ける必要があるといっても plan、apply の基本的な流れは同一ですから、当然共通化したいところです。baseとなるジョブを定義しておき、 include先でこれを extends すれば全体の記述量を減らす事ができます。

rules

Keyword reference for the `.gitlab-ci.yml` file | GitLab

Use rules to include or exclude jobs in pipelines.

「Merge Requestに動きがあったときだけ」「masterにマージされたときだけ」というフローを実現するためには、そのタイミングでだけ Job が生成される必要があります。こうした条件付けはこの rules で記述できます。なお、旧来は only, except で記述するのが標準的でしたが、 GitLab 12.3からより柔軟性のあるこちらの記法が登場しています。

これらを踏まえて、プロジェクトルートのテンプレートと各プロダクト配下での Job 定義はそれぞれ以下のようになります。

プロジェクト直下の .gitlab-ci.yml

stages:
  - fmt
  - plan
  - apply

.qa-common:
  before_script:
    - export AWS_ACCOUNT=${AWS_ACCOUNT_QA}
    - export AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID_QA}
    - export AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY_QA}

.prod-common:
  before_script:
    - export AWS_ACCOUNT=${AWS_ACCOUNT_PROD}
    - export AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID_PROD}
    - export AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY_PROD}

.fmt-common:
  stage: fmt
  script:
    - cd ${TF_PROJECT_DIR}
    - terraform init
    - terraform fmt -recursive -diff=true -check=true

.plan-common:
  stage: plan
  script:
    - cd ${TF_PROJECT_DIR}
    - terraform init
    - terraform plan | tfnotify --config ${CI_PROJECT_DIR}/.tfnotify.yml plan

.apply-common:
  stage: apply
  script:
    - cd ${TF_PROJECT_DIR}
    - terraform init
    - terraform apply -auto-approve | tfnotify --config ${CI_PROJECT_DIR}/.tfnotify.yml apply

include:
  - local: "/service1/.gitlab-ci.yml"
  - local: "/service2/.gitlab-ci.yml"

各プロダクト配下の .gitlab-ci.yml

こちらが extends 先の記述例です。 ${TF_PROJECT_DIR} 変数の具体的な値もこちらで記述します。

fmt_service1:
  extends:
    - .fmt-common
  variables:
    TF_PROJECT_DIR: "service1"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - service1/**/*

plan_qa_service1:
  extends:
    - .qa-common
    - .plan-common
  variables:
    TF_PROJECT_DIR: "service1/qa"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - service1/**/*

plan_prod_service1:
  extends:
    - .prod-common
    - .plan-common
  variables:
    TF_PROJECT_DIR: "service1/prod"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - service1/**/*

apply_qa_service1:
  extends:
    - .qa-common
    - .apply-common
  variables:
    TF_PROJECT_DIR: "service1/qa"
  rules:
    - if: '$CI_COMMIT_REF_NAME == "master"'
      changes:
        - service1/**/*

apply_prod_service1:
  variables:
    TF_PROJECT_DIR: "service1/prod"
  extends:
    - .prod-common
    - .apply-common
  rules:
    - if: '$CI_COMMIT_REF_NAME == "master"'
      changes:
        - service1/**/*
      when: manual

肝心な点として、Merge Requestが作成された時、「該当ディレクトリに変更があった場合のみ」 Job が生成されるのが望ましいです(さもないと全変更で全プロダクトのplanが走ってしまい、Merge Requestが大変見辛くなります)。これは rules 内で changes 句を用いて実現しています。

その他の工夫点

1. 新しいバージョンのTerraformも利用できるようにしたい

この2021年の間に、Terraformも 0.15, 1.0, そして 1.1数々のバージョンがリリースされました

CIに使うTerraformのバージョン、というのも意外に悩ましい課題になったりします。最初にCIを書いたTerraformのバージョンを他のプロダクトでも流用した結果、新しいバージョンに上げるのが困難に...という事態は避けたいものです。

私のチームではこの課題を解決するために tfenv を導入しました。

github.com

ローカルで複数バージョンのTerraformを使い分けるのに便利な tfenv ですが、CI中で複数バージョンのTerraformを使い分けたいときにも活用できます。私のチームではtfenvをあらかじめインストールしたDocker Imageを作成し、CIの実行環境に指定しています。

tfenvは、利用したいTerraformのバージョンを記載した .terraform-version ファイルをホームディレクトリかプロジェクトルートに置くとバージョンを特定してくれます。ファイルが見つからない場合は自動的に上の階層を探しに行ってくれるので、今回の構成では以下の場所にファイルを置き、 terraform コマンドの実行前に tfenv install, tfenv useを実行すればOKです。

unit1-infra
├── service1/
│   ├── prod/
│   ├── qa/
│   ├── .terraform-version
│   └── .gitlab-ci.yml
├── service2/
│   ├── prod/
│   ├── qa/
│   ├── .terraform-version
│   └── .gitlab-ci.yml
├── service3/
│   ├── prod/
│   ├── qa/
│   ├── .terraform-version
│   └── .gitlab-ci.yml
...
└── .gitlab-ci.yml

これで、新しくサービスを立ち上げたりクラウド移行する際にも新しいバージョンのTerraformを心置きなく利用できます。

2. セキュリティリスクを抑えたい

GitLab CIでTerraformを実行する場合、強めの権限を持ったアクセスキーを Runner に渡すことになります。

Terraformと言うツールの特性上、Administrator Access 相当の権限を渡すのはある程度しょうがない部分もありますが、「万が一キーが流出したらノーガードでなんでもできてしまう」というのはあまり安全ではありませんし想像すると夜も眠れません。なんとか適切に最小権限の原則を適用したいものです。

Unit1では、CI用のアカウントのアクセスをネットワーク経路のレイヤーで制限してこの課題に対処しています。

弊社ではGitLabそのものは別のチーム(全社横断的なインフラを支える "コアSRE" チーム)が管理しており、主要なRunnerはUnit1とは別のAWSアカウントで動作しています。なので、具体的にはこの「コアSRE管理のAWSアカウントからのアクセスであれば許可する」という方針を取る事にしました。

AWSアカウントのPrivate Subnet内でaws cliを叩いた場合、各サービスへのリクエストは概ね以下の2パターンの経路でAWSに到達します。

  • インターネット経由で、NAT Gatewayから出て各サービスのエンドポイントへ
  • VPC Endpointを経由して各サービスのエンドポイントへ

後者が意外と見逃しやすく、アカウント内にVPC Endpointを設けている場合は、対象サービスへのアクセスをSourceIPで制限できなくなります。この点を踏まえると、

の2種類のcontext keyでアクセス制限を構成する必要があります。IAM Policyの記述例は、以下のようになります。

resource "aws_iam_user" "terraform-ci-user" {
  name = "terraform-ci-user"
}

## IP Addressでの条件付き許可。基本的にはこちらでアクセスが許可される
resource "aws_iam_user_policy_attachment" "allow_by_ip" {
  policy_arn = aws_iam_policy.allow_by_ip_address.arn
  user       = aws_iam_user.terraform-ci-user.name
}
resource "aws_iam_policy" "allow_by_ip_address" {
  name   = "${var.envname}-terraform-ci-user-allow-by-ip-address"
  policy = data.aws_iam_policy_document.allow_by_ip_address.json
}
data "aws_iam_policy_document" "allow_by_ip_address" {
  statement {
    effect    = "Allow"
    resources = ["*"]
    actions   = ["*"]

    condition {
      test     = "IpAddress"
      values   = ["xx.xxx.xxx.xxx/32", "xx.xxx.xxx.xxx/32"]  # NAT GatewayのIPアドレス
      variable = "aws:SourceIp"
    }
  }
}

## VPC Endpoint IDでの条件付き許可。VPC Endpointを使っている場合、例えばS3へのアクセスなどはこれがないと通らない
resource "aws_iam_user_policy_attachment" "allow_by_vpce" {
  policy_arn = aws_iam_policy.allow_by_vpc_endpoint.arn
  user       = aws_iam_user.terraform-ci-user.name
}
resource "aws_iam_policy" "allow_by_vpc_endpoint" {
  name   = "${var.envname}-terraform-ci-user-allow-by-vpc-endpoint"
  policy = data.aws_iam_policy_document.allow_by_vpc_endpoint.json
}
data "aws_iam_policy_document" "allow_by_vpc_endpoint" {
  statement {
    effect    = "Allow"
    resources = ["*"]
    actions   = ["*"]

    condition {
      test     = "StringEquals"
      values   = ["vpce-xxxxxxxx", "vpce-xxxxxxxx"]  # VPC EndpointのID
      variable = "aws:SourceVpce"
    }
  }
}

なお、Terraform内で利用するリソースの種類によっては、もう少し別種の条件が必要になることもあります*2。こうした場合の調査とトラブルシューティングに関しては、 CloudTrail に記録されたAPI実行ログが非常に役立ちました。

まとめ

以上、TerraformとGitLab CIの統合に関する知見をご紹介しました。

GitLabには Managed Terraform StateArtifact Report といった機能もあり、Terraformとの統合には力を入れています。私のチームでは諸々検討した末採用には至りませんでしたが、場合によっては検討に含めても良いかと思います。

今回の事例ではほとんど Terraform と GitLab CI だけでシンプルにパイプラインを構成しましたが、

  • 使ってみて得た学びをフローの改善に活かせた
  • プロダクトごとにバージョン選定の自由度を担保できた
  • 一定程度のセキュリティを担保できた

といった点は良かったなと感じています。

IaCに限らない話ですが、構築初期にCIに力を入れておくと、それ以降の改善も高速で回せます。筆者も、SREという肩書きの傍らプロダクト開発にも手を出すエンジニアなので、DevもOpsも引き続き両面頑張っていく所存です*3

We are Hiring!

エムスリーでは、クラウドインフラを最大限に活用しながらプロダクトを開発推進していく仲間を募集中です。

クラウド移行に最前線で向き合ったり、CIを整えてDeveloper's Experienceを高めていく事にご興味をお持ちの方は、ぜひとも以下のリンクからお問い合わせ下さい!

jobs.m3.com

*1:https://www.m3tech.blog/entry/2021/01/22/110000 でも過去ご紹介しました

*2:例えばAthenaのテーブル作成はその過程でAthenaからのAPIコールが発生するので、CalledVia context keyで呼び出し許可する必要がある、など

*3:今年触った言語を思い返したら、HCLの他は Ruby、TypeScript、Scala、 Java、LookML、Go、Python という感じでした。我ながら多い。