🗂

ベストなCDK for Terraform(CDKTF)のディレクトリ構成を探る

2024/04/05に公開

最近関わっているプロジェクトでは、大きなアプリケーションのリアーキテクチャを行っています。
そこではフルスクラッチでインフラの構成を書き直す機会がありました。

そのプロジェクトは社内では比較的複雑な構成になっていて、リアーキテクチャにあたっては、アプリケーション特有の事情により、考慮すべきポイントや難度が高いところがたくさんありました。
具体的には、複数のマイクロサービス間の協調性や独立性とそれに付随する認証/認可、クライアント証明書(mTLS認証)の管理、歴史的経緯による複雑性を正すためのawsアカウント移行などなど。

それらは別の機会で語る(or 同僚の誰かが語ってくれることに期待したい)として、この記事ではインフラをフルスクラッチで書き直すにあたって選択したCDKTFについて、紆余曲折を経つつも最終的に採用したディレクトリ構成について書きたいと思います。

タイトルには「ベストな」とありますが、あくまで自分達にとってのベストという意味で、万人に当てはまるという意味ではありません。

CDKTFとは

https://aws.amazon.com/jp/blogs/news/cdk-for-terraform-on-aws-jp/

CDKTFは2022年の8月にGAされた、HashiCorpとAWSとで共同開発で作られたIaCツールです。
これを利用することで、Terraformリソースを使い慣れたプログラミング言語で記述することが可能になります。
構成を記述することで、Terraformの構成ファイルを生成します。

なんでCDKTFを選んだの?

社内ではAWS Cloud Development Kit(AWS CDK)が多く使われています。
自分がプロジェクトに入った時に感じた問題点として、以下のような点がありました。

  • 当初インフラ構築をしたメンバーはもうすでにプロジェクトを去っていて、CDKに特別詳しいメンバーがいない
  • CDKの適用に失敗した時には、内部の動作を理解していない状態から始まるので苦労する

CDKの圧倒的なメリットは記述するコード量の少なさです。
しかし一方でCloudFormationの内部で作られるリソースへの理解(1つの記述で何が作られるか)が求められます。

その点ではTerraformは、1つ1つのリソースを定義していく必要があるため、記述量は増えてしまいますが、その分見通しは良くなります。

CDKTFを採用することで、Terraformの見通しの良さ + プログラミング言語を記述できることによる再利用性の高さの両方を得ることができると考えました。
Terraformを直接使わない副次的な理由としては、CDKTFのフォーマット(Stack, Construct)には全員が慣れているので、メンバーが理解しやすいというのもありました。

もちろんCDKのスペシャリストがチーム内にいる場合には、CDKが最良の選択となることもあります。これはケースバイケースですので、チームに合った技術選定を行うべきです。

自分自身がCDKよりもTerraformの経験があったので始めやすかったというのもありますが、主には以上のような理由があり、このプロジェクトではCDKTFを採用することにしました。

以下に個人的な観点での、CDKとCDKTFの比較をまとめます。

CDK/CDKTF共通

  • 使い慣れたプログラミング言語で構成を書けることによる、再利用性、表現可能性の向上

CDK

Pros

  • コード量が少なくて済む
  • AWSの第一の公式ツールなのでサポートが厚い
    • CDKTFも公式だけど、サポート状況はどうなんだろう?今の所あまり困っていないけど、あまり最新の機能だとCDKTFのサポートはCDKと比べて遅いとかはあるのかもしれない

Cons

  • CloudFormationへの理解が必要
  • DIFFの比較が見づらい
    • コード量が少なくて済むものの、デプロイ時には多くのCloudFormation template生成されることになります。ここの比較を行うのはつらいです。
  • AWSリソースのデプロイしかできない
  • 手動でリソースを変更した時に、差分として検知できない

CDKTF

