Outsider's Dev Story

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

Apex(Terraform)로 API Gateway 구성하기 #1

AWS에서는 API Gateway를 제공하고 있다. Lambda로 서버리스 아키텍처를 구성해서 앞에 API Gateway를 연결하면 간단한 기능부터 좀 더 복잡한 API까지 구현해서 운영 걱정 없이 내부에서 사용하거나 외부에 노출할 수 있다.

전에 API Gateway를 Lambda와 연결하는 방법에 대해서 설명했지만 여기서는 API Gateway를 소개하고 간단한 사용법을 설명했을 뿐이고 Lambda 관리 도구인 Apex를 소개할 때도 API Gateway를 Apex에서 지원하는 Terraform으로 API Gateway를 구성하는 방법을 설명했지만 여기에서 목적도 Apex와 Terraform을 설명하는 것이었으므로 기능은 간단하게만 설명했다.

Apex

Lambda를 사용할 때 Apex를 거의 이용하는 편이다. Lambda는 간단한 함수 위주로 관리하면서 다양한 AWS 자원과 연동해서 사용해야 하므로 Lambda만 볼 때 이 Lambda가 어떻게 동작하고 어떤 AWS 자원과 연동되어 있는지 확인하기가 무척 어렵다. 요즘은 대부분의 AWS 자원에서 비슷한 느낌을 받지만 작은 단위로 사용하기 쉬운 Lambda를 사용할 때 관리가 어려운 상태가 되기 훨씬 쉽다고 생각한다.

Apex 말고도 빠르게 버전이 올라가면서 기능이 추가되고 있는 Serverless도 있지만, Apex를 사용하고 있다. Serverless는 스터디에서만 간단히 살펴보았는데 API Gateway까지 Serverless에서 모두 지원하고 있으므로 간단히 적용하기에는 훨씬 사용하기 간단해 보였다.

대신 Apex는 Serverless와는 달리 Lambda 함수의 배포, 버저닝, 테스트 등은 지원하지만 이전 글에서 설명했듯이 Apex를 Lambda 외 AWS 자원을 관리할 때 Terraform을 이용한다. 명령어는 apex infra를 사용하지만 실제로 이는 terraform의 별칭일 뿐이다. 그래서 실제로 Apex로 AWS 자원을 관리하려면 Terraform을 이용해야 한다. 나는 Terraform이란 도구를 맘에 들어 하지만 실제로 Serverless같은 도구보다 Apex를 Terraform에 대해서도 지식이 꽤 필요하다. 그런데도 Terraform을 이용하면 상세 설정을 할 수 있고 원하는 대로 구성이 가능하므로 Terraform을 더 선호하는 편이다.

API Gateway

Lambda를 배포하고 API Gateway를 구성하기 위해서 Terraform으로 설정하려면 꽤 복잡한 설정을 추가해야 한다. Apex로 실제 Lambda 함수 작성하기에서 Apex에서 Terraform을 이용하는 방법을 설명했으니 이전에 한 번도 사용하지 않아 봤다면 이 글이나 Terraform 관련 글을 먼저 보는 게 좋다.

대부분의 AWS 서비스가 복잡하지만, API Gateway는 특히 복잡하다고 느끼는 서비스 중 하나이다. API Gateway를 이용해서 뭔가 구성해 봤다면 대부분 느낄 것 같은데 설정이 꽤 복잡해서 제대로 연동하려면 삽질을 많이 하게 된다. AWS 웹 콘솔에서 작업해도 좀 어렵지만, GUI이므로 설정에 대한 도움을 꽤 받을 수 있지만 Terraform으로 API Gateway를 구성하려면 모든 요소를 직접 정의해야 하므로 각 요소에 대한 이해도가 높지 않으면 작업하기가 꽤 어렵다.

전에 한 번 작성해서 적용했는데 한 6개월 지나서 다시 해보려니 안되는 부분도 있고 해서 다시 테스트하면서 적용을 해보았다. 전에는 대충 이해하고 있었던 부분도 있고 해서...

그래서 이 글은 큰 범위에서는 Apex를 이용해서 Lambda로 API Gateway를 연결하는 방법에 관한 내용이지만 집중하는 부분은 Terraform으로 API Gateway를 설정하는 방법에 관한 내용이다.

