Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Apex로 실제 Lambda 함수 작성하기

며칠 전에 Apex를 소개했는데 이 글에서 기본적인 사용방법을 다루기는 했지만, 도구의 사용방법과 이를 이용해서 실제로 프로젝트를 진행하는 것은 다른 얘기다. 사용법을 익히고 나서도 실제로 뭔가 만들려면 고민되는 문제가 겪게 되는 문제가 있기 마련이다.

nodejs-ko에서 Node.js 공식블로그의 글을 번역하고 있는데 새로운 글이 올라오면 트위터로 공지하고 있다. 기존에 사용하던 봇이 동작하지 않아서 최근에는 계속 수동으로 올리다가 Lambda에 올리기에 적당해 보여서 새로운 글이 올라오면 트윗으로 올려주는 봇을 작성했다. 기능상으로 아주 간단하지만, 운영까지 신경 쓰고 싶지는 않았기에 로직만 작성하면 신경 안 써도 되는 Lambda에 올리는 것이 적당해 보였다.

이 글에서는 nodejs-ko에 새로운 글이 올라오면 트위터로 올려주는 nodejs-ko-botApex로 작성하면서 고민한 결과를 정리한다.

프로젝트 구성

작성할 함수명을 tweet이라고 했을 때 아주 기본적인 폴더 구조는 다음과 같다.

├── functions
│   └── tweet
│       ├── function.json
│       └── index.js
└── project.json

지금은 트윗을 올려주는 함수만 있지만 차후 더 필요한 부분이 있으면 여기에 계속 추가할 수 있도록 project.json에는 기본 설정만 넣고 함수에서 필요한 설정(timeout 이나 환경변수 등)은 function.json에 넣었다. index.js에는 실제 함수 로직이 들어갈 것이다.

지난번 글에서 본 Hello World 함수가 아니라면 당연히 npm 모듈이 필요하므로 package.json으로 의존성을 관리해야 한다. 그러면 다음과 같은 구조가 된다.

├── functions
│   └── tweet
│       ├── function.json
│       ├── index.js
│       ├── node_modules/
│       └── package.json
└── project.json

이런 모양이 되는 이유는 당연히 함수마다 사용하는 모듈이 다르므로 함수마다 의존성을 관리할 수 있어야 하고 tweet폴더가 zip 파일로 압축되어서 Lambda에 업로드되므로 설치한 의존성 모듈이 있는 node_modules 폴더도 tweet 폴더 아래에 있어야 한다. 실제로 어떻게 빌드 파일을 만드는지 확인해 볼 수 있도록 apex에서는 build 명령어를 제공하고 있다.

$ apex build tweet > out.zip

위 명령어를 실행하면 out.zip 파일이 생성되고 이 파일의 압축을 풀면 다음과 같은 구조로 되어 있다.

├── .env.json
├── _apex_index.js
├── index.js
└── node_modules
    └── ...

project.jsonfunction.json의 설정을 합쳐서 .env.json를 만들어 주고 실행 파일인 _apex_index.js를 넣어준다. 그리고 작성한 .js 파일과 node_modules폴더가 포함되어 있다. 이 압축 파일이 그대로 올라가고 AWS Lambda 내에서도 이를 그대로 사용하게 된다. 그래서 필요한 모듈을 node_modules에 다 설치해야 하고 JavaScript로만 작성되지 않고 gyp로 빌드가 필요한 모듈은 AWS에서 사용하는 Amazon Linux에 맞게 빌드가 되어 있지 않으면 동작하지 않는다.

빌드를 어떻게 되는지 이해하고 나니 프로젝트 구성에 대한 몇 가지 고민이 생겼다.

  1. package.json의 위치: 의존성 모듈을 관리하려면 함수 폴더 내에 package.json이 있는 게 맞지만 이럴 때 함수가 여러 개 생기면 폴더마다 들어가서 npm install을 실행해주어야 하므로 귀찮아진다.
  2. 개발용 의존성의 관리: 위 구조는 예시로 아주 간단한 파일만 넣었지만 실제로 개발하면 테스트 코드도 작성해야 하므로 mochachai같은 devDependencies를 설치해야 한다. 함수를 여러 개 만들더라도 이 devDependencies는 프로젝트 공통일 가능성이 크고 배포할 필요는 없으므로 배포될 node_modules내에서는 빼주어야 한다.

이 문제를 해결하려고 구성한 폴더 구조는 다음과 같다. 요즘은 ES2015로 코드를 작성하고 Babel로 트랜스파일하는 게 일반적이지만 Node.js v4에서도 어느 정도 ES2015의 기능을 사용할 수 있고 꼭 필요하지 않으면 트랜스파일을 사용하지 않기 때문에 Webpack등의 사용을 고려하지 않은 구조이다.

