Logo

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 없이 어떻게 개발 작업을 진행했는지 돌이켜보겠습니다.

  1. NodeJS 설치
  2. 디렉터리 생성
  3. package.json 파일 생성
  4. Express 패키지 설치
  5. 코드 작성
  6. 애플리케이션 구동

이렇게 우리가 하나씩 직접했던 작업을 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 안에서 이미 모두 갖춰지 있기 때문입니다. 또한 모든 개발자들이 동일한 개발 환경에서 작업하는 것을 보장받을 수 있어서 개개인 간의 미묘한 세팅 차이로 인한 황당한 상황도 피할 수 있습니다.