간단한 Lambda 함수

API Gateway에 Lambda 함수를 연동하기 위한 예시로 간단한 Lambda 함수를 만들어 보자. 이 Lambda는 단순히 전달받은 URL에 요청을 보내서 그 결과를 반환하는 함수이다.

// index.js
const request = require('request');

console.log('starting function');

const ping = (url, cb) => {
  if (!url) { return cb(new Error('[BadRequest] URL required')); }

  request(url, (err, response, body) => {
    console.log('ping statusCode:', response && response.statusCode);
    console.log('ping body:', body);
    cb(err, body);
  });
};

exports.handle = (event, ctx, cb) => {
  console.log('processing event: %j', event);

  ping(event.url, (err, result) => {
    if (err) {
      const error = {
        message: err.message,
        stackTrace: err.stack,
        event: event,
        requestId : ctx.awsRequestId,
      }
      if (!err.message.match(/BadRequest/)) {
        error.message = `[InternalServerError] ${err.message}`
      }
      console.log('err: %j', error);
      return cb(JSON.stringify(error));
    }

    console.log('result: %j', result);
    return cb(null, result);
  });
};

이 Lambda는 Node.js 6.10으로 작성된 코드이다. 간단히 설명하면 ping()request 모듈을 이용해서 HTTP 요청을 보내고 받은 응답을 돌려주는 함수이다. exports.handle가 Lambda의 진입점인데 요청으로 들어온 이벤트가 event변수로 들어온다. 예를 들어 이 Lambda 함수에 { "url": "https://status.github.com/api/last-message.json" }를 전달하면 {"status":"good","body":"Everything operating normally.","created_on":"2017-05-06T19:04:12Z"} 응답을 돌려준다.(저 URL은 github 사이트의 상태를 알려주는 API 이다.)

몇 가지 API Gateway와 연동할 이 Lambda에 관해서 설명할 부분이 있다.

  • 여기서는 작업이 완료되었을 때 cb 즉, 콜백으로 오류와 결과를 반환했다. Node.js의 관례대로 첫 인자는 오류이고 두 번째가 결과이다. 콜백 방식 성공했을 때는 ctx.succeed(result)를 실행하고 실패했을 때는 ctx.fail(JSON.stringify(error))를 사용해도 같다. 필요 한대로 사용하면 된다.
  • API Gateway가 Lambda 전용이 아니므로 이 Node.js로 작성된 Lambda와 직접 연동되지 않는다. 그래서 보통 작성하듯이 callback(error)를 반환해도 API Gateway가 오류 객체를 받지를 못한다.(Lambda가 모두 Node.js도 아니고 API Gateway도 그렇지 않으니까) 그래서 callback(new Error('something worng!'))과 같이 전달하면 API Gateway에서는 { "errorMessage": "something worng!" }로 받게 된다. 오류 객체가 결국 문자열로 변경이 되는데 결과에 따라 API Gateway에서 처리가 필요하므로 좀 더 정보를 많이 주어야 한다. 그래서 여기서는 다른 객체를 만들어서 오류 정보를 넣은 뒤에 이를 JSON.stringify로 문자열로 바꾸어서 반환한다. 이를 사용하는 방법은 뒤에서 좀 더 살펴볼 것이다.
  • 이 Lambda에 필수값인 url을 전달하지 않으면 [BadRequest] URL required 오류를 발생시킨다. 이 부분도 API Gateway에서 처리를 위해서 의도적으로 [BadRequest]를 넣은 것이고 마지막에 오류를 처리하기 전에 BadReqeust가 아니라면 [InternalServerError] 접두사를 오류 메시지에 붙여 주었다. 이 오류 메시지도 뒤에서 설명할 API Gateway에서 사용하는 방식을 설명하겠다.
  • console.log로 로깅을 해놓으면 CloudWatch에서 로그 메시지를 볼 수 있으므로 디버깅할 때 좀 더 편하다.

작성한 Lambda 함수를 Apex로 배포한다.

$ apex deploy
   • creating function         env= function=github-status
   • created alias current     env= function=github-status version=1
   • function created          env= function=github-status name=github-status_github-status version=1

