CDK를 이용한 AWS Lambda 함수 생성

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

CDK를 이용한 AWS Lambda 함수 생성

AWS CDK(Cloud Development Kit)는 프로그래밍 언어를 이용해 클라우드 인프라스트럭처를 정의할 수 있게 해 주는 도구다. CloudFormation 스크립트를 사용하는 것 보다 훨씬 쉽게 인프라스트럭처 컴포넌트를 정의하고 관계를 설정할 수 있다.

데이터베이스에 저장되어 있는 텍스트를 읽어 트위터에 주기적으로 트윗하는 간단한 앱을 만들려 한다. 데이터베이스로 DynamoDB를 사용할 것이고 트윗 기능은 Lambda 함수로 정의할 것이다. DynamoDB와 Lambda 정의, 이벤트 및 기타 환경 설정에 CDK를 이용할 것이다.

"전체 구조 다이어그램"

CDK 설치

CDK는 npm으로 간단히 설치할 수 있다.

$ npm install -g aws-cdk

프로젝트 생성

다음과 같이 프로젝트 루트로 사용할 디렉터리를 만들고 cdk init 명령으로 디렉터리를 초기화한다.

$ mkdir tweetbook
$ cd tweetbook
$ cdk init --app Tweetbook --language javascript

cdk init 명령은 해당 디렉터리를 Git 저장소로 만든다. Github에서 tweetbook-lambda-cdk.git 저장소를 생성하고, 연결하려면 다음과 같이 한다.

$ git remote add origin git@github.com:ntalbs/tweetbook-lambda-cdk.git
$ git push -u origin master

lib 디렉터리 안의 tweetbook-stack.js에 인프라스트럭처 정의 코드를 넣을 것이다. 처음에는 다음과 같이 아무것도 안 하는 템플릿 코드만 있다.

const cdk = require('@aws-cdk/core');

class TweetbookStack extends cdk.Stack {
  /**
   *
   * @param {cdk.Construct} scope
   * @param {string} id
   * @param {cdk.StackProps=} props
   */
  constructor(scope, id, props) {
    super(scope, id, props);

    // The code that defines your stack goes here
  }
}

module.exports = { TweetbookStack }

스택 이름이 TweetbookStack으로 되어 있다. 스택 이름은 디렉터리 이름을 참조해 생성된다.

DynamoDB 테이블 생성

DynamoDB 테이블을 만들기 위해 다음과 같이 @aws-cdk/aws-dynamodb 모듈을 설치한다.

$ npm install @aws-cdk/aws-dynamodb

그리고 lib/tweetbook-stack.js 파일을 열어 코드를 다음과 같이 수정한다.

const cdk      = require('@aws-cdk/core')
const dynamodb = require('@aws-cdk/aws-dynamodb')

class TweetbookStack extends cdk.Stack {

  constructor(scope, id, props) {
    super(scope, id, props)

    const quotesTable = new dynamodb.Table(this, 'Tweetbook-Quotes', {
      tableName: 'Tweetbook-Quotes',
      partitionKey: { name: '_id', type: dynamodb.AttributeType.NUMBER },
    })
  }
}

module.exports = { TweetbookStack }

이제 콘솔에서 cdk deploy 명령을 실행한다. 실행이 끝난 후 AWS 콘솔에 들어가보면 Tweetbook-Quotes 테이블이 생성된 것을 확인할 수 있다.

Lambda 함수 정의

Lambda 함수를 만들기 위해서는 @aws-cdk/aws-lambda 모듈이 필요하다.

$ npm install @aws-cdk/aws-lambda

Lambda 함수의 소스 코드를 저장할 디레터리를 다음과 같이 만든다.

$ mkdir -p lambdas/tweet

그리고 위 디렉터리에 다음 내용으로 index.js 파일을 만든다.

exports.handler = async (event) => {
  const response = {
    statusCode: 200,
    body: 'Hello Lambda'
  }
  return response
}

일단은 간단한 JSON을 리턴하도록 했다. 나중에 좀더 의미있는 일을 하도록 수정할 것이다.

이제 lib/tweetbook-stack.js 파일을 열어 다음 코드를 추가한다.

// ...
const lambda   = require('@aws-cdk/aws-lambda');

class TweetbookStack extends cdk.Stack {

  constructor(scope, id, props) {
    // ...
    const tweetFn = new lambda.Function(this, 'Tweetbook-Tweet', {
      functionName: 'Tweetbook-Tweet',
      runtime: lambda.Runtime.NODEJS_12_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambdas/tweet),
    })
  }
// ...