Pros

  • DIFFが見やすい
  • リソースの記述が分かりやすい
  • AWS Management Consoleから手動でリソース変更した際にも、差分を検知して比較できる
    • 色々試行錯誤している時には手動でやりたくなる場面も多いので、これは便利
  • マルチプラットフォームにデプロイ可能

Cons

  • CDKと比べると記述量は増える
  • Terraformへの理解が必要

最終的なディレクトリ構成

前置きが長くなりましたが、以下が本題です。
最終的なディレクトリ構成は以下のようになりました。

.
├── constructs/
│   ├── authorizer.ts
│   ├── alb.ts
│   ├── api-gateway-lambda-integration.ts
│   ├── api-gateway.ts
│   ├── fargate-service.ts
│   ├── lambda.ts
│   ├── nlb.ts
|    ...
├── environments
│   ├── production/
│   |   ├── index.ts
│   |   └── values.ts
│   ├── sandbox/
│   |   ├── index.ts
│   |   └── values.ts
│   └── staging/
│       ├── index.ts
│       └── values.ts
├── stack-sets
│   ├── serviceA.ts
│   ├── serviceB.ts
│   ├── serviceC.ts
|    ...
├─── stacks
|   ├── serviceA/
|   ├── serviceB/
|   ├── serviceC/
|   ├── ecr/
|   ├── dynamo-db/
|    ...

粒度の大きい順に見ていきます。

environments

環境ごとの差異は全てenvironmentsに記述します。
CDKTFのAppはこの環境ごとに作成しています。
インフラの構成そのものは環境ごとに同じものを再現することが多いため、できるだけ共通化したい気持ちがあり、共通化できないものはここに集約することにしました。
例えば staging/index.ts は以下のようになります。