├── functions
│   └── tweet
│       ├── function.json
│       ├── index.js
│       ├── node_modules/
│       └── package.json
├── node_modules/
├── package.json
└── project.json

package.json를 프로젝트 루트에도 넣고 함수 폴더 안에도 넣었다. 프로젝트 루트에 있는 package.json에서는 프로젝트 전체에서 공통으로 사용하지만 배포할 필요는 없는 의존성 모듈(주로 devDependencies)을 관리하고 함수 폴더 안에 있는 package.json에서는 Lambda에서 사용할 로직에 필요한 모듈만 관리한다. 그리고 루트의 package.json에서 npm-scriptspostinstall 훅을 다음과 같이 추가했다.

"scripts": {
  "postinstall": "find ./functions/* -name package.json -maxdepth 1 -execdir npm install \\;"
}

postinstallnpm install이 완료되면 실행되는 스크립트로 여기서 functions폴더 하위에 package.json 파일이 있으면 npm install을 실행하도록 추가했다. 이러면 루트에서만 npm install을 하면 각 함수 폴더에서도 npm install이 실행되어 위의 구조처럼 node_modules 폴더가 루트 말고 각 함수에도 생기게 된다. 이제 apex deploy를 하면 필요한 모듈만 배포할 수 있게 되었다.

유닛 테스트

간단한 함수가 아니라면 당연히 테스트를 작성해야 한다. apex invoke로 동작을 확인해 볼 수 있지만, 로직이 복잡해 질수록 매번 Lambda를 직접 실행해서 테스트하는 것 불편하기도 하고 좋은 방법도 아니다. 보통 Node.js 코드를 작성할 때와 마찬가지로 모듈화해서 모듈별로 테스트를 작성하는 게 좋다. 이번에 작성하던 기능은 nodejs-ko 블로그에 새로운 피드가 올라오면 이를 트위터로 보내주는 것이다.

그러면 다음의 기능이 필요하다.

  1. RSS 피드를 읽어온다.
  2. 새로운 글이 있는지 확인한다.
  3. 새로운 글을 트위터에 트윗으로 올린다.

여기서 새로운 글을 확인하려면 기존에 트윗한 글이 어떤 글인지 알고 있어야 하는데 Lambda는 함수가 호출될 때마다 새로 서버가 실행되므로 데이터를 유지할 방법이 없다. 이런 경우에는 어딘가에 데이터를 저장해야 하는데 데이터베이스를 사용할 정도는 아니므로 S3를 이용해서 파일로 저장했다. 다시 정리하면

  1. RSS 피드를 읽어온다.(feed.js)
  2. S3에서 기존에 트윗한 글 목록을 가져온다.(store.js)
  3. RSS 피드에서 기존에 트윗하지 않은 글을 찾는다.(feed.js)
  4. 트윗하지 않은 글이 있으면 이를 트윗한다.(tweet.js)
  5. 트윗한 새 글 목록을 S3에 다시 저장한다.(store.js)

대충 이 정도다. 이는 개별적으로 따로 기능을 구현하고 테스트를 할 수 있다.

├── functions
│   └── tweet
│       ├── feed.js
│       ├── function.json
│       ├── index.js
│       ├── node_modules/
│       ├── package.json
│       ├── store.js
│       └── tweet.js
├── node_modules/
├── package.json
├── project.json
└── test
    ├── feed.spec.js
    ├── store.spec.js
    └── tweet.spec.js

테스트 폴더는 배포할 필요가 없으므로 프로젝트 루트에 넣었다. 상대 경로로 대상 파일을 가져오면 테스트하는 데 문제가 없기 때문이다. 각 기능의 테스트가 문제없다면 기능이 모두 동작하는 것이므로 index.js에서는 각 기능을 엮어주는 정도만 작성하면 굳이 테스트를 작성하지 않고도 동작을 어느 정도 보장할 수 있고 전체 기능을 Lambda에서 한두 번 테스트해주면 된다.

테스트를 작성할 때 고민한 점은 일반적인 테스트에서는 기본 환경은 development로 두고 프로덕션에서는 NODE_ENVproduction이므로 이 관례에 따라서 동작하게 하는 게 일반적이지만 Lambda에서는 NODE_ENV 환경변수가 없으므로 이를 활용할 수가 없다.(설정하면 되긴 하지만) 나 같은 경우는 설정값에 기본값을 테스트 설정값으로 두고 실제 Lambda에서 돌아갈 때는 function.json에서 환경변수로 값을 전달하도록 해서 테스트와 다르게 동작하도록 했다. Apex에서 프로파일 별로 환경을 지정할 수 있지만 그 정도로 할 기능은 아니라서 간단하게 처리했다.