cdk diff 명령을 실행해보면 현재 프로젝트에서 생성할 스택과 이미 배포된 스택과의 차이를 보여준다. cdk deploy를 실행하면 모든 리소스를 새로 만드는 게 아니라 차이를 비교하고 리소스를 생성 또는 제거한다.

Lambda 함수의 코드가 아주 짧은 경우는 lambda.Code.fromInline을 사용해 코드를 인라인으로 지정하는 것도 가능하다. 파일이 하나라면 fs.readFileSync로 파일을 읽어 문자열로 지정할 수도 있다. 그러나 Lambda 함수에서 다른 모듈을 사용해야 한다면 Asset으로 배포해야 한다.

여기서는 Asset을 이용해 Lambda 코드를 배포하므로 DynamoDB 테이블을 생성할 때와 같이 그냥 cdk deploy만 실행하면 실패할 것이다. 먼저 cdk bootstrap 명령을 실행해야 한다. cdk bootstrap 명령을 한번 실행한 이후에는 매번 다시 실행하지 않아도 된다.

$ cdk bootstrap
$ cdk deploy

AWS 콘솔에 들어가보면 Lambda 함수가 생성된 것을 확인할 수 있다.

트리거 설정

주기적으로 트윗을 하기 위해서는 Lambda 함수를 주기적으로 호출해야 한다. EventBridge를 트리거로 추가하면 Lambda 함수를 규칙에 따라 주기적으로 호출할 수 있다. 이를 설정하려면 다음 두 모듈을 설치해야 한다.

$ npm install @aws-cdk/aws-events
$ npm install @aws-cdk/aws-events-targets

그리고 lib/tweetbook-stack.js에 다음 코드를 추가한다.

// ...
const events   = require('@aws-cdk/aws-events');
const targets  = require('@aws-cdk/aws-events-targets');

class TweetbookStack extends cdk.Stack {

  constructor(scope, id, props) {
    // ...
    const rule = new events.Rule(this, 'Tweetbook-EveryDayAt6am', {
      ruleName: 'Tweetbook-EveryDayAt6am',
      schedule: events.Schedule.expression('cron(0 6 * * ? *)')
    });

    rule.addTarget(new targets.LambdaFunction(tweetFn));
  }
// ...

트리거 이벤트는 매일 오전 6시(UTC)에 발생하도록 했다. cdk deploy를 실행한 다음 AWS 콘솔에 들어가 확인해보면 Lambda 함수에 트리거가 설정된 것을 확인할 수 있다.

테이블 접근 권한

Lambda 함수의 원래 목적은 DynamoDB 테이블에서 데이터를 읽어 트위터에 트윗하는 것이다. 그러나 위에서 정의한 Lambda 함수는 Tweetbook-Quotes 테이블을 읽을 권한이 없다. Lambda 함수가 실행될 때 Tweetbook-Quotes에서 데이터 한 건을 읽을 것이므로 지금은 GetItem 권한만 있으면 충분하다. Lambda 함수를 정의한 코드 바로 아래 다음 코드를 추가한다.

    // ...
    quotesTable.grant(tweetFn, 'dynamodb:GetItem');
    // ...

DynamoDB 접근

Lambda 함수에서 DynamoDB 데이터를 읽도록 수정하려 한다. DynamoDB를 읽을 때 DocumentClient를 사용하는 게 좀더 편하다. Tweetbook-Quotes 테이블에는 다음과 같은 식의 데이터가 들어있다고 가정한다.

{
    "_id": 10,
    "msg", "...",
    "src", "..."
}

_id는 0부터 999까지 1,000건의 데이터가 들어 있고, 0부터 999 사이의 랜덤 값을 구한 다음, 데이터를 읽을 때 _id로 제시하도록 했다. 범위를 미리 알고 있어야 하는 게 마음에 들지 않지만, DynamoDB에서 랜덤하게 데이터를 읽는 좋은 방법을 찾지 못했다.

이제 lambdas/tweetbook/index.js 파일을 열어 다음과 같이 수정한다.

const aws = require('aws-sdk')
const dynamodb = new aws.DynamoDB.DocumentClient()

const MAX_INDEX = 1000

function random() {
  return Math.floor(Math.random() * MAX_INDEX)
}

async function getQuote(id) {
  let param = {
    Key: {
      "_id": id
    },
    TableName: "Tweetbook-Quotes"
  }

  let q = await dynamodb.get(param).promise()

  return {
    msg: q.Item.msg,
    src: q.Item.src
  }
}

exports.handler = async (event) => {
  let id = random()
  let quote = await getQuote(id)

  const response = {
    statusCode: 200,
    body: quote
  }
  return response
}

Secrets Manager 설정

트위터에 API로 접근하기 위해서는 API 키와 토큰이 필요하다. 트위터 API 클라이언트로 사용할 twit을 초기화하는 데 consumer_key, consumer_secret, access_token, access_token_secret를 지정해야 하며 이 값은 트위터 개발자 사이트에서 얻을 수 있다. 여기서는 이 키와 토큰 값을 Secrets Manager에 저장하려 한다. 외부에서 제공하는 비밀정보이므로 AWS 콘솔에서 직접 생성한다.

{
  "TWITTER_ACCESS_TOKEN": "...access_token_value...",
  "TWITTER_ACCESS_TOKEN_SECRET": "...access_token_secret_value...",
  "TWITTER_CONSUMER_KEY": "...consumer_key_value...",
  "TWITTER_CONSUMER_SECRET": "...consumer_secret_value..."
}

시크릿을 생성했으면 tweetbook-stack.js에 다음 코드를 추가해 Lambda 함수에서 위 시크릿을 읽을 수 있도록 한다.

