나는 코드를 쓸테니, 너는 인프라를 맡거라.

hax0r·2019년 3월 5일
53
post-thumbnail

이 문서는 PageCall Console 을 개발하면서 인프라 구성이 귀찮았던 개발자 우 모씨가 Lambda, API Gateway, Cloud Formation 등의 AWS 리소스를 활용해 Serverless Architecture 를 구현 하면서 인프라 구성이라는 귀찮은 굴레 속에서 벗어나 코드 본연의 집중할 수 있었던 경험을 공유하고 있다.

뭐가 그렇게 귀찮아 ?

제품을 생산하고 배포하고 유지하는 과정을 높은 가용성과 확장성 있게 구성하는 것은 매우 중요한 일인 반면 꽤나 귀찮은 일이기도하다. 더욱이 자원이 한정적인 소규모 그룹의 경우에는 관리 포인트가 늘어남에따라 퍼포먼스 이슈 또한 배제할 수 없는 걸림돌이 된다.

serverless architecture 는 이러한 문제를 해결 할 수 있는 좋은 솔루션이다.
위 과정 중, 개발자에게는 생산에만 집중할 수 있도록 도와주기 때문이다.
이로써 개발자는 코드 본연에 신경쓸 수 있기에 비약적인 생산성 향상과 높은 품질의 제품을 생산할 수 있다.

그전까지 나는 위의 모든 과정을 수행해야했다.
대표적으로 우리의 서비스 PageCallAPI(이하 PCA) 의 배포 과정을 한 사례로 들어 수고스러움을 얘기해보겠다.

기본적으로 PCA 배포과정에서는 “Codedeploy”, “Elastic Load Balancer(이하 ELB)”, “Auto Scaling Group(이하 ASG)”, “S3” 등의 리소스를 필연적으로 사용하고있다.

개발자가 Remote git server master 브랜치에 push 하면 CI 는 이를 배포로 간주하고 정의된 프로세스가 구동된다. 그 과정 중 PCA에 패키지들은 압축되어 S3의 업로드되고 Codedeploy 클라이언트를 통해 배포가 진행된다.
이 절차를 수행하는 코드는 아래와 같다.

from __future__ import print_function
import os
import sys
from time import strftime, sleep
import boto3
from botocore.exceptions import ClientError
 
VERSION_LABEL = strftime("%Y%m%d%H%M%S")
BUCKET_KEY = os.getenv('APPLICATION_NAME') + '/' + VERSION_LABEL + \
    '-bitbucket_builds.zip'
 
def upload_to_s3(artifact):
    """
    Uploads an artifact to Amazon S3
    """
    try:
        client = boto3.client('s3')
    except ClientError as err:
        print("Failed to create boto3 client.\n" + str(err))
        return False
    try:
        client.put_object(
            Body=open(artifact, 'rb'),
            Bucket=os.getenv('S3_BUCKET'),
            Key=BUCKET_KEY
        )
    except ClientError as err:
        print("Failed to upload artifact to S3.\n" + str(err))
        return False
    except IOError as err:
        print("Failed to access artifact.zip in this directory.\n" + str(err))
        return False
    return True
 
def deploy_new_revision():
    """
    Deploy a new application revision to AWS CodeDeploy Deployment Group
    """
    try:
        client = boto3.client('codedeploy')
    except ClientError as err:
        print("Failed to create boto3 client.\n" + str(err))
        return False
 
    try:
        response = client.create_deployment(
            applicationName=str(os.getenv('APPLICATION_NAME')),
            deploymentGroupName=str(os.getenv('DEPLOYMENT_GROUP_NAME')),
            revision={
                'revisionType': 'S3',
                's3Location': {
                    'bucket': os.getenv('S3_BUCKET'),
                    'key': BUCKET_KEY,
                    'bundleType': 'zip'
                }
            },
            deploymentConfigName=str(os.getenv('DEPLOYMENT_CONFIG')),
            description='New deployment from BitBucket',
            ignoreApplicationStopFailures=True
        )
    except ClientError as err:
        print("Failed to deploy application revision.\n" + str(err))
        return False
 
    """
    Wait for deployment to complete
    """
    while 1:
        try:
            deploymentResponse = client.get_deployment(
                deploymentId=str(response['deploymentId'])
            )
            deploymentStatus=deploymentResponse['deploymentInfo']['status']
            if deploymentStatus == 'Succeeded':
                print ("Deployment Succeeded")
                return True
            elif (deploymentStatus == 'Failed') or (deploymentStatus == 'Stopped') :
                print ("Deployment Failed")
                return False
            elif (deploymentStatus == 'InProgress') or (deploymentStatus == 'Queued') or (deploymentStatus == 'Created'):
                continue
        except ClientError as err:
            print("Failed to deploy application revision.\n" + str(err))
            return False
    return True
 