"scripts": {
  "test": "./node_modules/mocha/bin/mocha -t 10000 test"
},

프로젝트 루트에 있는 package.json에서 test 스크립트를 추가해서 npm test로 전체 테스트를 확인해 볼 수 있도록 했다.

apex infra

이렇게 작성한 Lambda 함수를 apex deploy로 배포할 수 있다. 이 기능 같은 경우는 외부에서 호출하는 기능이 아니라 배치로 동작하면서 새로운 글이 올라오면 트윗으로 올려주는 기능이면 충분하므로 30분에 한 번 정도만 실행되면 된다. AWS Lambda에서 배치는 CloudWatch를 이용해서 일정 시간마다 Lambda 함수를 호출하게 할 수 있다. 이를 위해서 AWS 웹 콘솔에서 설정할 수도 있지만 apex로 터미널에서 다 작업을 하고 있는데 트리거는 웹에서 설정한다는 게 맞지 않고 Lambda 같은 기능일수록 어떻게 설정되었는지 확인하기 어려우므로 코드로 설정을 자동화해놓는 게 좋다고 생각한다.(AWS에 의존성이 있는 설정임에도...)

apex에서는 Lambda 외의 AWS 리소스를 설정하는 기능으로 HashiCorp에서 만든 Terraform을 지원하고 있다. apex infra라는 명령어를 사용하고 있지만, 이는 실제로 terraform을 대신 실행해 주는 별칭일 뿐이다. 인프라 관련 설정은 모두 infrastructure폴더 아래에 모이는데 이 아래 프로덕션, 스테이지 등 환경별로 만들 수도 있는데 여기서는 간단하게 프로덕션 환경 하나만 만들었다.

├── functions
│   └── tweet
├── infrastructure
│   ├── main.tf
│   └── variables.tf
└── test

구조를 간략화하면 위와 같은 형태가 된다. 여기서 apex infra plan을 실행하면 실제로는 cd infrastructure && terraform plan과 똑같다고 보면 된다. 그래서 로컬에 Terraform이 설치되어 있어야 한다. Terraform이 헷갈리면 직접 infrastructure폴더 아래로 들어가서 terraform을 직접 사용해도 된다.

나도 아직 Terrafrom을 자세히 알지는 못하고 이번 기능 구현을 위한 간단한 설정만 찾은 상태이다.

$ apex list --tfvars
apex_function_tweet="arn:aws:lambda:ap-northeast-1:410655858509:function:nodejs-ko-bot_tweet"

apex에서 필요한 변수를 Terraform에 자동으로 넘겨주는데 여기서 변수명을 알고 싶다면 apex list --tfvars을 실행하면 된다. 이는 배포한 Lambda 함수(여기서는 tweet)의 ARN이 apex_function_함수명의 이름으로 전달이 된다. Lambda 함수의 ARN이므로 함수를 배포해야 이 값이 출력된다. 이 변수를 Terraform에서 사용하려면 variables.tf을 만들어야 한다.

variable "aws_region" {}
variable "apex_function_tweet" {}

앞에서 출력된 apex_function_tweet외에 AWS 리전을 의미하는 aws_region도 변수로 내보내는데 (헷갈리게) 위의 --tfvars에서는 출력되지 않는다. 이 변수들을 Terraform에 전달하려면 위와 같이 빈 값인 {}로 정의하면 된다. 실제 값은 apex infra를 실행할 때 할당된다.

resource "aws_cloudwatch_event_rule" "every_thirty_minutes" {
    name = "every-thirty-minutes"
    description = "Fires every thirty minutes"
    schedule_expression = "rate(30 minutes)"
}

resource "aws_cloudwatch_event_target" "tweet_every_thirty_minutes" {
    rule = "${aws_cloudwatch_event_rule.every_thirty_minutes.name}"
    arn = "${var.apex_function_tweet}"
}

resource "aws_lambda_permission" "allow_cloudwatch_to_call_tweet" {
    statement_id = "AllowExecutionFromCloudWatch"
    action = "lambda:InvokeFunction"
    function_name = "${var.apex_function_tweet}"
    principal = "events.amazonaws.com"
    source_arn = "${aws_cloudwatch_event_rule.every_thirty_minutes.arn}"
}

