이번 주제는 배포 자동화다. 원래 CI(Continuous Integration)CD(Continuous Deployment)같은 것들을 이야기해보려 했으나, 배포 자동화라는 용어가 덜 추상적이고 더 명시적이라 용어 선택을 선회했다.

도입 이유

배포 자동화

배포라는 과정은 불필요한 반복 작업이다. 우리가 만들고 있는 웹 어플리케이션의 경우, 기능 추가나 버그 패치 등 어떤 단일 작업 하나를 완료한 후 → 테스트를 돌리고 → 문제가 없다면 배포한다. 우리의 상황에 맞추자면,

  1. 게시글 작성 API를 작업하고
  2. 이에 대응되는 테스트를 작성한 후
  3. 테스트를 돌리고
  4. 테스트가 모두 성공하면 zappa update 커맨드를 실행해 새로운 버전의 코드를 배포한다.

1, 2번은 분명히 개발자의 작업이 필요한 게 확실한데, 3, 4번에 해당하는 배포 과정은 자동화시킬 여지가 있다. 그 이유는,

  • '작업의 완료''master 브랜치에 대한 push'로 판단할 수 있다. 어떤 방법으로든 push 이벤트를 다른 곳에 알려주는 기능을 GitHub이 지원하기만 한다면, 코딩 후의 작업을 시스템적으로 처리할 수 있게 된다.
  • 테스트를 돌리고, 문제가 없는지를 판단하고, 배포하는 것은 결국 명령어의 집합일 뿐이다. 명령어를 대신 실행해주는 뭔가가 있다면, 필요한 명령어를 정리해 두기만 하면 된다.

그럼 명령어를 대신 실행해주는 뭔가가 있다고 치고 push가 일어나면 알아서 명령어를 실행하도록 설정해 두었다고 하면, 작업-배포 흐름이 아래처럼 바뀔 것이다.

  1. 게시글 작성 API를 작업하고
  2. 이에 대응되는 테스트를 작성한 후
  3. master 브랜치에 push하면 테스트랑 배포를 누군가가 대신 해준다.

이에 대한 흐름을 시스템적으로 조금 더 이야기하자면,

  • master 브랜치에 대한 push같이, GitHub 내에서 특정 이벤트가 일어났을 때 특정 URL로 HTTP 요청을 하게 만드는 webhook이라는 기능이 있다. 예를 들면, push가 일어났을 때 GitHub가 자동으로 http://mingyu.io/hook에 POST 요청을 하도록 설정할 수 있다.
  • 배포 자동화 서비스에게 이런 hook URL을 받아서 GitHub 저장소에 push에 대한 webhook을 추가하면, push가 발생할 때마다 대상 hook URL로 HTTP 요청을 보낼 것이다.(서비스에 따라 webhook을 자동으로 추가해주는 경우도 있다.)
  • webhook에 의해 HTTP 요청을 받은 배포 자동화 서비스는 'push가 일어났구나' 하며 저장소를 clone받는다.
  • 배포 자동화 서비스는 우리가 준비해 둔 빌드 스크립트(.travis.yml, circle.yml, buildspec.yml 등)에 따라 명령어들을 실행한다. 빌드 스크립트는 대부분 yml 파일로 작성하고, 동일한 동작을 명시하더라도 그 내용은 서비스마다 조금씩 다르다. 아래는 Travis-CI의 빌드 스크립트인 .travis.yml의 예다.
os: linux
sudo: required
language: python
cache: pip3

python:
  - "3.6"

install:
- pipenv install
- pipenv shell

script:
- python -m "nose" tests/

after_success:
- zappa update

notifications:
  email: false

따라서 이번엔 배포 자동화를 위한 서비스를 선택해 세팅하고, zappa update배포 자동화 시스템에 의해 실행되도록 만들어 보자.

의사결정

배포 자동화 서비스

배경과 요구사항

  • private repository에 대해서 사용하는 데에 비용적인 문제가 크게 없어야 한다.
  • Python 런타임이 제공되어야 한다.
  • GitHub과 잘 연동되어야 한다.
  • AWS와 잘 접합되면 좋다.
  • 개발 조직이 Travis-CI를 써본 경험이 많이 있다.

선택지

  • Travis-CI
  • CircleCI
  • Amazon CodeBuild와 CodeDeploy를 함께 사용
  • Amazon CodeBuild만 사용

의사결정