def main():
    if not upload_to_s3('/tmp/artifact.zip'):
        sys.exit(1)
    if not deploy_new_revision():
        sys.exit(1)
 
if __name__ == "__main__":
    main()

아래는 Codedeploy 의 배포 절차이다.

  • Provisioning replacement instances

  • Installing application on replacement instances

  • Rerouting traffic to replacement instances

  • Terminating original instances

이 과정에서 우리는 Blue/Green 배포 방식을 채택 하였기에 무중단 배포가 이루어진다. Provisioning 과정에서 대체 인스턴스는 appspec.yml 에 정의된 내용을 참고하여 이벤트 훅들을 실행하는데, 이 과정에서 우리가 정의해 놓은 dependency 들이나 환경변수등을 설정할 수 있다.
배포 소요시간은 대체 인스턴스와 정의된 스크립트 수에 비례된다.

아래 그림은 위 배포 절차에 대한 이해를 돕기 위해 첨부하였다.
그림 좌측이 전반적인 배포 절차이고, 우측이 Blue/Green 배포 형태에 대한 내용이다.

필요로하는 dependency , 환경변수들이 늘어난다면 우리는 매번 appspec.yml 의 정의한 이벤트 훅 스크립트에 내용을 추가해야한다.

위 과정에 대한 학습 비용이 필요 없다는 전제하에 현재 클라우드 르네상스 시대에 살고 있는 우리에게 1시간 이내면 끝난다.

하지만 당신이 serverless architecture 를 지향한다면, 이제 위 과정은 생략해도 좋다.

당신이 코드에만 집중할 수 있도록 Lambda, API Gateway, CloudFormation 등AWS 리소스들 이 serverless architecture 을 구현할 수 있도록 도움을 줄 것 이다. (Lambda, API Gateway, CloudFormation에 대한 간략한 설명은 밑에 기재해두었다.)

위 리소스들을 쉽고 간편하게 구성하고자 한다면 프레임워크를 사용하자. 우리는 서버리스 프레임워크들 중 Serverless 프레임워크를 채택하여 개발했다. (Serverless 프레임워크 이름이 Serverless 다..)

이외 프레임워크들은 Chalice, Apex 등도 존재한다.
플링크가 Serverless 프레임워크를 선택한 이유는 잘 구성된 문서와 활발한 커뮤니티 그리고 Typescript 지원이었다. (플링크의 모든 제품들은 대부분 Typescript 를 베이스로 개발되고있다.)

그렇다면 어떻게 기존의 배포 방식과는 다르게 배포할 수 있다는말인가 ?
그냥 코드를 작성하고, 아래 명령어 한 줄을 통해 배포하면된다.

serverless deploy

(신입 개발자 우모씨가 배포 방법에 대한 문서를 읽고 있다.)

거 좀 편해 보이네, 좀 더 자세히 말해봐.

위 비교글만 보면, 아주 간편하고 강력한 도구가 아닐 수 없다.

우리는 그저코드를 작성하고, 배포 명령어만 질의하면 뚝딱 높은 가용성과 확장성을 보장하는 서버 앱을 만들 수 있다.

그럼, 아주 간단한 앱을 만들어볼까?

본래 Pagecall Console 앱에 사용된 코드를 인용하고자했는데, 오히려 이해하는데 방해요소가 될까봐 배제했다.
또한 IAM 설정이나, Credentials 설정내용도 모두 배제했다.(이러한 설명은 오히려 공식 문서를 참고하는게 더 정확하다.)

대신 특정 리소스를 요청하면 “Hello i’m hax0r” 이라는 텍스트를 응답하는 간단한 함수를 만들어보자. 단 간단한 함수 또한 Typescript 로 작성할거다.

serverless cli 가 설치되어있고 cli를 통해 앱을 만들었다는가정하에, 추가적으로 “serverless-plugin-typescript”을 추가한다.

