도커로 노드 앱을 구축하며 배운것들

원문 http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html by John Lees-Miller

도커를 이용해 노드제이에스 어플리케이션을 개발 하고 배포 하면서 어렵게 배운 팁과 트릭을 공유하고자 한다.

이 튜토리얼 아티클에서는 socket.io chat example을 이용해 기초부터 프로덕션에 응용 가능한 상태까지 될 수 있으면 쉽게 이해할 수 있도록 설명하려 한다.

아래와 같은 내용을 다룬다.

  • 도커를 이용해 노드 어플리케이션을 시작하는 방법
  • 모든것을 루트로 실행 하지 않기(BAD!)
  • 개발시에 테스트-수정-리로드 사이클을 짧게 유지하기 위해 바인드를 이용하는 방법
  • 컨테이너에서 빠르게 리빌드를 하기위한 node_modules 관리(이렇게 할 수 있는 트릭이 있다)
  • npm shirinkwrap를 이용한 반복적인 빌드
  • 개발환경과 프로덕션환경에서의 Dockerfile 공유

이 튜토리얼은 도커나 노드제이에스에 약간 익숙한 사람들을 대상으로 작성되었다. 만약 도커에 대한 약간의 사전 지식을 알고 싶다면 도커에 대한 슬라이드를 참고하거나 아니면 여기 저기 많이 널려있는 도커관련 아티클들을 참고하면 된다.

Getting Started

일단 아무것도 없는 상태에서 시작해보자. 만들고자 하는 최종적인 코드는 여기에 있다. 그리고 진행될 각 스텝에 해당하는 태그들이 있다.

도커가 없다면 호스트에 노드와 필요한 디펜던시들을 설치 하는것을 시작으로 npm init 으로 새로운 패키지를 만들어야 한다. 이런 작업을 피할 수 없지만 시작부터 도커를 이용한다면 이야기는 달라진다. (그리고 어떤것도 호스트에 설치할 필요가 없는것은 도커의 큰 장점이다.) 그래서 node가 이미 설치되어 있는 “bootstrapping container”를 만드는 것으로 시작을 한다. 그리고 이것으로 어플리케이션을 위한 npm 패키지를 설정한다.

두개의 파일을 작성 해야하는데 Dockerfiledocker-compose.yml이다. 이 파일들은 나중에 계속 내용을 추가 할 것이다. Dockerfile부터 시작하자.

FROM node:4.3.2

RUN useradd --user-group --create-home --shell /bin/false app &&\
  npm install --global npm@3.7.5

ENV HOME=/home/app

USER app
WORKDIR $HOME/chat

이 파일은 상대적으로 짧다. 하지만 이미 중요한 포인트들은 모두 가지고 있다.

  1. 현 시점에서 가장 최신 버전의 공식 장기 지원(LTS) 도커이미지로 시작을 한다. 나는 node:argon이나 node:latest같은 유동적인 테그네임보다는 명확한 버전을 표기하는 것을 선호한다. 당신 혹은 누군가를 위해 이 이미지를 다른 머신에 빌드할때 동일한 버전을 사용할 수 있고 버전 차이로 인한 여러가지 문제를 피할 수 있다.
  2. 컨테이너 안에서 앱을 실행하기 위해 단순하게 app이라는 기본적인 권한의 유저를 만든다. 이렇게 하지 않으면 컨테이너 안의 모든 프로세스들은 루트권한으로 실행되게 되는데 이렇게 되면 보안상의 베스트 프랙티스와 원칙에 어긋나게 된다. 많은 튜토리얼들이 단순함을 위해 이 단계를 건너뛰고 있고 이 일을 하기 위해선 추가적으로 해야할 일이 있지만 나는 이 작업이 매우 중요하다고 생각한다.
  3. 가장 최근 버전의 NPM을 설치 한다. 이건 반드시 필요한 작업은 아니지만 NPM 최근 많은 부분에서 발전이 있었고 특히 npm shrinkwrap 지원은 더 나아졌다.(shrinkwrap에 대한 더 자세한 것들은 곧 다룬다.) 다시 말하지만 추후 빌드중에 의도치 않은 업그레이드를 피하기 위해 꼭 명확한 버전을 Dockerfile에 명시하는것이 최선이라고 생각한다.
  4. 마지막으로 하나의 RUN 명령당 두 개의 쉘 명령을 연결한다. 이것은 결과 이미지에 레이어 수를 줄여준다. 이 예제에서는 크게 중요해 보이지는 않지만 필요 이상의 레이어를 사용하지 않는 것은 좋은 습관이다. 이렇게 하면 패키지를 다운로드하거나 압축을 풀고, 빌드를 하고, 설치를 할때 사용되는 디스크 용량과 다운로드 시간을 아낄 수 있다. 그래서 각 스텝별로 중간 파일을 만들지 않고 한 스텝으로 정리할 수 있다.

