Profile picture

[배포 가이드] Containerize my apps - 이전에 만든 앱 컨테이너화 하기

JaehyoJJAng2023년 11월 23일

Container toplogy

image


📌 사전 준비

먼저 express 앱의 경우 redis 연동이 필요하니 express 앱을 컨테이너로 기동하기 전에 redis 컨테이너가 먼저 생성되어 있어야 한다.

컨테이너 간은 격리된 네트워크에서 동작하니 레디스 컨테이너와 express 컨테이너를 같은 네트워크에 묶어줘야 한다.


"express-net" 이라는 도커 네트워크를 만들어주자.

$ docker network create express-net

이제 컨테이너 생성 시 --network 옵션을 줘서 두 컨테이너를 같은 네트워크에 묶어주기만 하면 끝이다.


express 컨테이너의 경우 아래 단계들을 따라하며 천천히 진행해보도록 하고, 먼저 redis 컨테이너를 미리 생성해주도록 하겠다.

$ docker run -d -it --name redis --network=express-net redis:latest

정상적으로 레디스 컨테이너가 생성되었다면 이제 아래 배포 단계들을 따라해보도록 하자.


💿 배포

프로젝트 폴더 구조는 아래와 같다.

$ tree -L 2 .
.
├── Dockerfile
├── app
│   ├── app.ts
│   ├── index.test.ts
│   └── index.ts
├── dockerignore
├── github_team_workflows
│   ├── deploy.yaml
│   └── test.yaml
├── jest.config.js
├── package-lock.json
├── package.json
├── scripts
│   ├── kill-app.sh
│   └── start-app.sh
└── tsconfig.json

Dockerfile 내용을 기반으로 이미지를 생성하는 것부터 캐시 활용, 멀티 스테이지 빌드 등을 적용하면서 Dockerfile을 단계 별로 개선해 나갈 것이다.

아래 내용들을 천천히 따라해도록 하자.


• App 이미지 생성

이전에 만든 [Deploy] 가상서버(Virtual Machine)에 node API 배포하기 - AWS Lightsail express 앱을 도커 이미지로 만들어 볼 것이다.


base 이미지의 경우 node.js 에서 공식적으로 관리하는 노드기반 도커 이미지를 사용할거다.
(node:latest - Dockerhub)


현재 프로젝트 경로에 Dockerfile을 생성한 후 아래 코드를 붙여넣기 하도록 하자.

FROM node:18

COPY ./ ./

RUN npm install -D
RUN npm run build

CMD [ "npm", "run", "start"]

이제 위 Dockerfile을 기반으로 도커 이미지를 생성해보자.

$ docker build --tag yshrim12/express -f Dockerfile .

생성된 이미지를 확인해보자.

$ docker images yshrim12/express
REPOSITORY         TAG       IMAGE ID       CREATED              SIZE
yshrim12/express   latest    52c6374c7939   About a minute ago   1.18GB

종속성 설치 및 빌드 과정이 포함되어 있어 용량(1.18GB)이 상당히 크게 나온다.
아래 단계를 지나다보면 용량 문제를 해결하는 방법도 나오니 계속 따라해보자.


express 컨테이너를 생성해보자!

$ docker run -d -it --name express -p 4000:4000 --network=express-net yshrim12/express

express 컨테이너 상태와 로그를 조회해보자.

$ docker ps
CONTAINER ID   IMAGE              COMMAND                   CREATED          STATUS          PORTS                                       NAMES
a9573eaad2d4   yshrim12/express   "docker-entrypoint.s…"   41 seconds ago   Up 40 seconds   0.0.0.0:4000->4000/tcp, :::4000->4000/tcp   express

$ docker logs express
> express@1.0.0 start
> node build/index.js

trying to start server
App listening at port 4000

정상적으로 실행됨을 볼 수 있다.


• .env 분리

위에서 작성한 Dockerfile에는 치명적인 단점이 하나 있다.

