NRIネットコム Blog

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

グローバルアプリをCDKでデプロイする 〜田舎者でもデプロイできっぺ〜

f:id:y3-shimizu:20220325181019p:plain

こんにちは、桜の季節は毎年休むタイミング狙いすぎて休むの忘れる志水です。
以前、マルチアカウント、マルチリージョン、マルチ環境(prd/dev/etc...)なアプリケーションのインフラをCDKで構築しました。そのときにいくつか大変なこともあったので、どうやってCDKを設計し構築していったかを共有させていただきます。

作るもの

まずはどのようなものを作るのか、要件を整理していきます。

このアプリケーションはいろんな国の人が利用するグローバルアプリケーションになります。どの国も同じ作りですが、一部国ごとに挙動を変える必要があります。また、開発者のために本番とは別に開発出来る環境が必要になります。

設計

次に、要件を元に設計をしていきます。

インフラ設計

まずはインフラの設計をしていきます。

アプリケーションはコンテナで構築し、ALB+Fargate(on ECS)+S3の構成とします。アプリの基本の作りは同じで一部挙動が違うため、どの国も同じアプリケーションを利用し、挙動はECSの環境変数から変えるようにします。その為、各国ごとにアプリを配置する必要があるので、利用される国ごとに最適なリージョンを選択して構築する必要があります。今回は日本・韓国・アメリカ・カナダへデプロイすると仮定し、それぞれ東京リージョン、オレゴンリージョンへ構築していきます。

f:id:y3-shimizu:20220325180952p:plain

また、開発者は本番とは別の環境が必要とのことなので、本番環境と開発環境の2環境を作成し、それぞれ別アカウントで作成します。ちなみにAWSアカウントの分け方については下記記事が勉強になります。

AWSアカウントはなぜ&どう分けるべき?

f:id:y3-shimizu:20220325181301p:plain

ドメインやイメージリポジトリは環境によらず同じアカウントへ集約します。

f:id:y3-shimizu:20220325203252p:plain

次にリソースの名前ですが、下記理由により国名と環境名を付けた名前にします。

  • 同じリージョンに別の国の同じリソースがある
  • S3バケットの名前は全リージョン(中国・govは別)で一意

コード設計

インフラ設計が出来たので、次にインフラのコード設計をしていきます。

CloudFormation(CFn)スタック

まずはCFnのスタックについて設計していきます。

今回のメインのリソースはALB+Fargate+S3になります。スタックの分け方の1つにリソースのライフサイクルによって分離する考えがあります。今回はその考えに則ってALB+FargateとS3の2つのスタックに分離することとします。ただし、アプリケーションはCodeDeployからデプロイされるためCDK外となります。

f:id:y3-shimizu:20220325204138p:plain

その2つのスタックが国と環境それぞれに構築されるので、CFnスタックは下記のように4×4の16スタック作成することになります。

f:id:y3-shimizu:20220325204426p:plain

Route 53やECRはそれぞれ1つずつスタックを作成してデプロイすることとします。ドメインをCDKで管理すべきかどうかという問題はありますので、その辺りはプロジェクトごとに考えていきましょう。

CDKスタック

CFnスタックについて整理出来たので、ようやくCDKについて設計していきます。まず、1つのCFnスタックに対して1つのCFnテンプレートが必要です。その為、CDKスタックからCFnテンプレートをいかに効率的に作成出来るかが重要になります。

f:id:y3-shimizu:20220327091848p:plain

まず、ALB+FargateやS3などのライフサイクルの違う各リソースのCFnテンプレートの内容は全く違うので、それぞれ別のCDKスタックを作成する必要があります。
次に、同じリソースの他の国や環境のCFnテンプレートについて考えてみます。この場合、対象のリソースは同じなので記載内容は同じですがインフラ設計で記載したようにリソースの名前が国や環境によって違ってきます。なので、contextを利用してCDKスタックに国や環境を入力として与え、それぞれの国や環境のCFnテンプレートを作成していくことにします。
CDKコードからCFnテンプレートを作成することは出来ましたが、そのCFnテンプレートをデプロイする必要があります。今回、それぞれの環境や国によってAWSアカウントやリージョンが変わってきます。その為、CFnテンプレートのデプロイでAWSアカウント・リージョンを切り替えるために、cdk実施時にprofileを指定して切り替えることにします。

