Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.

LambCI의 Docker 이미지로 AWS Lambda 함수 로컬에서 테스트하기

AWS Lambda를 사용할 때 불편한 점은 Lambda에 배포해야 테스트를 해볼 수 있다는 부분이다. 그동안 Lambda를 쓰면서 느낀 경험은 최대한 로직은 유닛테스트로 기능을 확인하고 Lambda의 핸들러에서는 간단히 이 로직은 호출만 하는 정도로 작성하면 로컬에서 테스트를 해보면서 코드를 작성할 수 있다. 코드를 수정할 때마다 Lambda에 배포하는 것은 매우 불편한 일이므로 로컬에서 테스트할 수 있으면 시간을 많이 줄일 수 있다.

Node.js이든 Python이든 간단한 코드면 유닛테스트로 충분하지만 좀 더 복잡하게 사용할 일이 있다면 Lambda에 배포를 해야 하므로 매우 귀찮을 일이 된다. 특히 얼마 전에 사용해 본 Headless Chrome을 Lambda에서 사용하는 작업은 Lambda 환경의 제약을 확인해 봐야 하므로 잦은 배포를 해야 해서 아주 귀찮을 일이었다. 특히 Lambda 함수의 용량이 크다면 배포할 때마다 시간도 걸리기 때문에 더욱 작업이 번거로운 일이 된다.

lambci/docker-lambda

LambCI는 Lambda에서 CI를 할 수 있게 하는 서버리스 솔루션인데 여기서 Lambda 환경을 거의 같게 만든 Docker를 제공하고 있다. 내가 작업한 것은 아니지만 LambCI는 nodejs-ko에서 배포용으로 쓰고 있어서 알고는 있었지만 docker-lambda는 모르고 있다가 얼마 전에 AWS의 Lambda를 로컬에서 테스트해볼 수 있게 하는 SAM Local을 보다가 여기서 로컬 테스트 용도로 LambCI의 docker-lambda 이미지를 사용한다는 것을 알게 되어 Docker만 사용해보았다.(sam-local의 경우 CloudFormation기반이라서 테스트는 해보지 않았다.)

테스트 목적으로 docker-lambda를 사용해봤는데 100% Lambda와 같지는 않아도 거의 비슷하게 환경이 구성되어 있으므로 로컬에서 쉽게 테스트해볼 수 있다. 사용해본 느낌으로는 몇 가지 차이점만 실제 AWS Lambda에 배포해서 테스트해보고 나머지는 docker-lambda에서 동작 여부를 확인해 보는 정도로 충분해서 아주 편했다. AWS Lambda에서 안 되는 경우 docker-lambda에서도 안 되는지 확인하고 docker-lambda에서 동작하도록 코드를 수정한 뒤에 AWS Lambda에 배포하는 식으로 작업하니까 시간을 꽤 줄일 수 있었다. 물론 docker-lambda에서는 동작하지만 다른 환경 문제고 AWS Lambda에서는 안 되는 경우가 없는 것은 아니다.

docker-lambda로 Lambda 함수 실행하기

Lambda 함수를 LambCI의 Docker 이미지로 테스트해볼 수 있는데 Docker Hub에 환경별로 올려져 있다. nodejs4.3, 'nodejs', 'nodejs6.10', 'python2.7', 'python3.6', 'java8' 등 Lambda에서 지원하는 환경이 모두 Docker 이미지로 존재한다.

Node.js를 사용한다고 할 때 다음과 같은 index.js가 있다고 해보자.

// index.js
console.log('starting function');
exports.handler = (event, context, callback) => {
  console.log('event:', event);
  context.succeed('hello world');
};

여기서는 코드의 내용이 중요하진 않으므로 간단한 Lambda 코드만 사용했다. 터미널에서 Docker 이미지로 다음과 같이 Lambda 함수를 실행할 수 있다.