그리고 아래 sample tsconfig.json 을 추가한다.

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "lib": [ "es6", "dom" ],
    "moduleResolution": "node",
    "rootDir": "./",
    "sourceMap": true,
    "allowJs": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noImplicitThis": true,
    "strictNullChecks": true,
    "noImplicitReturns": true,
    "preserveConstEnums": true,
    "suppressImplicitAnyIndexErrors": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": [ "node_modules", "build", "webpack" ],
  "types": [ "typePatches" ]
}

이후, handler.js 를 handler.ts 로 치환하고 아래와 같이 간단한 코드를 작성할 수 있다.

import { Handler, Context, Callback } from 'aws-lambda';

interface HelloResponse {
  statusCode: number;
  body: string;
}

const hello: Handler = (event: any, context: Context, callback: Callback) => {
  const response: HelloResponse = {
    statusCode: 200,
    body: JSON.stringify({
      message: "Hello i'm hax0r"
    })
  };

  callback(undefined, response);
};

export { hello }

그리고 serverless.yml 에 아래와 같이 핸들러를 추가한다.

package:
  exclude:
    - node_modules/**/*
  include:
    handler.ts

이제 마지막으로 아래와 같이 End point 만 추가하면된다.
“/hello” 라는 경로로 요청 시, 우리가 기술한 hello 메소드가 질의될 것이다.

functions:
  hello:
    handler: handler.hello

    events:
      - http:
          path: hello
          method: get

그리고 아래 명령어를 통해 배포하면 APIGateway 를 통해 할당된 경로를 터미널 상에 출력할 것이다.

serverless deploy

어떤가 정말 너무 간단하지 않은가 ?

해당 프레임워크는 serverless.yml 설정 파일을 통해 함수 및 메모리크기 그리고 Route parameter 등의 여러 속성들은 제공하여 쉽고 간편하게 구성할 수 있다.

아래는 이 문서에서 담고 있는 용어들이 낯선 분들을 위해 간략한 설명을 적어 놓았다.

Serverless architecture 란 ?

서버를 따로 띄우지 않고, 코드를 업로드 하여 기존 서버에서 문제점이였던 관리, 스케일링과 같은 이슈를 해결해준다. 따라서 개발자는 코드 본연의 집중할 수 있어 양질의 코드를 생산할 수 있다.

Lambda 란?

Lambda는 AWS 에서 제공하는 FaaS(Function as a Service) 서비스 이다.
이를 통해 AWS 에서 Serverless architecture 를 구성할 수 있다. 현재까지의 FaaS 서비스 중, AWS 에서 제공하는 Lambda 가 독보적으로 인기가 많다.
Lambda 의 장점은 Serverless, Scaling, Demand-based Pricing 이다.
요금과 관련해서는 아래에 좀 더 자세히 다루겠다. 이외 다른 컴퓨팅 자원과 유연하게 연동할 수 있고 여러 트리거들을 제공한다는 이점이있다.

API Gateway 란?

API Gateway 는 Router 쯤으로 여기면 이해가 쉽다.
Endpoint를 단일화하고 관리를 맡김으로써 사용자는 핵심 비즈니스 구현에 집중할 수 있도록 도와주는 역할을 한다.
그리고 다른 컴퓨팅 서비스들과 유연하게 연동할 수 있다. (e.g. Lambda, Beanstalk) 결국에 Lambda는 독립적으로 사용되기 보다는 RETSful API를 구성을 위해 사용되는 경우가 일반적이다.

출처: https://docs.aws.amazon.com/ko_kr/apigateway/latest/developerguide/welcome.html

CloudFormation 이란 ?

CloudFormation은 인프라 형상 관리를 제공한다.
형상관리는 별도의 템플릿을 통해 이루어지며 공식으로 제공하는 템플릿이나 개발자가 작성한 템플릿을 이용해 리소스들을 쉽게 관리하는 방법을 제공한다. 템플릿에는 리소스의 생성과 관련된 설정은 물론 기타 관련 종속성 또는 런타임 매개변수에 관해 기술할 수 있으며 AWS 서비스 프로비저닝 순서나 이러한 종속성이 적용될 수 있도록 거쳐야 하는 세부 절차의 순서를 파악할 필요가 없다.

단점은 없어?

Warm start and cold start

Lambda 의 상태는 “Warm start” 와 “Cold start” 로 구분할 수 있다.
단어가 뜻하는바가 명확하기에 아마 짐작했을거라 생각한다.

