코드 레벨에서 살펴보는 AWS CDK 의 특징

AWS Community Day Online 에서 데모코드로 작성했던 AWS CDK 에 대해 좀 더 자세히 포인트를 짚어가며 설명하는 포스팅입니다.
2020.10.20

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

안녕하세요!ㅎㅎ Classmethod 컨설팅부 김태우입니다.

이번 글에서는 지난 AWS Community Day Online 에서 데모코드로 작성했던 AWS CDK 에 대해 좀 더 자세히 포인트를 짚어가며 설명하고자 합니다.

혹시 지난번 글을 읽지 않으셨던 분들이라면 아래 글의 세션을 시청하신 후에 본 포스팅을 읽어주시면 좀 더 포스팅의 내용이 쉽게 이해가 될 수 있을 것 같습니다!

또한, AWS CDK 에 대해 완전히 처음이신 분들은 아래 포스팅을 읽어보신 후에 본 포스팅을 읽어주시면 훨씬 더 이해가 잘 될 수 있을 것 같습니다.

그럼, 시작하겠습니다 :)

본 포스팅에서 설명할 코드의 Github 리포지토리

(Github) twkiiim/aws-community-day-2020-demobackend/ 에 해당합니다.

포인트 설명

전체적으로 위 Github 리포지토리에서 코드를 보시면 이해되시겠지만, 좀 더 쉽고 효과적으로 이해하시기 위해 포인트가 될 만한 부분들을 설명하며 본 포스팅을 써내려가도록 하겠습니다.

make 를 활용하여 AWS Lambda 를 패키징합니다

AWS CDK 를 사용하면 AWS Lambda 함수 자체는 리소스로써 쉽게 배포할 수 있지만, Lambda 함수에서 의존관계가 있는 서드파티 라이브러리 등을 사용하는 경우 이러한 라이브러리를 모두 포함하여 zip 파일로 먼저 패키징을 해야할 필요가 있습니다. 이를 위해 쉘스크립트를 작성하거나, 도커를 사용하거나, Lambda Layer 를 사용하거나, Serverless framework 의 패키징 기능을 이용하거나 하는 등의 다양한 방법으로 접근할 수 있겠지만, 대부분의 개발자들이 익숙해하는 make 커맨드를 활용하여 패키징을 하는 것도 나쁘지 않은 방법이라 생각합니다. 아직까지 AWS CDK 에서 AWS Lambda 함수를 어떻게 패키징해서 배포해야 할 지에 대한 베스트 프랙티스가 알려지지 않은 이상, 다양한 방법들을 시도해보며 가장 좋은 방법을 찾아내는 재미(?)도 빼놓을 수 없는 것 같습니다.

이번 데모에서는 Makefile 을 통해 AWS Lambda 함수의 패키징을 하는 방법을 소개합니다.

backend/ (CDK 프로젝트의 루트 디렉토리)에서의 Makefile 은 아래와 같이 작성합니다.