$ docker run -v "$PWD":/var/task lambci/lambda:nodejs6.10
START RequestId: 543c5021-013e-17b4-ffea-e179e78c5b7b Version: $LATEST
2017-08-27T16:35:19.970Z  543c5021-013e-17b4-ffea-e179e78c5b7b  starting function
2017-08-27T16:35:19.972Z  543c5021-013e-17b4-ffea-e179e78c5b7b  event: {}
END RequestId: 543c5021-013e-17b4-ffea-e179e78c5b7b
REPORT RequestId: 543c5021-013e-17b4-ffea-e179e78c5b7b  Duration: 7.65 ms Billed Duration: 100 ms Memory Size: 1536 MB  Max Memory Used: 28 MB

"hello world"

위에서 보듯이 현재 폴더에 있는 Lambda 함수를 nodejs6.10 환경에서 실행해 볼 수 있고 실제 AWS Lambda에서 볼 수 있는 로그처럼 실행 결과까지 모두 볼 수 있다. Node.js 6.10 환경이 아니라면 다른 이미지를 사용하면 되고 -v "$PWD":/var/task에서 볼 수 있듯이 현재 폴더를 Docker 내에서 실행하게 된다.

// dist.js
console.log('starting function');
exports.entry = (event, context, callback) => {
  console.log('event:', event);
  context.succeed('hello world');
};

위처럼 기본값인 index.jshandler 함수를 사용하지 않고 dist.js 파일에 entry라는 함수를 진입점으로 쓰고 싶다면 Docker 명령어 마지막에 dist.entry처럼 핸들러를 지정해 주면 된다.(AWS Lambda의 설정에서도 handler를 지정할 수 있다.)

docker run -v "$PWD":/var/task lambci/lambda:nodejs6.10 dist.entry
START RequestId: d5f50084-0ea8-1530-8183-dc2fce92b7b4 Version: $LATEST
2017-08-27T16:45:18.067Z  d5f50084-0ea8-1530-8183-dc2fce92b7b4  starting function
2017-08-27T16:45:18.069Z  d5f50084-0ea8-1530-8183-dc2fce92b7b4  event: {}
END RequestId: d5f50084-0ea8-1530-8183-dc2fce92b7b4
REPORT RequestId: d5f50084-0ea8-1530-8183-dc2fce92b7b4  Duration: 8.79 ms Billed Duration: 100 ms Memory Size: 1536 MB  Max Memory Used: 28 MB

"hello world"

지금까지는 입력 이벤트가 없이 실행했는데 입력 이벤트가 있는 경우에는 docker 명령어 마지막에 JSON을 전달하면 된다.

$ docker run -v "$PWD":/var/task lambci/lambda:nodejs6.10 dist.entry '{ "name": "outsider" }'
START RequestId: b86d5ed1-293d-1be5-9fc6-fb87968e741c Version: $LATEST
2017-08-27T16:48:21.068Z  b86d5ed1-293d-1be5-9fc6-fb87968e741c  starting function
2017-08-27T16:48:21.070Z  b86d5ed1-293d-1be5-9fc6-fb87968e741c  event: { name: 'outsider' }
END RequestId: b86d5ed1-293d-1be5-9fc6-fb87968e741c
REPORT RequestId: b86d5ed1-293d-1be5-9fc6-fb87968e741c  Duration: 11.63 ms  Billed Duration: 100 ms Memory Size: 1536 MB  Max Memory Used: 28 MB

"hello world"

위 로그에서 보듯이 전달한 JSON이 출력된 것을 볼 수 있다.

Headless Chrome을 사용하면서 꽤 많은 테스트를 해보았을 때 Lambda 환경이 거의 그대로 구현되어 있어서 테스트를 상당히 편리하게 도와준다. 그리고 sam-local에서도 채택한 걸 보면 LambCI에서 제공하는 Docker 이미지가 신뢰할 수 있는 퀄리티를 제공하고 있다고 생각된다. AWS에서 직접 공식 이미지를 제공해 주면 아주 좋겠지만....

2017/08/28 01:54 2017/08/28 01:54

Headless Chrome으로 AWS Lambda에서 웹사이트 스크린샷 찍기 #2

이 글은 Headless Chrome으로 AWS Lambda에서 웹사이트 스크린샷 찍기 #1에서 이어진 글이다.