여기서는 Apex 자체에 사용법은 자세히 설명하지 않으므로 Apex - AWS Lambda 관리 도구를 참고하기 바란다.

apex infra로 API Gateway 구성하기

앞에 길게 설명했지만, 이게 이 글의 본론이다. API Gateway가 복잡해서 이해가 잘하지 못한 부분도 있지만 Terraform에서 문서가 아직 제대로 안 된 부분 등을 테스트해보고 확인하느라 고생을 꽤 많이 했다. 앞에서도 설명했지만, 명령어는 apex infra를 사용하지만 실제로는 Terraform이다. 하지만 apex infra를 이용하면 배포한 Lambda에 대한 정보를 Terraform 변수로 제공한다.

apex infra를 이용하려면 infrastructure 폴더를 만들고 이 안에 Terraform 파일을 만들어야 한다.

// infrastructure/variables.tf

variable "aws_region" {}

variable "apex_function_github-status" {}
variable "apex_function_github-status_name" {}

이 파일은 테라폼에서 사용할 변수를 모아놓은 파일이다. apex로 Lambda를 사용하기 위해 설정한 AWS 정보와 배포한 Lambda 함수에 대한 정보를 테라폼내에서 사용하려면 변수를 정의해놓아야 한다. 내용은 알아서 apex가 채워주지만, 선언은 해야 한다. apex_function_github-status가 배포한 Lambda 함수의 ARN이다.(그래서 배포를 먼저 해놓아야 한다.) apex_function_github-status_name는 배포한 Lambda의 이름이다. 이 변수는 apex list --tfvars로 확인할 수 있는데 여기서는 apex_function_github-status만 출력되므로 나머지는 문서를 보고 찾아야 한다.

API Gateway 생성

AWS 웹 콘솔에서라도 API Gateway를 만들어 본 적이 없다면 일단 웹 콘솔에서 만들어 보기 바란다. 열심히 설명하겠지만 만들어 본 적이 없다면 아마 이 글을 이해하기가 무척 어려울 것으로 생각한다.

API Gateway의 웹 콘솔

API 게이트웨이는 위처럼 생겼다. 이는 새로운 API를 하나 만들고 /check라는 리소스를 하나 만들고 여기에 POST 메서드를 추가해서 Lambda와 연결하면 이렇게 만들어진다. 여기서 크게 3단계 정도로 하나의 API 리소스를 만들었지만 테라폼으로 작성하려면 요소를 다 지정해 주어야 한다.

// infrastructure/github-status.tf

// API 정의
resource "aws_api_gateway_rest_api" "status_api" {
  name        = "StatusAPI"
  description = "상태를 조회하는 API"
}

// API 리소스 정의 (/check 같은 경로)
resource "aws_api_gateway_resource" "status_api_resource_check" {
  rest_api_id = "${aws_api_gateway_rest_api.status_api.id}"
  parent_id   = "${aws_api_gateway_rest_api.status_api.root_resource_id}"
  path_part   = "check"
}

// API 리소스의 메서드 정의 (GET, POST 등)
resource "aws_api_gateway_method" "status_api_resource_check_post" {
  rest_api_id   = "${aws_api_gateway_rest_api.status_api.id}"
  resource_id   = "${aws_api_gateway_resource.status_api_resource_check.id}"
  http_method   = "POST"
  authorization = "NONE"
}

// API와 Lambda 통합
resource "aws_api_gateway_integration" "status_api_resource_check_post" {
  rest_api_id = "${aws_api_gateway_rest_api.status_api.id}"
  resource_id = "${aws_api_gateway_resource.status_api_resource_check.id}"
  http_method = "${aws_api_gateway_method.status_api_resource_check_post.http_method}"
  type = "AWS"
  integration_http_method = "POST"
  uri = "arn:aws:apigateway:${var.aws_region}:lambda:path/2015-03-31/functions/${var.apex_function_github-status}/invocations"
}