바로 Dockerfile의 COPY ./ ./ 이 부분인데, .env 환경 변수를 포함한 현재 프로젝트 경로에 있는 모든 파일이 express 컨테이너로 옮겨진다는 점이다.

만약 .env 파일이 포함된 이미지를 Docker hub나 다른 regisry 저장소에 퍼블릭으로 배포를 하게되면 보안에 민감한 환경 변수 내용들이 모두 노출되는 것이다.


이러한 점을 예방할 수 있는 가장 좋은 방법이 있다. 바로 .dockerignore 파일을 사용하는 것이다. git에는 `.gitignore`가 있는 것 처럼 도커에도 `.dockerignore`가 존재한다.

현재 .gitignore에는 아래와 같은 디렉토리 및 파일들이 포함되어있다.

$ cat .gitignore
.env
node_modules/
build/
.DS_STORE/
app_log/

.dockerignore에도 컨테이너에 포함되지 않을 파일 및 디렉토리를 추가해주면 된다.

$ cat .dockerignore
.git/
.gitignore
Dockerfile
.env.sample
.env
.github/
github_team_workflows/

이제 .dockerignore를 추가했으니 이미지를 다시 빌드한 후 컨테이너를 실행해보자!

$ docker build --tag yshrim12/express .
$ docker run -d -it --name express -p 4000:4000 --network=express-net yshrim12/express

그런데 express 컨테이너가 제대로 실행되지가 않는다!

$ docker logs express
> express@1.0.0 start
> node build/index.js

/build/index.js:44
    throw new Error("PORT is required");
    ^

Error: PORT is required
    at Object.<anonymous> (/build/index.js:44:11)
    at Module._compile (node:internal/modules/cjs/loader:1356:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1414:10)
    at Module.load (node:internal/modules/cjs/loader:1197:32)
    at Module._load (node:internal/modules/cjs/loader:1013:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:128:12)
    at node:internal/main/run_main_module:28:49

Node.js v18.19.0

PORT 환경 변수가 빈 값으로 넘어간 것 같다. 이유가 뭘까?


🔺 트러블 슈팅

어찌보면 당연한 애기이다. .dockerignore에 등록된 파일 및 폴더는 컨테이너 빌드 과정에 포함되지 않는다.

그 말인 즉슨, .env 파일 또한 컨테이너에 올라가지 않는다는 것! 그렇기 때문에 코드에서 환경 변수를 읽어들일 수 없게 된 것이다.

도커에는 --env-file 이라는 옵션이 존재한다. 환경 변수 파일을 --env-file로 컨테이너에 등록할 수 있게되는 거다.

--env-file로 환경 변수 파일을 등록할 경우 컨테이너에는 해당 파일의 내용을 가지고 컨테이너에 환경 변수가 등록되게 된다.


먼저, 기존 오류가 난 컨테이너를 없애버리자.

$ docker rm express

그런 다음에 다시 --env-file 옵션을 적용하여 다시 컨테이너를 실행해보자.

$ docker run -d -it --name express --env-file=.env --network=express-net -p 4000:4000 yshrim12/express

express 컨테이너 로그를 다시 확인해보자.

$ docker logs express
> express@1.0.0 start
> node build/index.js

trying to start server
App listening at port 4000

이제는 정상적으로 실행된다!


• 도커 캐시 활용

빌드 과정은 상황에 따라서 매우 오래 걸릴 수 있게 된다.

도커는 이미지 빌드 프로세스에서 캐싱을 사용하여 효율성을 높이고 빌드 시간을 단축할 수 있다. 캐싱은 이전 빌드에서 생성된 산출물을 재사용함으로써 중복 작업을 피하고, 변경 사항이 없는 부분은 다시 계산하지 않도록 도와준다.