Amazon CodeBuild만 사용하는 것을 선택하겠다. 그 이유는,

  • 개발 조직이 Travis-CI를 많이 써 봤지만, CircleCI나 Travis-CI나 private repository에 붙이려면 유료 플랜을 결제해야 하며 가격이 만만치 않다.
  • 관리 포인트가 여기저기에 분산되어 있으면 관리하기 불편하다. 되도록 AWS 내에서 모두 해결할 수 있도록 하기 위해서다.
  • CodeBuild는 private repository도 무료로 붙일 수 있게 되어 있다. 단지 빌드를 실행한 시간만큼만 지불하면 되는데, 월 100분까지는 무료로 제공되며 이를 넘더라도 과금이 그렇게 많지 않다. 메모리 3GB, 2개의 vCPU를 지원하는 build.general1.small 인스턴스 기준으로 하면 빌드 분당 0.005달러만 지불하면 된다.
  • CodeDeploy를 함께 사용하지 않는 이유는 바로 아래에서 더 설명한다.

CodeDeploy를 사용하지 않는 이유

zappa update라는 건 사실 우리의 웹 어플리케이션을 정말로 lambda에 새로 업데이트하는 배포의 과정이라, '코드 배포 자동화'라는 주제를 가진 CodeDeploy라는 서비스가 더 어울릴 것이라 생각할 수도 있다. 필자도 그렇게 생각했어서 검토를 위해 조금 찾아봤더니, CodeDeploy는 정말 '배포 자체'에 대해 많은 지원을 하고 있었다. 무슨 뜻이냐면,

  • 우리가 그냥 zappa update 커맨드 하나로 패키징부터 업데이트까지 다 끝내니까 배포라는 게 정말 쉬워 보일 수 있겠지만, 배포 과정을 손수 정의하게 된다면 단일 작업이 많이 쪼개져 있어서 그리 쉬운 일이 아니다. 패키징도 해야 하고, 배포가 끝나면 어플리케이션 reload(서버 껐다 켜기)도 해줘야 하고, 서버가 다중화되어 있다면 그들도 함께 배포를 진행해야 한다. CodeDeploy는 일차적으로 '배포를 정의'하는 일을 많이 도와준다.
  • 어플리케이션을 배포하는 동안 가동 중지 시간을 최소화시키는 것도 배포에 있어서 매우 중요한 일이다. 그래서 구 버전과 새로운 버전의 코드를 함께 올려둔 채 트래픽을 나눠 전송하고, 새 버전의 어플리케이션으로 보내는 트래픽의 비율을 점진적으로 증가시키는 Canary 배포나, 구 버전과 새로운 버전의 코드를 함께 올려둔 채 트래픽을 이동시키고 구 버전의 코드를 제거하는 Blue/Green 배포같은 걸 쓰곤 한다. 에러가 생기면 롤백도 하고. 이러한 배포 프로세스들도 손수 정의하기 어려운 일이라 CodeDeploy가 도와준다.

엄청 좋아 보이지만 우리가 CodeDeploy를 사용하지 않는 이유는, CodeDeploy를 써서 얻을 수 있는 메리트가 그리 많지 않을 것이라고 생각했기 때문이다. 위에서도 말했듯 그냥 zappa update 커맨드가 패키징부터 배포까지 알아서 다 해주니 배포 정의에 대해 도움을 받을 이유가 딱히 없다. 게다가 가동 중지 시간 최소화는 lambda 자체에서 알아서 잘 챙겨준다. 현재로선 그냥 명령어를 대신 실행해 줄 주체가 필요할 뿐이다.

작업

CodeBuild 세팅

새로운 소스 코드로 배포가 이루어지기 전엔 빌드라는 과정을 거친다. 그런데 사실 파이썬은 컴파일 언어도 아니고, 뭐 따로 패키징하고 그런 작업도 없기 때문에 빌드라는 단어는 별로 어울리지 않는 것 같다고 생각했는데, 그냥 '배포 전처리 과정'을 빌드라고 관례적으로 이야기하는 것 같다.

여기서는 CodeBuild 세팅 방법을 한국어 버전 console을 기준으로 설명한다.

빌드 프로젝트 생성