SUBDIRS := $(wildcard ./handlers/source/*)
# SUBDIRS := $(filter-out ./handlers/source/shared, $(SUBDIRS))

all clean : $(SUBDIRS)

$(SUBDIRS) :
	$(MAKE) -C $@ $(MAKECMDGOALS)

.PHONY: all clean $(SUBDIRS)

handlers/source/ 에서의 모든 디렉토리의 Makefile 에 대하여 make 를 실행한다는 의미네요!

그리고나서 handlers/source/ 아래의 디렉토리에서의 Makefile 의 예로, 아래와 같이 작성할 수 있습니다.

FUNC_NAME=이곳에 AWS Lambda 함수명을 입력합니다. (예: requestDelivery)
PROJ_ROOT_DIR=../../../
SOURCE_DIR=$(PROJ_ROOT_DIR)/handlers/source/$(FUNC_NAME)
BUILD_DIR=$(PROJ_ROOT_DIR)/handlers/build/$(FUNC_NAME)
PACKAGED_DIR=$(PROJ_ROOT_DIR)/handlers/packaged

build:
	mkdir -p $(BUILD_DIR)
	mkdir -p $(PACKAGED_DIR)
	pip3 install -r ${SOURCE_DIR}/requirements.txt --target=$(BUILD_DIR)
	cp $(SOURCE_DIR)/*.py $(BUILD_DIR)/
	cd $(BUILD_DIR) && zip -rq $(FUNC_NAME).zip *
	cp $(BUILD_DIR)/$(FUNC_NAME).zip $(PACKAGED_DIR)/

이해하기 쉬운 코드이므로 읽어보시면 바로 이해되시리라 생각하지만, handlers/source/ 의 전체 파일을 handlers/build/ 로 복사하여 requirements.txt 에 근거해 필요한 라이브러리를 전부 인스톨하고, 그것들을 다시 모두 zip 파일로 만들어서 handlers/packaged/ 에 복사해두는 코드입니다. AWS CDK 코드에서는 이 handlers/packaged/ 아래에 위치한 zip 파일을 읽어들이기 위해 아래와 같은 코드를 작성할 수 있습니다.

registerTasks(taskNames: string[]) {
    let taskMap = new Map<string, lambda.Function>();

    for(const taskName of taskNames) {
      const task = new lambda.Function(this, `task-${taskName}-${this.deployEnv}`, {
        functionName: `${StackConfig.PROJ_PREF}-${taskName}-${this.deployEnv}`,
        runtime: lambda.Runtime.PYTHON_3_7,
        code: lambda.Code.fromAsset(`handlers/packaged/${taskName}.zip`),
        handler: `${taskName}.handler`,
      });
      taskMap.set(taskName, task);
    }

    return taskMap;
}

따라서, Lambda 함수 코드가 변경될 떄마다

  • make
  • cdk deploy

의 커맨드를 세트로 입력해주시면 새로운 Lambda 함수를 배포할 수 있습니다.

AWS Lambda 함수 배포코드 작성이 즐거워집니다

AWS CDK 는 코드를 어떻게 작성하느냐에 따라 완전히 다른 형태로 IaC (Infrastructure as Code) 를 실현할 수 있습니다.

여기서, "완전히 다른 형태" 라고 말한 의미는, 배포방법이 다르다거나 한 것이 아니라, 리소스관리를 위한 수고로움, 리소스관리를 위한 사고방식, 리소스관리의 단위 등이 코드를 어떻게 작성하느냐에 따라 완전히 달라진다는 의미입니다.

예를 들어, 저는 이번 데모에서AWS Lambda 함수를 정의하기 위해 아래와 같이 코드를 작성하였는데요,

enum LambdaFunctions {
  stripeWebhook = 'stripeWebhook',
  requestDelivery = 'requestDelivery',
  cancelDelivery = 'cancelDelivery',
  cancelOrder = 'cancelOrder',
  cancelPayment = 'cancelPayment',
  confirmOrder = 'confirmOrder',
  orderTransactionDone = 'orderTransactionDone',
}

...

 registerTasks(taskNames: string[]) {
    let taskMap = new Map<string, lambda.Function>();

    for(const taskName of taskNames) {
      const task = new lambda.Function(this, `task-${taskName}-${this.deployEnv}`, {
        functionName: `${StackConfig.PROJ_PREF}-${taskName}-${this.deployEnv}`,
        runtime: lambda.Runtime.PYTHON_3_7,
        code: lambda.Code.fromAsset(`handlers/packaged/${taskName}.zip`),
        handler: `${taskName}.handler`,
      });
      taskMap.set(taskName, task);
    }

    return taskMap;
  }

AWS Lambda 함수의 목록과 같은 용도로도 활용될 수 있는 enum 을 정의해두고, Lambda 함수는 for loop 을 통해 생성하는 형태를 하고 있습니다. 이렇게 함으로써 배포해야할 AWS Lambda 함수가 늘어나더라도, 실행환경이 같다는 전제하에 (이번에는 python 3.7) enum 에 필요한 Lambda 함수의 이름만 추가해주는 것으로 간단하게 Lambda 함수를 추가로 배포할 수 있습니다.

혹은, 어떠한 단위로 constructor 를 생성해두고, 이 constructor 를 오브젝트화(new) 시키는 것으로 리소스관리의 단위도 바꿀 수도 있겠죠. 이번에는 그렇게까지는 하지 않았습니다만 충분히 활용도가 높은 리소스 관리 방법이라고 생각합니다.

그리고, 아래와 같이 string 을 enum 화 시켜놓고 사용하고 있기 때문에 Type 이 존재하는 언어의 특징으로 인해 오타 등이 컴파일타임에 잡아지는 혜택도 있습니다.

    // set abbreviations
    const lf = LambdaFunctions;
    const ts = taskStates;

    // success flow
    ts.get(lf.stripeWebhook).next(
      startOrderTransaction
        .branch(
          ts.get(lf.confirmOrder), 
          ts.get(lf.requestDelivery),
        )
        .next(ts.get(lf.orderTransactionDone))
    );

그 밖에도 리소스 관리방법은 여러가지가 있겠지만, 이 포스팅을 보고 계신 독자분들이 원하시는 방법으로 여러가지 시도해보시면서 직접 다양한 방식들을 체험해 보시는건 어떨까요?!ㅎㅎ

Step Functions 의 정의가 쉽게 이해됩니다

AWS CDK 를 사용하면서 정말로 좋았다고 생각했던 한가지가 Step Functions 의 정의 및 실행 흐름이 굉장히 파악하기 쉬워졌다는 점입니다.

success flowfailure flow 를 봅시다. 개발시에 실행의 흐름이 한눈에 들어오지 않나요?

    // set abbreviations
    const lf = LambdaFunctions;
    const ts = taskStates;

    // success flow
    ts.get(lf.stripeWebhook).next(
      startOrderTransaction
        .branch(
          ts.get(lf.confirmOrder), 
          ts.get(lf.requestDelivery),
        )
        .next(ts.get(lf.orderTransactionDone))
    );
    
    // failure flow - stripeWebhook to orderTransactionDone
    ts.get(lf.stripeWebhook).addCatch(compensateOrderTransaction, { resultPath: '$.error' })

    // failure flow - startOrderTransaction to orderTransactionDone
    startOrderTransaction.addCatch(compensateOrderTransaction, { resultPath: '$.error' });
    
    compensateOrderTransaction.branch(
      ts.get(lf.cancelPayment), 
      ts.get(lf.cancelDelivery), 
      ts.get(lf.cancelOrder),
    ).next(ts.get(lf.orderTransactionDone))
    

    // set up Step Functions State Machine
    const stateMachine = new stepf.StateMachine(this, `${StackConfig.PROJ_PREF}-stepf-statemachine-${this.deployEnv}`, {
      stateMachineName: `${StackConfig.PROJ_PREF}-stepf-statemachine-${this.deployEnv}`,
      definition: ts.get(lf.stripeWebhook)
    });

참고로, 이 스테이트머신은 아래의 그림과 같이 표현되는데요,

이걸 Serverless Framework 플러그인 등을 사용하여 YAML 형식으로 작성하면 아래와 같은 느낌이 됩니다. (스테이트 머신 자체가 약간 다르긴 하지만 거의 비슷하므로 살짝 다른건 무시해주세요)

stepFunctions:
  stateMachines:
    postOrderStateMachine:
      name: jawsdays2020-demo-postOrderStateMachine
      events:
        - http:
            path: stripe-webhook
            method: post
            cors: true
      definition:
        StartAt: StripeWebhook
        States:
          StripeWebhook:
            Type: Task
            Resource:
              Fn::GetAtt: [stripeWebhook, Arn]
            Next: PaymentSucceeded
            Catch: 
              - ErrorEquals: ["States.ALL"]
                Next: PaymentFailed

          PaymentSucceeded:
            Type: Pass
            Next: StartOrderTransaction

          PaymentFailed:
            Type: Pass
            End: true

          StartOrderTransaction:
            Type: Parallel
            Next: OrderSucceeded
            ResultPath: null
            Branches:
              - StartAt: ConfirmOrder
                States:
                  ConfirmOrder:
                    Type: Task
                    Resource:
                      Fn::GetAtt: [confirmOrder, Arn]
                    End: true

              - StartAt: RequestDelivery
                States:
                  RequestDelivery:
                    Type: Task
                    Resource:
                      Fn::GetAtt: [requestDelivery, Arn]
                    End: true
            Catch:
              - ErrorEquals: ["States.ALL"]
                Next: OrderTransactionFailed
                ResultPath: null

          OrderTransactionFailed:
            Type: Pass
            Next: CompensateOrderTransaction

          CompensateOrderTransaction:
            Type: Parallel
            Next: OrderFailed
            ResultPath: null
            Branches:
              - StartAt: CancelPayment
                States:
                  CancelPayment:
                    Type: Task
                    Resource:
                      Fn::GetAtt: [cancelPayment, Arn]
                    Retry:
                      - ErrorEquals: [ "States.ALL" ]
                        IntervalSeconds: 1
                        MaxAttempts: 3
                        BackoffRate: 2
                    End: true

              - StartAt: CancelDelivery
                States:
                  CancelDelivery:
                    Type: Task
                    Resource:
                      Fn::GetAtt: [cancelDelivery, Arn]
                    Retry:
                      - ErrorEquals: [ "States.ALL" ]
                        IntervalSeconds: 1
                        MaxAttempts: 3
                        BackoffRate: 2
                    End: true

              - StartAt: CancelOrder
                States:
                  CancelOrder:
                    Type: Task
                    Resource:
                      Fn::GetAtt: [cancelOrder, Arn]
                    Retry:
                      - ErrorEquals: [ "States.ALL" ]
                        IntervalSeconds: 1
                        MaxAttempts: 3
                        BackoffRate: 2
                    End: true

          OrderFailed:
            Type: Task
            Resource:
              Fn::GetAtt: [orderFailed, Arn]
            Retry:
              - ErrorEquals: [ "States.ALL" ]
                IntervalSeconds: 1
                MaxAttempts: 3
                BackoffRate: 2
            End: true
          
          OrderSucceeded:
            Type: Task
            Next: SendOrderConfirmEvent
            ResultPath: null
            Resource:
              Fn::GetAtt: [orderSucceeded, Arn]
            Retry:
              - ErrorEquals: [ "States.ALL" ]
                IntervalSeconds: 1
                MaxAttempts: 3
                BackoffRate: 2
            
            
          SendOrderConfirmEvent:
            Type: Task
            Resource:
              Fn::GetAtt: [sendOrderConfirmEvent, Arn]
            End: true

YAML 만 해도 이정도인데... JSON 으로 적으면... 말 다했죠..ㅎㅎ

이에 비해 상대적으로 AWS CDK 로 작성한 코드는 훨씬 이해가 잘된다는 점에 모두들 수긍하시리라 믿습니다!ㅎㅎ

AppSync 배포가 편안해집니다

최근(?)에 AWS AppSync 의 Direct Lambda Resolver 라는 기능이 서포트되었는데요, 기존에는 AWS Lambda 든 다른 데이터 소스든 반드시 VTL 템플릿 파일을 통해 접근해야해서 개발자들에게 꽤 많은 거부감을 주었다고 한다면, 이제는 이러한 VTL 템플릿 파일이 없어도 즉시 AWS Lambda 함수를 GraphQL 의 리졸버로써 사용하는 것이 가능해졌습니다.

또, 이러한 기능으로 인해 AWS CDK 로 AppSync 를 관리할 때의 코드 작성이나 신경쓸 점들도 많이 줄어들어서 굉장히 편안해지게 되었습니다.

enum AppSyncQuery {
  getOrderStatus = 'getOrderStatus',
}

enum AppSyncMutation {
  createOrderReservation = 'createOrderReservation',
  confirmOrderReservation = 'confirmOrderReservation',
  cancelOrderReservation = 'cancelOrderReservation',
  finalizeOrder = 'finalizeOrder',
}

...

    // set the new Lambda function as a data source for the AppSync API
    const lambdaDs = api.addLambdaDataSource('appsyncLambdaResolverDS', appsyncLambdaResolver);

    // set Queries and Mutations for AppSync API
    const queries = Object.values(AppSyncQuery);
    const mutations = Object.values(AppSyncMutation);

    for(const query of queries) {
      lambdaDs.createResolver({
        typeName: 'Query',
        fieldName: query
      });
    }

    for(const mutation of mutations) {
      lambdaDs.createResolver({
        typeName: 'Mutation',
        fieldName: mutation
      });
    }

네, 코드를 보시면 AWS Lambda 함수 때와 같이 for loop 를 돌면서 Query 나 Mutation 을 정의하고 있습니다. 이번에는 수동으로 직접 resolver 나 Lambda 함수명 등을 enum 에 입력해둔 형태이지만, 프로젝트의 구조를 정형화시키고 resolver 나 Lambda 함수명을 정형화된 프로젝트 구조로부터 이름을 읽어들이는 코드를 작성해둔다면, 이 조차도 자동으로 처리될 수 있을 것 같습니다. (각각의 장단점이 있겠지만요)

하지만, 이렇게까지 높은 자유도를 가지고 리소스 관리를 할 수 있게된다는 점을 생각해보면, 특히 서버리스 개발시에는 AWS CDK 를 선택하지 않을 이유가 거의 없어지는 것 같다고 개인적으로 생각하고 있습니다.

스택 설정 파일에서 공통 변수를 관리합니다

이번 데모에서는 project-config.ts 라는 파일에서 아래의 변수들을 관리합니다.

/*
 * ------ CAUTIONS --------
 * 
 * If you modify the value in this file, it might occur a lot of unexpected resource deletion in the entire stacks.
 * You're definitely aware of what you're doing.
 * 
 * ------------------------
 */
export const StackConfig = {
    PROJ_PREF: 'aws-commday-2020',
    DEPLOY_ENV: process.env.DEPLOY_ENV || 'dev',
}

특히 DEPLOY_ENV 는 같은 어카운트, 같은 리젼에서 복수의 환경을 구축하고 싶은 경우 활용할 수 있는 옵션입니다.

bin/backend.ts 파일에서 이 변수를 읽어들이거나,

...
const app = new cdk.App();

let stackName = '';
const deployEnv = StackConfig.DEPLOY_ENV;

...

아래와 같이 각 스택에서 읽어들이거나 해서,

...
export class StepFunctionsStack extends cdk.Stack {

  deployEnv: string = StackConfig.DEPLOY_ENV;
  
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps, refStack?: AppsyncStack) {
    super(scope, id, props);
...

DEPLOY_ENV 의 값을 변경하면 전혀 다른 스택 및 리소스명이 생성되므로 환경별로 리소스를 완전히 분리시키는데에 사용될 수 있습니다.

스택 간에 Output 변수가 참조가 가능합니다

이것도 여러가지 방법으로 실현할 수 있겠지만, 가장 간단한 방법으로는 bin/backend.ts 에서 다른 스택 자체를 파라미터로서 넘겨주는 방법입니다. 아래의 코드를 살펴봅시다.

stackName = 'appsync-stack';
const appsyncStack = new AppsyncStack(app, stackName, generateStackProps(stackName));

stackName = 'stepf-stack';
const stepfunctionsStack = new StepFunctionsStack(app, stackName, generateStackProps(stackName), appsyncStack);

가장 아랫줄에서 appsyncStack 를 4번째 파라미터로써 넘겨주고 있는데요, StepFunctionsStack 에서 이 파라미터를 받아서 아래와 같이 사용하고 있습니다.

  ...
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps, refStack?: AppsyncStack) {
    super(scope, id, props);

    const lambdaFuncNames = Object.values(LambdaFunctions);
    const lambdaFuncMap = this.registerTasks(lambdaFuncNames);

    lambdaFuncMap.get(LambdaFunctions.confirmOrder)!.addEnvironment('APPSYNC_API_ENDPOINT_URL', refStack!.APPSYNC_API_ENDPOINT_URL);
    lambdaFuncMap.get(LambdaFunctions.confirmOrder)!.addEnvironment('APPSYNC_API_KEY', refStack!.APPSYNC_API_KEY);
    lambdaFuncMap.get(LambdaFunctions.cancelOrder)!.addEnvironment('APPSYNC_API_ENDPOINT_URL', refStack!.APPSYNC_API_ENDPOINT_URL);
    lambdaFuncMap.get(LambdaFunctions.cancelOrder)!.addEnvironment('APPSYNC_API_KEY', refStack!.APPSYNC_API_KEY);
    lambdaFuncMap.get(LambdaFunctions.orderTransactionDone)!.addEnvironment('APPSYNC_API_ENDPOINT_URL', refStack!.APPSYNC_API_ENDPOINT_URL);
    lambdaFuncMap.get(LambdaFunctions.orderTransactionDone)!.addEnvironment('APPSYNC_API_KEY', refStack!.APPSYNC_API_KEY);
    ...

네! 정말 직관적이고 사용하기가 편리하네요! 스택 간의 Output 참조도 정말 직관적이 되었습니다.

여가까지 읽으시면 정말 다양한 아이디어들이 샘솟지 않으시나요? 두근두근 거리지 않으시나요?ㅎㅎ

AWS CDK 는 바로 이러한 놀라운 자유도로 활용될 수 있는 진정한 IaC 도구입니다.

마치며

발표영상에서 본 포스팅에서 설명한 내용들을 포함한 실제 배포과정이나 Amplify 활용방법 등의 다양한 요소들을 함께 말씀드리고 싶었는데, 본 포스팅으로라도 AWS CDK 의 매력이 전달되었으면 합니다. 특히, AWS 서버리스 아키텍쳐로 개발하고 있는 팀에서는 이러한 서버리스 리소스를 배포하는데 있어서 AWS CDK 를 꼭 검토해보시기를 추천드립니다.

이상으로 본 포스팅을 마치겠습니다. 컨설팅부의 김태우였습니다 :)