이 상태에 따라 퍼포먼스 이슈가 존재한다.

아래 머신건으로 비유하면 이해가 편할 것 같다.
머신건은 발사전 천천히 회전을하고 이후 엄청난 추진력을 받아 총알을 내뿜는다. 발사를 잠시 멈춘다하더라도 회전이 멈추지 않은 상태, 예열된 상태라면 머신건은 다시 빠르게 총알을 내뱉는다.

Lambda 도 똑같다.
람다는 5분동안 요청이 없으면 Cold start 상태로 변한다.
이 경우 초기에 지연시간이 발생하고 Warm start 상태가 되면 다시 빠르게 응답한다.

이는 Heroku 인스턴스 성질과 비슷하다.
만약 이러한 지연시간에 민감하다면 아래와 같은 방법으로 해결 할 수 있다.

  • ColudWatch를 통해 Lambda를 매 5분 한번씩 지속적으로 호출 한다.

  • Route 53에서 Health Check를 Lambda 함수 API Gateway Endpoint 에 설정해둔다.

  • 별도의 프로그램을 작성하여 매 5분마다 요청하다.

플링크팀은 두 번째 방법을 통해 해당 이슈를 해결했다. (월 마다 프리티어가 존재하여 현재까지는 비용이 발생하지 않지 않는다.)

무거운 작업

Lambda 는 정의한 자원으로 최대 15분까지 사용할 수 있다.
결국에 15분이 초과되는 작업들은 Lambda 로 수행할 수 없다.
한마디로 단시간의 많은 자원을 필요로 하는 작업은 Lambda 와 맞지 않는다.

이럴때 해결 방법은 ECS Fargate를 사용하는 방법이다.
ECS Fargate는 단 시간 많은 자원을 필요로 하는 작업에 적합하다. ECS Fargat e 사용자가 만든 도커이미지를 별도의 설정없이 실행하고 실행된 시간만큼만 금액을 지불한다.

또 한가지의 해결 방안은 기다림이다.
몇달 전까지만 해도 Lambda 에 최대 사용시간은 5분이었다.
게속해서 발전해나아가고있다.
사실 단정 지을 수는 없으나 전의 지표들을 볼 때, 지속적으로 최대 사용시간이 증가했다.

그.. 그거 비싸지?

람다는 공식문서에서 “사용한 시간에 대해서만 비용 지불" 강조한다.

EC2의 경우에는 현재 “인스턴스 수 x 인스턴스 타입의 비용 x 서버가 운영중인 기간 (1시간 단위)”으로 과금이 이루어 지는 반면, Lambda 의 경우 과금은 요청 요금과 컴퓨팅 요금의 합으로 계산된다.

EC2 와 비교했을 때, EC2 는 이벤트가 있으나 없으나 모든 시간에 대한 요금을 부과하지만 Lambda 는 코드가 실행되지 않을 때는 요금이 부과되지 않는다.
정말 사용한 리소스에 대한 값만 요구한다.

여기서 요청 요금은 Lambda 함수를 질의한 수에 대해 요금을 부과하고, 컴퓨팅 요금은 이 함수의 소요시간에 대해 부과한다. 컴퓨팅 요금은 사용자가 설정한 메모리 크기에 선형 비례하여 다르게 부과된다.

메모리 크기는 최소 128MB 최대 3008MB 까지 지원한다.
메모리 크기가 높을 수록 더 큰 CPU 파워를 제공한다.

아래는 메모리 크기별 요금표이다.

128MB 메모리에서는 100ms당 0.000000208이고256MB128MB의약두배인0.000000417이고 256MB는 128MB의 약 두 배인 0.000000417이다. 그리고 512MB에서는 256MB의 두 배인 0.000000834$이다.

이 처럼 Lambda는 함수가 수행된 시간을 100ms 단위로 올림하여 과금한다.
API Gateway의 경우 100만 요청 당 3.5 USD가 과금된다.

상황에 따라, Lambda 와 API Gateway 를 통해 엄청난 비용을 세이브할 수 있다. PageCall Console 은 제품 특성상 시간대별로 이벤트가 잦지 않기 때문에 이 구성이 안성맞춤인 것 같다 라는 생각을 한다.

삽질한거 있으면 공유 좀.

CloudFormation’s 200 Resource Limit