자, 이제 docker-compose.yml 을 구성해보자.

chat:
  build: .
  command: echo 'ready'
  volumes:
    - .:/home/app/chat

이 파일은 Dockerfile에 의해 만들어지는 한개의 서비스를 정의 한다. 이것이 하는 일은 ready를 echo 하고 종료하는 것 밖에 없다. 불륨 라인의 .:/home/app/chat는 도커에게 호스트의 어플리케이션 폴더 .을 컨테이너 안쪽의 /home/app/chat에 마운트하게 해 호스트의 소스파일이 자동적으로 컨테이너 안쪽으로 반영되도록 하게 한다. (상호 반영됨) 개발중에 test-edit-reload 사이클을 가능한 짧게 유지하는 것은 매우 중요하지만 NPM이 디펜던시 모듈들을 설치할 때 이슈를 만들게 된다. 이것에 대해서는 곧 다시 다루게 된다.

아무튼 지금까지는 잘 진행된다. docker-compose를 실행하게 되면 도커는 이미지를 만들어 Dockerfile에 정의된 내용대로 노드를 셋업하게 되고 그 이미지와 함께 컨테이너를 시작한 뒤 모든게 정상이라고 OK를 보여주는 echo 명령을 RUN한다.

$ docker-compose up
Building chat
Step 1 : FROM node:4.3.2
 ---> 3538b8c69182
...
lots of build output
...
Successfully built 1aaca0ac5d19
Creating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | ready
dockerchatdemo_chat_1 exited with code 0

그리고 이제 같은 이미지로 부터 만들어진 컨테이너 안의 인터렉티브 쉘을 실행한 뒤 초기 패키지 파일을 만든다.

$ docker-compose run --rm chat /bin/bash
app@e93024da77fb:~/chat$ npm init --yes
... writes package.json ...
app@e93024da77fb:~/chat$ npm shrinkwrap
... writes npm-shrinkwrap.json ...
app@e93024da77fb:~/chat$ exit

그리고 호스트에 작업한 파일들을 버전 컨트롤 시스템에 커밋할 수 있다.

$ tree
.
├── Dockerfile
├── docker-compose.yml
├── npm-shrinkwrap.json
└── package.json

여기 에서 지금까지 만들어진 코드들을 확인할 수 있다.

디펜던시 설치

다음에 할 일은 앱의 디펜던시들을 설치하는 것이다. docker-compose up을 처음 실행하면 앱이 실행될 준비를 하기 위해 Dockerfile의 내용대로 컨테이너에 디펜던시들이 설치된다.

그렇게 하기 위해 Dockerfilenpm install을 실행하기 전에 package.jsonnpm-shrinkwrap.json 파일을 이미지 안으로 복사한다. 아래는 복사를 위한 Dockerfile의 change 정보이다.

diff --git a/Dockerfile b/Dockerfile
index c2afee0..9cfe17c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,5 +5,9 @@ RUN useradd --user-group --create-home --shell /bin/false app &&\

 ENV HOME=/home/app

