mazyu36の日記

某SIer所属のクラウドエンジニアのブログ

AWS CDKをチーム開発する際に採用したプロジェクト管理、開発のフローについて

はじめに

今年度初めてAWS CDKによるチーム開発を行ってみた。その際に採用したソースコードのプロジェクト管理方法や、デプロイのフローについて考えたこと含め整理としてまとめておく。

※これが最適だとは思っていないので、イマイチな点などあれば意見もらえると嬉しいです。

なお、考えたことが以下の書籍で言語化されている箇所もあったので、必要に応じて引用させていただく(オライリーのInfrastructure as Codeの第2版。2023/1現在で、残念ながら邦訳は第1版のみ。)。

前提

今回の仕組み構築にあたっては以下を前提としている(多分よくある話)。

  • インフラ(AWS)の構築を行うメンバが複数人いる。
  • チーム全体で使用する環境としては以下の3つがある
    • 開発環境(dev):開発用の資材をデプロイする環境。
    • 試験環境(stg):主にテストや本番リリース前の資材の検証に使用する環境。
    • 本番環境(prd):本番リリース時の資材をデプロイする環境。

導入検討時の話(そもそも言語どうする問題)

インフラ担当者は私含め全員TypeScript未経験者である。しかし情報量の多さなどを踏まえるとTypeScriptが良いと考え採用した。

結果的には良かったと思う。プログラミング言語で書くとはいえ、高機能な設定ファイルを書いている感覚に近いのでTypeScriptを触ったことがなくてもなんとかなる。

全体像

前提を踏まえ今回のチーム開発で採用していたやり方は以下である。

ポイントとしては以下2点である。

  1. CDKのプロジェクト管理:1リポジトリでコードを管理。またブランチ運用は基本的にmainの一本。インフラ作業者は個別にブランチを切って作業し、作業が終わったらmainにマージする。
  2. 開発のフロー:インフラ作業者がCDKの開発を行う場合はブランチを作成し、専用の環境に対して手動で cdk deploy でデプロイおよび検証する。作業後mainにマージすると、パイプラインをトリガーしdev, stg, prdに自動でデプロイする。

以下それぞれ詳細を記載する。

なお、今回記載した方式によるCDKプロジェクトのサンプルは以下に格納している。

github.com

1. CDKのプロジェクト管理

コード管理プロジェクトについて

1つのコード管理プロジェクトから複数環境をデプロイする形としている。当たり前だが1元管理した方が管理が楽だからである。

これは参考書籍の Patterns for Building Environmentsで紹介されているパターンの内、Pattern: Reusable Stackに相当する。

A reusable stack is an infrastructure source code project that is used to create multiple instances of a stack. (p72)

上記を踏まえ、1つのCDKプロジェクトから以下の環境に対してデプロイが行えるよう実装している。

  • インフラ作業者専用の環境(図のinfraA, infraB):インフラ作業者(CDK開発者)がそれぞれ占有する環境。CDK開発中は設定変更やリソースの生成・破壊などを繰り返すため作業者単位で環境をデプロイ可能とする。
  • チーム全体で使用する環境(dev, stg, prd):アプリ開発者など含めチーム全体で使う環境。CDKの実装内容がある程度フィックスした段階でこちらに反映する。

ただし、これを実現する上では環境ごとのパラメータを切り替える方式が必要である。その方法は後述する。

デプロイ先の環境の切り替えについて

「どの環境に対してデプロイするか」は「cdkコマンド実行時にcontextで指定」、「環境変数から取得」等が多いと思う。 今回は前者を採用した。

docs.aws.amazon.com

具体的にはAppの実装上以下のようにcontext値を取得するようにしておき

const app = new cdk.App();

// デプロイ先の環境はcontextから取得する
const envType = app.node.tryGetContext('env');

コマンド実行時に以下のように指定する。

cdk deploy -c env=dev

その上でスタック生成時にデプロイ先の環境名を引き渡し、環境名により各種パラメータを切り替えられるようにしてる。

またstackNameにおいてcontextで指定した環境名を付与することで、環境間でスタック名が競合しないようにしている。