Pagecall Console 에는 40개 정도의 함수가 존재하는데, 그 중 Route path 가 가장 긴 함수로 인해 배포과정에서 오류를 마주쳤다. CloudFormation 은 스택 당 리소스를 200으로 제한하기 때문에 마주친 오류다.

The CloudFormation template is invalid: Template format error: Number of resources, 201, is greater than maximum allowed, 200

serverless-plugin-split-stacks 이라는 플러그인을 통해 Nested Stacks 을 사용하여 스택 계층을 만들 수 있다.

Timeout

질의한 요청이 절대 무거운 요청이 아닌데, Timeout 되어 의아했다.
Stackoverflow 에 검색해보니 꽤나 많이 걸리는 트랩인 것 같다.
context callbackWaitsForEmptyEventLoop 속성에 false 를 주는 것으로 문제를 해결했다. 람다에서의 콜백은 기본적으로는 이벤트 루프 안의 모든 작업이 비워질때까지 호출되지 않는다.

아래는 Stackoverflow 답변을 발췌했다.

By default calling the callback() function in a NodeJS Lambda function does not end the function execution. It will continue running until the event loop is empty. A common issue with NodeJS Lambda functions continuing to run after callback is called occurs when you are holding on to open database connections. You haven’t posted any code, so I can’t give specific recommendations, but you would need to determine if you are leaving database connections open in your code or something similar.

Alternatively, you can change the behavior such that the execution ends as soon as the callback}}function is called by

답변 출처: https://stackoverflow.com/questions/46061612/why-does-my-lambda-function-time-out-even-though-the-api-gateway-callback-has-al

마치며

모노리스의 단점이 곧 서버리스에 장점이었다.
서버리스를 도입하면서 가장 좋았던 것은 역시 새로운 것을 배우고 맛보는 것도 있겠지만, 정말 순수하게 Business Logic 에만 집중할 수 있는 시간이 생겨서 좋았다.

서버리스는 현재 태동기를 지나 성장기에 접어들었다 생각한다.
우리가 지향하는 미래를 볼 때, 앞으로의 눈부신 성장이 예상된다.
서버리스 도입을 고려하는 팀이라면, 이 문서가 조금이나마 도움이 되었길 바란다.

다음 시간에는 모니터링 시스템과 테스트에 대해 얘기를 해보고자한다.

참고

profile
루키

3개의 댓글

comment-user-thumbnail
2019년 3월 8일

serverless.js에서도 발생하는 현상일지 모르겠는데, Python에선 WSGI 어플리케이션을 lambda에 배포할 수 있도록 돕는 zappa라는 것이 있어서 조심해야 할만한 부분들을 조금 첨언하자면,

  1. IAM Role을 지 입맛대로 수정해버립니다. 특히 배포 할때마다 trust relationship 바꿔버리는 것 때문에, 거기에 물려 있던 ECS 클러스터가 AssumeRole 실행 못하고 에러 뿜어버리는 경우가 있었습니다.
  2. 5분마다 생기는 cold start는 keep warm해주면 되는 건데, 배포 직후의 cold start 현상은 zappa 단에서 해결할 수 없었습니다. 이를 해결하기 위한 방법 중 하나는, lambda function에 alias를 만들어 두고, API Gateway에서는 alias된 함수의 arn을 가르키도록 설정해둔 상태에서, 배포 파이프라인을 다음과 같이 변경하면 됩니다 : 배포(UpdateFunctionCode) -> warm해지도록 함수 호출(lambda invoke) -> API Gateway에 물려 뒀던 alias가 가르키는 버전 변경 serverless.js는 API Gateway가 가르킬 function arn을 직접 설정할 수 있을런지 모르겠지만, zappa는 그렇지가 않아서..
  3. API Gateway는 response body size가 5MB를 넘기면 500번대 에러를 뿜습니다. response size가 크다면 gzip을 사용하는 것이 이로울 것 같습니다.
  4. 코드 실행의 context가 모두 실행될 것을 보장받지 못합니다. Flask의 경우 요청부터 응답 후까지의 context를 모두 라이브러리 단에서 helper화 시켜 두었는데, '응답 후'에 대한 콜백이 lambda에선 실행되지 않더라구요. Elastic APM의 Flask extension이 그걸 쓰는데, 메트릭이 제대로 넘어가지 않아서 삽질했던 기억이 있습니다.
1개의 답글