이는 다음과 같은 이점을 제공한다.

  • 빠른 빌드 속도: 이미지 빌드 중에 변경되지 않은 부분은 이전에 캐시된 레이어를 사용하여 해당 부분을 다시 빌드하지 않아도 됩니다. 이것은 전반적으로 빌드 시간을 크게 단축시켜줍니다.
  • 효율적인 리소스 사용: 이미지 빌드 과정에서 변경되지 않은 부분은 재사용되므로, 불필요한 자원 소비를 줄여줍니다.
  • 일관성 있는 환경: 캐싱을 통해 동일한 Dockerfile을 사용하여 일관된 환경에서 이미지를 만들 수 있습니다. 변경된 부분만 새로 빌드되고, 나머지는 이전의 레이어를 사용함으로써 일관성을 유지합니다.
  • 더 작은 이미지 크기: 이전 레이어가 재사용되면서 중복이 줄어들고, 결과적으로 이미지 크기가 줄어듭니다.

일반적으로 Dockerfile에서 각 명령은 각각의 레이어로 생성된다. 이 때문에 Docker는 각 단계를 캐시하고, 이전에 빌드된 캐시를 활용하여 변경된 부분만 다시 빌드하게 되는 것이다. 하지만 캐시는 적절하게 사용해야 하며, 때로는 캐시를 무시하고 다시 빌드해야 하는 경우도 있다.
(예: 종속성이나 소스 코드 변경 등)


좋은 Dockerfile을 작성하여 캐시를 효과적으로 활용하면 더 빠르고 효율적인 이미지 빌드를 할 수 있다. 우리도 캐싱을 사용하여 기존 Dockerfile을 개선시켜보자.

FROM node:18-alpine

COPY ./package*.json ./
RUN npm install -D

COPY ./ ./
RUN npm run build

CMD [ "npm", "run", "start" ]

위 내용을 기반으로 이미지를 다시 빌드해보자.

$ docker build --tag yshrim12/express -f Dockerfile .
[+] Building 37.8s (10/10) FINISHED                                                       docker:default
 => [internal] load .dockerignore                                                                   0.0s
 => => transferring context: 117B                                                                   0.0s
 => [internal] load build definition from Dockerfile                                                0.0s
 => => transferring dockerfile: 162B                                                                0.0s
 => [internal] load metadata for docker.io/library/node:18-alpine                                   1.5s
 => CACHED [1/5] FROM docker.io/library/node:18-alpine@sha256:4bdb3f3105718f0742bc8d64bb4e36e8f955  0.0s
 => [internal] load build context                                                                   0.0s
 => => transferring context: 481B                                                                   0.0s
 => [2/5] COPY ./package*.json ./                                                                   0.0s
 => [3/5] RUN npm install -D                                                                       30.6s
 => [4/5] COPY ./ ./                                                                                0.0s
 => [5/5] RUN npm run build                                                                         4.4s
 => exporting to image                                                                              1.3s
 => => exporting layers                                                                             1.3s
 => => writing image sha256:9ec2f8e209a06aa85fba42bb92d502037384c40158c116b1dd51985a488c71bb        0.0s
 => => naming to docker.io/yshrim12/express

최초 빌드시 37.8s가 걸린 것을 확인할 수 있다.


app/app.ts의 소스 코드에서 일부분을 변경해보자.

