Docker 로 Node.js 배포하기

얼마전 Dockercon 16 이 성공적으로 막을 내린걸로 알고 있다렸다. 바햐흐로 Docker 세상이 도래하고 있다. Docker 는 영어권에서는 다커로 발음하고 있는 것 같으니 다커 로 발음하시면서 읽으시면 되겠다. 도커로 하셔도 되지만...

Node.js 와 Single-threaded 모델

본격적으로 Docker 이야기로 넘어가기 전에 Node.js 먼저 언급해야겠다. Node.js 를 묘사하는 대표적인 키워드 3가지 1) single-threaded 2) asynchronous non-block I/O 3) event-driven 를 아마 귀에 딱지 붙을정도로 많이 들었을텐데 다른 것보다 싱글 스레드 에 집중해보자.

다 아는 얘기를 한번 더 하자면 대표적인 웹서버인 Apache 와 같은 경우 전부 한 리퀘스트를 처리하는 동안 다른 리퀘스트가 들어오면 새롭게 스레드가 만들어져서 처리하게 된다. 물론 이 스레드를 무한정 늘릴순 없는것이고, 스레드가 꽉 차면 더이상 처리할 수 없어 클라이언트는 서버에서의 대답을 기다려야 하는 hang 이 발생하게 된다.

node.js 반면 멀티 스레드를 지원하지 않는다. 그럼 드는 생각은 "아니 그럼 접속을 두명이 하면 멈출텐데?" 물론 그렇진 않다. 브라우저와 비슷한 형태로 Event-loop 를 사용해 비동기 방식으로 멈추지 않고 응답을 기다렸다가 리턴 하는 방식으로 동시 접속을 처리하게 된다. 여기서 다루려고 하는 내용은 아니니 자세한 건 아래 Youtube 동영상을 참조하자. 반 정도 번역했는데 귀찮아서 요즘 못하고 있...

왜 굳이 아파치 얘기까지 꺼내와서 이런 이야기를 하냐면, node.js 는 싱글 스레드 결국 하나의 cpu core 만 사용한다는 이야기인데, 요즘 시대가 어느 시댄대 64 core 서버가 즐비한 가운데 코어를 하나만 쓴다니 무슨 이 시대 착오적인 소리인가 싶다. 물론 실제로 서비스할때는 nginx 를 앞단에 두고 reverse proxy 와 함께 load balancing 을 하는 경우가 대다수이겠지만, 새로운 서버에 배포를 해야 하는 경우가 생기거나 하면 매우 귀찮은게 사실이다.

Docker 와 Virtualization

Docker 는 복잡하게는 Linux Container 어쩌구로 들어가지만 쉽게 생각하면 OS 코어와 가까운 가벼운 가상머신 정도라고 생각하면 편하다. 이렇게 설명하는 것이 Docker 를 지나치게 단순화 시키는 것이긴 하지만, Docker 의 개념을 이해하기 보다는 사용하면서 느끼는 것에 좀 더 집중하고 싶다. Docker 에 대해서 더 궁금한 사람은 가장 빨리 만나는 Docker 원고가 웹으로 공개되어 있으니 참고하자.

앞서 이야기했던 것처럼 Node.js 어플리케이션은 80 포트로 바인딩해서 사용하기 보다는 리버스 프록싱을 사용하게 된다. 그러다보니 대표적인 비동기 웹서버인 Nginx 가볍고 성능이 좋아 궁합이 잘 맞다. 또한 Nginx 는 자체적으로 Load Balancing 을 지원하기 때문에 호스트 하나에 동일한 서비스를 여러개 띄워서 사용할 수도 있다. 다만, 한번 배포시에 여러 서비스를 관리해야 하기 때문에 일반적인 케이스에서는 문제가 될 여지가 별로 없겠지만, 긴급 패치 시, 호스트를 추가해야하는 경우 등에서 여러가지 복잡한 절차를 거쳐야 하는 것이 사실이다.

이 포스트에서는 Docker 를 이용하여 Nginx 웹 서버를 통해 여러개의 동일한 Node.js 어플리케이션을 배포하는 방법을 알아보려 한다.

Node.js Application

샘플 어플리케이션을 만들어보자.

$ npm init -f

npm init 명령어로 프로젝트를 생성하자. 프로젝트와 큰 관계가 없는 설정 부분은 생략한다. express 를 이용하여 간단한 API 서버를 만들고, 각 API 서버를 식별하기 위한 uuid 를 생성하기 위에 각각 패키지를 설치한다.

