11/06/2023

Dockerfile

발단

강의로만 듣다가 실제로 Dockerfile을 작성하는 시간이 왔다! "길을 아는 것과 길을 걷는 것의 차이가 있다."라는 영화 속의 대사처럼 강의보고 주어진 연습문제만 풀다가 실제로 적용하려고 하니 어려움이 굉장히 많았다... 주말 전부를 수정하고 Docker Compose로 정상적으로 작동하는지 확인하는데 소비했다. 힘들고 지쳤지만 결과는 항상 달콤하다~


시작부터 고난

일단 작성을 하려고 feature/dockerfile 브랜치를 생성하고 Dockerfile 파일을 만들었다. 그리고 멘붕이 왔다... 기본 이미지를 정해야 하는데 여기서부터 막혔다! 강의에서는 Alpine Linux 배포판을 주로 사용해서 그대로 사용하려고 했는데 알고보니 NodeJS 버전도 중요하기에 터미널을 켜고 버전을 찍어보니 16.15.0! FROM 명령 옆에 이를 작성하고 다음으로 넘어갔는데 또 어떻게 해야하지... 머리가 하얘졌다~ 결국 검색을 하면서 Dockerfile을 참조했고 삽질을 반복하면서 여러 관행도 배웠다.


기본 이미지

알고보니 기본 이미지 선택이 가장 어렵다고 한다! 완벽한 기본 이미지는 없는데 그 이유는 바로 모든 것이 타협이기 때문이다. 다시 말해, 단순함, 유연함, 보안 또는 크기와 같은 기준을 저울질 하다보면 타협이 불가결하다. 그렇다면 NodeJS 이미지의 일반적인 목표는 무엇일까? 크게 4가지로 NodeJS 팀의 티어 1(완전한 테스트 커버리지를 위한 인프라를 유지 관리하며 테스트가 실패하면 릴리스를 차단한다.) 지원, 최소한의 CVE(보안 취약성)으로 위험도가 높은 CVE는 없어야 한다. 버전(패치 수준 포함)이 제어되어 빌드/테스트 재현성을 보장해야 하고 코드와 node_modules를 제외하면 이미지 크기가 200MB 미만이어야 한다. 찾아보면 Alpine, Slim, Ubuntu, Debian 등 NodeJS 이미지가 많은데 처음에 사용한 Alpine을 사용하지 말라고 한다!

Alpine 이미지는 보안을 중요시하는 busybox와 musl libc를 기반으로 하지만 공식 Alpine 기반 NodeJS 이미지(node:alpine)는 몇 가지 결함을 가진다. musl libc는 NodeJS에서 실험적(컴파일되지 않을 수 있거나 테스트 스위트가 통과하지 않을 수 있다.)으로만 간주되며 Alpine 패키지 버전은 주요 또는 패치 수준에서 신뢰할 수 없이 고정될 수 없다. 이미지 태그를 고정할 수 있지만 내부의 apk 패키지를 고정하면 언젠가 apk 시스템이 패키지 버전을 업데이트하면 이미지 빌드가 실패할 수 있다는 의미이다. 또한, 이미지 크기를 줄이기 위해 Alpine을 사용하는 것은 적합하지 않는데 애플리케이션 의존성은 보통 기본 이미지 크기보다 크다! 그리고 무엇보다 다른 기본 이미지(node:slim, ubuntu, distroless)도 Alpine과 거의 동일한 크기를 가지면서 잠재적인 부정적인 요소가 없다. 마지막으로 여러 프로덕션 문제(파일 I/O 및 성능)가 존재한다고 한다.

공식 표준 이미지도 문제가 있다고 한다! 편리하지만 사용 편의성에 중점을 두었으며 운영 환경에는 적합하지 않다. 운영 환경에서 전혀 필요하지 않을 많은 패키지가 포함되어 있는데 예를 들면, imagemagick, 컴파일러, MySQL 클라이언트, svn/mercurial과 같은 패키지들이다. 그래서 이미지에 위험도가 높은 CVE가 많이 존재하기에 운영 환경에서 사용하기 어렵다.

다른 기본 이미지에 대해서도 많은 내용이 있었는데 사용 목적에 따라 권장되는 몇 개의 기본 이미지가 존재한다. 일반적인 개발/테스트/운영의 경우 사용하기 편리한 기본 이미지는 Slim, CVE가 더 적은 기본 이미지의 경우 자체 기본 이미지를 빌드하여 Ubuntu와 NodeJS를 설치한다(공식 빌드, 이미지 복사 또는 deb 패키지). 이러한 내용을 바탕으로 Slim을 선택했고 NodeJS 버전에 맞게 node:16.15.0-slim을 기본 이미지로 정했다!


