Docker를 이용한 NodeJS 개발
최근에 많은 NodeJS 프로젝트들이 Docker를 이용해서 개발되고 있습니다. 이번 포스팅에서는 NodeJS로 간단한 Express 서버 애플리케이션을 작성해보고, Docker를 이용해서 이 애플리케이션을 어떻게 컨테이너화(containerized) 할 수 있는지에 대해서 알아보도록 하겠습니다.
실습 프로젝트 환경 구성
원하는 위치에 디렉터리를 생성하고, 그 안에 package.json
파일을 생성합니다. (컴퓨터에 NodeJS가 설치가 안 되어 있다면 먼저 NodeJS를 다운로드 받아 설치해야 합니다.)
$ mkdir app && cd app
$ npm i -y
Wrote to /Users/dale/temp/app/package.json:
{
"name": "app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
$ ls
package.json
그 다음, npm을 통해 Express 패키지를 설치합니다.
$ npm i express
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN app@1.0.0 No description
npm WARN app@1.0.0 No repository field.
+ express@4.17.1
added 50 packages from 37 contributors and audited 126 packages in 1.787s
found 0 vulnerabilities
서버 애플리케이션 작성
index.js
파일을 생성하고, 그 안에 다음과 같이 어떤 요청이 들어오든 Hello World!
를 응답하는 서버 코드를 작성합니다.
- index.js
const express = require("express");
const app = express();
const port = 3000;
app.get("/", (req, res) => res.send("Hello World!"));
app.listen(port, () =>
console.log(`Example app listening at http://localhost:${port}`)
);
그리고 node
로 서버를 실행하기 위한 npm start
스크립트를 package.json
파일에 추가합니다.
- package.json
// 생략
"scripts": {
"start": "node ."
},
// 생략
그 다음, index.js
파일을 실행하여, Express 서버 애플리케이션을 실행해보겠습니다.
$ npm start
> app@1.0.0 start /Users/dale/temp/app
> node .
Example app listening at http://localhost:3000
브라우저 또는 새 터미널 탭에서 http://localhost:3000
에 접속을 하면 다음과 같이 예상했던 응답 결과를 볼 수 있으실 것입니다.
$ curl http://localhost:3000
Hello World!
Dockerfile 작성
이제부터 Docker를 이용한 개발 환경을 구축해보도록 하겠습니다.
가장 먼저 할 일은 지금까지 작성한 서버 애플리케이션에 대한 이미지(image)를 뜨기 위한 Dockerfile
을 작성하는 것입니다.
container image에는 애플리케이션 코드 뿐만 아니라 애플리케이션이 필요한 환경 구성 및 실행에 필요한 작업 내용이 포함되어야 합니다.
Docker는 Dockerfile
이 담고 있는 명령어를 순차적으로 실행하면서 image를 빌드(build)합니다.
예를 들어, 지금까지 Docker 없이 어떻게 개발 작업을 진행했는지 돌이켜보겠습니다.
- NodeJS 설치
- 디렉터리 생성
package.json
파일 생성- Express 패키지 설치
- 코드 작성
- 애플리케이션 구동
이렇게 우리가 하나씩 직접했던 작업을 Dockefile
에 그대로 녹인다고 생각하면서, 필요한 명령어를 나열해보면 다음과 같습니다.
- Dockefile
FROM node:12-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --silent
COPY . .
CMD [ "npm", "start" ]
EXPOSE 3000
Docker image의 파일 시스템의 node_modules
디렉터리가 현재 로컬 작업 디렉터리의 node_modules
디렉터리로 덮어써지지 않도록 .dockerignore
파일도 추가해줍니다.
- .dockerignore
node_modules
npm-debug.log
Image Build
위에서 작성한 Dockerfile
를 토대로, container image를 Docker로 빌드(build)합니다.
$ docker build -t app .
Sending build context to Docker daemon 2.004MB
Step 1/7 : FROM node:12-alpine
---> f77abbe89ac1
Step 2/7 : WORKDIR /app
---> d35e95c03571
Step 3/7 : COPY package*.json ./
---> 7e77d6876704
Step 4/7 : RUN npm install --silent
---> Running in 7453abb5fdc0
added 50 packages from 37 contributors and audited 126 packages in 1.206s
found 0 vulnerabilities
Removing intermediate container 7453abb5fdc0
---> 454302f786e2
Step 5/7 : COPY . .
---> 19c3d2fe74fe
Step 6/7 : CMD [ "npm", "start" ]
---> Running in e5e9051ceb31
Removing intermediate container e5e9051ceb31
---> a00e9754efe5
Step 7/7 : EXPOSE 3000
---> Running in c6245ff2c051
Removing intermediate container c6245ff2c051
---> 72d0694caa8a
Successfully built 72d0694caa8a
Successfully tagged app:latest
방금 빌드한 이미지는 다음과 같이 확인할 수 있습니다.
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 72d0694caa8a About a minute ago 92.1MB
node 12-alpine f77abbe89ac1 12 days ago 88.1MB
Container 실행
이제 빌드된 image를 Docker container로 실행해볼 차례입니다.
host의 포트 5000
로 들어오는 트래픽을 container의 포트 3000
으로 포워딩(forwarding)시키겠습니다.
$ docker run -p 5000:3000 app
> app@1.0.0 start /Users/dale/temp/app
> node .
Example app listening at http://localhost:3000
위에서 로컬 디렉터리에서 실행했을 때와 동일한 로그가 보일 것입니다.
마찬가지로 브라우저 또는 새 터미널 탭에서 http://localhost:5000
에 접속을 하면 동일한 응답 결과를 볼 수 있으실 것입니다.
$ curl http://localhost:5000
Hello World!
여기서 주의할 점은 http://localhost:3000
가 아닌 http://localhost:5000
에 접속을 해야 한다는 점입니다.
3000
은 container의 내부 네트워크에서 사용되는 포트이고, 지금은 host에서 접속하는 상황이기 때문에 포워딩된 포트 5000
으로 접속해야 합니다.
서버 애플리케이션 수정
Hello World!
대신에 Hello NodeJS!
를 응답하도록 서버 애플리케이션을 수정해보겠습니다.
- index.js
// 생략
app.get("/", (req, res) => res.send("Hello NodeJS!"));
// 생략
로컬에서 아직 서버가 떠 있다면 중단시키고 다시 서버를 띄우면 변경된 코드가 동작할 것입니다.
$ node .
Example app listening at http://localhost:3000
$ curl http://localhost:3000
Hello NodeJS!
이렇게 코드를 변경할 때 마다 서버를 재구동하면 개발이 매우 불편할 것입니다. 따라서, Nodemon을 설치하여 코드 변경을 감지하여 자동으로 서바가 재구동되도록 셋업을 하겠습니다.
먼저, npm으로 nodemon
패키지를 설치합니다.
$ npm i -D nodemon
그리고 node
대신에 nodemon
으로 서버를 실행하기 위해서 npm start
스크립트를 수정합니다.
- package.json
// 생략
"scripts": {
// "start": "node ."
"start": "nodemon ."
},
// 생략
다시 서버를 구동하고, 코드를 수정하면 바로 서버가 재구동되어 변경 사항이 반영되는 것을 확인할 수 있습니다.
$ npm start
> app@1.0.0 start /Users/dale/temp/app
> nodemon .
[nodemon] 2.0.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node .`
Example app listening at http://localhost:3000
[nodemon] restarting due to changes...
[nodemon] starting `node .`
Example app listening at http://localhost:3000
Image Re-build
패키지를 추가로 설치하고, 애플리케이션 코드가 변경이 되었기 때문에 container image를 다시 빌드해야 합니다.
$ docker build -t app .
Sending build context to Docker daemon 4.101MB
Step 1/7 : FROM node:12-alpine
---> f77abbe89ac1
Step 2/7 : WORKDIR /app
---> Using cache
---> d35e95c03571
Step 3/7 : COPY package*.json ./
---> 19a62b523c51
Step 4/7 : RUN npm install --silent
---> Running in c065e658a150
Love nodemon? You can now support the project via the open collective:
> https://opencollective.com/nodemon/donate
added 152 packages from 81 contributors and audited 267 packages in 7.603s
1 package is looking for funding
run `npm fund` for details
found 0 vulnerabilities
Removing intermediate container c065e658a150
---> ce1d94d9a42c
Step 5/7 : COPY . .
---> 1c6a54516b65
Step 6/7 : CMD [ "npm", "start" ]
---> Running in 4fe914c9f3f7
Removing intermediate container 4fe914c9f3f7
---> e34bbe1f6f29
Step 7/7 : EXPOSE 3000
---> Running in 11ed3ee81bc3
Removing intermediate container 11ed3ee81bc3
---> 25aa20b5aa5d
Successfully built 25aa20b5aa5d
Successfully tagged app:latest
이제 재빌드된 image를 Docker container로 다시 실행해보면 마찬가지로 Nodemon이 애플리케이션을 실행해주는 것을 확인할 수 있습니다.
$ docker run -p 5000:3000 app
> app@1.0.0 start /app
> nodemon .
[nodemon] 2.0.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node .`
Example app listening at http://localhost:3000
http://localhost:5000
에 접속해보면 변경된 응답을 확인할 수 있습니다.
$ curl http://localhost:5000
Hello NodeJS!
Bind Mount
여기서 Docker로 개발을 진행하는데 한 가지 큰 문제점이 발생합니다. 바로, 아무리 로컬(host 컴퓨터)에서 코드를 수정해도, container 내부에서 돌아기는 Nodemon은 이 변경 사항을 감지하지 못한다는 것입니다. 왜냐하면, container는 host로 부터 격리된 파일 시스템을 가지기 때문에, image 빌드 당시의 코드 본사본을 계속 쳐다보고 있기 때문입니다.
이 문제를 해결하기 위해서는 container 내부에서 돌아가는 애플리케이션도 로컬 작업 디렉터리를 바라볼 수 있게 마운트(mount)해줘야 합니다.
$ docker run -p 5000:3000 -v $PWD:/app app
자 이제, 로컬에서 코드를 수정하면 container에서 돌아가는 Nodemon이 변경 사항을 감지하고 서버를 재구동해줄 것입니다.
저는 Hello NodeJS!
대신에 Hello Docker!
를 응답하도록 서버 애플리케이션을 수정해보았습니다.
$ curl http://localhost:5000
Hello Docker!
마치면서
이상으로 NodeJS로 작성한 간단한 Express 서버 애플리케이션을 container image로 빌드하여 Docker container 안에서 실행하는 방법에 대해서 살펴보았습니다. 또한, 로컬에서 수정한 코드를 container 안 에서 돌아가는 애플리케이션에 자연스럽게 반영하는 방법에 대해서도 알아보았습니다.
이렇게 개발 환경을 컨테이너화(containerization)해놓고 해당 프로젝트를 Dockerfile
함께 Github와 같은 코드 저장소에 올려두면 개발자들은 Docker만 설치하면 바로 애플리케이션을 띄우고 개발을 시작할 수 있습니다.
왜냐하면, NodeJS 런타임 설치부터 Express, Nodemon 패키지 설치가 image가 떠져서 container 안에서 이미 모두 갖춰지 있기 때문입니다.
또한 모든 개발자들이 동일한 개발 환경에서 작업하는 것을 보장받을 수 있어서 개개인 간의 미묘한 세팅 차이로 인한 황당한 상황도 피할 수 있습니다.