// スタックを作成
new AppStack(app, 'CDKPipelinesStack', {
  stackName: `${envType}-App-Stack`,  // Stack名に環境名を含め重複防止
  env: envConfig,  // アカウントID、リージョンを設定
  terminationProtection: true,  // Stackの削除保護を有効化
  envType: envType  // contextをStackに引き継ぐ
})

パラメータの切り替え方法について

App経由で引き継がれたcontextのデプロイ先環境名を元に、環境ごとのパラメータの切り替えを行う方式としている。

ここの切り替え方式もcdk.jsonを使う、環境変数を使う、オブジェクトを使う等があるが、今回はオブジェクトを使う方式を採用した。理由としてはTypeScriptの特性を活かして型安全で開発ができるからである。

具体的には以下記事のconfigファイルを作成し、環境に応じた設定値を取得できる関数を使用するやり方を参考にさせていただいた。

maku.blog

文章だけだとわかりづらいため実装例を参考に詳細を記載する。

.
├── appStack.ts
├── config
│   └── vpcConstructConfig.ts
└── constructs
    └── vpcConstruct.ts

実装例のプロジェクトではVPCに関する定義をコンストラクトとして作成し、それに対応する設定ファイルをconfig配下に作成している。ここではVPCのみだが実プロジェクトでは複数のコンストラクトファイルと設定ファイルを作成する形となる。

設定値はコンストラクトファイルの中に定義しても良いが、コンストラクトの実装が長くなるケースもあるので、設定はconfig配下に別だしし各環境の設定値が一目でわかる+変更したい場合もconfig配下のみいじれば良いので統制がとれやすいためこの形とした。

※コンストラクトによる構造化は以下が参考になる。

tmokmss.hatenablog.com

■設定ファイルの実装

以下のように設定値の型を定義し、指定した環境の設定値を返す関数を実装しているのみ。

関数におけるswitch文で環境ごとに分岐している。infraAinfraBはインフラ作業者専用の環境であり、作業者が増えた場合はここを増やしていく形となる。

なお、インフラ作業者専用の環境は、デプロイ時にインフラ作業者が手動でcontext値をタイプ(env=infraAなど)する形であるが、参考書籍では手動でパラメータを与えるのはManual Stack Parameters(p81)としてアンチパターンであると記載されている(理由としては手動だとオペミスが生じ得るから)。

しかしインフラ作業者用の環境は手動デプロイとしたかった(理由は後述)のため、存在しない環境をcontext値で指定した場合はエラーとするよう実装している。

// 型を定義
export type VpcConstructConfig = {
  cidr: string, // VPCに割り当てるcidr
  maxAzs: number // VPCの最大AZ数
}

// 環境名を引数とし、指定した環境の設定値を返す関数を実装
// VPCのIPレンジについて、チーム全体で使うdevなどはクラスA、インフラ作業者が使う環境はクラスB、というように区分によって分けるようにしていた
export function getVpcConstructConfig(envType: string): VpcConstructConfig {
  switch (envType) {
    case 'dev':
      return {
        cidr: '10.0.0.0/16',
        maxAzs: 2
      }
    case 'stg':
      return {
        cidr: '10.1.0.0/16',
        maxAzs: 2
      }
    case 'prd':
      return {
        cidr: '10.2.0.0/16',
        maxAzs: 2
      }
    case 'infraA':
      return {
        cidr: '172.16.0.0/16',
        maxAzs: 1
      }
    case 'infraB':
      return {
        cidr: '172.16.1.0/16',
        maxAzs: 1
      }
    // 存在しない環境名を指定した場合はエラー
    default:
      throw new Error(
        `The VPC config in "${envType}" environment does not exist.`
      )
  }
}

■コンストラクトの実装

上記を元に対応するconstructにおいて、設定値を取得して該当箇所に設定する。 具体的には以下のような形である。

    // ----------------------- Config ------------------------------
    // 環境名をconfigで定義した関数に渡し、設定値を取得する
    const vpcConfig: VpcConstructConfig = getVpcConstructConfig(props.envType)


    // ----------------------- VPC ------------------------------
    // 取得した設定値を元にリソースを定義する。
    new ec2.Vpc(scope, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr(vpcConfig.cidr), // 設定値から反映
      maxAzs: vpcConfig.maxAzs,  // 設定値から反映
      natGateways: 0,
      vpcName: `${props.envType}-vpc`
    }
    )