f:id:y3-shimizu:20220325205041p:plain

構築

設計が出来たので、次に構築時に確認すべき部分について説明します。CDKの言語はPythonを利用し、CDKバージョンは2が出る前に構築したので今回は1で説明します。

CDKの実行方法

まずはCDKの実行時に気をつける点を説明します。

CDK実行はタスクランナーから行うことにします。今回はmakeを使ってますが、Pythonを利用しているのでinvokeなども良いです。タスクランナーを利用する背景は、国や環境を指定するとAWSアカウントやリージョンが一意になるのと、開発者がAWSアカウントやリージョンの指定を間違えないようにするためです。

CDKからAWSアカウントとリージョンを切り替える時にはprofile(--profileオプション)を利用し、国と環境の切り替えにはcontext(-cオプション)を利用します。ただし、AWSアカウントは環境、リージョンは国に紐づくので、下記のようにmakeから入力するのは国と環境のみになります。

f:id:y3-shimizu:20220328223237p:plain

makeの実行は下記のように環境変数を付与して行います。一部国や環境で共通なリソース(Route 53やECRなど)はCOUNTRYやENVまたはその両方を入れないケースもあります。

$ make deploy-hoge COUNTRY=japan ENV=dev
$ make deploy-fuga ENV=dev

上記で付与したCOUNTRYとENVの環境変数を元に、下記のMakefileのようにprofileを切り替え実行しています。profileは各AWSアカウント、リージョンのものを作成し利用します。

.PHONY: deploy-hoge
deploy-hoge:
  $(eval PROFILE_ENV := dev)
ifeq ($(ENV),prd)
    $(eval PROFILE_ENV := prd)
endif
    $(eval PROFILE_REGION := tokyo)
ifeq ($(COUNTRY),us)
    $(eval PROFILE_REGION := oregon)
endif
    cdk deploy hoge-$(COUNTRY)-$(ENV)-stack -c country=$(COUNTRY) -c env=$(ENV) --profile aws-$(PROFILE_ENV)-$(PROFILE_REGION)

CDKコード

次に、コーディング時の気をつける点について説明します。

profile

先程cdk実行時にprofileを設定すると、CDKコードではCDK_DEFAULT_ACCOUNTCDK_DEFAULT_REGIONの環境変数が自動でセットされます。なので、下記のようにアカウントとリージョンをその環境変数からセットしてスタックに入れることで、実行時のprofileでスタックをデプロイされることになります。profileの設定がない場合を考慮するとos.environ.getなども利用した方が安心ですが、今回はタスクランナーでprofileを入れる事をある程度保証しているのでこのまま利用していきます。

cdk_default_env = core.Environment(
  account=os.getenv('CDK_DEFAULT_ACCOUNT'), 
  region=os.getenv('CDK_DEFAULT_REGION')
)
HogeStack(app, "hogehoge", env=cdk_default_env)
context

次にcontextについてです。CDK実行時-c country=hogehogeのように-cオプションで実行するとCDK内でcontextが利用出来ます。CDKコード内ではapp.node.try_get_context("country")で実行時に入れた文字列(hogehoge)が取得できます。もし、実行時に入れたもの以外のcontext値を取得しようとすると、Noneが返ってきます。そこで、下記のようなコードを$ cdk deploy hoge-stackのようにcontextを指定せず実行します。

yomi = {'japan': 'nihon', 'us':'amerika'}
country_name = app.node.try_get_context("country")
print(yomi[country_name])

すると、contextが設定されず2行目でNoneが返ってくるため最終行でエラーが出てしまいます。
この問題がスタックを跨ぐと難しくなります。下記のように開発者が2名いて、それぞれのブランチで各スタックを構築してmasterにマージするケースを考えます。

f:id:y3-shimizu:20220330072435p:plain

