Docker, NodeJS, Nginx! 너로 정했다!
멀티코어 환경에서 빠르고 안정적인 서비스 환경 구축하기
강원우
2018-05-25
편집자 주
아래와 같이 용어를 표기하기로 저자와 협의함
Docker, NodeJS, Nginx
Overview
안녕하세요. 칼 같은 들여쓰기에 희열을 느끼는 브랜디 개발자 강원우입니다! 서버를 운영해본 개발자라면 Fatal 에러, 아웃오브메모리 에러, 또는 전날 흡수한 알코올로 인해 손을 떨다가 한 번쯤 서버를 요단강 너머로 보내봤을 겁니다. 만약 테스트 서버였다면 잠시 마음을 가다듬으면 되지만, 현재 상용 서비스 중인 서버라면 얘기는 달라집니다.
이런 간담이 서늘해지는 경험은 저 하나로 족합니다. 그래서 고군분투했던 지난 날을 되돌아보면서 빠르고 안정적이며, 죽어도 죽지 않는 좀비 같은 서버 구축 방법을 쓰려고 합니다.
준비물
서비스를 운영할 때 가장 중요하게 여겨야 하는 건 역시 안정성입니다. 이번 글에서는 오래 전부터 개발 세계의 뜨거운 감자였던 Docker와, 단일 스레드와 이벤트 루프로 태생적으로 심플하고 민첩한 NodeJS, 마지막으로 고성능을 목표로 개발된 Nginx를 활용하겠습니다.
1. Docker
Docker는 컨테이너 기반의 오픈소스 가상화 플랫폼입니다. 대표적으로 LXC(Linux Container)가 있습니다. 화물 컨테이너처럼 어떠한 일련의 기능을 완전히 격리된 소프트웨어 환경에서 작동하게 만드는 기술을 말합니다.
OS 가상화와 별반 다를 게 없는 것 같지만 소프트웨어적으로 작동한다는 차이가 있습니다. 다시 말해, 현재 OS의 자원을 그대로 사용하기 때문에 하이퍼 바이저가 가상환경을 위해 가상의 커널을 만드는 오버헤드가 거의 없다는 것이죠.
이미지와 속도도 차이를 보입니다. 완벽하게 구성한 세팅을 그대로 이미지화할 수 있고, 해당 이미지는 Docker 위에서 완벽히 동일하게 동작하는 걸 보장합니다. 해당 이미지로 컨테이너를 제작할 땐 1~2초면 새로운 컨테이너가 생겨날 정도로 엄청나게 빠른 속도도 자랑합니다. 1)
또한 Docker는 자주 사용되는 다양한 이미지를 퍼블릭 레포지토리에 공유해 사용할 수 있기도 합니다. 양파도 아닌데 특징이 계속 나오죠? 다음 글에서 Docker의 특징을 더 자세히 다루겠습니다.
Docker는 리눅스만 지원했었지만, 요즘은 Docker for Windows와 Docker for Mac으로 거의 모든 OS에서 사용할 수 있습니다. 2) Docker 설치 링크는 윈도우와 맥으로 나뉘어져 있습니다. 리눅스는 아래를 참고하세요.
curl -fsSL https://get.docker.com/ | sudo sh
2. NodeJS
NodeJS는 구글이 구글 크롬에 사용하려고 제작한 V8 오픈소스 자바스크립트 엔진을 기반으로 제작된 자바스크립트 런타임입니다. NodeJS에는 몇 가지 특징이 있습니다.
- 단일 스레드입니다.
- 비동기 방식입니다.
- 이벤트 루프를 사용합니다
- NPM이라는 끝내주는 동반자가 있습니다.
비유하자면 예전엔 낡은 곡괭이로 큰 돌을 캐내려고 수십 명의 인부가 달라 붙었는데, 지금은 육중한 포크래인으로 거대한 돌을 쑥! 뽑아버리는 것과 비슷합니다. 굉장히 효율적이죠. NodeJS는 단일 스레드의 장점을 극대화하려고 이벤트 루프를 통해 모든 처리를 비동기로 수행합니다. 서버 사이드의 묵직한 CPU들이 빠르게 일을 처리하고 이벤트 루프에 등록된 일을 감지해 다음 작업을 빠르게 수행하는 방식입니다.
마지막으로 NPM(Node Package Manager)은 NodeJS에서 사용할 수 있는 다양한 모듈을 관리해주는 프로그램입니다. 도커와 상당히 유사합니다. NodeJS에서는 무언가 기능을 만들기 전에 NPM을 먼저 뒤져보라는 말이 있을 정도로 풍부한 모듈 생태계가 구성되어 있습니다. 이는 로깅이나 날짜 계산 등 생각보다 까다로운 것들을 가져다 사용할 수 있게 도와주기 때문에 개발이 빨라집니다. NodeJS 설치링크는 여기를 클릭하세요. 이 글의 예제에서는 NodeJS의 현재시점 LTS인 codename Carbon버젼을 사용합니다!
우선 서비스 구성을 위해 간단한 NodeJS 어플리케이션을 작성해보겠습니다.
첫째, packge.json를 작성합시다.
{
"name": "nodejs_tutorial_server",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node nodejs_tutorial_server.js"
},
"description": "NodeJS Tutorial Server",
"author": {
"name": "WonwooKang"
},
"dependencies": {
"express": "^4.16.3",
"uuid": "^3.2.1"
}
}
nodejs_tutorial_server.js 파일을 메인으로 실행합니다. HTTP Request를 처리하려면 express를 사용해야 하며, 서버를 구분하려면 uuid모듈이 필요합니다.
둘째, package.json의 의존 파일들을 설치합시다.
npm install
셋째, 간단한 웹 어플리케이션을 작성합시다.
var express = require('express');
var app = express();
const port = 3000;
var server = app.listen(port, function () {
console.log("Express server has started on port : "+port);
});
app.get('/', function (req, res) {
res.send('Hello?');
});
넷째, package.json의 script start 구문을 실행하여 서버를 로드합시다.
npm start
접속해볼까요?
그런데 수정할 때마다 서버를 매번 다시 띄우면 귀찮을 겁니다. 이럴 땐 nodemon 모듈을 사용합시다. nodemon은 Nodejs의 파일이 수정되는 걸 감지해 자동으로 리로드해주는 편리한 도구입니다.
nodemon설치
npm install nodemon -g
package.json script 변경
"scripts": {
"start": "nodemon nodejs_tutorial_server.js"
},
nodemon 실행확인을 위해 약갼의 수정
//nodejs_tutorial_server.js 수정
app.get('/', function(req, res) {
res.send('Hello Nodemon');
});
성공적으로 단 하나의 GET 요청을 처리할 수 있는 심플한 NodeJS 기반 웹 어플리케이션을 완성했습니다. 이제 웹 어플리케이션을 Docker Container위에서 구동해봅시다!
3. Docker로 NodeJS Express 서버 구동하기
이제 Docker Container위에서 NodeJS서버를 구동할 건데요. 그러려면 우선 Dockerfile을 작성해야 합니다. 물론 Docker의 이미지를 당겨 받고, 컨테이너를 생성하고, 또 컨테이너를 실행해서 Attach하고, 필요한 파일들을 밀어넣는 등 귀찮은 방법도 있습니다. 하지만 개발자에게 이것은 힘든 작업이므로 Dockerfile을 적극 활용합시다. (Dockerfile의 D는 대문자여야 합니다! 꼭이요)
Node 도커 이미지에 어플리케이션 파일을 추가해 실행하는 Dockerfile 작성하기
FROM node:carbon
MAINTAINER Wonwoo Kang kangww@brandi.co.kr
#app 폴더 만들기 - NodeJS 어플리케이션 폴더
RUN mkdir -p /app
#winston 등을 사용할떄엔 log 폴더도 생성
#어플리케이션 폴더를 Workdir로 지정 - 서버가동용
WORKDIR /app
#서버 파일 복사 ADD [어플리케이션파일 위치] [컨테이너내부의 어플리케이션 파일위치]
#저는 Dockerfile과 서버파일이 같은위치에 있어서 ./입니다
ADD ./ /app
#패키지파일들 받기
RUN npm install
#배포버젼으로 설정 - 이 설정으로 환경을 나눌 수 있습니다.
ENV NODE_ENV=production
#서버실행
CMD node nodejs_tutorial_server.js
Dockerfile 내용은 node:carbon에서 :carbon이 NodeJS의 이미지 버전 Tag 입니다.
Dockerfile을 통해 docker image 빌드하기
docker build –tag 레포지토리명: 태그 Dockerfile 경로
docker build --tag node_server:0.0.1 [Dockerfile이 위치하는 경로]
빌드 결과 생성된 이미지 확인하기
docker images
NodeJS Carbon 이미지를 기반으로 한 node_server 이미지를 제작했습니다. 사이즈는 둘이 합쳐 1Gb가 넘을 것 같지만 실제로는 변경된 부분만 저장됩니다. 그러므로 node_server 이미지의 크기는 6~10Mb 정도입니다.
생성된 이미지로 컨테이너 만들기
컨테이너 생성 명령어는 아래와 같습니다.
docker create --name [서버명] -p [외부 포트:컨테이너 내부포트] [이미지명:버전태그]
주의할 점이 있습니다. 포트번호 바인딩 중 왼쪽은 우리가 접속할 실제 포트이고, 오른쪽은 컨테이너 내부의 NodeJS서버 할당 포트가 된다는 것입니다. 공유기의 포트포워딩 설정과 같습니다.
docker create --name NODE_SERVER_0 -p 3000:3000 node_server:0.0.1
컨테이너 확인하기
생성한 컨테이너를 확인해볼까요?
docker ps
옵션을 추가합니다.
docker ps -a
docker ps 명령어는 현재 실행 중(STATUS:Up)인 컨테이너의 목록을 보여줍니다. -a 옵션은 실행하지 않는 모든 컨테이너를 보여줍니다. 위의 이미지에서 node_server:0.0.1이미지로부터 NODE_SERVER_0 이라는 이름으로 2분 전에 생성되었다는 걸 알 수 있습니다. 3)
컨테이너 실행하기
docker start NODE_SERVER_0
다시 확인하기
docker ps
외부 3000번 포트 -> 내부 3000번 포트로 연결되었습니다. 서버도 실행되었고요! 이제 접속해볼까요?
내용도 안 바꾸고 새로고침도 빨라서 뜬 건지 잘 모르겠군요. 내용을 수정해서 다시 확인하겠습니다.
//nodejs_tutorial_server.js 수정
app.get('/', function (req, res) {
res.send('Hello I\'m In Docker Container Now!');
});
파일 변경해서 다시 확인하기
//버전 태그도 0.0.2로 업해주고
docker build --tag node_server:0.0.2 [Dockerfile위치]
//이미지가 잘 생성되었는지 확인하고
docker images
//기존 컨테이너를 삭제합니다. -f 옵션은 실행중인 컨테이너도 강제로 삭제하겠다는 뜻입니다.
docker rm -f NODE_SERVER_0
//잘지워졌나 확인하고
docker ps -a
//0.0.2 버젼 이미지로 컨테이너를 다시 생성합니다.
docker create --name NODE_SERVER_0 -p 3000:3000 node_server:0.0.2
//서버를 실행합니다.
docker start NODE_SERVER_0
이제 다시 접속해봅시다.
이제 Docker로 여러 개의 서버를 띄우겠습니다. NodeJS는 싱글 스레드이기 때문에 하나의 CPU를 여럿이 나눠 갖는 건 비효율적입니다. 따라서 CPU 숫자에 맞춰서 서버를 띄워보겠습니다.
CPU수에 맞춰 추가로 생성하기
추가로 컨테이너를 생성하고, 서버를 실행합니다. 서버 목록도 확인해야겠죠.
포트번호는 같은 포트를 쓸 수 없기 때문에 3001, 3002, 3003으로 매핑합니다. 브라우저로 접속해서 확인해보겠습니다.
미리 만들어둔 이미지 덕분에 서버 3대를 띄우는 데에 5분도 안 걸렸습니다. 하지만 Docker 서버를 여러 개 띄워도 결국 사람의 손이 닿아야 합니다. 따라서 이번에는 NodeJS의 Cluster를 활용해 적은 수의 Docker Container를 이용하면서도 다수의 CPU를 사용하겠습니다. 또 죽은 워커를 다시 살려 서버가 다운되는 것을 막아 안정적인 서비스도 구축해보겠습니다.
4. 멀티코어대응 NodeJS Cluster 구성
2컨테이너용 NodeJS Cluster서버 어플리케이션 작성하기
var cluster = require('cluster');
var os = require('os');
var uuid = require('uuid');
const port = 3000;
//키생성 - 서버 확인용
var instance_id = uuid.v4();
/**
* 워커 생성
*/
var cpuCount = os.cpus().length; //CPU 수
var workerCount = cpuCount/2; //2개의 컨테이너에 돌릴 예정 CPU수 / 2
//마스터일 경우
if (cluster.isMaster) {
console.log('서버 ID : '+instance_id);
console.log('서버 CPU 수 : ' + cpuCount);
console.log('생성할 워커 수 : ' + workerCount);
console.log(workerCount + '개의 워커가 생성됩니다\n');
//CPU 수 만큼 워커 생성
for (var i = 0; i < workerCount; i++) {
console.log("워커 생성 [" + (i + 1) + "/" + workerCount + "]");
var worker = cluster.fork();
}
//워커가 online상태가 되었을때
cluster.on('online', function(worker) {
console.log('워커 온라인 - 워커 ID : [' + worker.process.pid + ']');
});
//워커가 죽었을 경우 다시 살림
cluster.on('exit', function(worker) {
console.log('워커 사망 - 사망한 워커 ID : [' + worker.process.pid + ']');
console.log('다른 워커를 생성합니다.');
var worker = cluster.fork();
});
//워커일 경우
} else if(cluster.isWorker) {
var express = require('express');
var app = express();
var worker_id = cluster.worker.id;
var server = app.listen(port, function () {
console.log("Express 서버가 " + server.address().port + "번 포트에서 Listen중입니다.");
});
app.get('/', function (req, res) {
res.send('안녕하세요 저는<br>워커 ['+ cluster.worker.id+'] 입니다.');
});
}
CPU 숫자를 받아 CPU 수(4)를 컨테이너 수(2) 로 나눠 워커를 생성하는 NodeJS 클러스터 구성입니다. 이렇게만 해도 운영에는 무리가 없지만 컨테이너 2개의 구분이 안 되서 확인할 수가 없습니다.
그러므로 마스터와 워커의 통신을 이용해 마스터의 uuid를 얻겠습니다. (워커와 마스터 간의 데이터 이동은 통신 말고는 메모리DB 등의 데이터 저장소밖에 없습니다)
마스터의 아이디를 알아오는 로직이 추가된 어플리케이션 작성
var cluster = require('cluster');
var os = require('os');
var uuid = require('uuid');
const port = 3000;
//키생성 - 서버 확인용
var instance_id = uuid.v4();
/**
* 워커 생성
*/
var cpuCount = os.cpus().length; //CPU 수
var workerCount = cpuCount/2; //2개의 컨테이너에 돌릴 예정 CPU수 / 2
//마스터일 경우
if (cluster.isMaster) {
console.log('서버 ID : '+instance_id);
console.log('서버 CPU 수 : ' + cpuCount);
console.log('생성할 워커 수 : ' + workerCount);
console.log(workerCount + '개의 워커가 생성됩니다\n');
//워커 메시지 리스너
var workerMsgListener = function(msg){
var worker_id = msg.worker_id;
//마스터 아이디 요청
if (msg.cmd === 'MASTER_ID') {
cluster.workers[worker_id].send({cmd:'MASTER_ID',master_id: instance_id});
}
}
//CPU 수 만큼 워커 생성
for (var i = 0; i < workerCount; i++) {
console.log("워커 생성 [" + (i + 1) + "/" + workerCount + "]");
var worker = cluster.fork();
//워커의 요청메시지 리스너
worker.on('message', workerMsgListener);
}
//워커가 online상태가 되었을때
cluster.on('online', function(worker) {
console.log('워커 온라인 - 워커 ID : [' + worker.process.pid + ']');
});
//워커가 죽었을 경우 다시 살림
cluster.on('exit', function(worker) {
console.log('워커 사망 - 사망한 워커 ID : [' + worker.process.pid + ']');
console.log('다른 워커를 생성합니다.');
var worker = cluster.fork();
//워커의 요청메시지 리스너
worker.on('message', workerMsgListener);
});
//워커일 경우
} else if(cluster.isWorker) {
var express = require('express');
var app = express();
var worker_id = cluster.worker.id;
var master_id;
var server = app.listen(port, function () {
console.log("Express 서버가 " + server.address().port + "번 포트에서 Listen중입니다.");
});
//마스터에게 master_id 요청
process.send({worker_id: worker_id, cmd:'MASTER_ID'});
process.on('message', function (msg){
if (msg.cmd === 'MASTER_ID') {
master_id = msg.master_id;
}
});
app.get('/', function (req, res) {
res.send('안녕하세요 저는<br>['+master_id+']서버의<br>워커 ['+ cluster.worker.id+'] 입니다.');
});
}
Docker Container에 올리기 전 로컬 테스트를 먼저 진행합니다. 서버 구동!
이제 워커로 CPU 수만큼 워커를 생성할 수 있게 되었습니다. 이제 워커가 어떻게 안정적으로 서비스되는지 테스트하겠습니다.
워커 킬링 테스트하기
워커 킬러 로직 작성
//워커 킬링 테스트
app.get("/workerKiller", function (req, res) {
cluster.worker.kill();
res.send('워커킬러 호출됨');
});
실험에 앞서 똑같은 상황 재연 마스터 아이디를 유심히 봐주세요. 워커 킬러를 실행하겠습니다.
아래는 호출된 결과입니다. 하나의 워커가 죽자마자 곧장 다른 워커가 태어나(?) 3000번을 Listen하기 시작했습니다.
이제 워커 킬러를 여러 번 호출해보겠습니다. CMD+R을 꾸욱 눌러 연속으로 킬링해봤는데 아래 화면처럼 바로 살아납니다.
접속해서 현재 워커를 확인합니다.
위의 화면처럼 마스터의 UUID가 그대로인데 워커만 교체되었습니다. 준비는 끝났습니다. 이제 Docker를 이용해 2명의 워커를 가진 2개의 NodeJS서버를 실행하고, 4개의 귀여운 CPU를 불살라봅시다!
5. Docker로 NodeJS Cluster 서버 실행하기
docker build --tag node_server:0.0.3 /Users/kww/eclipse-workspace/nodejs-for-article
docker create --name NODE_SERVER_0 -p 3000:3000 node_server:0.0.3
docker create --name NODE_SERVER_1 -p 3001:3000 node_server:0.0.3
docker start NODE_SERVER_0
docker start NODE_SERVER_1
0.0.3번 이미지로 생성된 2개의 컨테이너 서버가 무사히 로드되었습니다. 이제 접속해서 확인해볼까요?
WOW! 2개의 URL, 2개의 UUID, 각 2명의 워커까지. 완벽한 2.2.2입니다. 마치 홍진호를 보는 듯한 서버 현황입니다. 이제 워커 킬러로 습격해보겠습니다.
위의 이미지를 보면 3000번 포트서버에서 13명, 3001번 포트서버에서 22명의 워커가 사망했습니다. UUID를 통해 2개의 서버에서 일정량의 워커가 매우 안정적으로 서버를 지키고 있는 걸 알 수 있었습니다.
지금까지 2개의 컨테이너로 4개의 서버를 구성해보았습니다. CPU 숫자와 나눠지는 수에 따라 컨테이너의 수, NodeJS 클러스터 서버의 수를 유동적으로 조정할 수 있습니다. 전에 운영하던 API서버는 16코어 서버였고, 로드벨런서 및 기타 작업용 1코어의 여분을 남기고 15코어 / 3 으로 5개의 워커를 가진 3개의 NodeJS서버를 도커 컨테이너로 운영했었습니다.
여기서 문제점이 생깁니다. 우리는 어떤 서비스를 할 때 하나의 도메인을 쓰는데 포트번호가 2개죠? 어떻게 해야 할까요. 여기서 바로 한참을 기다렸던 불곰국의 Nginx가 등장합니다.
6. Nginx로 로드밸런싱 하기
Nginx은 “더 적은 자원으로 더 빠르게”를 지향합니다. 러시아의 이고르 시쇼브(Игорь Сысоев)는 Apache에서 10,000개의 접속을 동시에 다루기 힘든 걸 해결하려고 Nginx를 개발합니다.
Nginx는 NodeJS와 유사하게 싱글 스레드 방식에 이벤트 드리븐 구조 사용하는 오픈소스 HTTP서버로 최근 아파치의 점유율을 상당히 뺏고 있는 서버입니다. 다운로드 링크를 아래에 써두었습니다.
Nginx 설치
- Window
Nginx 다운로드 - Mac
brew install nginx
- Linux
apt-get install nginx or yum install nginx
서버 조작방법
서버 시작 : nginx
서버 중지 : nginx -s stop
서버 재시작 : nginx -r reload (맥에선 이건 안되는듯?)
기본 설정은 8080포트로 되어있습니다. 원하는 포트르 로드벨런싱 설정을 해보겠습니다.
Nginx 로드밸런싱 설정
아래는 Nginx의 로드밸런싱입니다.
#http블럭 내부에 추가
#NodeJS 서버 로드밸런싱
upstream nodejs_server {
#least_conn;
#ip_hash;
server localhost:3000 weight=10 max_fails=3 fail_timeout=10s;
server localhost:3001 weight=10 max_fails=3 fail_timeout=10s;
}
#3333번 포트 NodeJS 서버로 연결
server{
listen 3333;
server_name localhost;
location / {
proxy_pass http://nodejs_server;
}
}
로드밸런싱이 잘 적용되었는지 확인해보겠습니다.
모든 브라우저에서 3333번으로 접속했는데 서로 다른 2개의 서버가 번갈아 접속되고, 워커가 가끔 바뀌는 걸 확인할 수 있습니다. 이번엔 로드밸런서로 워커 킬러를 호출하겠습니다.
Nginx 로드밸런서가 확실하게 작동하는 걸 확인할 수 있었습니다. 위의 이미지에서 서버가 자꾸 바뀌는 모습을 볼 수 있는데, 이는 세션이 유지되지 않기 때문입니다. 실제 서비스에서는 세션의 유지를 위해 ip_hash 옵션이 꼭 필요합니다.
ip_hash : 동일한 IP의 접속은 같은 서버로 접속하도록 하는 옵션입니다.
least_conn : 가장 접속이 적은 서버로 접속을 유도하는 옵션으로 ip_hash와 같이쓰입니다.
Conclusion
자, 고생하셨습니다. 여기까지 Docker와 NodeJS, Nginx를 이용해 관리하기 쉽고, 일부러 죽여도 죽지 않는 안정적인 서비스 환경을 구축해봤습니다. 한 가지 주의할 점이 있습니다. NodeJS의 Cluster는 죽은 워커를 바로 살리는데 싱글스레드여서 그런지 그 속도가 정말 어마어마합니다. 따라서 NodeJS Cluster를 사용할 땐 여러 핸들링에 신중하세요. 모든 promise에 반드시 catch를 달아 핸들링하고, 오류가 날 것 같은 로직엔 반드시 try - catch를 달아 핸들링을 해야 합니다. 그렇지 않으면 다시 살아나는 워커에 의해 서버의 자원이 고갈될 수 있습니다.
예전에 16코어 서버를 운영할 땐 서버 자원에 비해 사용자가 적어서..(눈물) 5워커 2개의 서버만 구동하고 여유를 두었습니다. 그리고 서버 패치가 있을 때 3번째 서버를 대기시켰습니다. 앱에서 업데이트가 완료되는 시점에 Docker Container를 바꿔치기 하는 방식으로 Non-Stop서비스를 운영했죠. 혹시 코어가 빵빵한 여유 서버가 있는데 재빠르고 좀비 같은 서비스를 구성해야 한다면 위와 같은 환경 구축을 강력히 추천합니다. 지금까지 긴 글을 읽어주셔서 감사합니다.
ps. 글 쓰다 보니 해가 떴네요. 하하.
참고
1) 가상 머신은 작은 이미지라도 기가바이트 단위의 사이즈와 Load되기까지 상당한 시간이 소요된다.
2) 그러나 Windows의 경우, Hiper-v위에 리눅스를 띄워 도커를 구동한다. Mac에서도 가상 머신 위에서 구동된다. 따라서 성능적인 강점은 리눅스에만 적용된다.
3) 도커에서는 NAME 속성을 지어주지 않으면 알아서 이름을 지어주는데 romantic한 단어가 많다.