이 파일은 infrastructure/github-status.tf에 API Gateway를 설정한 것이다. 일부러 Lambda 함수와 파일명을 맞추었다. 앞에 GUI에서 만든것과 동일한 과정이다. aws_api_gateway_rest_api는 API 자체를 만드는 것이고 여기에 aws_api_gateway_resource로 리소스를 추가하는데 경로는 check이므로 실제로는 /check가 된다. 그리고 이 리소스에 aws_api_gateway_methodPOST 메서드를 추가한다.

마지막으로 aws_api_gateway_integration에서 이 메서드를 배포한 Lambda와 통합한다. 여기서 AWS 자원과 통합하므로 typeAWS로 지정하고 Lambda는 항상 POST로 호출해야 하므로 integration_http_method로 지정했고 uri는 HTTP나 AWS 일때는 필수 값인데 AWS에서는 arn:aws:apigateway:{region}:{subdomain.service|service}:{path|action}/{service_api}같은 형식이 된다. 이 URI를 만들기 위해서 변수에서 지정했던 var.aws_regionvar.apex_function_github-status를 이용했다. 이 값은 배포한 Lambda의 리전과 ARN을 apex가 넣어준다.

$ apex infra plan

+ aws_api_gateway_integration.status_api_resource_check_post
    http_method:             "POST"
    integration_http_method: "POST"
    passthrough_behavior:    "<computed>"
    resource_id:             "${aws_api_gateway_resource.status_api_resource_check.id}"
    rest_api_id:             "${aws_api_gateway_rest_api.status_api.id}"
    type:                    "AWS"
    uri:                     "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:410000000000:function:github-status_github-status/invocations"

+ aws_api_gateway_method.status_api_resource_check_post
    api_key_required: "false"
    authorization:    "NONE"
    http_method:      "POST"
    resource_id:      "${aws_api_gateway_resource.status_api_resource_check.id}"
    rest_api_id:      "${aws_api_gateway_rest_api.status_api.id}"

+ aws_api_gateway_resource.status_api_resource_check
    parent_id:   "${aws_api_gateway_rest_api.status_api.root_resource_id}"
    path:        "<computed>"
    path_part:   "check"
    rest_api_id: "${aws_api_gateway_rest_api.status_api.id}"

+ aws_api_gateway_rest_api.status_api
    created_date:     "<computed>"
    description:      "상태를 조회하는 API"
    name:             "StatusAPI"
    root_resource_id: "<computed>"


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

이를 plan으로 확인해 보면 4개의 리소스가 설정되는 것을 볼 수 있다. 이제 apex infra apply로 적용하면 아래와 같이 API Gateway가 새로 만들어진 것을 볼 수 있다.

생성된 API Gateway

API Gateway


API Gateway 배포

이어서 계속 설정을 해보자. API Gateway는 위처럼 리소스와 메서드를 정의한 뒤에 실제 서비스에서 사용하듯이 test, stage, production 등의 스테이지를 만들어서 배포할 수 있다. API를 수정하거나 작업을 한 뒤에 정해진 스테이지에 배포를 하면 실제 서비스에 적용이 된다. 아래 내용은 이 배포를 위해서 infrastructure/github-status.tf에 추가한 부분이다. 참고로 테라폼은 파일 간 순서 등이 상관없으므로 다른 파일에 작성해도 상관없지만 여기서는 한 파일에 모두 작성을 했다.

// infrastructure/github-status.tf

// API 배포
resource "aws_api_gateway_deployment" "status_api_test" {
  depends_on = ["aws_api_gateway_integration.status_api_resource_check_post"]
  rest_api_id = "${aws_api_gateway_rest_api.status_api.id}"
  stage_name  = "test"
  stage_description = "${timestamp()}"
  description = "Deployed at ${timestamp()}"
}

// Lambda에 호출할 권리
resource "aws_lambda_permission" "status_api_resource_check_post" {
  statement_id = "AllowInvokeFromAPIGateway"
  action = "lambda:InvokeFunction"
  function_name = "${var.apex_function_github-status_name}"
  principal = "apigateway.amazonaws.com"
  source_arn = "${aws_api_gateway_deployment.status_api_test.execution_arn}/${aws_api_gateway_integration.status_api_resource_check_post.integration_http_method}${aws_api_gateway_resource.status_api_resource_check.path}"
}

여기선 두 가지 리소스를 정의했다.

