원문 : http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html
도커를 이용해 노드제이에스 어플리케이션을 개발 하고 배포 하면서 어렵게 배운 팁과 트릭을 공유하고자 한다.
이 튜토리얼 아티클에서는 socket.io chat example을 이용해 기초부터 프로덕션에 응용 가능한 상태까지 될 수 있으면 쉽게 이해할 수 있도록 설명하려 한다.
아래와 같은 내용을 다룬다.
node_modules
관리(이렇게 할 수 있는 트릭이 있다)Dockerfile
공유이 튜토리얼은 도커나 노드제이에스에 약간 익숙한 사람들을 대상으로 작성되었다. 만약 도커에 대한 약간의 사전 지식을 알고 싶다면 도커에 대한 슬라이드를 참고하거나 아니면 여기 저기 많이 널려있는 도커관련 아티클들을 참고하면 된다.
일단 아무것도 없는 상태에서 시작해보자. 만들고자 하는 최종적인 코드는 여기에 있다. 그리고 진행될 각 스텝에 해당하는 태그들이 있다.
도커가 없다면 호스트에 노드와 필요한 디펜던시들을 설치 하는것을 시작으로 npm init
으로 새로운 패키지를 만들어야 한다. 이런 작업을 피할 수 없지만 시작부터 도커를 이용한다면 이야기는 달라진다. (그리고 어떤것도 호스트에 설치할 필요가 없는것은 도커의 큰 장점이다.) 그래서 node가 이미 설치되어 있는 “bootstrapping container”를 만드는 것으로 시작을 한다. 그리고 이것으로 어플리케이션을 위한 npm 패키지를 설정한다.
두개의 파일을 작성 해야하는데 Dockerfile
과 docker-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
이 파일은 상대적으로 짧다. 하지만 이미 중요한 포인트들은 모두 가지고 있다.
node:argon
이나 node:latest
같은 유동적인 테그네임보다는 명확한 버전을 표기하는 것을 선호한다. 당신 혹은 누군가를 위해 이 이미지를 다른 머신에 빌드할때 동일한 버전을 사용할 수 있고 버전 차이로 인한 여러가지 문제를 피할 수 있다.app
이라는 기본적인 권한의 유저를 만든다. 이렇게 하지 않으면 컨테이너 안의 모든 프로세스들은 루트권한으로 실행되게 되는데 이렇게 되면 보안상의 베스트 프랙티스와 원칙에 어긋나게 된다. 많은 튜토리얼들이 단순함을 위해 이 단계를 건너뛰고 있고 이 일을 하기 위해선 추가적으로 해야할 일이 있지만 나는 이 작업이 매우 중요하다고 생각한다.npm shrinkwrap
지원은 더 나아졌다.(shrinkwrap에 대한 더 자세한 것들은 곧 다룬다.) 다시 말하지만 추후 빌드중에 의도치 않은 업그레이드를 피하기 위해 꼭 명확한 버전을 Dockerfile에 명시하는것이 최선이라고 생각한다.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
의 내용대로 컨테이너에 디펜던시들이 설치된다.
그렇게 하기 위해 Dockerfile
의 npm install
을 실행하기 전에 package.json
과 npm-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
다시 약간의 변화를 주었지만 몇가지 중요한 포인트가 있다.
COPY
명령을 통해 어플리케이션 전체를 호스트에서 $HOME/chat
으로 복사할 수 있지만 우리는 패키징 파일들만 복사하게 된다. 이렇게 우리가 필요로 하는 파일만 복사하고 npm install
을 통해서 나머지를 카피하는 것이 도커 빌드시 시간적인 장점이 있다는 것을 추후 알 수 있게 된다. 이것은 docker build
의 레이어 캐싱의 장점을 더 잘 활용하는 것이다.COPY
명령을 통해 컨테이너로 복사된 파일들은 결과적으로 root가 소유하게 되어 우리의 비특권유저인 app
이 읽거나 쓰지 못하게 한다. 그래서 카피한 이후 간단히 chown
명령을 이용해 사용을 허가할 수 있다.(USER app
단계 이후에 COPY
를 수행해서 app
유저로 파일 카피를 수행하면 더 좋을 것 같지만 아직은 불가능하다.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
적용하는 것은 간단하지만 이렇게 하기 위해 내부적으로는 꽤 작업이 수행된다.
빌드를 할때, npm install
은 디펜던시들을(다음 섹션에서 추가하게됨) 이미지 내의 $HOME/chat/node_modules
에 인스톨한다. 이미지의 파일들을 파란색으로 표현했다.(역: 마크다운 문법으로 인해 색은 생략함, 아래 코드블럭내용이 모두 파란색)
~/chat$ tree # in image
.
├── node_modules
│ ├── abbrev
...
│ └── xmlhttprequest
├── npm-shrinkwrap.json
└── package.json
추후 컴포즈파일을 이용해 이미지에서 컨테이너를 시작하게 되면 도커는 호스트의 어플리케이션 폴더를 컨테이너의 $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
이다.
그러나 아직 끝난 게 아니다. 다음으로 도커는 이미지 안의 $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
이제 우리가 원하는 것을 얻었다. 호스트의 우리의 소스파일들은 컨테이너 안으로 바인드되어 빠른 수정을 가능케하는 반면 디펜던시들은 컨테이너 안에 있어 앱을 실행할때 사용할 수 있다.
(추가팁: 아마도 이런 디펜던시 파일들이 실제로 어디에 저장되어 있는지 궁금할 것이다. 짧게 말하면 도커에 의해 관리되는 분리된 호스트의 디렉터리에 있다. 더 자세한 정보는 여기에서 확인할 수 있다.
이제 이미지를 다시 빌드해 패키지를 설치해보자
$ 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.json
은 Gemfile.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.js
와 index.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
에서 앱이 동작하는것을 볼 수 있다.
여기 에서 지금까지의 코드를 받을 수 있다.
이제 우리는 개발 환경에서 도커 컴포즈를 이용해 앱을 실행할 수 있다.(멋지다.!) 이제 가능한 다음 단계에 대해 알아보자.
만약 프로덕션의 우리의 애플리케이션 이미지를 배포하고자 한다면 위의 이미지에 어플리케이션 소스를 빌드하고 싶을 것이다. 이렇게 하기 위해선 npm install
을 한 후에 단순히 어플리케이션 폴더를 컨테이너에 복사하게 된다. 물론 npm install
은 package.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
이렇게 실행하면 추가적인 아웃풋은 생략할 수 있다.)
여기 지금까지의 코드가 있다.
nested volume
트릭으로 어느정도 쉽게 해결할 수 있었다.여기서 다룬 것은 매우 간단한 어플리케이션이었지만 아래의 내용에 해당하는 추가 아티클들이 많이 있다.
npm link
를 사용하면 서비스들이 공유해 패키지안의 코드를 재사용할 수 있다.여기까지 읽은 사람은 꼭 내 twitter 를 팔로우 해주면 고맙겠다. 그리고 Overleaf 에서는 사람을 구하고 있다 :)
드래프트 버전의 이 아티클을 리뷰해준 Michael Mzaour 와 John Hammersley 에게 감사를 표한다.