LayerX エンジニアブログ

LayerX の エンジニアブログです。

解剖!Terraform monorepo

バクラク事業部 Platform Engineering部 SREの id:itkq です。バクラク事業部では2022年にアプリケーションのmonorepo化を始め、現在では対応するインフラもmonorepoで運用しています。今回は、そのうちTerraformについて紹介します。

monorepoに至るまで

2022年、アプリケーションをmonorepo化していくプロジェクトが始まりました (通称layerone。リポジトリ名もlayerone)。これについての詳細は次のスライドを参照してください。

これに合わせて、対応するインフラを記述するTerraformも同じlayeroneリポジトリに集約しました。そのほうが特に自動生成の都合が良かったためです。この大枠は当時 @civitaspo がほぼ一人で作り上げました。その後、様々な事情 (例えばGitHub APIのRate-limitなど) があり、2023年にlayerone-infraというインフラ専用のmonorepoに分離しました。

monorepo化が進行する以前は、各プロダクトに一対一で対応するTerraformリポジトリが存在しました。これらのリポジトリはDevOpsチームも管理していたことから、リポジトリの増加にしたがってメンテナンスコストも増加する傾向にありました。そこでlayerone-infraにそれらのリポジトリを同時にすべて統合し、真のTerraform monorepoとなりました。この頃から、layerone-infra全体の運用は私が中心的に行っています。

ディレクトリ構成

次に示すのは、layerone-infraの terraform ディレクトリのざっくりとした様子です。

./terraform
├── modules
│   ├── aws
│   ├── awscli
│   ├── datadog
│   ├── gcp
│   ├── mysql
│   └── snowflake
├── platform
│   ├── authlete
│   ├── aws
│   ├── datadog
│   ├── gcp
│   ├── mysql
│   ├── sendgrid
│   ├── snowflake
│   └── twingate
├── repos
│   ├── aws
│   ├── datadog
│   └── mysql
└── services
    ├── aws
    │   ├── layerone-auth-x-token
    │   │   ├── base
    │   │   ├── dev
    │   │   ├── prd
    │   │   └── stg
    │   ├── ...
    ├── datadog
    │   ├── layerone-auth-x-token
    │   ├── ...
    ├── gcp
    └── mysql

第一階層は次の意味を持ちます。

  • modules: Terraform modules
  • platform: 基盤的なリソース
  • repos: 元々別だったリポジトリを統合時に置いた場所
  • services: 自動生成サービスのリソース

第二階層はTerraform providerを表します。Terraform providerごとにtfstateを分離しており、現在サポートするproviderは次の通りです。

第三階層は各サービスやプロジェクトを表し (この階層に相当する概念を以降 Terraform project と呼んでいます)、第四階層は運用環境に対応します。バクラク事業部ではプロダクトの運用環境としてdev, stg, prdがあります。環境差分を減らすため、それぞれのterraform projectでは共通モジュールであるbaseを利用するだけにしています。base module内ではvar.envによる条件分岐を最小限にするべく、環境ごとのパラメータは別のvariable (object variable) で注入するよう努力しています。

サービス定義による自動生成

layerone (layerone-infra) では、サービスの性質を記述するサービス定義と、それを基とした自動生成をふんだんに活用しています。サービス定義はJsonnetで記述します。サービスの識別子はdomainとserviceから成り立ちます。たとえば次の定義は auth domainの token というserviceであり、標準的なサービスです。詳細は省きますが、domainはserviceを束ねる概念です。

domain(
  'auth', baseDomain([
    connectService { name: 'token', owners: ['id'] },
  ])
)

ECSサービスとして動作するconnectrpc/connect-goベースのアプリケーションの雛形にくわえて、Terraform projectの雛形が生成されます。Terraformの生成物のイメージは以下です。

# ./terraform/services/aws/layerone-auth-x-token/base
cloudwatch_logs.gen.tf
ecr.gen.tf
ecs_service_set.gen.tf
iam_evb_layerone_event_bus.gen.tf
iam_s3_ecs_logs.gen.tf
kms.gen.tf
oidc.gen.tf
s3.gen.tf
sg.gen.tf
sg.tf
variables.gen.tf
versions.gen.tf

# terraform/services/aws/layerone-auth-x-token/dev
aqua-checksums.json
aqua.yaml
local.tf
main.gen.tf
variables.tf
versions.tf

# terraform/services/datadog/layerone-auth-x-token/base
alb.gen.tf
defaults.local.gen.tf
ecs.gen.tf
ecs_log.gen.tf
grpc.gen.tf
variables.gen.tf
versions.gen.tf

Terraform pipeline

Terraform pipelineはGitHubでCIOps的に構築しており、Pull requestでは terraform plan, push (merge) では terraform apply を自動的に実行します。plan, apply結果はsuzuki-shunsuke/tfcmtでコメント通知されます。実行環境はAWS CodeBuildです。

Why CodeBuild?

プロダクトと一対一で対応するTerraformリポジトリでも、元々CodeBuildでTerraformを実行していました。on: pull_requestでの攻撃可能性をはじめ、様々なセキュリティ上の懸念を考慮した結果だったようです。monorepoであるlayerone-infraでも、この選定をそのまま採用しました。本体のbuildspecは、aquaproj/aquaで必要なバイナリをダウンロードして実行するだけという極めてシンプルな内容です。tj-actions/changed-filesの件が記憶に新しいですが、昨今の攻撃の様子をみていると、悪くない判断だったと言えるのではないかと今では思います。

pipelineのアーキテクチャ