    // ...
    const secret = sm.Secret.fromSecretAttributes(this, 'TweetbookSecret', {
      secretArn: '<<secret-arn>>'
    })

    secret.grantRead(tweetFn)
    // ...

메시지 트윗

lambdas/tweetbook 디렉터리 안에서 다음 명령을 실행해 Twit을 설치한다.

$ npm init
$ npm install twit

lambdas/tweetbook 디렉터리는 Lambda 함수를 정의하는 새로운 패키지의 루트 디렉터리로, 람다 함수에서 사용하는 외부 라이브러리는 이 디렉터리에 설치되어야 하므로 npm init을 실행해 package.json을 생성해야 한다.

트위터 API를 초기화하는 데 필요한 consumer_key, consumer_secret, access_token, access_token_secret를 Secrets Manager에서 읽어오도록 index.js 파일에 다음 코드를 추가한다.

const Twit = require('twit')

async function getTokens () {
  let secrets = await secretsManager.getSecretValue({
    SecretId: 'TwitterBookBotApiTokens'
  }).promise()
  return JSON.parse(secrets.SecretString)
}

let T;
async function tweet(quote) {
  if (!T) {
    let tokens = await getTokens();
    T = new Twit({
      consumer_key: tokens.TWITTER_CONSUMER_KEY,
      consumer_secret: tokens.TWITTER_CONSUMER_SECRET,
      access_token: tokens.TWITTER_ACCESS_TOKEN,
      access_token_secret: tokens.TWITTER_ACCESS_TOKEN_SECRET
    })
  }

  let msg = `${quote.msg}\n${quote.src}`
  return T.post('statuses/update', {status: msg})
}

그리고 DynamoDB에서 읽은 메시지를 트위터에 트윗하도록 handler 함수를 다음과 같이 수정한다.

exports.handler = async (event) => {
  let id = random()
  let q = await getQuote(id)
  let t = await tweet(q)

  const response = {
    statusCode: 200,
    quote: q,
    tweetId: t.data.id
  }

  return response;
}

모든 게 제대로 설정되었다면, AWS 콘솔에서 Lambda 함수를 실행한 후 트위터를 확인해보면 메시지가 트윗되었음을 확인할 수 있다.

스택 제거

cdk destroy 명령을 실행하면 위에서 생성한 모든 AWS 리소스를 제거한다. DynamoDB 테이블은 삭제되지 않는데 아마도 실수로 인한 데이터 손실을 방지하기 위한 것으로 보인다. Secrets Manager에서 수작업으로 생성한 시크릿 또한 이 스택에 포함되지 않으므로 삭제되지 않는다. 따라서 DynamoDB 테이블과 수작업으로 생성한 시크릿은 직접 제거해야 한다.

정리

AWS CDK를 이용하면 CloudFormation 스크립트나 AWS-CLI를 이용하는 것보다 훨씬 쉽게 AWS 인프라스트럭처를 정의할 수 있다. CDK를 쓸 때 JavaScript 외에도 TypeScript, Python, C#, Java도 지원하므로 마음에 드는 언어를 사용하면 된다.

사용 언어에 따라 차이가 있겠지만, 여기서 JavsScript로 작성한 CDK 코드는 40여줄에 불과하다. CDK로 생성한 JSON 형식의 CloudFormation 스크립트는 200줄이 넘는다. CDK를 사용하면 CloudFormation 스크립트를 사용할 때보다 훨씩 적은 코드로 AWS 스택을 정의할 수 있다.