AWS Lambda 함수 작성

AWS Lambda에서는 시작 파일이 필요하므로 index.js를 만들어야 한다.

// index.js
exports.handler = (event, context, callback) => {
  context.succeed('Hello World');
};

Lambda에서 exports.handler를 실행하므로 이 함수가 시작점이 된다. 앞에서 작성한 모듈을 사용해서 스크린샷을 찍어서 S3에 저장하도록 코드를 수정했다.

// index.js
const { connectChrome } = require('./chrome');
const { uploadFile } = require('./s3');

exports.handler = (event, context, callback) => {
  connectChrome()
    .then(uploadFile)
    .then(context.succeed)
    .catch(context.fail);
};

이제 Lambda 함수를 다 작성했다. 로컬에서 실행할 때는 데스크톱에 설치된 크롬을 사용하지만, Lambda 환경에는 Chrome이 없으므로 headless_shell을 포함해야 한다. 크롬에서 빌드한 headless_shellGitHub에 올려두었으므로 이 파일을 다운받아서 저장한 뒤 같이 업로드하면 된다.

├── chrome.js
├── headless_shell
├── index.js
├── node_modules
└── s3.js

그래서 파일 구조를 보면 위와 같은 형태가 된다.

$ zip -r function.zip .

Lambda에 올리려면 함수를 zip으로 압축해야 하므로 현재 폴더를 function.zip으로 압축했다. headless_shell이 130MB인데 function.zip로 압축하면 44MB가 된다.

여기서 Lambda에 올릴 수 있는 압축파일의 최대 크기는 50MB이므로(실제로는 좀 더 올라가지만...) 44MB는 문제없이 headless_shell를 사용할 수 있다. 다만 여기서는 예제코드라서 간단하지만 npm 모듈을 더 추가하다 보면 금방 50MB를 넘어설 수 있다. 예제는 최대한 간단하게 작성하지만, 실제 내가 Lambda를 쓸 때는 Apex를 사용하므로 배포할 때 hook을 지정해서 webpack으로 필요한 코드만 배포되도록 사용하고 있다. 이렇게 하면 50MB가 넘어갈 일은 없어 보인다.

AWS Lambda 함수 배포

배포 준비도 끝났으니 AWS Lambda에 올려서 실행해 보자.

AWS Lambda 생성

AWS Lambda에서 새로운 함수를 만들어서 zip 파일로 앞에서 만든 function.zip을 업로드한다. 이때 Lambda 내에서 headless_shell을 사용할 수 있도록 앞에서 설명한 대로 CHROME_PATH 환경변수로 /var/task/headless_shell로 지정한다. 업로드한 코드는 모두 /var/task 밑으로 올라간다. 메모리 설정은 256MB로 지정하고 타임아웃은 60초로 지정했다. 예시에서는 스크린샷을 1장만 찍지만 여러 장을 찍는다면 사용 메모리는 점점 올라가지만 1장만 찍는다고 하더라도 128MB로는 부족하다.

업로드한 Lambda 함수 실행 결과

AWS Lambda에서 제공하는 테스트 기능으로 Lambda를 실행한 결과이다. 성공적으로 실행이 되었고 11초가 걸렸고 153MB의 메모리를 사용했다. 여기서는 Lambda를 실행하는 IAM Role과 S3 버킷에 대한 접근 권한에 대한 설명은 생략했다. S3 버킷에 파일을 제대로 올릴 수 있도록 권한을 주어야 한다.

Lambda에서 생성한 깃헙 스크린샷

위 파일은 Lambda로 찍은 GiHub.com의 스크린샷이다. 지정한 대로 정상적으로 찍은 것을 알 수 있고 위 예시를 수정하면 Lambda에서 원하는 웹사이트의 스크린샷을 자동으로 찍어서 저장하게 할 수 있다.

CJK 폰트 문제

여기까지 테스트를 했을 때 이제 다 작성한 줄 알았지만 실제로 사용하려고 하니까 그렇지 않았다.

이 블로그의 스크린샷에서 한글이 깨진 화면