export function app() {
  const app = new App();

  new ServiceAStackSet(app, StagingValues, { /*... configurations here */  });
  new ServiceBStackSet(app, StagingValues, { /*... configurations here */  });
  new ServiceCStackSet(app, StagingValues, { /*... configurations here */  });
// ...

StackSetは後述しますが、関連の強いStackの呼び出しをまとめたものです。
ここでは基本的にはStackSetを呼び出すことで、環境ごとに同等の環境を再現します。
とはいえ特定の環境にのみ作りたいリソースがどうしても発生することもあります。そういったユースケースはここを調整することでハンドルします。
環境ごとに割り当てるメモリやインスタンスサイズなどは違うこともあると思いますが、そういう部分はvalues.tsに記述します。

export const Values = {
  serviceA: {
    desireCount: 2,
    memory: 1024,
    vcpu: 4,
  }
  // ...
}

stack-sets

stacksの節で詳しくは後述しますが、今回のプロジェクトではStackを細かい粒度で作成する選択をしました。

StackSetは複数の関連するStackをまとめたものです。
以下のようになります。

export class ServiceAStackSet extends StackSet {
  constructor(
    app: App,
    values: EnvironmentValues,
    config: ServiceAStackSetConfig,
  ) {
    super(app, values);

    const serviceADynamoDbStack = this.addStack(
      new DynamoDBStack(
        app,
        `${this.values.environment.name}-service-a-dynamodb`,
        {
          region: this.values.region,
          tableDefinition: ServiceATableDef,
        },
      ),
    );

    const serviceAECRStack = this.addStack(
      new EcrRepositoryStack(
        app,
        `${this.values.environment.name}-service-a-ecr-repository`,
        {
          region: this.values.region,
          name: "staging-access-token-manager",
        },
      ),
    );

    this.addStack(
      new ServiceAStack(
        app,
        `${this.values.environment.name}-service-a`,
        {
          region: this.values.region,
          dynamodbStack: serviceADynamoDBStack,
          ecrStack: serviceAECRStack,
        },
      ),
    );
    // ...
  }
}

当初はStackSetを作らずenvironmentsに各Stackの呼び出しをそれぞれ書いていたのですが、同じ構成を再現する上では同じ呼び出し方法になることがほとんどなので、どうしても冗長になってしまいました。
そこで処理の共通化を行うために、関連性の高い部分を切り出してまとめたものがStackSetになります。
関連性の高い部分を切り出しているので、サービス単位になることが多く、実際にほぼ全てサービス名が付いたファイルになります。(一部共通で使用するリソース(VPCなど)用のStackSetがあったりはします。)

StackやConstructはCDKTFに用意されたクラスですが、このStackSetは自前で用意しているものになります。(なのでCloudFormation StackSetsとは異なります。)
Stackの作成にあたって共通で必要となるような処理は、このクラスで共通化しています。
上記の例では addStack に作成されたStackを渡していますが、addStack にはtfstateのbackendの登録といった全てのStackに必要な処理が記述されています。

export abstract class StackSet {
  protected app: App;
  protected values: EnvironmentValues;

  protected constructor(app: App, values: EnvironmentValues) {
    this.app = app;
    this.values = values;
  }

  protected addStack<S extends TerraformStack>(stack: S): S {
    new S3Backend(stack, {
      bucket: this.values.stateBucket,
      key: `state/terraform.${stack.node.id}.tfstate`,
      region: this.values.region,
    });
    return stack;
  }
}

stacks

CDKTFのStackをここにまとめています。
CDKTFではStackごとにstateファイルを作成することになります。
環境ごとにstateファイルを作るか(staging.tfstate)、それともサービス単位とするか(staging-service-a.tfstate)で考え方が大きく分かれるところかなと思います。

公式にもStackを分ける考え方について記載があります。
https://developer.hashicorp.com/terraform/cdktf/create-and-deploy/best-practices#separate-business-units-with-stacks

envごとにstateを作成するか、サービスごととするかは悩みました。一般的なベストプラクティスとしてはまとめてしまう方が良いとされることも多いですが、今回はリアーキテクチャというプロジェクトの特性もありサービスごとにstateを作る方法を取ることにしました。

リアーキテクチャではすでに存在しているサービスの構成を壊さないように、一部分だけを既存のリソースからインポートして修正するといった作業が多くあります。一気にやってしまえれば理想なのですが、作業効率や安全性の観点からも少しづつ小さい部品の移行から始める必要がありました。

また、サービス単位を基本としつつ、デプロイの単位として分けたいユースケースがある場合には、そこからさらにリソースを抽出して定義をしているものもあります。
具体的には、サービスAのfargateで使用するメモリを変更するユースケースを想定した時に、DynamoDBやECRはそれとは関係ないから分離しておく、みたいな感じです。

そこまで細かく分けずとも差分がなければ何も起きないので気にしなくても良いのでは、という気持ちもあったりしますが、既存サービスのリアーキテクチャを行う上では複数人で関連するリソース変更を伴う作業をすることもあり、競合を避ける意味では素晴らしくワークしました。
小さい単位でのデプロイとなるので、作業者の予期しない大きなdiffが発生することはなく、plan/applyの速度も速いです。

現状ではプロダクション適用した後も上手く運用できていますが、これから先の懸念としてはデプロイ単位が増えることで未デプロイのリソースができてしまったりすることがあると思います。
これに関してはTerraform CloudAtlantisを使うことで解決できると考えています。
実施できたらまた書きたいです。

constructs

単体ではデプロイしないものの、Stackで作るリソースにおいて、共通化したい部分を切り取ったモジュールがConstructです。Constructが今回作成した構成では一番小さな単位となります。
具体的な書き方やユースケースなどは本来のCDKTFと変わる部分はないので、公式のドキュメントを参照するのが一番分かりやすいかなと思います。

https://developer.hashicorp.com/terraform/cdktf/concepts/constructs

おわりに

まだリアーキテクチャプロジェクトは続いており、これから変わっていく部分もあると思います。何か運用していくうえで変わった部分は、また改めて書きたいと思います。
どなたかの参考になれば幸いです。

CureApp テックブログ

Discussion