AWS 콘솔에서 CodeBuild에 들어가, 우측 상단의 '빌드 프로젝트 생성' 버튼을 눌러 내용을 채우자. 총 5개의 메뉴 - 프로젝트 구성 / 소스 / 환경 / Buildspec / 아티팩트에 대해 뭔가 설정하게 되어 있는데, 아래 것들만 채워주면 된다.

  • 프로젝트 구성 - 프로젝트 이름
  • 소스 - '소스 공급자'를 'GitHub'으로
  • 소스 - '리포지토리'를 '내 GitHub 계정의 리포지토리'로 선택하고, GitHub 계정을 연결
  • 소스 - 'GitHub 리포지토리'에서 만들어 두었던 저장소를 선택
  • 환경 - '운영 체제'를 'Ubuntu'로
  • 환경 - '런타임'을 'Python'으로
  • 환경 - '실행 시간 버전'을 'aws/codebuild/python:3.6.5'로

모두 완료했다면, '빌드 프로젝트 생성' 버튼을 눌러 생성을 완료하자.

buildspec.yml 추가

프로젝트 화면에서 '빌드 시작'을 누르고 다른거 건들 거 없이 아래로 내려가 '빌드 시작'을 한 번 더 눌러 보자. 빌드 화면에서 '단계 세부정보'를 보면, DOWNLOAD_SOURCE에서 실패가 일어난 것을 확인할 수 있다.

CodeBuild가 우리 대신 명령어를 실행해 줄텐데, 이게 정리된 파일인 buildspec.yml 파일이 없어서 생긴 문제다. 이 파일을 추가한 뒤, 다시 빌드를 시작하면 에러가 생기지 않을 것이다. 'Build Started'를 콘솔에 찍는 커맨드 하나만 들어 있는 buildspec.yml을 추가했다.

version: 0.1

phases:
  build:
    commands:
      - echo Build started!

buildspec.yml 추가 직후 스냅샷

빌드를 다시 시작하면, 문제 없이 잘 실행되는 것을 볼 수 있다.

webhook 추가

빌드가 필요할 때마다 손으로 '빌드 시작' 버튼을 클릭하는 것보단, push가 일어났을 때 CodeBuild가 자동으로 가져가서 buildspec.yml에 따라 빌드를 실행하게 만드는 게 더 이상적이다. CodeBuild 콘솔에서 방금 만들었던 빌드 프로젝트를 선택하고, 편집 - Source를 눌러 'Edit Source' 화면에 들어간 후 스크롤을 내리면 보이는 'Rebuild every time a code change is pushed to this repository'라는 이름의 체크박스를 클릭해 활성화하자. 그리고 'Update source' 버튼을 눌러 변경을 반영하자.

GitHub 저장소에 들어가, Settings - Webhooks 메뉴에 들어가면 webhook이 자동으로 추가된 것을 볼 수 있다.

capture004.PNG

zappa update 커맨드 추가하기

이제 push가 일어나면 CodeBuild가 코드를 가져가buildspec.yml에 따라 빌드를 실행하게 만들었다. 이제 그 buildspec.yml에 아래 3개의 커맨드를 추가하면 된다.

  • pipenv install
  • pipenv shell
  • zappa update

이들은 yml 파일의 build → commands 하위에 밀어넣는 것보다, 각각을 적절한 phase에 나누어 정의해두는 게 좋다.

phase

위에서 빌드를 실행한 후 '단계 세부정보'에서 보았듯, CodeBuild는 빌드를 여러 단계로 나누어서 실행한다. submitted - queued - provisioning - ... - completed 순서로 이루어지는데, 이는 빌드 커맨드를 '설치 단계', '빌드 직전 단계', '빌드 단계', '빌드 후 단계' 등으로 체계화시키기 위함도 있고, 빌드 단계별로 접근 가능한 데이터들을 몇가지 붙여주기 위함도 있다. post_build에서 '빌드 성공 여부'(정확히는 build phase의 exit code)를 판단하기 위해 접근할 수 있는 환경변수 CODEBUILD_BUILD_SUCCEEDING을 예로 들 수 있다.

적절한 phase에 나누어 buildspec.yml에 명령어 추가하기

pipenv installinstall phase에, pipenv shellpre_build phase에, zappa updatebuild phase에 추가했다.

version: 0.1

phases:
  install:
    commands:
      - pipenv install
  pre_build:
    commands:
      - pipenv shell
  build:
    commands:
      - zappa update

또한 배포가 잘 이루어지는지 알아보기 위해 GET /에서 내려주던 'Hello World'라는 문자열을 'This is version 2'로 변경했다. 코드 스냅샷

push 후 삽질

hook을 걸어 두었으니, push만 하고 기다렸을 때 CodeBuild가 hook에 의해 코드를 알아서 가져가서 잘 빌드하는지 지켜보자.