plan, applyともに構造的に同様のため、ここではplanのみを説明します。CodeBuildのWebhookトリガー (PULL_REQUEST_*) により、まず “tfplan-dispatcher” というCodeBuild projectが起動します。dispatcherは変更内容からどのTerraform projectをplanするかを判断し、planする別のCodeBuild project “tfplan” をstart-buildします。この tfplan projectはproviderごとに存在し、Terraform projectをEnvironment variablesとして入力します。

Terraform pipelineのアーキテクチャ

Require status checks to pass問題

layerone-infraのリポジトリ設定では、 “Require status checks to pass” を設定しつつ、planがすべて正常終了したときのみマージ可能にすべきです。ただし、ここで指定するstatus checkは静的である必要があり、動的にTerraform projectを決定しBuildを実行していると都合が悪いです。また、他のCIをGitHub Actionsで実行していることも影響します。達成すべきはCodeBuild, Actions関係なく「すべてのチェックが成功したときのみマージできる」でした。そこでGitHub Webhook (status, check run) をトリガーとする小さなLambda Functionを用意しました。このFunctionは、layerone-checks-conclusionというstatusを次のように更新します。

  1. Pull requested openedまたはstatus, check runが実行状態になったとき、layerone-checks-conclusion statusをpendingにする
  2. あるstatusまたはcheck runが失敗したとき、layerone-checks-conclusion statusをfailureにする。以降はstatus, check runの完了イベントを無視
  3. あるstatusまたはcheck runが成功したとき、該当commitに対するstatusとcheck runがすべて成功だった場合、layerone-checks-conclusion statusをsuccessにする

layerone-checks-conclusionをRequired status checkとして指定することで、目的を達成しました。

動的に作られたRequired status check (layerone-checks-conclusion)

リリースフロー

あるTerraform projectの変更をマージし、main branchにpushされると、Terraform pipelineによりdev環境への terraform apply が即時で実行されます。一方、stg, prd環境への適用はSongmu/tagprと似たフローで実行されます。Terraform projectのbase moduleが変更されると、そのcommit hashでstg, prdのbase moduleのrefを変更するPull requestが自動で作成されます。これをマージすることで、対応した環境への適用を行います。また、Pull requestを作成・更新する際はChangelogを自動で生成したり、該当する変更をマージした人をAssignするように工夫しています。このようにすることで、あるTerraform projectのstg, prdへの変更は常に1つのPull requestで表現されるとともに、マージするだけで変更を適用できるようにしています。

リリースPull requestの例

その他の工夫

コードオーナーの設定

サービス定義に記述したowner情報からGitHubの CODEOWNERS ファイルも自動生成しており、またリポジトリの設定で “Require review from Code Owners” を有効化しています。こうすることでコードオーナーのメンテナンス負荷を下げると同時に、コードオーナーの承認なしでマージされることを防いでいます。

Renovate Pull requestの集約

Terraform本体やTerraform providerの継続的アップデートのため、Renovateを導入しています (Self-hosted)。Pull requestの作成頻度や最大作成数などを設定はしていますが、Terraform projectはそこそこ数があることから、すべてを一対一で作成しようとすると大量のPull requestが必要になります。そこで additionalBranchPrefix を工夫することで、ある程度集約するようにしています。例えば次の設定は、 terraform/services/datadog/layerone-{domain} のプレフィックスをもつTerraform projectで集約してdatadog providerをアップデートするものです。

{
  packageRules: [
    {
        matchFileNames: ['terraform/services/datadog/**/versions.tf'],
        // aggregate 'terraform/services/datadog/layerone-domain' level
        commitMessageTopic: "{{{ replace '-x-[^/]+/[^/]+$' '' packageFileDir }}} {{depName}}",
        additionalBranchPrefix: "{{{ replace '-x-[^/]+/[^/]+$' '' packageFileDir }}}-",
        matchManagers: ['terraform'],
        matchPackageNames: ['datadog/datadog'], // limit package explicitly
        addLabels: ['terraform-provider'],
        enabled: true,
    }
  ]
}

現状の課題

ここまで現状について紹介しましたが、直近着手したい課題についても触れておきます。

依存モジュールの変更

例えば特定のIAM Policyなど、繰り返し記述されるものはTerraform moduleとしてメンテナンスしているものがあります。モジュールを変更した場合、本来そのモジュールに依存するTerraform projectをすべてplan/applyの対象とすべきですが、今のところできていません。

plan fileの活用

Terraformでは、plan時に -out オプションを与えることでplan内容を出力できます。これをapply時に引数で渡すことで、planの結果を確実にapplyすることができます。

Terraform pipeline上のCodeBuildでTerraform実行する際、特にAWS providerの場合は assume_role を活用しつつ権限の分離を図っています。具体的には、apply時 terraform_operator_role_arn というvariableを指定することでロールを変更するようにしています。しかし、plan fileにはproviderの内容が埋め込まれており、apply時にvariableで変更することができないという制約があります。plan fileの利用はTerraformにおいて良いプラクティスだと考えていますが、この制約により利用できていません。ただ stg, prd 環境は先述したリリースフローがあることからクリティカルではないと考えていました。

正直plan fileを活用することは諦めていたのですが、次の記事によるとEphemeral variablesを利用することで解決できそうなことが分かりました。このような情報を公開してくれることは非常にありがたいです…!

zenn.dev

おわりに

バクラク事業部のTerraform monorepoについて説明しました。tfcmtやaquaをはじめとした github.com/suzuki-shunsuke さん作のツールや、共有される情報には普段からたいへんお世話になっており、この場で感謝いたします。今後も特にセキュリティには悩まされ続けると思いますが、自分も公開できる情報は公開するなどコミュニティに貢献できればと思っています!