USER node

Docker 공식 문서를 보면 기본적으로 컨테이너는 루트 사용자로 실행된다. 하지만 사실 루트로 실행될 이유는 거의 없으며 이는 보안 문제를 야기할 수 있다. 보안 관례상 아무런 권한이 없는 애플리케이션으로 포트에서 수신 대기하는 것이 권장되기에 공식 NodeJS 이미지는 기본 이미지에 node라를 사용자를 만들어 놓았다. COPY 명령으로 파일, WORKDIR 명령으로 디렉터리 작업 시 node 사용자로 권한을 설정한다. 그렇기에 소스 코드 복사, 의존성 설치와 같은 작업 전 이 명령을 추가한다.


NPM

리팩토링 시 패키지를 설치하기 위해 위해서 패키지 관리 도구인 npm을 사용했는데 NodeJS 설치 시 자동으로 최신 npm이 설치되며 속도와 성능 때문에 최선 버전이 좋을 수 있지만 구체적인 NodeJS 버전을 선택한 것처럼 안정성을 위해 다시 한 번 터미널을 켜고 npm 버전을 확인한 결과 8.5.5! RUN 명령에 이를 작성했다. 그리고 애플리케이션 의존성 설치하려고 했는데 npm ci라는 명령이 있었다. 개발 환경처럼 npm install을 사용하면 되는 줄 알았는데 그게 아니었다!! 먼저 npm ci을 사용하여 운영 환경(단, NODE_ENV=production)만 설치한 다음 개발 환경과 테스트 환경을 그 위에 설치해라고 한다. 즉, package.json 파일을 복사한 다음 일단 운영 환경 의존성만 설치하고 나중에 개발 환경 및 테스트 환경 설치 시 npm install을 사용한다(npm install과 npm ci에 대한 차이는 이 포스팅에서 다룬다).


일단 완료

빌드 중 몇 가지 오류가 발생했는데 큰 문제는 아니라서 넘어가겠다~ 아래와 같이 Dockerfile을 작성했고 Docker Hub에 애플리케이션 이미지를 푸시했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 슬림을 사용하고 추가 패키지가 필요한 경우 apt를 사용한다.
# Alpine은 NodeJS에서 공식적으로 지원되지 않으므로 기본 Debian을 사용한다.
FROM node:16.15.0-slim
 
# 운영 환경으로 NODE_ENV를 설정한다.
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
 
# NodeJS의 기본 포트를 3000으로 설정한다.
ARG PORT=3000
ENV PORT $PORT
 
# 컨테이너가 실행시간에 해당 포트에서 수신 대기하지만 포트를 공개하지는 않는다.
EXPOSE $PORT
 
# 속도와 버그 수정을 위해 NodeJS 버전과 상관없이 최신 NPM을 사용할 수 있지만 개발 환경과 맞는 NPM을 설치한다.
RUN npm i npm@8.5.5 -g
 
# 공식 NodeJS 이미지는 보안을 위해 특권이 없는 사용자를 제공하는데 수동으로 활성화 해야한다.
# NPM이 애플리케이션을 실행하는 동일한 사용자로 의존성을 설치한다.
USER node
 
# USER를 먼저 설정하면 WORKDIR이 올바른 권한(즉, node)을 설정한다.
WORKDIR /opt/node_app
COPY --chown=node:node package*.json ./
# 운영 환경은 npm ci를 사용한다(NODE_ENV=production: 배포 의존성만 설치한다.).
RUN npm ci && npm cache clean --force
ENV PATH /opt/node_app/node_modules/.bin:$PATH
 
USER node
WORKDIR /opt/node_app/app
# 소스 코드를 복사한다.
# 캐시 때문에 변화가 많은 소스 코드를 의존성 뒤에 복사한다.
COPY --chown=node:node . .
 
USER node
WORKDIR /opt/node_app/app
# 애플리케이션을 빌드한다.
RUN npm run build
# 애플리케이션을 실행한다.
CMD ["node", "dist/src/main"]
cs

푸시 완료!

근데 중요한 점은 제대로 작동하는지 테스트를 해야하는데 어떻게 하지?? 또, 개발 환경과 테스트 환경은?!


다단계 빌드