+COPY package.json npm-shrinkwrap.json $HOME/chat/
+RUN chown -R app:app $HOME/*
+
 USER app
 WORKDIR $HOME/chat
+RUN npm install

다시 약간의 변화를 주었지만 몇가지 중요한 포인트가 있다.

  1. COPY 명령을 통해 어플리케이션 전체를 호스트에서 $HOME/chat으로 복사할 수 있지만 우리는 패키징 파일들만 복사하게 된다. 이렇게 우리가 필요로 하는 파일만 복사하고 npm install을 통해서 나머지를 카피하는 것이 도커 빌드시 시간적인 장점이 있다는 것을 추후 알 수 있게 된다. 이것은 docker build의 레이어 캐싱의 장점을 더 잘 활용하는 것이다.
  2. COPY 명령을 통해 컨테이너로 복사된 파일들은 결과적으로 root가 소유하게 되어 우리의 비특권유저인 app 이 읽거나 쓰지 못하게 한다. 그래서 카피한 이후 간단히 chown 명령을 이용해 사용을 허가할 수 있다.(USER app 단계 이후에 COPY를 수행해서 app 유저로 파일 카피를 수행하면 더 좋을 것 같지만 아직은 불가능하다.
  3. 마지막으로 npm install을 끝에 추가한다. 이 명령은 app 유저로 실행되어 $HOME/chat/node-modules에 디펜던시들을 컨테이너에 설치하게 된다. (추가적으로 npm cache clean을 추가하여 NPM이 인스톨중에 다운로드한 tar파일들을 지우는 게 좋다. 이 파일들은 이미지를 다시 빌드할 때 아무런 도움이 되지 않으니 공간을 더 확보하는 편이 좋다.)

마지막 내용은 개발시 이미지를 사용할 때 몇몇 문제의 원인이 된다. 왜냐하면 $HOME/chat을 컨테이너에서 호스트의 어플리케이션 폴더로 바인드 했기 때문이다. 바인드로 인해 불행히도 우리가 인스톨한 node_modules를 효과적으로 숨겨주기 때문에 node_modules 폴더는 호스트에 존재하지 않게 된다.

node_modules 불륨 트릭

이 문제를 해결하기 위해 여러가지 방법이 있지만 내 생각에 가장 우아한 방법은 불륨을 바인드안에서 node_modules를 포함하도록 하는 것이다. 이렇게 하기 위해선 도커 컴포즈파일 끝부분에 라인 한줄만 추가하면 된다.

diff --git a/docker-compose.yml b/docker-compose.yml
index 9e0b012..9ac21d6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,3 +3,4 @@ chat:
   command: echo 'ready'
   volumes:
     - .:/home/app/chat
+    - /home/app/chat/node_modules

적용하는 것은 간단하지만 이렇게 하기 위해 내부적으로는 꽤 작업이 수행된다.

  1. 빌드를 할때, npm install은 디펜던시들을(다음 섹션에서 추가하게됨) 이미지 내의 $HOME/chat/node_modules에 인스톨한다. 이미지의 파일들을 파란색으로 표현했다.(역: 마크다운 문법으로 인해 색은 생략함, 아래 코드블럭내용이 모두 파란색)
    ~/chat$ tree # in image
    .
    ├── node_modules
    │   ├── abbrev
    ...
    │   └── xmlhttprequest
    ├── npm-shrinkwrap.json
    └── package.json
    
  2. 추후 컴포즈파일을 이용해 이미지에서 컨테이너를 시작하게 되면 도커는 호스트의 어플리케이션 폴더를 컨테이너의 $HOME/chat 안으로 바인드한다. 호스트의 파일은 빨간색으로 표현했다.(역: 상동)
    ~/chat$ tree # in container without node_modules volume
    .
    ├── Dockerfile
    ├── docker-compose.yml
    ├── node_modules
    ├── npm-shrinkwrap.json
    └── package.json
    

불행히도 컨테이너 내의 node_modules는 바인드에 의해 숨겨지기 때문에 호스트에 보이는 것은 빈 node_modules 이다.

  1. 그러나 아직 끝난 게 아니다. 다음으로 도커는 이미지 안의 $HOME/chat/node_modules를 가지고 있는 불륨을 생성하고 컨테이너에 마운트한다. 그리고 이는 다시 호스트의 바인드로부터 숨겨진다. (역: 다 빨간색이고 node_modules과 하위만 파란색)
    ~/chat$ tree # in container with node_modules volume
    .
    ├── Dockerfile
    ├── docker-compose.yml
    ├── node_modules
    │   ├── abbrev
    ...
    │   └── xmlhttprequest
    ├── npm-shrinkwrap.json
    └── package.json
    

이제 우리가 원하는 것을 얻었다. 호스트의 우리의 소스파일들은 컨테이너 안으로 바인드되어 빠른 수정을 가능케하는 반면 디펜던시들은 컨테이너 안에 있어 앱을 실행할때 사용할 수 있다.

(추가팁: 아마도 이런 디펜던시 파일들이 실제로 어디에 저장되어 있는지 궁금할 것이다. 짧게 말하면 도커에 의해 관리되는 분리된 호스트의 디렉터리에 있다. 더 자세한 정보는 여기에서 확인할 수 있다.

패키지 설치 그리고 Shrinkwrap

이제 이미지를 다시 빌드해 패키지를 설치해보자

$ docker-compose build
... builds and runs npm install (with no packages yet)...

채팅앱은 express버전 4.10.2가 필요하다. npm install—save옵션과 함께 실행 하여 package.json에 기록하고 npm-shrinkwrap.json도 업데이트한다.

$ docker-compose run --rm chat /bin/bash
app@9d800b7e3f6f:~/chat$ npm install --save express@4.10.2
app@9d800b7e3f6f:~/chat$ exit

꼭 정확한 버전을 명시할 필요는 없다. 그냥 npm install —save express로 어떤 버전이던 최종버전으로 설치해도 좋다. package.json과 shrinkwrap은 이렇게 설치해도 해당 버전으로 기록이 되기 때문이다.

npm의 shrinkwrap 기능을 이용하는 이유는 직접적인 디펜던시의 버전들은 package.json통해서 고정할 수 있는 반면 그 디펜던시들의 디펜던시들은 고정할 수 없기 때문이다.(루즈하게 명시된 버전의 경우) 이 말은 나중에 누군가가 이미지를 다시 빌드할때 간접적인 디펜던시들의 버전이 다름으로 인해 앱이 깨지는것에 대해 보장 할 수 없다는 말이다. 나에겐 생각보다 자주 발생했다. 그래서 shrinkwrap을 사용하는것을 권장한다. 만약 당신이 루비의 훌륭한 bundler 디펜던시 매니저에 익숙하다면 npm-shrinkwrap.jsonGemfile.lock과 같다고 보면 된다.

마지막으로 주목해야 할 내용은 우리는 일회성으로 docker-compose run에 의해 컨테이너를 실행하기 때문에 실질적으로 우리가 설치한 모듈들은 사라진다. 하지만 다음에 도커빌드를 다시 실행하면 도커는 package.json와 shrinkwrap의 변경을 감지하고 npm install을 다시 실행해 필요한 패키지가 이미지에 설치된다.(매우 중요한 점이다.)

$ docker-compose build
... lots of npm install output
$ docker-compose run --rm chat /bin/bash
app@912d123f3cea:~/chat$ ls node_modules/
accepts              cookie-signature  depd ...
...
app@912d123f3cea:~/chat$ exit

여기 에 지금까지의 코드가 있다.

앱을 실행하기

우리는 드디어 앱을 설치할 준비가 되었다. 남아있는 소스파일들 index.jsindex.html을 설치한다. 그리고 socket.it 패키지를 이전 섹션에서 한 것처럼 npm install —save를 이용해 설치한다.

우리의 Dockerfile에서 이미지를 이용해 컨테이너를 시작할 때 어떤 커맨드를 실행해야 하는지 알려줄 수 있고 이 경우엔 node index.js가 된다. 도커 컴포즈파일에서 더미 커맨드를 지워 도커가 Dockerfile을 이용해 커맨드를 실행하게 한다. 마침내 도커 컴포즈에게 호스트의 컨테이너에서 3000포트를 열게 하여 브라우저에서 엑세스 할 수 있게 한다.

diff --git a/Dockerfile b/Dockerfile
index 9cfe17c..e2abdfc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,3 +11,5 @@ RUN chown -R app:app $HOME/*
 USER app
 WORKDIR $HOME/chat
 RUN npm install
+
+CMD ["node", "index.js"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 9ac21d6..e7bd11e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,7 @@
 chat:
   build: .
-  command: echo 'ready'
+  ports:
+    - '3000:3000'
   volumes:
     - .:/home/app/chat
     - /home/app/chat/node_modules

그리고 마지막 빌드를 하고 docker-compose up 실행한다.

$ docker-compose build
... lots of build output
$ docker-compose up
Recreating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | listening on *:3000

그리고 (맥의 경우 boot2docker VM에서 3000포트를 사용할 수 있게 하기 위해 몇가지 작업을 해야한다.) http://localhost:3000 에서 앱이 동작하는것을 볼 수 있다.

실행화면

여기 에서 지금까지의 코드를 받을 수 있다.

Docker for Dev and Prod

이제 우리는 개발 환경에서 도커 컴포즈를 이용해 앱을 실행할 수 있다.(멋지다.!) 이제 가능한 다음 단계에 대해 알아보자.

만약 프로덕션의 우리의 애플리케이션 이미지를 배포하고자 한다면 위의 이미지에 어플리케이션 소스를 빌드하고 싶을 것이다. 이렇게 하기 위해선 npm install을 한 후에 단순히 어플리케이션 폴더를 컨테이너에 복사하게 된다. 물론 npm installpackage.json이나 npm-shrinkwrap.json이 변경되었을 때만 다시 실행하는 것이지 우리의 소스파일이 수정되었기 때문에 실행하는 것은 아니다. 또한 루트에 의한 카피문제는 여기서도 우회해야 한다.

diff --git a/Dockerfile b/Dockerfile
index e2abdfc..68d0ad2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,4 +12,9 @@ USER app
 WORKDIR $HOME/chat
 RUN npm install

+USER root
+COPY . $HOME/chat
+RUN chown -R app:app $HOME/*
+USER app
+
 CMD ["node", "index.js"]

이제 호스트의 어떠한 불륨도 필요 없이 컨테이너를 단독으로 실행할 수 있게 되었다. 도커 컴포즈는 컴포즈 파일에서 코드의 중복을 피하기 위해 여러 컴포즈 파일을 구성 할 수 있지만 이번 프로그램은 매우 단순하기 때문에 단순히 두 번째 컴포즈 파일, docker-compose.prod.yml 를 추가해 프로덕션 환경에서 응용 프로그램을 실행시킨다.

chat:
  build: .
  environment:
    NODE_ENV: production
  ports:
    - '3000:3000'

아래와 같이 어플리케이션을 ‘production mode’로 실행한다.

$ docker-compose -f docker-compose.prod.yml up
Recreating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | listening on *:3000

그리고 유사하게 컨테이너를 개발환경에 특화시킬 수 있다. 예를들면 소스 파일이 수정되면 자동적으로 컨테이너 안에서 다시 로드되도록 어플리케이션을 노드몬 에서 실행하는 것이다. (만약 도커 머신을 맥에서 사용한다면 아직 정상적으로 동작하지 않을 것이다. virtualbox의 쉐어드 폴더는 inotify로 동작하지 않기 때문이다. 빠른 시일내에 나아지길 바랄 뿐이다.) npm install —save-dev nodemon을 컨테이너에서 실행한 뒤 다시 빌드한다. 이제 개발횐경에서 더 적합 설정의 컨테이너에서 node index.js 디폴트 프로덕션 커맨드를 재정의 한다.

diff --git a/docker-compose.yml b/docker-compose.yml
index e7bd11e..d031130 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,8 @@
 chat:
   build: .
+  command: node_modules/.bin/nodemon index.js
+  environment:
+    NODE_ENV: development
   ports:
     - '3000:3000'
   volumes:

풀패스를 이용해 nodemon을 실행하는데 이는 NPM 디펜던시로 설치되어 있어 PATH 환경변수를 통해 접근할 수 없기 때문이다. NPM 스크립트를 이용해 nodemon을 실행할 수 있지만 이 방식은 문제점이 있다. NPM 스크립트로 실행하게 되면 NPM이 TERM시그널을 Docker에서 실제 프로세스로 전달하지 않아 종료까지 10초 정도가 걸리게 되는 경향이 있다.(기본적인 제한) 그래서 직접 명령을 실행하는 것이 나을 수 있다..(이 문제는 NPM 3.8.1 이상에서 수정된 것 같다. 이제 NPM스크립트를 컨테이너에서 사용할 수 있다.!)

$ docker-compose up
Removing dockerchatdemo_chat_1
Recreating 3aec328ebc_dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | [nodemon] 1.9.1
chat_1 | [nodemon] to restart at any time, enter `rs`
chat_1 | [nodemon] watching: *.*
chat_1 | [nodemon] starting `node index.js`
chat_1 | listening on *:3000

특수화된 도커 컴포즈파일은 같은 Dockerfile과 이미지를 다수의 환경에 걸쳐 사용할 수 있게 한다. 프로덕션에 개발 디펜던시를 설치하는 것이 공간 효율적으로 좋지는 않겠지만 내 생각엔 좋은 개발-프로덕션 환경을 위해 희생해야할 등가의 작은 희생이라고 생각한다. 현명한 사람이 이야기했듯 ‘test as you fly, fly as you test.’ 지금 이 순간 우린 어떠한 테스트도 가지고 있지 않다. 하지만 원한다면 아래와 같이 쉽게 적용할 수 있다.

$ docker-compose run --rm chat /bin/bash -c 'npm test'
npm info it worked if it ends with ok
npm info using npm@3.7.5
npm info using node@v4.3.2
npm info lifecycle chat@1.0.0~pretest: chat@1.0.0
npm info lifecycle chat@1.0.0~test: chat@1.0.0

> chat@1.0.0 test /home/app/chat
> echo "Error: no test specified" && exit 1

Error: no test specified
npm info lifecycle chat@1.0.0~test: Failed to exec test script
npm ERR! Test failed.  See above for more details.

(팁: npm —silent 이렇게 실행하면 추가적인 아웃풋은 생략할 수 있다.)

여기 지금까지의 코드가 있다.

결론

  • 도커안에서 앱을 작성하고 개발환경과 프로덕션환경에서 완벽히 실행해 봤다. 오예!
  • 호스트에 아무것도 설치하지 않고 노드환경을 구성하기 위해 몇가지의 허들을 넘어야했지만 이것이 도움이 된다는 것을 알았으면 좋겠다. 그리고 이런 작업은 한번만 수행하면 된다.
  • 하위 폴더에 노드와 NPM의 디펜던시를 두는것은 루비의 bundler가 디펜던시를 다른곳에 설치하는 것에 비하면 조금 복잡할 수 있지만 nested volume 트릭으로 어느정도 쉽게 해결할 수 있었다.
  • 여기서 다룬 것은 매우 간단한 어플리케이션이었지만 아래의 내용에 해당하는 추가 아티클들이 많이 있다.
    • 예를들어 API, Service Worker, 정적 프론테 엔드등 여려 서비스의 프로젝트를 구축한다. 하나의 큰 저장소가 서비스를 각각의 저장소로 분리하는 것보다 관리하기 쉬워보이지만 이것도 약간의 복잡성을 가지고 있다.
    • npm link를 사용하면 서비스들이 공유해 패키지안의 코드를 재사용할 수 있다.
    • 도커를 이용해 로그관리 툴이나 프로세스 모니터링 툴을 프로덕션에서 교체한다.
    • 데이터 마이그레이션을 포함해 상태나 구성을 관리한다.

여기까지 읽은 사람은 꼭 내 twitter 를 팔로우 해주면 고맙겠다. 그리고 Overleaf 에서는 사람을 구하고 있다 :)

드래프트 버전의 이 아티클을 리뷰해준 Michael MzaourJohn Hammersley 에게 감사를 표한다.