NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

注目のタグ

    CDKでTypeScriptファイルを利用した複数環境の切替方法

    こんにちは、越川です。 皆さんはCDK利用していますでしょうか? TypeScriptファイルを利用した環境切替を検証する機会があったので、備忘録も兼ねてブログを執筆しようと思います。今回は環境を切替えることのメリット、またTypeScriptファイルで環境を管理することのメリットをお伝えします。実際のデモも交えていますので最後までお楽しみください。

    CDKとは

    CDK(Cloud Development Kit)とは、TypeScript、Pythonといったプログラミング言語でAWSリソースを定義してCloudFormationを通じてAWSアカウントへ展開できるサービスです。App>Stack>Constructというツリー構造を持ち、複数のCloudFormation Stackを1つのAppファイルからデプロイ・管理することができます。

    環境を切替えるとは?

    CDKでは環境毎に簡単にパラメータを切替えることができます。もう少し具体例を出して説明してみようと思います。例えば、STGとPRODの2環境があるとします。STGはコストの観点からNAT Gatewayは1つにして、PRODは可用性の観点から2つにするといったイメージです。加えて、VPCのCIDRも当然、環境が変われば変化しますよね。スタックのCDKファイルは同じ物を使うのですが、環境毎に切替えたい値をパラメータとして外だしして、各環境でスタックをデプロイする際に任意のパラメータで上書きすることができます。

    環境の切替方法

    環境の切替方法は幾つか方法がありますが、大きく分けると2つです。Contextを利用する場合と、各プログラミングの拡張子ファイルで作成した外部ファイルを利用する場合です。

    方式 概要 備考
    Context CDK独自の概念でキーバリュー形式で値を管理 Contextの渡し方には幾つか方法がある
    外部ファイル 各プログラミングの拡張子ファイルで作成 各プログラミング言語の特性を活かせる

    ContextとはCDKの概念で、キーバリュー形式で値を管理します。このContextは複数の渡し方が存在しますがcdk init時に生成されるcdk.jsonというjson形式のファイルを使って管理するのがシンプルかなと思います。

    cdk.jsonを使った管理イメージ

    {
      "context": {
        "stage": {
          "envName": "Stage",
          "vpcCidr": "10.100.0.0/16"
        },
        "prod": {
          "envName": "Prod",
          "vpcCidr": "10.200.0.0/16"
        }
    }
    

    一方で、各プログラミング言語の拡張子で作成する外部ファイルを利用する場合もあります。この場合、追加で環境ファイルを作成する必要がありますが、各プログラミング言語の特徴を活かすことができます。今回はTypeScriptを使った外部ファイルの実装方法となります。

    なぜTypeScriptファイルを使うのか

    その中で、TypeScriptファイル(外部ファイル)を利用するメリットは大きく2つです。
    1つは補完が効くこと、そしてもう1つは型定義です。一つずつ解説していきます。

    ➀補完が効く

    CDKではAWS側で用意されたライブラリがあり、それをインポートすることで補完の恩恵をフルに活用できます。例えばAmazon EC2のライブラリをインポートすればインスタンスタイプを補完で指定することができます。逆に存在しないインスタンスタイプを指定するとIDE上でエラー検知してくれるわけです。

    MEDIUM・LARGEなど補完でサイズが表示される

    存在しないサイズを指定するとエラーになる

    ➁型定義

    1つ目のメリットは他の言語でも利用できるのですが、2つ目はTypeScriptを使うメリットですね。インターフェイスを作成することで事前に型定義を行うことができます。その型チェックを実装することでタイプミスを抑止する効果が期待できます。

    例として、以下のインターフェイスファイルと環境ファイルを作成します。instanceTypeSampleNameというパラメータを用意します。インターフェイスのファイルでそれぞれの型を定義し、意図的にSampleNameにstringではなくnumberを入力すると、このようにIDE上でエラー検知してくれます。

    interface.ts

    export interface ISampleParam {
      instanceType: ec2.InstanceType;
      SampleName:string;
    }
    

    sampleEnvfile.ts

    export const SampleParam: inf.ISampleParam = {
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM),
      SampleName:"sample",
    }
    

    stringに対してnumberを指定するとIDE上でエラー検知される

    実装デモ

    では早速、実装してみようと思います。cdk init --language typescriptを実行してプロジェクトファイルを生成します。 冒頭、簡単にどのようなことを今回実装しようとしているのか簡単に説明します。

    • stgとprod用の環境ファイルを作成。
    • その環境ファイルを利用してVPCスタックをデプロイ。
      • 環境ファイルではCidrとNatGatewayの数をパラメータ化して、cdk deployコマンド実行時に環境ファイルを指定。

    先ず、構成を理解し易いように今回のディレクトリ構成を記載します。

    ├── bin
    │   └── cdk-sample.ts
    ├── lib
    │   └── vpc-sample-stack.ts
    └── envfile
         ├── interface.ts
         ├── stg.ts
         └── prod.ts

    ここからは各ファイルについて解説していきます。

    bin/cdk-sample.ts

    import 'source-map-support/register';
    import * as cdk from 'aws-cdk-lib';
    import { VpcSampleStack } from '../lib/vpc-sample-stack';
    import * as fs from 'fs';
    import { IEnv } from '../envfile/interface';
    
    const app = new cdk.App();
    
    // This context need to be specified in args
    const argContext = 'environment';
    const envKey = app.node.tryGetContext(argContext);
    if (envKey == undefined)
    throw new Error(`Please specify environment with context option. ex) cdk deploy -c ${argContext}=dev`);
    
    //Read TypeScript Environment file
    const TsEnvPath = './envfile/' + envKey + '.ts';
    if (!fs.existsSync(TsEnvPath)) throw new Error(`Can't find a ts environment file [../params/` + envKey + `.ts]`);
    const env:IEnv = require('../envfile/' + envKey); 
    
    //Set pjPrefix
    const pjPrefix = env.EnvInf.envName;
    
    //Create Vpc Stack
    new VpcSampleStack(app, `${pjPrefix}-Vpc`, {
      vpcCidr:env.VpcParam.vpcCidr,
      natGatewayNumber:env.VpcParam.natGatewayNumber,
    });
    

    一か所ずつ解説していきます。

    環境名を指定する処理

    // This context need to be specified in args
    const argContext = 'environment';
    const envKey = app.node.tryGetContext(argContext);
    if (envKey == undefined)
    throw new Error(`Please specify environment with context option. ex) cdk deploy -c ${argContext}=dev`);
    

    こちらのコードはBLEAを参考に作成しました。
    BLEAとはAWSが提供している汎用テンプレートです。 コードの解説を簡単にしますと、tryGetContextとはCDK独自のメソッドでContext情報を取得します。

    今回の場合は--contextcdkオプションを利用した方法でContext情報を渡します。この--contextcdkはコマンド実行時cdk deploycdk synth実行時に値を渡します。したがって、このコードではコマンド実行時にcdk deploy --contextcdk environment=環境名という形でユーザにContext情報を指定して貰い、指定が無い場合はエラーを投げるという内容になっています。そして、ユーザが指定したenvironment(キー)のバリュー値がenvKey という定数に格納されます。--contextcdkは-cという略称で指定することも可能です。

    ここからは今回、独自に実装した内容になります。

    環境ファイルを定数に格納する処理

    //Read TypeScript Environment file
    const TsEnvPath = './params/' + envKey + '.ts';
    if (!fs.existsSync(TsEnvPath)) throw new Error(`Can't find a ts environment file [../params/` + envKey + `.ts]`);
    const env:IEnv = require('../params/' + envKey); 
    

    envfileフォルダを作成して今回は2環境分(stage,prod)の環境ファイルを作成します。そして、その環境ファイルを動的に読み込んでenvという定数に格納します。ファイルのインポートにはrequireを利用しています。このrequireが今回の検証のミソになります。requireを利用するとインポートするファイルのパスを動的に設定することが可能です。

    今回は先ほどのtryGetContextで取得したenvKeyを利用してユーザが指定した環境名に応じて動的に環境ファイルを格納します。例えば、cdk deploy --contextcdk environment=stgとユーザがコマンド実行すると、envKeyにはstgが格納されます。そうするとrequireのパスが動的に「/envfile/stg」に変更され、stg.tsファイルが定数envに格納されるといった流れになっています。こうすることで、今後、仮にdev環境が増えた際はenvfile下にdev.tsの環境ファイルを作成するだけで済み、拡張性を考慮した設計となっています。

    pjPrefix の設定とVPCスタックの初期化処理

    //Set pjPrefix
    const pjPrefix = env.EnvInf.envName;
    
    //Create Vpc Stack
    new VpcSampleStack(app, `${pjPrefix}-Vpc`, {
      vpcCidr:env.VpcParam.vpcCidr,
      natGatewayNumber:env.VpcParam.natGatewayNumber,
    }
    

    ここのpjPrefixはスタックをデプロイする際のスタック名に相当します。 VpcSampleStackを初期化(new)する処理で第二引数でこのpjPrefixを指定します。このpjPrefix は各環境名を付与した形になります。例えば、stg環境でしたら、Stg-Vpcといった形でスタックが命名されます。この環境名は後述する環境ファイルから取得します。

    実際のスタックを初期化(new)する処理では、パラメータとなるvpcCidrnatGatewayNumberを渡しています。因みにこのパラメータを渡す処理は下記のような記載することも可能です。スプレッド構文という記載方法で、この1行で全てのパラメータを渡すことができます。

    //Create Vpc Stack
    new VpcSampleStack(app, `${pjPrefix}-Vpc`, {
      //vpcCidr:env.VpcParam.vpcCidr,
      //natGatewayNumber:env.VpcParam.natGatewayNumber,
      
      //スプレッド構文を利用して一括でパラメータを渡す
      ...env.VpcParam,
    }
    

    lib/vpc-sample-stack.ts

    import * as cdk from 'aws-cdk-lib';
    import { Construct } from 'constructs';
    import { aws_ec2 as ec2 } from 'aws-cdk-lib';
    
    export interface VpcSampleStackProps extends cdk.StackProps {
      vpcCidr: string;
      natGatewayNumber: number;
    }
    
    export class VpcSampleStack extends cdk.Stack {
      constructor(scope: Construct, id: string, props: VpcSampleStackProps) {
        super(scope, id, props);
    
        new ec2.Vpc(this, 'Vpc', {
          cidr: props.vpcCidr,
          natGateways: props.natGatewayNumber,
          flowLogs: {},
          subnetConfiguration: [
            {
              name: 'Public',
              subnetType: ec2.SubnetType.PUBLIC,
            },
            {
              name: 'Private',
              subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
            },
          ],
        });
      }
    }
    

    パラメータを受け取る処理

    export interface VpcSampleStackProps extends cdk.StackProps {
      vpcCidr: string;
      natGatewayNumber: number;
    }
    

    こちらはVPCスタックです。スタック側でパラメータを受け取る場合は上記の記載が必要になります。今回はvpcCidrnatGatewayNumberを受け取るので(スタック側から見て)、双方のパラメータと型を記載します。今回はパブリックとプライベートサブネットを用意しました。パラメータで受け取る値は、props.パラメータ名という記載をします。

    stg.ts

    import * as inf from './interface';
    
    export const VpcParam: inf.IVpcParam = {
        vpcCidr: '10.100.0.0/16',
        natGatewayNumber: 1,
      };
    
      export const EnvInf: inf.IEnvInf = {
        envName: 'Stg',
      };
    

    prod.ts

    import * as inf from './interface';
    
    export const VpcParam: inf.IVpcParam = {
        vpcCidr: '10.200.0.0/16',
        natGatewayNumber: 2,
      };
    
      export const EnvInf: inf.IEnvInf = {
        envName: 'Prod',
      };
    

    stgとprod用の環境ファイルを作成します。それぞれ、cidrとNatGatewayの数が違う値になってるかと思います。先述したpjPrefix の環境名はconst EnvInfenvNameから取得します。

    interface.ts

    export interface IVpcParam {
        vpcCidr: string;
        natGatewayNumber: number;
      }
    
      export interface IEnvInf {
        envName: string;
    }
    
      export interface IEnv {
        VpcParam: IVpcParam;
        EnvInf:IEnvInf;
    }
    

    型制御をするために別途、インターフェイスのファイルを作成します。ここで各環境ファイルで定義しているオブジェクトファイル内にあるパラーメータの型を指定します。そうすることで、ユーザが誤って違う値を入れるといったミスを低減します。

    IEnv というインターフェイスはappファイルで定義したconst env用のインターフェイスです。このインターフェイスを作成することで、実際にenvファイルで各オブジェクトを呼び出す際に補完が効くようになります。

    env.まで入力すると候補が出てくる

    デプロイ&動作確認

    では実際にデプロイして環境毎に値が切り替わるか確認してみましょう。 STGとPROD環境をそれぞれデプロイします。
    STG環境
    cdk deploy -c environment=stg

    PROD環境
    cdk deploy -c environment=prod

    問題なく双方とも作成されていますね。加えて、環境名も入ってます。

    cidrも各環境ファイルで指定したものになっています。

    NatGatewayもSTGは1つでPRODは2つになっていますね。

    こちら再掲しておきます。

    おわりに

    今回は環境毎にファイルを作成しましたが、サービスカットでファイルを作るといった方法もあります。環境の切替方法は様々な方法がありますので、今回はその一例として参考になれば嬉しいです。

    執筆者越川

    インフラエンジニアで主にAWSを取り扱っています。


    執筆記事一覧:https://tech.nri-net.com/archive/author/t-koshikawa