Dockerfile을 작성했는데 큰 문제는 현재 Dockerfile은 순전히 운영 환경에서만 작동하도록 작성되었다. 개발 환경과 테스트 환경은 어떻게 해야할까? 해결책은 Compose였다! Compose는 다중 Docker 컨테이터 애플리케이션을 정의하고 실행하기 위한 툴로 YAML 파일을 사용하여 애플리케이션의 서비스를 구성한다. 간단하게 말해서, docker container run 명령의 모든 옵션을 기억할 필요없이 여러 개의 컨테이너를 가상 네트워크로 연결하고 서로 간의 관계를 설정하고 각 컨테이너의 포트만 공개해서 하나의 명령으로 모든 컨테이너를 실행하고 종료할 수 있다. 2개의 컨테이너 리팩토핑 애플리케이션과 데이터베이스인 MySQL 컨테이너가 필요하다.

앞서 말했듯이 현재 Dockerfile은 운영 환경에서만 사용 가능하다. 개발 환경과 테스트 환경에서 Dockerfile을 사용할려면 각각 Dockerfile을 생성할 수 있다. 하지만 이는 너무 비효율적이고 유지 보수하기 어렵다. 그래서 사용할 수 있는 것이 바로 다단계 빌드이다. 즉, 하나의 Dockerfile을 개발 환경, 테스트 환경 및 운영 환경 모두에 사용하는 개념이다. Dockerfile을 최적화하는 동시에 읽기 쉽고 유지보수 하기 쉬운 상태로 유지할 수 있다. 각 단계의 일반적인 흐름은 다음과 같다.
  • base: 모든 운영 환경 의존성을 가지지만 코드는 없다.
  • dev: base 단계를 기반으로 하며 모든 개발 의존성을 가지며 base 단계처럼 아직 코드는 없다(dev 단계는 소스 코드가 이미 바인드 마운트되어 있다.).
  • source: base 단계를 기반으로 하며 코드를 추가한다.
  • test/audit: source 단계를 기반으로 하며 테스트를 실행하기 위해 dev 단계의 의존성을 복사(COPY --from=dev)하고 선택적으로 코드를 검사하고 린트한다(이미 git push에서 하지 않은 경우).
  • prod: source 단계를 기반으로 하며 souce 단계에서 변경사항이 없지만 특정 단계를 지정하지 않은 경우 빌더는 기본적으로 이 단계를 사용한다.

--target dev는 개발 환경을 위해 컨테이너로 바인드 마운트하는 경우, --target test는 단위 테스트 같은 자동화된 CI 테스트를 하는 경우, --target prod는 devDependencies를 포함하지 않고 서버에서 실행하는 경우를 위해 사용된다.

이 내용을 기반으로 다단계 빌드를 적요한 Dockerfile은 다음과 같다. dev 단계와 test 단계를 Compose 파일에 명시해서 개발 환경과 테스트 환경을 구축할 수 있는데 파일 내용을 적지는 않겠다. 스크롤의 압박... 개선이 필요한 점은 CMD 명령 부분이다. npm, yarn과 같은 노드 프로세스 관리자를 사용하지 말고 그냥 node를 사용하라고 하는데 개발 환경과 테스트 환경은 적용 방법을 아직 알아내지 못했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# base 단계
# 항상 slim을 사용한다! 추가 패키지가 필요한 경우 apt를 사용하여 추가한다.
# Alpine은 Node.js에서 공식적으로 지원되지 않으므로 기본 Debian을 사용한다.
FROM node:16.15.0-slim as base
 
# 기본적으로 운영 환경으로 NODE_ENV를 설정한다.
# Docker Compose를 사용하여 빌드 및 실행을 위해 개발, 테스트 환경을 설정한다.
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
 
# NodeJS의 기본 포트를 3000으로 설정한다.
ARG PORT=3000
ENV PORT $PORT
 
# 컨테이너가 실행시간에 해당 포트에서 수신 대기하지만 포트를 공개하지는 않는다.
EXPOSE $PORT
 
# 의존성 이미지가 시작될 때까지 기다리기 위한 유틸리티(즉, MySQL 완료 후 NestJS 애플리케이션 실행한다.)
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
# 속도와 버그 수정을 위해 NodeJS 버전과 상관없이 최신 NPM을 사용할 수 있지만 개발 환경과 맞는 NPM을 설치한다.
RUN npm i npm@8.5.5 -g
 
# 공식 NodeJS 이미지는 보안을 위해 특권이 없는 사용자를 제공하는데 수동으로 활성화 해야한다.
# NPM이 애플리케이션을 실행하는 동일한 사용자로 의존성을 설치한다.
USER node
 