위는 Lambda로 이 블로그의 스크린샷을 찍은 화면인데 보다시피 한글이 제대로 나오지 않는다. 브라우저는 OS에 설치된 폰트를 사용하는데 Lambda에는 한글 폰트가 설치되어 있지 않으므로 당연히 한글이 제대로 출력되지 않는다. 내 블로그는 한글이 문제가 된 것이지만 폰트 문제에서 보통 그렇듯이 중국어, 일본어에서도 당연히 같은 문제가 발생할 것이다.

결국, Lambda에서 실행하는 headless Chrome이 CJK 폰트를 사용할 수 있게 설정해 주어야 한다. 이런 문제는 다른 headless chrome 프로젝트에서도 이슈가 되고 있는데 일단 나는 Google Noto 폰트를 사용하기로 했다.

Noto CJK에서 CJK 폰트를 제공하므로 이 중에서 한글 폰트인 NotoSansKR-[weight].otf를 다운로드 받았다. 이 안에는 많은 weight가 포함되어 있는데 NotoSansKR-Regular.otf를 여기서 사용한다. 다른 Weight나 CJK 폰트가 다 필요하다면 다른 파일을 사용해야 한다.

구글의 폰트 관련 문서를 보면 리눅스에서는 HOME 디렉터리 아래 .fonts 디렉터리를 만들고 그 아래 폰트 파일을 두면 크롬이 자동으로 폰트를 로딩해서 사용한다. Lambda에서도 이 방법을 사용해야 하는데 업로드하는 파일에 넣으면 /var/task 아래 만들고 여기를 HOME으로 지정해야 하는데 다른 문서에도 이렇게 나와 있긴 하지만 권한 문제 때문인지(/var/task는 읽기 전용이다.) 제대로 되지 않았다. 그래서 /tmpHOME으로 지정하고 Lambda가 실행될 때 S3에 올려둔 폰트를 다운로드 받도록 했다. 한글 폰트 하나는 4.5MB 정도이지만 많은 weight가 필요하거나 CJK 폰트를 모두 쓰려면 NotoSansCJK-Regular.ttc가 18.7MB이므로 zip 파일의 용량이 50MB가 넘어서 어차피 올릴 수가 없다.

// s3.js
const AWS = require('aws-sdk');
const fs = require('fs');

const s3 = new AWS.S3({ apiVersion: '2006-03-01' });

module.exports = {
  uploadFile: (buffer) => {
    // 생략
  },
  downloadFont: () => {
    return new Promise((resolve, reject) => {
      return s3.getObject({
        Bucket: 'outsider-demo',
        Key: 'NotoSansKR-Regular.otf'
      }, (err, data) => {
        if (err) { return reject(err); }

        fs.mkdir('/tmp/.fonts', (err) => {
          if (err) { return reject(err); }
          fs.writeFile('/tmp/.fonts/NotoSansKR-Regular.otf', data.Body, (err) => {
            if (err) { return reject(err); }
            resolve();
          });
        });
      });
    });
  },
};

S3 버킷에서 NotoSansKR-Regular.otf 폰트를 다운받는 downloadFont() 함수를 추가했다. 폰트는 미리 S3에 올려놨고 이 파일을 다운로드 받아서 /tmp/.fonts/에 저장한다.

// index.js
const { connectChrome } = require('./chrome');
const { uploadFile, downloadFont } = require('./s3');

exports.handler = (event, context, callback) => {
  downloadFont()
    .then(connectChrome)
    .then(uploadFile)
    .then(context.succeed)
    .catch(context.fail);
};

index.js에서 크롬을 실행하기 전에 폰트를 먼저 다운로드 받도록 했다.

환경변수를 변경해서 새 Lambda 함수의 배포 화면

압축한 function.zip을 다시 업로드 하고 HOME/tmp로 지정했다. 새로 업로드한 Lambda 함수를 다시 실행하면 다음과 같이 한글이 제대로 나타나는 걸 볼 수 있다.

한글이 제대로 나온 이 블로그의 스크린샷



2017/08/26 23:09 2017/08/26 23:09