$ npm i --save express uuid

다음과 같이 index.js 파일을 작성하자.

index.js
// 디팬던시
var express = require('express');
var uuid = require('uuid');

var app = express();
var id = uuid.v4();
var port = 3000;

app.get('/', function (req, res) {
  res.send(id)
});

app.listen(port, function () {
  console.log('Example app listening on port: ' + port);
});

이제 서버를 실행한다.

$ node index.js

그리고 브라우저를 통해 http://locahost:3000 로 접속하면 다음과 같은 내용이 출력된다. uuid 를 서버 생성할때 한번만 만들기 때문에 여러번 새로고침을 해도 동일한 내용이 출력된다.

테스트를 완료하면 앱을 중지하고 다음 단계로 넘어가자.

Docker 로 Node.js 어플리케이션 실행하기

다음은 만들어진 Node.js 어플리케이션을 Docker 를 통해 실행해보자. 설치 방법은 이곳 을 참고한다. Docker 는 기본적으로 Dockerfile 를 레시피로 이미지를 생성한다. Docker 는 기본적으로 Docker Hub 에 있는 이미지를 베이스 이미지로 해서 만들게 되는데, 공식 Node.js 이미지가 있으니 그것을 사용하도록 하자.

먼저 node_modules 을 직접 복사하는 일이 없도록 .dockerignore 파일을 작성한다.

.dockerignore
node_modules/
Dockerfile
FROM node:6
COPY package.json /src/package.json
RUN  cd /src; npm install
COPY . /src
EXPOSE 3000
WORKDIR /src

CMD node index.js

작성이 끝나면 아래 명령어를 실행시킨다.

$ docker build --tag node-nginx:test .

Docker 는 위에서부터 차례차례 실행시키며 이미지를 생성한다.

$ docker build --tag node-nginx:test .
Sending build context to Docker daemon 7.168 kB
Step 1 : FROM node:6
6: Pulling from library/node
357ea8c3d80b: Already exists
52befadefd24: Already exists
3c0732d5313c: Pull complete
ceb711c7e301: Pull complete
868b1d0e2aad: Pull complete
61d10f626f84: Pull complete
Digest: sha256:12899eea666e85f23e9850bd3c309b1ee28dd0869f554a7a6895fc962d9094a3
Status: Downloaded newer image for node:6
 ---> 800da22d0e7b
Step 2 : COPY package.json /src/package.json
 ---> 7f3344975b1e
Removing intermediate container ae1d0482e982
Step 3 : RUN cd /src; npm install --production
 ---> Running in 222a0585301b

...

Successfully built 08a5b1c92fcf

위와 같은 명령어가 출력되면 제대로 생성되었는지 확인해보자.

$ docker images
REPOSITORY                     TAG                 IMAGE ID            CREATED              SIZE
node-nginx                     test                08a5b1c92fcf        About a minute ago   654.5 MB

만들어진 이미지를 실행한다.

$ docker run --name node-nginx-instance -p 3000:3000 node-nginx:test
Example app listening on port: 3000

다시 브라우저로 http://localhost:3000 으로 접속하면 동일한 결과가 나오는 것을 확인할 수 있다. 물론 uuid 는 변경된다. 터미널을 하나 더 열어 $ docker ps 로 확인해보자.

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                    NAMES
30179995521c        node-nginx:test     "/bin/sh -c 'node ind"   About a minute ago   Up About a minute   0.0.0.0:3000->3000/tcp   node-nginx-instance

$ docker run 이 열려있는 터미널에서 ctrl + c 로 중지하거나 $ docker stop node-nginx-instance 로 실행되고 있는 인스턴스를 중지한다.

$ docker rm node-nginx-instance

이제 여러개의 인스턴스를 동시에 실행해보자. 그전에 위처럼 만들어져있는 instance 를 삭제한다.

$ docker run -d --name node-nginx-instance-0 -p 3000:3000 node-nginx:test
$ docker run -d --name node-nginx-instance-1 -p 3001:3000 node-nginx:test
$ docker run -d --name node-nginx-instance-2 -p 3002:3000 node-nginx:test