# 개발 시 애플리케이션 바인드 마운트를 쉽게하기 위해 의존성을 먼저 다른 위치에 설치한다.
# USER를 먼저 설정하면 WORKDIR이 올바른 권한(즉, node)을 설정한다.
WORKDIR /opt/node_app
COPY --chown=node:node package*.json ./
# CI, 배포, 테스트와 같은 자동화 환경의 경우 npm ci 사용(NODE_ENV=production -> 배포 의존성만 설치한다.)
RUN npm ci && npm cache clean --force
 
ENV PATH /opt/node_app/node_modules/.bin:$PATH
 
 
# dev 단계
# 개발 시 애플리케이션을 바인드 마운트 할 것이기 때문에 COPY 명령을 생략한다.
# 개발 환경에서 Docker Compose를 사용하여 개발할 때 시간을 절약한다.
FROM base as dev
 
# NODE_ENV를 개발 환경으로 설정한다.
ENV NODE_ENV=development
 
USER node
 
WORKDIR /opt/node_app
# 개발 환경은 npm install을 사용한다.
RUN npm install
 
WORKDIR /opt/node_app/app
CMD ["npm", "run", "start:dev"]
 
 
# source 단계
# 소스 코드를 다음 두 단계에서 사용하기 위해 빌더로 가져온다.
# 두 번 복사하지 않도록 자체 단계로 구성한다.
FROM base as source
 
USER node
 
WORKDIR /opt/node_app/app
# 소스 코드를 복사한다.
COPY --chown=node:node . .
 
 
# test 단계
# CI에서 사용할 수 있다.
FROM source as test
 
# 테스트 환경은 개발 환경과 동일하다.
ENV NODE_ENV=development
 
USER node
 
# 모든 의존성(운영 환경 및 개발 환경)을 복사한다.
COPY --from=dev /opt/node_app/node_modules /opt/node_app/node_modules
 
# 단위 테스트를 실행한다(TO-DO: E2E 테스트).
CMD ["npm", "run", "test"] 
 
 
# prod 단계(빌드 시 --target 옵션으로 단계를 지정하지 않으면 기본 단계로 설정된다.)
FROM source as prod
 
USER node
 
WORKDIR /opt/node_app/app
# 애플리케이션을 빌드한다.
RUN npm run build
 
# 애플리케이션을 실행한다.
CMD ["node", "dist/src/main"]
cs


헬스체크

운영 환경의 경우 애플리케이션의 실행 여부를 넘어 상태가 정상인지 확인하는 방법이 필요한데 이를 위해 HEALTHCHECK 명령을 사용한다. Dockerfile, Compose 파일, docker container run 명령, 스택 파일, service update 및 service create 명령 등 모든 다양한 파일에서 작동하며 운영 환경으로 옮길 때 HEALTHCHECK 명령의 옵션을 테스트하는 것을 강력히 권장된다. 처음에는 curl을 설치한 다음 애플리케이션이 정상일 경우 HTTP 상태 코드 200을 반환하는 엔드포인트를 만들었는데 이 방법은 보안 때문에 권장하지 않으며 이미지의 크기도 증가한다. 그래서 더 적합한 방법은 curl보다 더 많은 작업을 수행할 수 있는 사용자 정의 코드(e.g., node healtcheck.js)를 사용해서 처음부터 curl을 설치하지 않는 것이다. NestJS 공식 문서의 Terminus 패키지를 설치해서 헬스체크를 수행하는 모듈을 만들고 이 모듈의 컨트롤러의 엔드포인트를 호출하는 파일을 생성하였고 애플리케이션 실행 결과 정상 상태를 반환했다!
1
2
3
4
5
6
7
8
9
10
11
12
13
...
 
# curl을 설치한다. 이미지 크기가 꽤 증가한다.
# RUN apt-get update && apt-get install -y curl
 
# curl을 사용하면 호출하는 엔드포인트
# HEALTHCHECK --interval=30s CMD curl -f http://localhost:3000/healthcheck || exit 1
 
# 사용자 정의 파일에서 위의 엔드포인트를 호출한다. 즉, 기능은 같지만 curl 설치가 필요없고 다른 기능을 추가할 수 있다.
# 애플리케이션이 HTTP 200을 반환하는지 확인하려면 30초마다 확인한다.
HEALTHCHECK --interval=30s CMD node /opt/node_app/app/dist/healthcheck.js
 
...
cs

정상 상태(healthy)!

update: 2023.11.06

댓글 없음:

댓글 쓰기