첫번째 실패

pipenv shell 명령어를 실행하다가 'Inappropriate ioctl for device'라는 에러 메시지와 함께 빌드에 실패했다. 구글링 해보니 Trouble running pipenv in CI라는 제목의 이슈를 찾을 수 있었고, 애초에 그냥 pipenv shell을 실행하지 말고, pipenv가 필요한 커맨드의 앞에 pipenv run을 붙이면 될 것 같았다. buildspec.yml을 아래와 같이 수정했고, 다시 push했다.

version: 0.1

phases:
  install:
    commands:
      - pipenv install
  build:
    commands:
      - pipenv run zappa update

pipenv shell을 제거한 직후의 스냅샷

두번째 실패

pipenv 관련된 이슈가 해결되어 zappa update 명령어도 실행 시작까지 잘 됐지만, CodeBuild 머신에선 aws configure에 의해 'default' profile이 설정된 적 없어서 또 빌드에 실패했다.

zappa가 AWS 관련 작업을 할 때 boto3라는 라이브러리를 사용하는데, 이 친구는 자격 증명을 위해 환경 변수도 확인한다. 더 많은 정보는 boto3 User Guide - Credentials를 확인하자. 아무튼 우린 환경 변수에 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY를 설정해 주면 되고, 이는 CodeBuild에서 빌드 프로젝트를 선택한 후 '편집' 드롭다운을 열고 '환경'에 들어가 '추가 구성'을 열면 설정할 수 있다.

'환경 업데이트' 버튼을 누르고, zappa가 config를 무시하고 바로 환경 변수를 참조할 수 있도록, 아래와 같이 zappa_settings.json에서 profile_name 부분을 지운 후 push하여 빌드를 유발하자.


{
    "dev": {
        "app_function": "run.app",
        "aws_region": "ap-northeast-2",
        "project_name": "blog-sampleapp", -> DELETE!
        "runtime": "python3.6",
        "s3_bucket": "blog-sampleapp-l9n8yyqk1"
    }
}

zappa_settings.json에서 profile_name을 제거한 직후의 스냅샷

세번째 실패

그러나 이번에도 빌드는 실패한다.

virtual environment가 활성화되어 있지 않다고 한다. 근데 사실 zappa가 virtual environment의 활성화 상태를 확인하는 건 환경 변수라서(코드), pre_build에서 VIRTUAL_ENV 환경변수를 설정해주면 될 것이다. 그 값은 virtual environment의 경로여야 하는데, 이는 pipenv --venv로 알아낼 수 있다.

version: 0.1

phases:
  install:
    commands:
      - pipenv install
  pre_build:
    commands:
      - export VIRTUAL_ENV=$(pipenv --venv)
  build:
    commands:
      - pipenv run zappa update

pre_build에 export 커맨드를 추가한 직후의 스냅샷

다섯번째 실패

빌드는 또 실패한다.

export 명령어가 안 먹히는 것 같아서 구글링을 좀 해봤더니, Environment variables not being set on AWS Codebuild라는 스택오버플로우 질문이 있었고, buildspec 버전을 0.2로 변경하면 잘 될 거라는 답변이 있어서 buildspec.yml을 한 번 더 변경해 봤다.

version: 0.2

phases:
  ...

buildspec 버전을 0.2로 변경한 직후의 스냅샷

성공!

드디어 성공했다. URL에 접속해 보면, 'This is version 2'라는 문자열이 반환되는 걸 볼 수 있다.

수많은 실패 끝에 드디어 배포 자동화 세팅을 성공시켰다. 사실 독자 입장에서는 여기까지 오는 데 별로 긴 시간이 걸리지 않았겠지만, 필자는 CodeBuild를 처음 써보는 입장이었어서 빌드 실패만 16번 만들고 4시간동안 계속 구글링하며 삽질했다. 그러나 배포 자동화는 세팅 삽질로 하루를 보내더라도 아깝지 않을 정도로 우리의 생산성에 많은 도움을 줄테니, 좋은 경험이라고 생각하고 넘어가고자 한다.

이제부턴 push가 일어날 때마다 빌드가 실행될 것이고, 빌드 한 번에 1분 30초 정도가 소요된다. 우리가 build.general1.small 인스턴스 타입을 쓰고 있으니, free tier로 제공되는 100분을 넘긴 후부턴 AWS CodeBuild 요금에 따라 push 한 번당 0.0075$(약 8~9원) 정도의 요금이 발생할 것이다.