aws_api_gateway_deployment는 위에서 설명한 배포이다. 배포는 위에서 만든 aws_api_gateway_integration.status_api_resource_check_post가 완성된 후에 이루어져야 하므로 depends_on으로 지정을 했다.(여기선 하나뿐이지만 여러 개가 있다면 배열 안에 추가하면 된다.) 그리고 스테이지 이름은 여기서는 test로 지정했다. 이렇게 하면 test 스테이지가 생성된다. 테라폼 문서를 보면 aws_api_gateway_stage 리소스가 정의되어 있는데 온갖 테스트를 다 해봤지만, 이 리소스를 사용하는 방법을 모르겠다. aws_api_gateway_deployment로도 스테이지가 만들어지고 aws_api_gateway_stage로도 스테이지가 만들어지므로 같은 이름을 지정하면 이미 존재한다고 오류가 발생하고 aws_api_gateway_stage를 정의해서 aws_api_gateway_deployment에서 가져다가 쓰려고 해도 aws_api_gateway_stage에서 aws_api_gateway_deployment의 ID가 필수값이라 순환참조 오류가 발생한다.

여기서 배포에 대해 좀 더 설명해야 하는데 리소스, 메서드, 스테이지 등은 AWS의 정의된 리소스이지만 배포는 리소스가 아니라 행위이다. 말이 모호하기는 한데 API Gateway를 구성하고 구성이 완료되면 배포를 하는 것이다. 그러므로 앞에서 정의한 API Gateway의 리소스나 메서드를 새로 추가하거나 수정을 하면 배포도 다시 해야 한다. 테라폼 문서에는 안 나와 있지만, 테스트를 해보고 찾아본 결과 description = "Deployed at ${timestamp()}"을 추가해야 한다. 이를 추가하지 않으면 API에 변경을 했을 때 새로 배포하지 않는다. 이는 뒤에서 API를 수정할 때 보여주겠다.
꼭 시간을 지정할 필요는 없지만, 확인을 위해서 배포된 시간을 기록하도록 했다. 추가로 stage_description = "${timestamp()}"를 지정했는데(이 부분도 문서에 없었다. ㅠ) stage_description를 지정하지 않으면 새로 배포를 하기는 하지만 배포 히스토리가 쌓이지 않고 기존 배포를 업데이트해버린다. 좀 당황스러운 동작이지만 기존 배포에 ID가 할당되고 테라폼이 이를 기억하고 있으므로 description를 지정하면 새로 배포하면서 기존 배포의 내용을 업데이트한다. stage_description를 지정하면 각 배포가 구분되므로 언제 배포했는지를 히스토리로 쌓을 수 있다. 대신 apex infra plan을 실행할 때마다 변경사항이 없어도 항상 배포를 새로 하려고 시도한다. API를 변경할 때만 배포되도록 하는 방법은 아직 찾지 못했다.

aws_lambda_permission는 API Gateway에서 Lambda를 실행할 수 있도록 권한을 주는 것이다. 언제부터 있었는지 모르겠지만, 전에는 IAM role을 하나 만들어서 이를 통해서 실행했는데 Lambda에 바로 권한을 추가해버릴 수 있게 되었다. 여기서 function_name을 지정하기 위해 var.apex_function_github-status_name 변수를 사용했다.

여기서 지정한 source_arn는 설명이 더 필요하다. 여기에 ${aws_api_gateway_deployment.status_api_test.execution_arn}/${aws_api_gateway_integration.status_api_resource_check_post.integration_http_method}${aws_api_gateway_resource.status_api_resource_check.path}라는 아주 긴 값을 사용했다. 이는 arn:aws:execute-api:ap-northeast-1:410000000000:5psac8pgq9/test/POST/check같은 형태로 지정하기 위해서 각 변수의 값을 조합한 것이다. 여기서 test 스테이지에 배포했으므로 여기 배포한 POST /check에서 앞에서 배포한 Lambda를 호출할 수 있도록 권한을 부여하는 것이다. 이렇게 하면 개별 API나 스테이지 별로 특정 Lambda만 실행할 수 있게 권한을 제어할 수 있다.