スタックAはcontextにenvのみ必要でスタックBではcountryとenvの両方が必要だとします。

f:id:y3-shimizu:20220330072444p:plain

それぞれの開発者がfeatureブランチでスタックを構築し、diffを確認してエラーが出ないことを確認したので、masterにマージしてデプロイしようとします。

f:id:y3-shimizu:20220330073557p:plain

すると、先にマージしたBスタックは正常にデプロイが成功しましたが、その後にマージしたAスタックのデプロイでエラーが出ました。開発者Aさんはfeatureブランチでは正常だったのと、エラーがBスタックのコードから出ていたので開発者Bさんに確認します。しかし、Bさんもmasterでデプロイも正常に出来ているので、Aさんのコードが悪いんじゃないかと疑い、つい関西弁でキレてしまいます。

本事象は、Aスタックのコードが先程のようなcontextがない状態を考慮せず構築した事が原因です。大体こういうときはキレたほうが悪いです。CDKは特定のスタックのdiffやdeployを実行するとapp.pyを上から下まで全て見て、そこから指定したスタックのdiffやdeployを行います。つまり、指定していないスタックも読み込みされるので、そこでエラーのあるコードがあれば、実行しているスタックが問題無くてもエラーが出てしまいます。しかし、他のスタックを読み込む時も実行時のcontextを利用しているので、入力にないcontextはNoneとなりエラーが発生しました。

このように関西弁でキレられたくないので、下記のようなcontext値を持つクラスを作成してcontext値を取得する場合はこのクラス経由で実行することにしました。

from aws_cdk import (
  core
)

class Context():
  def __init__(self, node):
    self.node = node
    self.country = self.node.try_get_context("country")
    if not self.exists_context("country"):
      self.country = 'japan'

  def exists_context(self, input_context) -> None:
    context_name = self.node.try_get_context(input_context)
    if context_name is None:
      return False
    return True

context値取得時にNoneの場合はデフォルトの値を入れることにしてNoneが返らないようにしたのと、exists_contextで存在確認も出来るようにして、エラーを回避出来るようにしました。実行時は下記のようにクラスから取得するようにします。

yomi = {'japan': 'nihon', 'us':'amerika'}
context = Context(self.node)
country_name = app.node.try_get_context("country")
print(yomi[context.country])

また、app.py側でもcontext値の存在確認をしてスタックの実行を分けることでcontext値の有無でそもそもapp.pyから読まれないようにして、エラーが出ないようにしました。

if context.exists_context('country'):
    BStack(app, "stack-b", env=cdk_default_env)
else:
    AStack(app, "stack-a", env=cdk_default_env)
リソース名

次にリソースの命名規則についてです。インフラ設計でリソースの名前について設計をしましたが、タイポせず設計通りに開発者は作ってくれるでしょうか。間違っているものに全て気づけるでしょうか。このように考えると不安になるので、命名規則もロジック化した方が安全です。下記の記事を参考にPythonでクラス化してみました。

AWS CDK 地味にめんどくさいAWSリソースの命名をロジック化してみた

クラス化したものの一部が下記になります。基本的には対象のリソースによって国や環境の名前が入るので、クラス内でcontextのオブジェクトを持ちつつ利用することにしてます。そのcontext値を利用して各リソースごとに名前を返す関数を作成します。

class ResourceName():
    def __init__(self, context):
        self.context = context

    def basic_name(self, name):
        system_name = 'global-app'
        name_suffix = ''
        if self.context.exists_context('country'):
            name_suffix = {self.context.country}-{self.context.env}
        else:
            name_suffix = {self.context.env}
        prefix_name = f'{system_name}-{name_suffix}'
        return f'{prefix_name}-{name}'

    def bucket_name(self, name):
        return self.basic_name(f'{name}-bucket')

    def stack_name(self, name):
        return self.basic_name(f'{name}-stack')

リソース名だけでなく、スタック名も関数化するとスタック作成時にスタック名を切り替えられるので良いです。上記のクラスの利用は下記のように行います。

from resource_name import ResourceName