例では ipAddressesmaxAzsを環境ごとで切り替える形としている。設定値を型で定義しているため型安全で開発を行うことができ、IDEによる補完が効く、誤った項目を参照している場合はエラーが出るなど恩恵を受けられる。

■Stackの実装

コンストラクトを作成したらstackからnewするのみである。

// StackPropsを拡張しAppからenvTypeを注入
interface AppStackProps extends StackProps {
  envType: string
}

export class AppStack extends Stack {
  constructor(scope: Construct, id: string, props: AppStackProps) {
    super(scope, id, props);

    // VPCを生成
    new VpcConstruct(this, 'VpcConstruct', {
      envType: props.envType
    })
  }
}

2. 開発のフロー

大きく分けると「インフラ作業者の開発、デプロイ」と「チーム全体の環境へのデプロイ」の2つに分かれる

インフラ作業者の開発、デプロイ

以下のようにインフラ作業者はそれぞれ専用の環境を持ち、CDK開発を実施する。

具体的な流れとしては以下である。CDK開発時のデプロイまでパイプライン経由にすると煩わしいため、手動でデプロイする形としている。

  • mainから作業用のブランチを切り出す。
  • 対象のブランチにおいて開発を行い、自分専用の環境に手動でデプロイを行い検証する。
  • テスト実装含め開発が終わったらmainにマージする。

チーム全体の環境へのデプロイ

mainにマージを行うとパイプラインが自動でトリガーされ、各環境(dev, stg, prd)に対してデプロイが行われる。

パイプライン経由でのデプロイとしているのは以下のためである。

  • 手動デプロイを抑えられるのでオペミスが防止できる。誤ってmain以外のものを適用してしまった、等が起こらない。
  • パイプラインにテストステージ、承認ステージを設けることで、不完全な資材がデプロイされるのを防止できる。
  • 開発者の権限を弱くできることでマネコン上でのオペミスも防止できる。特に本番環境ではアカウントごと分離し、パイプライン経由以外ではリソースを作成できないようにする等が可能となる。

なお、パイプラインは以下記事のものを使用している。

mazyu36.hatenablog.com

ブランチ戦略について

CDKのコードのブランチはmainのみが定常的に存在する運用とした。開発用のブランチは基本的に短命であり、数日単位でマージを行う(トランクベース開発に近い)。

上記のようにした理由は以下である。

  • mainのみで各環境向けの設定値切り替えを行えるよう実装しているため。例えばdevの設定値だけ変更したい場合でも、config配下でdevに該当する設定値の箇所のみ修正すれば、mainを更新しても他の環境(stg, prd)には影響しない(パイプライン自体は起動するので、実態としては差分なしでデプロイが空振りする形となる)。
  • インフラにおいて後方互換性がなくなるような変更はしない想定であったことから、基本的に最新のCDKコードを適用した状態としておきたいため。複数ブランチ運用にした場合、反映漏れや多くの変更を一度にprdに反映するというケースが発生し得るので、それぐらいであればmain一本にして商用環境含め小さい単位で変更を反映した方が良いと判断。
  • 新しいリソースを生やす場合などにどうしても商用環境に反映したくないものが出てきた場合は、フィーチャーフラグ的なものを実装して、商用環境(prd)にはデプロイされないように制御すれば良いと判断。

結果的には良かったと思う。本番環境(prd)においてインフラの変更を行うのはどうしても緊張感のある作業である。 そのため、たとえprdの設定が一切変わっておらず空振りに終わったとしても、常に最新のCDKのコードが適用されている状態であるというのは精神的に良かった。

終わりに

まとめると今回採用したAWS CDKのチーム開発の仕組みとしては以下である。

  • 1リポジトリでコードを管理。ブランチは基本mainのみ。
  • CDK開発時は開発用ブランチを切り、各作業者専用の環境に手動でデプロイし検証。
  • 開発用ブランチでの作業が完了したらmainにマージ。パイプラインをトリガーし、チーム全体の環境(dev, stg, prd)に自動で適用。