... 
app.get("/", (request, response) => {
    response.status(200).send("hello from express, new image");

다시 이미지를 빌드해보자!

$ docker build --tag yshrim12/express -f Dockerfile .
[+] Building 5.2s (10/10) FINISHED                                                        docker:default
 => [internal] load .dockerignore                                                                   0.0s
 => => transferring context: 117B                                                                   0.0s
 => [internal] load build definition from Dockerfile                                                0.0s
 => => transferring dockerfile: 162B                                                                0.0s
 => [internal] load metadata for docker.io/library/node:18-alpine                                   0.7s
 => [1/5] FROM docker.io/library/node:18-alpine@sha256:4bdb3f3105718f0742bc8d64bb4e36e8f955ebbee29  0.0s
 => [internal] load build context                                                                   0.0s
 => => transferring context: 1.17kB                                                                 0.0s
 => CACHED [2/5] COPY ./package*.json ./                                                            0.0s
 => CACHED [3/5] RUN npm install -D                                                                 0.0s
 => [4/5] COPY ./ ./                                                                                0.0s
 => [5/5] RUN npm run build                                                                         4.4s
 => exporting to image                                                                              0.0s
 => => exporting layers                                                                             0.0s
 => => writing image sha256:f17d4f25c30b976c803ba5942e9b0731d3876764a899510b0a1fd7ee1b44fdbd        0.0s
 => => naming to docker.io/yshrim12/express

캐싱 덕분에 5.2초 밖에 걸리지 않은 것을 확인할 수가 있다!


• Multi stage build

Dockerfile의 경우 build와 production 스테이지를 각각 분리하여 도커 이미지를 경량화 시킬 수 있다.

이전 이미지의 경우 의존성으로 추가된 패키지들을 설치하고, 빌드과정까지 포함되다 보니 상당히 큰 용량으로 탄생하게 된다.

기존에 사용하던 이미지처럼 빌드와 실행 과정을 모두 담아도 상관은 없지만 빌드와 실행을 나누는 Builder pattern도 존재한다.

Builder image에서는 앱 빌드에 필요한 의존성 설치와, 빌드 후 바이너리를 생성하고
실제로 동작하는 Running image에서는 Builder image로부터 바이너리만 받아서 실행하는 방식이다. 이러한 과정을 거치면 결국 Build에만 필요한 불필요한 도구, 라이브러리, 이미지 내 파일들을 제외하고 아주 컴팩트한 이미지에서 바이너리만 가지고 앱을 실행시킬 수 있게된다.


사용법은 아주 단순하다. 한 파일에서 Base 이미지를 바꿔 사용하면 마치 2개 이상의 Dockerfile이 있는 것과 동일하게 빌드 수행이 가능하다. Builder에서 빌드한 바이너리를 실행할 이미지로 전달해주기 위해서 COPY--from 옵션을 통해 실행 이미지로 전달해줄 수 있다.


기존 Dockerfile을 아래와 같이 수정하자. 베이스 이미지도 기존 node:18 이미지가 아닌 경령화 이미지인 node:18-alpine 이미지를 사용할 것이다.

# Build Stage
FROM node:18-alpine AS builder
COPY ./package*.json ./
RUN npm install -D
COPY ./ ./
RUN npm run build

# Production Stage
FROM node:18-alpine AS prod
COPY --from=builder ./build ./build
COPY --from=builder ./package*.json ./
RUN npm install --only=production
CMD [ "npm", "run", "start" ]

위 Dockerfile을 기반으로 새 이미지를 빌드해보자.

$ docker build --tag yshrim12/express-light -f Dockerfile .

새 이미지(yshrim12/express-light)를 확인해보자.

$ docker images yshrim12/express-light
REPOSITORY               TAG       IMAGE ID       CREATED         SIZE
yshrim12/express-light   latest    72b5b7354731   2 minutes ago   176MB

용량이 매우 경량화 된 것을 확인할 수 있다!


새 이미지로 컨테이너를 실행해보자.

$ docker run -d -it --name express -p 4000:4000 --env-file=.env --network=express-net yshrim12/express-light

express 앱이 정상적으로 실행되고 있는지 컨테이너 로그를 확인해보자.

$ docker logs express

> express@1.0.0 start
> node build/index.js

trying to start server
App listening at port 4000

• WORKDIR 적용

현재 생성된 express 컨테이너에 접근하여 ls -lh / 명령어를 실행해보자.

$ docker exec express ls -lh /
total 264K
drwxr-xr-x    1 root     root        4.0K Dec  2 01:26 bin
drwxr-xr-x    2 root     root        4.0K Dec  3 08:20 build
drwxr-xr-x    5 root     root         360 Dec  3 08:29 dev
drwxr-xr-x    1 root     root        4.0K Dec  3 08:29 etc
drwxr-xr-x    1 root     root        4.0K Dec  2 01:26 home
drwxr-xr-x    1 root     root        4.0K Dec  2 01:26 lib
drwxr-xr-x    5 root     root        4.0K Nov 30 09:32 media
drwxr-xr-x    2 root     root        4.0K Nov 30 09:32 mnt
drwxr-xr-x   75 root     root       12.0K Dec  3 08:28 node_modules
drwxr-xr-x    1 root     root        4.0K Dec  2 01:26 opt
-rw-rw-r--    1 root     root      181.6K Dec  3 08:28 package-lock.json
-rw-rw-r--    1 root     root         784 Dec  1 11:17 package.json
dr-xr-xr-x  326 root     root           0 Dec  3 08:29 proc
drwx------    1 root     root        4.0K Dec  3 08:34 root
drwxr-xr-x    2 root     root        4.0K Nov 30 09:32 run
drwxr-xr-x    2 root     root        4.0K Nov 30 09:32 sbin
drwxr-xr-x    2 root     root        4.0K Nov 30 09:32 srv
dr-xr-xr-x   13 root     root           0 Dec  3 08:29 sys
drwxrwxrwt    1 root     root        4.0K Dec  2 01:26 tmp
drwxr-xr-x    1 root     root        4.0K Dec  2 01:26 usr
drwxr-xr-x   12 root     root        4.0K Nov 30 09:32 var

/ 경로에 node_modules 등 패키지 모듈 및 build 폴더 등이 널부러져 있는 것을 알 수가 있다.


저렇게 해놓으면 가독성이 안좋고 유지보수가 하기 힘들뿐더러 볼륨 잡기도 상당히 번거로워질 수 있다.
위와 같은 문제를 해결하기 위해서 Dockerfile에서는 WORKDIR 이라는 옵션을 제공하고 있다.


WORKDIR은 Dockerfile에서 작업 디렉토리를 설정하는 데 사용된다. 이 명령은 다음과 같은 이유로 유용하다.

  • 작업 디렉토리 설정: WORKDIR 명령은 컨테이너 내부에서 실행될 명령이나 파일 작업을 위한 기본 디렉토리를 설정합니다. 이렇게 함으로써 명령을 실행하는 동안 경로를 지정할 필요 없이 상대 경로로 작업할 수 있게 됩니다.
  • 가독성 및 유지보수: Dockerfile을 작성할 때 WORKDIR를 사용하면 명령의 가독성을 높여줍니다. 작업 디렉토리를 설정하면 해당 디렉토리 내에서의 모든 작업이 명확해지므로, Dockerfile을 이해하고 유지보수하는 데 도움이 됩니다.
  • 경로 충돌 방지: 컨테이너 내부에서 경로 충돌을 방지하기 위해 WORKDIR를 사용할 수 있습니다. 작업 디렉토리를 설정함으로써 다른 디렉토리와의 혼란을 줄이고 명시적인 경로를 사용할 수 있습니다.

WORKDIR을 기존 Dockerfile에 적용해보자!

# Build Stage
FROM node:18-alpine AS builder
WORKDIR /builder
COPY ./package*.json ./
RUN npm install -D
COPY ./ ./
RUN npm run build

# Production Stage
FROM node:18-alpine AS prod
WORKDIR /prod
COPY --from=builder /builder/build ./build
COPY --from=builder ./builder/package*.json ./
RUN npm install --only=production
CMD [ "npm", "run", "start" ]

이미지를 재빌드 하도록 하자.

$ docker build --tag yshrim12/express-light .

재빌드된 이미지로 컨테이너를 실행해보자.

$ docker run -d -it --name express -p 4000:4000 --env-file=.env --network=express-net yshrim12/express-light

express 컨테이너에 pwd 명령을 날려보자.

$ docker exec express pwd
/prod

현재 경로가 /prod에 있다고 나온다.
이는 Dockerfile에서 prod stage의 WORKDIR이 /prod로 설정해놨기 때문이다.


• Docker volume 적용

소스코드 실시간 동기화를 위해 Docker Volume을 적용해볼 것이다.


먼저 개발용 Dockerfile을 만들어주자.
(./Dockerfile.dev)

FROM node:18-alpine

WORKDIR /my-app
RUN npm install -g nodemon

COPY ./package*.json ./
RUN npm install -D

COPY ./ ./
RUN npm run build

CMD ["npm", "run", "dev"]

해당 Dockerfile을 기반으로 새로운 이미지를 생성하자.

$ docker build --tag yshrim12/express-dev -f Dockerfile.dev .

volume 옵션을 적용하여 컨테이너를 만들어보자.

$ docker run -d -it --name express-dev -p 4000:4000 \
--env-file=.env --network=express-net \
-v "$(pwd)/app:/my-app/app" \
yshrim12/express-dev

컨테이너가 잘 실행됐는지 로그를 살펴보자.

$ docker logs express-dev
9:14:41 AM - File change detected. Starting incremental compilation...
[0]
[1] [nodemon] restarting due to changes...
[0]
[0] 9:14:42 AM - Found 0 errors. Watching for file changes.
[1] [nodemon] restarting due to changes...
[1] [nodemon] starting `node build/index.js`
[1] trying to start server
[1] App listening at port 4000

이제 로컬에서 변경된 코드가 컨테이너에서도 nodemon을 통해 실시간으로 감지되고 변경되는지 확인해보자.

먼저 로컬에서 app/app.ts 소스코드 일부분을 아래처럼 변경하자.

...
app.get("/", (request, response) => {
    response.status(200).send("hello from express, Added volume options!");

코드를 변경했으면 curl localhost:4000 명령을 날려 변경된 값으로 출력되는지 확인하자.

$ curl localhost:4000
hello from express, Added volume options!

💥 Docker-Compose로 간편하게 개발하기

매번 docker run으로 컨테이너를 띄운다는 것이 얼마나 귀찮고 번거로운가?

이런 설정들을 Docker Compose로 저장하여 간단하게 실행해볼 수 있다!

💡 Docker Compose 를 잘 작성하려면 YAML 문법에 대해 알고 있어야 한다. 관련 내용은 아래 포스팅을 참고하도록 하자.
YAML 이해하기 - WTT Devlog

Docker Compose 관련 문법이나 내용들은 아래 포스팅들을 참고하도록 하자.
Docker-Compose - WTT Devlog


현재 프로젝트 경로에 docker-compose.yaml 란 파일을 만들고 아래 설정들을 붙여넣기 하자.

version: "3.2"

services:
  express:
    depends_on:
      - "redis"
    build:
      context: .
      dockerfile: Dockerfile.dev
    restart: always
    volumes:
      - type: bind
        source: "./app"
        target: "/my-app/app"
    ports:
      - "4000:4000"
    env_file:
      - ".env"
    environment:
      REDIS_URL: "redis://redis:6379"
      PORT: 4000
    networks:
      - "express-net"
    container_name: express-dev

  redis:
    image: redis:alpine
    restart: always
    volumes:
      - type: volume
        source: "data"
        target: "/data"
    networks:
      - "express-net"
    container_name: redis

volumes:
  data: {}

networks:
  express-net:
    driver: bridge
    external: false

docker-compose.yaml 파일 작성이 모두 끝났다면 이제 컨테이너를 배포해보자!

$ docker-compose up -d --build

컨테이너가 정상적으로 실행됐는지 확인하자.

$ docker-compose ps
NAME          IMAGE             COMMAND                   SERVICE   CREATED              STATUS              PORTS
express-dev   express-express   "docker-entrypoint.s…"   express   About a minute ago   Up About a minute   0.0.0.0:4000->4000/tcp, :::4000->4000/tcp
redis         redis:alpine      "docker-entrypoint.s…"   redis     About a minute ago   Up About a minute   6379/tcp

마무리

이렇게 전통적인 방식으로 가상서버에 배포하는 법부터 도커로 배포하는 법 까지 살펴보았다.

이제 차후에 작성될 포스팅에서는 AWS 보안 관련 및 ECS, AWS Image Registry , Docker-Compose로 CI, AWS OpenID Connect 등을 활용하여 컨테이너를 활용한 완벽한 CI/CD 파이프라인을 구축해볼 것이다!


Loading script...