-d 옵션을 주어 데몬의 형태로 인스턴스로 만들어주었다.

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
450a19e72bb4        node-nginx:test     "/bin/sh -c 'node ind"   32 seconds ago      Up 30 seconds       0.0.0.0:3002->3000/tcp   node-nginx-instance-2
d2f5ec891c75        node-nginx:test     "/bin/sh -c 'node ind"   37 seconds ago      Up 35 seconds       0.0.0.0:3001->3000/tcp   node-nginx-instance-1
a38935fd7d4f        node-nginx:test     "/bin/sh -c 'node ind"   51 seconds ago      Up 50 seconds       0.0.0.0:3000->3000/tcp   node-nginx-instance-0

세 인스턴스가 돌아가고 있다. 브라우저로 접속해서 확인해보자.

NGINX 로 Load Balancing

다음과 같이 nginx/ 폴더 아래에 nginx.conf 파일을 작성하자. 단 upstreamserver 설정에서 YOUR_IP_ADDRESS 를 현재 호스트의 IP 로 지정해준다. http://ifconfig.co 와 같은 사이트를 사용하면 편리하다.

nginx/nginx.conf
worker_processes 4;

events { worker_connections 1024; }

http {
  upstream node-app {
    least_conn;
    server YOUR_IP_ADDRESS:3000 weight=10 max_fails=3 fail_timeout=30s;
    server YOUR_IP_ADDRESS:3001 weight=10 max_fails=3 fail_timeout=30s;
    server YOUR_IP_ADDRESS:3002 weight=10 max_fails=3 fail_timeout=30s;
  }

  server {
    listen 80;

    location / {
      proxy_pass http://node-app;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_cache_bypass $http_upgrade;
    }
  }
}

Dockerfile 도 만들어준다. 역시 Docker hub 의 공식 이미지를 사용하였다.

nginx/Dockerfile
FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf

실행해보자.

$ cd nginx
$ docker build --tag node-nginx-lb:test .
$ docker run -d --name node-nginx-instance-lb -p 4000:80 node-nginx-lb:test

이제 http://localhost:4000 로 접속하면 똑같은 화면을 볼 수 있다. 단 이 상태에서 새로고침을 하면 세 인스턴스를 번갈아 가며 접속하는 것을 확인할 수 있다.

$ docker ps
CONTAINER ID        IMAGE                COMMAND                  CREATED             STATUS              PORTS                           NAMES
68588d475364        node-nginx-lb:test   "nginx -g 'daemon off"   3 minutes ago       Up 3 minutes        443/tcp, 0.0.0.0:4000->80/tcp   node-nginx-instance-lb
450a19e72bb4        08a5b1c92fcf         "/bin/sh -c 'node ind"   18 minutes ago      Up 18 minutes       0.0.0.0:3002->3000/tcp          node-nginx-instance-2
d2f5ec891c75        08a5b1c92fcf         "/bin/sh -c 'node ind"   18 minutes ago      Up 18 minutes       0.0.0.0:3001->3000/tcp          node-nginx-instance-1
a38935fd7d4f        08a5b1c92fcf         "/bin/sh -c 'node ind"   18 minutes ago      Up 18 minutes       0.0.0.0:3000->3000/tcp          node-nginx-instance-0

docker-compose up

뭔가 이상하다. 로컬에서 로드밸런싱을 하고 있음에도 Docker instance 는 각각의 localhost 를 가지고 있기 때문에 localhost 에 바인딩 하는 방식으로는 nginx 를 사용할 수 없다. 보통 192.168 로 시작하는 내부 IP 로 접근이 가능하긴 하지만 이 역시 바뀔때마다 새로 설정해줘야 하는 번거로움이 있다.

docker-compose 는 docker 를 실행할때마다 입력해줘야 하는 설정들 -d -p -name 을 파일로 관리할 수 있게 하는 해준다. docker-compose 를 설치하고 좀 더 편리하게 서버를 실행해보자.

우선 현재 실행되고 있는 모든 컨테이너를 삭제한다.

$ docker rm -f $(docker ps -a -q)

docker-compose 는 Python 으로 작성되어 있기 때문에 설정파일은 .yml 형식을 따른다.

docker-compose.yml
version: '2'

services:
  nginx:
    container_name: node-nginx-lb
    build: ./nginx
    links:
      - app-1:app-1
      - app-2:app-2
      - app-3:app-3
    ports:
      - 3000:80
    depends_on:
      - app-1
      - app-2
      - app-3

  app-1:
    container_name: node-nginx-1
    image: node-nginx:test
    ports:
      - 3000

  app-2:
    container_name: node-nginx-2
    image: node-nginx:test
    ports:
      - 3000

  app-3:
    container_name: node-nginx-3
    image: node-nginx:test
    ports:
      - 3000