위 파일은 main.tf이다. 여기서 CloudWatch로 30분마다 Lambda 함수를 호출할 수 있게 정의한 것이다. every_thirty_minutes라는 이름으로 30분마다 호출되는 CloudWatch 규칙을 만들고 tweet_every_thirty_minutes라는 이름으로 CloudWatch의 타겟을 정의한다. 이때 apex에서 전달받은 변수를 정의하기 위해 ${var.apex_function_tweet}로 정의했다. 그리고 정의한 규칙에서 대상 Lambda 함수를 호출할 수 있도록 allow_cloudwatch_to_call_tweet라는 권한을 설정하면 된다. 이렇게 설정하면 30분마다 CloudWatch에서 배포함 tweet Lambda 함수를 호출하게 설정할 수 있다.

이제 실제로 사용해 보자.

$ apex infra plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but
will not be persisted to local or remote state storage.


The Terraform execution plan has been generated and is shown below.
Resources are shown in alphabetical order for quick scanning. Green resources
will be created (or destroyed and then created if an existing resource
exists), yellow resources are being changed in-place, and red resources
will be destroyed. Cyan entries are data sources to be read.

Note: You didn't specify an "-out" parameter to save this plan, so when
"apply" is called, Terraform can't guarantee this is what will execute.

+ aws_cloudwatch_event_rule.every_thirty_minutes
    arn:                 "<computed>"
    description:         "Fires every thirty minutes"
    is_enabled:          "true"
    name:                "every-thirty-minutes"
    schedule_expression: "rate(30 minutes)"

+ aws_cloudwatch_event_target.tweet_every_thirty_minutes
    arn:       "arn:aws:lambda:ap-northeast-1:410655858509:function:hello-world_tweet"
    rule:      "every-thirty-minutes"
    target_id: "<computed>"

+ aws_lambda_permission.allow_cloudwatch_to_call_tweet
    action:        "lambda:InvokeFunction"
    function_name: "arn:aws:lambda:ap-northeast-1:410655858509:function:hello-world_tweet"
    principal:     "events.amazonaws.com"
    source_arn:    "${aws_cloudwatch_event_rule.every_thirty_minutes.arn}"
    statement_id:  "AllowExecutionFromCloudWatch"


Plan: 3 to add, 0 to change, 0 to destroy.

apex infra plan 즉, terramform plan은 설정을 가상으로 AWS에 적용해 보고 어떤 변화가 생기는지를 적용 전에 확인할 수 있는 기능이다. 이를 실행하면 위처럼 3개의 규칙(규칙, 타겟, 권한)이 추가 되는 것을 볼 수 있고 마지막 Plan에서 3개가 추가된다고 알려주는 것을 볼 수 있다.

plan으로 확인할 설정에 문제가 없다면 apex infra apply(terraform apply)를 실행하면 실제로 AWS에 적용된다.

$ apex infra apply
aws_cloudwatch_event_rule.every_thirty_minutes_test: Creating...
  arn:                 "" => "<computed>"
  description:         "" => "Fires every thirty minutes"
  is_enabled:          "" => "true"
  name:                "" => "every-thirty-minutes"
  schedule_expression: "" => "rate(30 minutes)"
aws_cloudwatch_event_rule.every_thirty_minutes_test: Creation complete
aws_cloudwatch_event_target.tweet_every_thirty_minutes_test: Creating...
  arn:       "" => "arn:aws:lambda:ap-northeast-1:410655858509:function:hello-world_tweet"
  rule:      "" => "every-thirty-minutes"
  target_id: "" => "<computed>"
aws_lambda_permission.allow_cloudwatch_to_call_tweet_test: Creating...
  action:        "" => "lambda:InvokeFunction"
  function_name: "" => "arn:aws:lambda:ap-northeast-1:410655858509:function:hello-world_tweet"
  principal:     "" => "events.amazonaws.com"
  source_arn:    "" => "arn:aws:events:ap-northeast-1:410655858509:rule/every-thirty-minutes"
  statement_id:  "" => "AllowExecutionFromCloudWatch"
aws_cloudwatch_event_target.tweet_every_thirty_minutes_test: Creation complete
aws_lambda_permission.allow_cloudwatch_to_call_tweet_test: Creation complete

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

이제 인프라 설정까지 코드로 자동화가 되었다.(S3 버킷 설정 등은 빠졌지만...) 이제 문제가 생기거나 다른 계정 혹은 다른 VPC에서도 언제든지 똑같은 환경으로 Lambda 설정을 배포할 수 있게 되었다. terraform으로 인프라를 적용하고 나면 terraform.tfstate라는 파일이 생기는데 여기서 적용된 인프라의 버전을 관리하고 있으므로 이 파일도 보관하고 있어야 인프라를 변경했을 때 문제없이 배포할 수 있다.

여기서 설명한 전체 코드는 GitHub 저장소에 올려져 있다.

2016/09/23 03:38 2016/09/23 03:38