Docker 로 Heroku 에 배포하기

며칠 전 5월 5일, 유명 PaaS 인 Heroku 가 Docker 로 배포하는 도구를 발표했습니다 (heroku). 요즘 회사에서 사내 개발자들을 위한 PaaS (Heroku 또는 AWS Beanstalk 와 비슷한) 를 만들고 있고, Docker 에도 관심이 많아서 한 번 살펴봤습니다. 제가 안 하면 아무도 안 해 줄 것 같기도 했습니다.
살펴본 결론은 이렇습니다. 우선 아무 Docker 이미지나 Heroku 에 올려서 돌릴 수 있는 것은 아닙니다. Heroku 가 자동으로 만들어 주는 Dockerfile 을 사용하거나, FROM heroku/cedar:14 이미지를 사용해야 Heroku에 올릴 수 있습니다. /app 아래에 파일들이 들어가야 한다는 조건도 있습니다. 그리고 ONBUILD 로 코드를 이미지에 포함시키기 때문에, 소스코드를 수정하면 heroku docker:start 를 다시 해 주어야 합니다. 개발용으로 volume mount 를 하는 등의 다른 방법이 분명 있을 것입니다.
장점은 (제 생각에) 앱에 있는 바이너리 의존성, 예를 들어 imagemagick 같은 것을 buildpack 방식에서는 복잡하게 해결해야 했는데, Docker 방식에서는 단순히 Dockerfile 에 의존성을 추가하고 PATH 를 추가하면 된다는 점입니다.
heroku-docker 설치
$ heroku plugins:install heroku-docker
샘플 앱 clone
$ git clone https://github.com/heroku/node-js-getting-started.git
$ cd node-js-getting-started
docker:init - 현재 디렉터리의 언어에 따라 Dockerfile 생성
$ heroku docker:init
Wrote Dockerfile (node)
$ cat Dockerfile
FROM heroku/cedar:14
RUN useradd -d /app -m app
USER app
WORKDIR /app
ENV HOME /app
ENV NODE_ENGINE 0.12.2
ENV PORT 3000
RUN mkdir -p /app/heroku/node
RUN curl -s https://s3pository.heroku.com/node/v$NODE_ENGINE/node-v$NODE_ENGINE-linux-x64.tar.gz | tar --strip-components=1 -xz -C /app/heroku/node
ENV PATH /app/heroku/node/bin:$PATH
RUN mkdir -p /app/.profile.d
RUN echo "export PATH=\"/app/heroku/node/bin:/app/bin:/app/node_modules/.bin:\$PATH\"" > /app/.profile.d/nodejs.sh
RUN echo "cd /app/src" >> /app/.profile.d/nodejs.sh
EXPOSE 3000
ONBUILD RUN mkdir -p /app/src
ONBUILD COPY . /app/src
ONBUILD WORKDIR /app/src
ONBUILD RUN npm install
docker:init 내부 구현
docker:init 은 buildpack 과 동일한 방식으로 언어를 detect 합니다. 예를 들어 package.json 가 존재하면 Node.js 로 detect 합니다 (github). 구현은 platforms.detect(dir) 와 fs.writeFileSync(dockerfile, contents) 부분에서 볼 수 있습니다. 현재 지원되는 언어는 node, python, ruby, scala 입니다. heroku docker:init --template minimal 으로 최소한의 Dockerfile 을 만들 수 있고, 여기서 생성되는 Dockerfile 이 minimal 템플릿입니다.
heroku/cedar:14 도커 이미지 내부 구현
이미지는 Docker Registry 에 올라가 있습니다. Cedar-14 는 ubuntu 14.04 LTS 를 기반으로 한 스택이고, 설치되는 ubuntu package 목록 도 따로 정리되어 있습니다.
Dockerfile 은 아래와 같이 단순합니다 (github).
FROM ubuntu-debootstrap:14.04
COPY ./bin/cedar-14.sh /tmp/build.sh
RUN LC_ALL=C DEBIAN_FRONTEND=noninteractive /tmp/build.sh \
&& rm -rf /var/lib/apt/lists/*
ubuntu-debootstrap 은 Docker Registry 에 있고, 공식 이미지 목록 에서 github.com/tianon/docker-brew-ubuntu-debootstrap 여기에 Dockerfile 이 있다는 것을 알 수 있습니다. 실제 ubuntu-debootstrap:14.04 의 Dockerfile 은 링크에서 볼 수 있습니다.
즉 heroku/cedar:14 는 용량이 작은 최소한의 ubuntu docker image 에 cedar-14.sh 를 실행시킨 것입니다.
docker:start - 로컬에 컨테이너를 시작
$ heroku docker:start
building image...
Sending build context to Docker daemon 285.2 kB
Sending build context to Docker daemon
Step 0 : FROM heroku/cedar:14
Pulling repository heroku/cedar
66766a8c5395: Download complete
c40496787f7c: Download complete
f90b0e99841d: Download complete
ac0bf528748f: Download complete
Status: Downloaded newer image for heroku/cedar:14
---> 66766a8c5395
... docker 이미지 빌드 생략 ...
Successfully built d3e547511a82
starting container...
web process will be available at http://localhost:3000/
Node app is running on port 3000
docker:start 내부 구현
start.js 가 핵심입니다. Procfile 을 읽어서 여기에 적혀 있는 command 가 Docker 가 실행될 때 command 로 전달됩니다. docker.ensureStartImage 가 ensureStartImage() 와 buildImage 를 호출합니다. 빌드 명령은 build --force-rm --file="${dockerfile}" --tag="${id}" "${dir}" 입니다. 실행은 runImage() 에서 run -w /app/src -p 3000:3000 --rm -it ${mountComponent} ${envArgComponent} ${imageId} sh -c "${command}" || true 형태로 수행합니다.
컨테이너에 명령 실행
$ heroku docker:exec npm install --save --no-bin-links cool-ascii-faces
$ heroku docker:exec ls node_modules
docker:exec 내부 구현
docker exec 를 한 번 감싼 형태입니다. 구현은 exec.js 와 runImage() 에서 볼 수 있습니다.
docker:release - 배포
아래의 명령으로 배포 가능합니다.
$ heroku docker:release
이것은 heroku 가 기존에 사용하던 Buildpack 기반의 빌드시스템을 사용하지 않습니다. 대신, Docker 이미지에 앱의 코드와 의존성을 포함하고, 로컬의 Docker 이미지를 직접 배포합니다.
docker:release 내부 구현
Heroku 로 배포할 때 컨테이너를 올려서 /app 디렉터리를 압축합니다. 이 압축파일에는 language 의 runtime 과 앱의 소스코드를 포함합니다.
구체적으로는 release.js 에서 아래의 흐름을 따릅니다.
docker run -d ${imageId} tar cfvz /tmp/slug.tgz -C / --exclude=.git --exclude=.heroku ./app
docker wait ${containerId}
docker cp ${containerId}:/tmp/slug.tgz ${slugPath}
docker rm -f ${containerId}
slug.tgz
만들어진 slug 압축파일을 HTTP PUT 을 통해 업로드하도록 구현되어 있습니다 (github, heroku).