그리고 nginx/nginx.conf 의 IP 를 다음과 같이 수정해준다.

nginx/nginx.conf
  ...

  upstream node-app {
    least_conn;
    server app-1:3000 weight=10 max_fails=3 fail_timeout=30s;
    server app-2:3000 weight=10 max_fails=3 fail_timeout=30s;
    server app-3:3000 weight=10 max_fails=3 fail_timeout=30s;
  }

  ...

이제 다시 원래 node 프로젝트 디렉토리로 돌아와서 docker-compose 명령어를 실행하자.

$ cd ..
$ docker-compose up
Recreating node-nginx-3
Recreating node-nginx-1
Recreating node-nginx-2
Recreating node-nginx-lb
Attaching to node-nginx-1, node-nginx-2, node-nginx-3, node-nginx-lb
node-nginx-1 | Example app listening on port: 3000
node-nginx-2 | Example app listening on port: 3000
node-nginx-3 | Example app listening on port: 3000
node-nginx-lb | 172.19.0.1 - - [22/Aug/2016:16:07:32 +0000] "GET / HTTP/1.1" 200 36 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Safari/602.1.50"
node-nginx-lb | 172.19.0.1 - - [22/Aug/2016:16:07:32 +0000] "GET / HTTP/1.1" 200 36 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Safari/602.1.50"
node-nginx-lb | 172.19.0.1 - - [22/Aug/2016:16:07:33 +0000] "GET / HTTP/1.1" 200 36 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Safari/602.1.50"
node-nginx-lb | 172.19.0.1 - - [22/Aug/2016:16:07:33 +0000] "GET / HTTP/1.1" 200 36 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Safari/602.1.50"

이제 http://localhost:3000 으로 접속 하면 각 컨테이너에 번갈아 접속하면서 로그를 출력하는 것을 확인할 수 있다. 최종적인 docker 컨테이너 상태는 다음과 같다.

$ docker ps
CONTAINER ID        IMAGE                   COMMAND                  CREATED             STATUS                  PORTS                           NAMES
8b0fe632e458        dockernodenginx_nginx   "nginx -g 'daemon off"   1 seconds ago       Up Less than a second   443/tcp, 0.0.0.0:3000->80/tcp   node-nginx-lb
6dafacd1e4d2        node-nginx:test         "/bin/sh -c 'node ind"   2 seconds ago       Up 1 seconds            0.0.0.0:32776->3000/tcp         node-nginx-2
cf6bb53c5397        node-nginx:test         "/bin/sh -c 'node ind"   2 seconds ago       Up 1 seconds            0.0.0.0:32775->3000/tcp         node-nginx-3
618b74662d16        node-nginx:test         "/bin/sh -c 'node ind"   2 seconds ago       Up 1 seconds            0.0.0.0:32774->3000/tcp         node-nginx-1

현재 로컬에 node-nginx:test 이미지가 존재하기 때문에 별도의 추가 다운로드 없이 docker-compose 는 3개의 Node.js 어플리케이션 container 와 1개의 nginx 로드 밸런서를 생성한 후 실행한다. 만약 로컬에 이미지가 존재하지 않을 경우 docker hub 에서 다운로드를 시도한다.

정리

Docker 는 서버를 관리하고 배포를 구성할때 매우 편리한 도구이다. 최근에 출시된 docker swarm 이나 Kuberanates 와 같은 서비스를 활용하면 여러대의 가상서버에 서비스를 자유자재로 배포하기 매우 편리하다. 현재 쏘카에서는 ZEROCAR API 서버를 위와 유사한 형태로 구성하여 이보다 훨씬 복잡하지만 사용하고 있다.

배포의 편리함은 물론이거니와 이제 윈도우 및 맥용 docker 베타 버젼 도 출시된 만큼, 클라이언트 개발자에게 디팬던시 걱정없이 개발 서버를 설정할 수 있게 하는 것도 가능하다. 또한 스테이징 서버를 거의 완전하게 동일한 형태로 운영하거나, 서버를 추가할 때 배포 자동화를 매우 편리하다는 점 등 최근 DevOps 기술 스택에서 가장 각광받고 있다고 해도 과언이 아니다. 특히 Node.js 의 경우 싱글 스레드 컴퓨팅의 한계를 아주 편리하게 극복할 수 있다 보니 적극적으로 사용하면 좋을 것이다.

이 포스트에서 사용한 코드는 https://github.com/colus001/docker-nginx-node 에서 확인할 수 있다.

참고자료