다음 두 ARN을 보자.

  1. arn:aws:execute-api:ap-northeast-1:410000000000:5psac8pgq9/test/POST/check
  2. arn:aws:execute-api:ap-northeast-1:410000000000:5psac8pgq9/*/POST/check

여기서 지정한 것은 1번 ARN이고 2번 ARN은 스테이지 부분(여기서는 test)을 와일드카드 처리했다. 이렇게 하면 스테이지에 상관없이 접근 권한을 줄 수 있다. 이게 왜 중요하냐 하면 1번 ARN으로 지정하면 API Gateway 웹 콘솔에서 테스트 버튼을 눌러서 테스트할 수가 없다. 테스트는 스테이지에서 하는 게 아니라 리소스 밑의 메뉴에서 하므로 권한 없음 오류가 발생한다.

source_arn = "arn:aws:execute-api:${var.aws_region}:${var.aws_account_id}:${aws_api_gateway_rest_api.status_api.id}/*/${aws_api_gateway_integration.status_api_resource_check_post.integration_http_method}${aws_api_gateway_resource.status_api_resource_check.path}"

테스트에 불편하므로 2번 형태로 ARN을 지정하고 싶다면 위처럼 훨씬 복잡하게 지정해야 한다.(나중에 테라폼에서 지원해 줄지 모르지만...) 여기서 경로에 ${var.aws_account_id}를 사용했는데 이는 계정의 ID이고 이 예시에서 410000000000가 계정 ID이다. AWS에서 자신의 계정 메뉴에 들어가면 이 ID 값을 볼 수 있다. 이는 가져올 수 있는 곳이 없으므로 미리 찾아서 변수로 지정한 것인데 사용하는 패턴에 따라 변수든 다른 방식이든 이 값을 가져와야 한다. Apex는 이 값을 주지 않는다.

이 설정을 배포해보자.

$ apex infra plan

+ aws_api_gateway_deployment.status_api_test
    created_date:      "<computed>"
    description:       "Deployed at 2017-05-07T17:22:09Z"
    execution_arn:     "<computed>"
    invoke_url:        "<computed>"
    rest_api_id:       "1w03z7dzmi"
    stage_description: "2017-05-07T17:22:09Z"
    stage_name:        "test"

+ aws_lambda_permission.status_api_resource_check_post
    action:        "lambda:InvokeFunction"
    function_name: "github-status_github-status"
    principal:     "apigateway.amazonaws.com"
    source_arn:    "${aws_api_gateway_deployment.status_api_test.execution_arn}/${aws_api_gateway_integration.status_api_resource_check_post.integration_http_method}${aws_api_gateway_resource.status_api_resource_check.path}"
    statement_id:  "AllowInvokeFromAPIGateway"


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

이를 적용하면 다음과 같이 스테이지가 만들어지고 배포된 것을 볼 수 있다.

웹 콘솔에서 API가 배포된 화면

그리고 Lambda 함수에 가서 Triggers 메뉴에 가면 Terraform으로 지정한 대로 API Gateway의 권한 설정이 된 것을 볼 수 있다.

Lambda에서 권한이 추가된 화면

실제 API가 동작하는지 테스트를 해보자.

$ curl -i \
  -X POST https://1w03z7dzmi.execute-api.ap-northeast-1.amazonaws.com/test/check \
  -d '{ "url": "https://status.github.com/api/last-message.json"}'
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Content-Length: 36
Connection: keep-alive
Date: Sun, 07 May 2017 17:27:04 GMT
x-amzn-RequestId: 62ba1cb4-334a-11e7-b673-1d08c1f2e7b5
X-Cache: Error from cloudfront
Via: 1.1 561f6699cf8751ed16dd95a8118df9bb.cloudfront.net (CloudFront)
X-Amz-Cf-Id: CSz_S04uofasovxiV4FgPJuIyvchQUxLdwAxvwKctA49UXuCYvg6dw==

{"message": "Internal server error"}

오류 응답이 왔고 앞에서 코드를 보았듯이 이 오류는 Lambda에서 온 게 아니라 API Gateway에서 준 것이다.


이 글은 Apex(Terraform)로 API Gateway 구성하기 #2로 이어진다.

2017/05/08 04:10 2017/05/08 04:10