bucket_name = resource_name.bucket_name('data')
self.artifact_bucket = s3.Bucket(self, bucket_name,
    bucket_name=bucket_name
)

このようにS3バケットの名前に利用していますが、idもスタック内で一意にする必要があるのでここも使い回しています。

単体テスト

次に単体テストについて説明します。下記記事のFine-grained assertionsの注意点を説明します。

CDK with Pythonの自動テスト事情 〜TypeScriptなんて羨ましくなんかないぞ〜 - NRIネットコムBlog

まず、テストコードは下記になります。

def test_bucket_access_block():
    app = core.App(context={'country': 'japan', 'env': 'dev'})
    stack_name = f"global-app-japan-dev-bucket-stack"
    BucketStack(app, stack_name)
    resources = app.synth().get_stack(stack_name).template['Resources']
    template = ''
    for key, property in resources.items():
        if property['Type'] == 'AWS::S3::Bucket':
            template = resources[key]
            break

    expect_conf = {
        "BlockPublicAcls": True,
        "BlockPublicPolicy": True,
        "IgnorePublicAcls": True,
        "RestrictPublicBuckets": True
    }
    assert(template["Properties"]["PublicAccessBlockConfiguration"] == expect_conf)

テスト内容としては、S3バケットのpublic blockの設定が入ってるよねテストをしています。基本的にはFine-grained assertionsの流れとしては変なことはしてませんが(一部イケてないが)、今回のグローバルアプリを構築するケースで違うのは下記です。

    app = core.App(context={'country': 'japan', 'env': 'dev'})

公式のテスト記事を見ると、Appの中身を入れてないので引っかかりやすいですが、contextを今回利用しているので、Appを宣言するときに引数として入れる必要があります。CDKドキュメントも追加でcontextを利用する時は必要と記載があります。 たまにcontextを利用した時にFine-grained assertionsは諦める的な記事も見かけるので、是非この部分は注意頂きたいです。

CI

最後にCIについて説明します。今回はCIにGitlab CIを利用しています。CIでは先程の記事の下記を実行してテストを行っています。

  • 静的解析
  • 単体テスト
  • 結合テストの一部(diffテスト)

静的解析ではもちろん全てのコードについてチェックをしますが、単体テストや結合テストではコード単位でなくスタック単位で確認が必要になります。また、今回は国や環境でスタックを分けているので、全ての国と環境でのテストをしていく必要があります。もちろん同じようなことをしているので不要だという判断もあるかと思われますが、少なくとも結合テストは必要でしょう。理由としては、今回のように国や環境が増えるとデプロイ漏れ(更新漏れ)が出てくる可能性があります。CIだけでなくCDも設定すればその漏れも少なくなりますが、あまりCDKなどのIaCでCDまでしているケースは影響等考えると少ないと思われます。なので、漏れを考えると全ての国や環境でdiffテストとしてcdk diffを行い、差分が無いことを確認すべきです。

今回diffテストを行うにあたって気にすべきことは、複数profileを利用する点です。1つのprofileであればAWS_ACCESS_KEY_IDやAWS_SECRET_ACCESS_KEYを環境変数にセットするだけで良いですが、複数であればその方法が使えません。そこで今回は、Gitlab CIのファイルタイプの変数を利用します。key値をAWS_SHARED_CREDENTIALS_FILE、valueを下記のようなAWSのprofileを設定することで複数環境への切り替えが可能になります。

[dev]
aws_access_key_id = hogehoge
aws_secret_access_key = hogehoge

[prd]
aws_access_key_id = hogehoge
aws_secret_access_key = hogehoge

まとめ

複数の国で利用されるアプリケーションをマルチアカウント、マルチリージョン、マルチ環境としてCDKで構築した時のtipsについて共有しました。この記事を見て関西弁でキレられないようにCDKの構築をエンジョイしていきましょう。

f:id:y3-shimizu:20210330181533j:plain

執筆者志水友輔

2021 APN ALL AWS Certifications Engineers & AWS Top Engineer
大阪でAWSを中心としたクラウドの導入、設計、構築を専門に行っています。

Twitter