레이블이 Docker인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Docker인 게시물을 표시합니다. 모든 게시물 표시

10/22/2024

Kubernetes 배포(Skaffold)

Kubernetes는 컨테이너 오케스트레이션 서비스로 오케스트레이션이라는 단어에서 알 수 있듯이 컨테이너화된 애플리케이션을 하나 이상의 노드로 구성된 클러스트에서 실행한다. 분산 컴퓨팅과 Docker 기반 컨테이너의 확산으로 Kubernetes를 비롯한 많은 컨테이너 오케스트레이션 서비스가 개발되었다. 마이크로서비스 아키텍처의 특성을 고려하면 Kubernetes와 찰떡궁합이라고 말할 수 있다. 이를 적용하면서 배운 내용을 정리한다.


Deployment

디플로이먼트는 포드를 관리하는 오브젝트로 설정 YAML 파일을 작성할 때 여러 속성들 때문에 조금 헷갈렸다. 설정 YAML 파일에서 spec은 생성하려는 오브젝트에 적용하는 속성을 명시하는 부분이다. selector 속성은 생성할 모든 포드를 찾는 방법을 디플로이먼트에 알려주며 matchLabels 속성은 selector 속성이 일치할 레이블을 명시한다.  template 속성은 디플로이먼트가 생성할 각 포드의 설정을 정의한다. 즉, metadata 속성과 spec 속성을 다시 명시하는 부분이다. 리뷰 서비스의 디플로이먼트 YAML 파일은 다음과 같다.
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
apiVersion: apps/v1
kind: Deployment
metadata:
  name: review
spec:
  replicas: 1
  selector:
    matchLabels:
      app: review
  template:
    metadata:
      labels:
        app: review
    spec:
      containers:
        - name: review
          image: csup96/tour-review
          env:
            - name: NODE_ENV
              value: 'development'
            - name: PORT
              value: '3000'
            - name: MONGO_URI
              value: 'mongodb://review-mongo:27017/review'
            - name: REDIS_HOST
              value: 'review-redis'
            - name: REDIS_PORT
              value: '6379'
            - name: JWT_ACCESS_SECRET
              value: 'personal-tour-project-in-nodejs-typescript-access'
            - name: JWT_REFRESH_SECRET
              value: 'personal-tour-project-in-nodejs-typescript-refresh'
            - name: NATS_URL
              value: 'http://nats:4222'
            - name: NATS_CLUSTER_ID
              value: 'tour'
            - name: NATS_CLIENT_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
 
cs


Service

포드를 관리하는 디플로이먼트 설정 파일을 작성하는데 크게 어려운 점이 없었지만 서비스의 경우 달랐다. 서비스는 오브젝트의 일종으로 서로 다른 포드 간의 통신을 설정하거나 클러스터 외부에서 포드에 접근할 수 있는 URL을 제공한다. 따라서, 네트워킹이나 통신을 생각할 때는 항상 서비스를 염두에 두어야 한다. 다시 말해, 생성하는 거의 모든 포드나 디플로이먼트는 항상 관련된 서비스를 가진다고 볼 수 있다. Kubernetes는 크게 4개의 서비스를 제공한다. Cluster IP는 클러스터 내에서만 접근 가능한 포드에 쉽게 기억할 수 있는 URL을 설정한다. Node Port는 개발 목적으로만 사용되는 경우가 많으며 포드를 클러스터 외부에서 접근 가능하게 한다. Load Balancer도 포드를 클러스터 외부에 노출한다. External Name은 클러스터 내 요청을 CNAME URL로 리다이렉트 한다.

일단 클러스터 내의 포드 간 통신은 당연히 Cluster IP를 사용하면 된다. 서비스 A 포드가 서비스 B 포드와 이벤트로 통신을 할 경우 서비스 A가 이벤트를 방출하면 이벤트 버스 포드의 Cluster IP로 요청을 보내고 이벤트 버스는 서비스 A 포드의 Cluster IP로 응답을 전송한다. 예를 들어, 리뷰 서비스의 서비스 YAML 파일을 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
  name: review
spec:
  selector:
    app: review
  ports:
    - name: review
      protocol: TCP
      port: 3000
      targetPort: 3000
  type: ClusterIP
cs


클러스터 외부와의 통신의 경우 Node Port와 Load Balancer중 하나를 골라야 하는데 Node Port의 큰 단점은 생성 시 기본적으로 무작위 포트를 열기 때문이다. 정확한 포트를 지정할 수 없고 클러스터 내에서 임의의 포트를 할당받게 받기 때문에 Node Port를 변경하게 되면 할당받는 포트가 다를 가능성이 있다. Load Balancer는 이러한 고민을 없애준다. Load Balancer는 클러스터 전체에 대한 단일 진입 지점을 설정하고 내부에서 요청을 적절한 포드로 라우팅 한다. 다시 말하지만 포드로 라우팅한다는 것은 실제로는 포드에 대해 생성할 Cluster IP를 참조하는 것이다. 그렇다면 라우팅은 어떻게 처리할까? 바로 Ingress 포드이다!


Ingress

이제 문제는 Load Balancer로 들어온 요청을 어느 포드로 전달하는 것이다. 즉, 라우팅 규칙을 설정해야 하는데 이를 관리하는 것이 Ingress 포드이다. 여러 Ingress 포드 중에서 Ingress NGINX 포드를 사용하기로 결정했다. 사실 Load Balancer는 AWS, GCP 그리고 Azure와 같은 클라우드 서비스를 통해 제공받아야 하는데 Ingress-Nginx는 Ingress 포드뿐만 아니라 Load Balancer도 지원한다.


호스트 파일

하나의 Kubernetes 클러스터 내에서 여러 다른 애플리케이션을 다양한 도메인에 호스팅할 수 있는데 예를 들어, 애플리케이션 A는 blog-app.com 애플리케이션 B는 tasty-delivery.org에 있을 수 있다. Ingress NGINX는 여러 애플리케이션을 다양한 도메인에서 호스팅할 수 있음을 전제로 설정된다. 그런데 개발 환경에서는 큰 단점이 있다. 바로 모든 실행 중인 서버에 localhost로 접근한다. 그렇다면 라우팅 규칙이나 특정 도메인을 사용하는 아이디어가 Ingress NGINX와 어떻게 맞아 떨어질까? 개발 환경에서는 도메인을 localhost와 동일하게 인식하도록 속여야 한다. 만약 호스트가 blog-app.com이면 연결하려고 할 때 인터넷에 존재하는 실제 blog-app.com이 아닌 현재 컴퓨터에 연결하도록 속이는 것이다. 이를 위해 호스트 파일의 설정을 변경해야 한다. Windows는 C:\\Windows\System32\Drivers\etc\hosts, MacOS와 Linux는 /etc/hosts 파일을 수정한다.


ingress.yaml

NGINX는 와일드카드를 지원하지 않기 때문에 정규 표현식을 경로에 사용해야 한다. 경로의 순서는 매우 중요하다. 위에서부터 밑으로 경로를 순서대로 확인하기 때문에 OAuth 2.0 HTML 파일과 일치하는 정규 표현식이 가장 밑에 위치해야 한다. 만약 이 경로를 맨 위에 두면 경로 일치가 미리 발생해서 백엔드 경로를 사용할 수 없다. 또한, 리뷰나 예약과 같이 여행을 지칭하는 tours로 시작하는 API의 경우 tours 경로보다 위에 있어야 한다.
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tour-ingress
  annotations:
    nginx.ingress.kubernetes.io/use-regex: 'true'
spec:
  ingressClassName: nginx
  rules:
    - host: tour.xyz
      http:
        paths:
          - path: /api/v1/auth/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/oauth2/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/users/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/admin/users/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/tours/?(.*)/bookings
            pathType: ImplementationSpecific
            backend:
              service:
                name: booking
                port:
                  number: 3000
          - path: /api/v1/bookings/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: booking
                port:
                  number: 3000
          - path: /api/v1/admin/bookings/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: booking
                port:
                  number: 3000
          - path: /api/v1/payments/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: payment
                port:
                  number: 3000
          - path: /api/v1/tours/?(.*)/reviews
            pathType: ImplementationSpecific
            backend:
              service:
                name: review
                port:
                  number: 3000
          - path: /api/v1/reviews/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: review
                port:
                  number: 3000
          - path: /api/v1/admin/reviews/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: review
                port:
                  number: 3000
          - path: /api/v1/tours/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: tour
                port:
                  number: 3000
          - path: /api/v1/admin/tours/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: tour
                port:
                  number: 3000
          - path: /?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: client
                port:
                  number: 3000
cs


Skaffold

애플리케이션을 실제 운영 환경에서 배포할 경우 애플리케이션 수정이 발생하면 Docker 이미지를 빌드하고 Docker Hub에 푸시한 후 kubectl rollout restart 명령어를 사용해서 배포를 진행한다. 하지만 개발 환경에서 이 과정은 너무 번거로운데 이를 도와주는 도구가 바로 Skaffold이다. Skaffold는 Kubernetes 개발 환경에서 다양한 작업을 자동으로 처리해주는 CLI 도구이다. 실행 중인 포드의 애플리케이션 코드를 매우 쉽게 수정할 수 있고 프로젝트와 관련된 모든 오브젝트들을 매우 빠르게 생성하고 삭제할 수 있다. Skaffold를 다운로드하고 설정하는 것은 홈페이지에서 쉽게 할 수 있다.
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
apiVersion: skaffold/v4beta3
kind: Config
manifests:
  rawYaml:
    - ./k8s/**/*
build:
  local:
    push: false
  artifacts:
    - image: csup96/tour-auth
      context: auth
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-booking
      context: booking
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-expiration
      context: expiration
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-payment
      context: payment
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-review
      context: review
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-tour
      context: tour
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-client
      context: client
      docker:
        dockerfile: Dockerfile
cs

update: 2024.10.22

jlink로 Spring Boot 애플리케이션 Docker 이미지 크기 줄이기

항상 NodeJS 기반 프레임워크의 Docker 이미지만 만들다가 Spring 애플리케이션의 Docker 이미지를 Dockerfile로 생성했는데 크기가 무려 500MB가 넘어갔다... ExpressJS 만큼 작을 수는 없어도 크기가 너무 컸다. 무엇보다도 Dockerfile을 어떻게 작성해야 하는지 감이 전혀 잡히지 않았다. 그래서 이번 기회에 Spring 애플리케이션의 Docker 이미지 크기를 줄이는 방법에 대해 알아본다.


기본 이미지

Docker 이미지의 크기가 작으면 컨테이너 빌드와 배포 속도가 빨라질 뿐만 아니라 불필요한 파일을 없애서 취약점을 제거하고 이로 인해 보안도 향상시킬 수 있다. NodeJS 기반 Docker 이미지처럼 첫 단계는 항상 어떤 기본 이미지를 선택하는 것인데 당연히 작아야 한다. 그 기본 이미지는 Alpine Linux이다. Alpine Linux는 musl libc와 BusyBox를 기반으로 구축된 Linux 배포판으로 이미지 크기는 단 5MB에 불과하다. 하지만 Java 자체도 이미지 내에서 일부 공간을 차지한다. 어떻게 이 크기를 줄일 수 있을까? 그것은 jlink 라는 도구로 Java의 크기를 줄일 수 있다. 다시 말해, jlink를 사용하면 애플리케이션에 필요한 모듈만 선택하여 런타임 이미지로 연결할 수 있다.


Dockerfile

기본 이미지와 jlink를 사용해서 이제 Dockerfile을 작성한다. base 단계에서 Spring 애플리케이션을 위한 Java 런타임을 설정하고 BellSoft에서 로컬 환경에서 사용한 OpenJDK 버전을 다운로드하고 압축을 푼다. 이 때 반드시 Alpine Linux로 설정된 릴리즈를 다운로드 해야한다. 그런 다음 jlink 명령을 실행하여 JDK가 포함된 사용자 정의 이미지를 생성한다. 애플리케이션을 실행하기 필요한 최소한의 Java 모듈을 포함해야 한다. 이는 jdeps 명령을 실행하여 JAR 파일에 필요한 모듈 목록을 확인할 수 있다. 사실 jdeps를 사용해서 필요한 모듈을 찾으려고 했는데 not found 오류 때문에 고생을 좀 했다. 검색을 해도 마땅한 해결책을 찾을 수 없어 Dockerfile로 빌드에서 오류로 나오는 모듈을 그대로 적었다. 검색을 조금 더 해보니 내가 지금 사용하는 JDK 17에 버그가 있다고 한다... 여하튼 jdeps 문제는 해결이 필요하다! 마지막으로 prod 단계에서 base 단계에서 생성된 디렉터리에서 최적화된 JDK 버전을 /opt/jdk 경로에 복사한 후java -jar 명령을 사용하여 애플리케이션을 실행한다. Dockerfile은 다음과 같다!
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
FROM alpine:3.16 AS base
 
ENV JAVA_HOME /opt/jdk/jdk-17.0.12
ENV PATH $JAVA_HOME/bin:$PATH
 
ADD https://download.bell-sw.com/java/17.0.12+10/bellsoft-jdk17.0.12+10-linux-x64-musl.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/bellsoft-jdk17.0.12+10-linux-x64-musl.tar.gz -C /opt/jdk/
 
WORKDIR /opt/app
COPY . .
 
RUN ./gradlew clean build -x test
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/jdk/jdk-17.0.12/jmods/", \
     "--add-modules", "java.base,java.sql,java.xml,java.management,java.compiler,java.security.jgss,java.instrument,java.scripting,java.logging,java.naming,java.desktop,jdk.unsupported", \
     "--no-header-files", "--no-man-pages", \
     "--output", "/runtime"]
 
 
FROM alpine:3.16 AS prod
 
COPY --from=base /runtime /opt/jdk
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 3000
 
COPY --from=base /opt/app/build/libs/whooa-blog-0.0.1-SNAPSHOT.jar /opt/app/
 
CMD ["java", "-jar", "/opt/app/whooa-blog-0.0.1-SNAPSHOT.jar"]
cs


Eclipse Temurin

이미지의 크기는 168MB로 jlink 사용 전에 크기에 비하면 약 67%가 넘게 감소했다. 하지만 여기서 더 줄일 수 없을까? dive라는 Docker 이미지 분석 도구를 통해서 생성된 Docker 이미지를 보면 168MB 중에 Java가 84MB로 많은 용량을 차지하고 있다. Eclipse Temurin에서 제공하는 OpenJDK를 사용하면 용량을 더 줄일 수 있다! Dockerfile의 차이는 BellSoft를 Eclipse Temurin으로 변경하는 것 밖에 없다. Docker 이미지 빌드 결과 크기가 147MB 21MB 정도 감소했다! dive로 분석을 하면 Java 용량이 62MB로 줄어들었다.
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
FROM alpine:3.16 AS base
 
ENV JAVA_HOME /opt/jdk/jdk-17.0.12+7
ENV PATH $JAVA_HOME/bin:$PATH
 
ADD https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz -C /opt/jdk/
 
 
FROM base AS source
 
WORKDIR /opt/app
COPY . .
 
RUN ./gradlew clean build -x test
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/jdk/jdk-17.0.12+7/jmods/", \
     "--add-modules", "java.base,java.sql,java.xml,java.management,java.compiler,java.security.jgss,java.instrument,java.scripting,java.logging,java.naming,java.desktop,jdk.unsupported", \
     "--no-header-files", "--no-man-pages", \
     "--output", "/runtime"]
 
 
FROM alpine:3.16 AS prod
 
COPY --from=source /runtime /opt/jdk
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 3000
 
COPY --from=source /opt/app/build/libs/whooa-blog-0.0.1-SNAPSHOT.jar /opt/app/
 
CMD ["java", "-jar", "/opt/app/whooa-blog-0.0.1-SNAPSHOT.jar"]
cs


비루트 사용자

NodeJS에서 파일과 디렉터리를 생성 및 복사할 때 항상 node라는 비루트(non-root) 사용자를 사용했으며 Distroless 이미지의 경우 1000이라는 사용자가 존재한다. Spring은 어떠할까? 검색을 했는데 Spring은 딱히 비루트 사용자가 존재하지 않아 사용자를 만들어야 한다. Linux 명령어를 사용해서 만들 수 있다.
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
FROM alpine:3.16 AS base
 
ENV JAVA_HOME /opt/jdk/jdk-17.0.12+7
ENV PATH $JAVA_HOME/bin:$PATH
 
ADD https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz -C /opt/jdk/
 
 
FROM base AS source
 
WORKDIR /opt/app
COPY . .
 
RUN ./gradlew clean build -x test
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/jdk/jdk-17.0.12+7/jmods/", \
     "--add-modules", "java.base,java.sql,java.xml,java.management,java.compiler,java.security.jgss,java.instrument,java.scripting,java.logging,java.naming,java.desktop,jdk.unsupported", \
     "--no-header-files", "--no-man-pages", \
     "--output", "/runtime"]
 
 
FROM alpine:3.16 AS prod
 
ARG USER=spring
RUN adduser --no-create-home -u 1000 -D $USER
 
WORKDIR /opt/app
RUN chown -R 1000:1000 /opt/app
 
USER 1000
 
COPY --from=source --chown=1000:1000 /runtime /opt/jdk
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 3000
 
COPY --from=source --chown=1000:1000 /opt/app/build/libs/whooa-blog-0.0.1-SNAPSHOT.jar /opt/app/
 
CMD ["java", "-jar", "/opt/app/whooa-blog-0.0.1-SNAPSHOT.jar"]
cs

update: 2024.10.22

10/20/2024

Distroless 이미지로 NodeJS 기반 애플리케이션 Docker 이미지 크기 줄이기

NestJS 프로젝트를 진행할 때 사용한 다단계 빌드로 Docker 이미지를 만들었는데 이상하게 이미지의 크기가 너무 컸다. 이번에도 같은 방법으로 ExresspJS 애플리케이션의 Docker 이미지를 만들었는데 크기가 무려 600MB... NodeJS 기반 프레임워크의 애플리케이션이 이렇게 클 수가 없다고 생각하여 검색과 여러 자료를 찾아보면서 Docker 이미지 크기를 줄이는 방법을 발견했다. Docker 이미지 크기를 줄이는 방법을 알아본다.


의존성

이전 프로젝트에서도 겪은 문제인데 NestJS나 ExpressJS를 TypeScript와 함께 사용하면 항상 문제가 되는 것이 바로 개발 환경 의존성이다. 다시 말해 @types로 시작하는 타입과 관련한 여러 패키지! 로컬에서 개발하고 실행할 때는 별 문제가 없지만 Dockerfile을 통해서 Docker 이미지를 만들 경우 타입 오류가 항상 발생한다. 처음에는 저번처럼 TypeScript 파일을 생성해서 문제를 해결하려고 생각했으나 다른 방법이 생각났다. 바로 개발 환경 의존성을 포함한 모든 의존성을 다 설치하고 빌드 후 개발 환경 의존성만 제거하는 것이다. 검색을 통해서 npm prune 명령어가 이 작업을 처리한다는 것을 발견했다. 즉, npm prune --omit=dev


다단계

검색을 통해서 여러 Dockerfile을 보면서 NestJS 프로젝트에 적용한 다단계 빌드가 Docker 이미지의 크기를 줄여주지 않은 이유를 발견했다. 알고보니 이전 단계를 그대로 가져왔다! 즉, 기본 이미지를 선택하고 COPY --from에서 이전 단계를 선택해야 하는데 FROM 단계1 AS 단계2를 사용한 것이 문제였다. 여기서 Docker 이미지의 크기를 더 줄이고 싶으면 COPY --from을 통해 이전 단계에서 모든 파일과 디렉터리가 아니라 애플리케이션에 필요한 파일과 디렉터리만 가져오면 된다!


Distroless 이미지

개발 환경 의존성을 제거했지만 여전히 Docker 이미지의 크기는 상당했다. 기본 이미지는 Slim을 사용해서 더 줄일 수 없다고 생각했는데 알고보니 애플리케이션이 필요한 의존성만 가지는 Distroless 이미지가 있다. 쉘이나 패키지 관리자와 같은 유틸리티를 포함하지 않는다. Distroless 이미지는 마지막 Dockerfile 단계에서 애플리케이션 디렉터리 트리를 복사하는 용도로 사용된다. 이미지를 절대 최소한으로 유지하기 위해 설계되었으며 낮은 CVE(공통 취약점 및 노출) 수를 가지는 장점이 있다. 

하지만 당연히 단점도 있는데 개발 또는 테스트 단계에서 쉘과 패키지 관리자가 필요하기 때문에 이러한 단계에서는 사용할 수 없다. 또한, Distroless 이미지는 특정 버전으로 고정하기가 어렵다. 주(major) 버전만 고정할 수 있으며 gcr.io/distroless/nodejs20-debian12와 같은 형식으로 사용할 수 있어서 결정론적 빌드를 원하는 사람들에게는 버전 태그로는 충분하지 않다. 참고로, 결정론적 빌드란 모든 구성 요소가 정확한 버전으로 고정되고 한 달 간격으로 이미지를 두 번 빌드하더라도 동일한 결과가 나와야 한다는 의미이다. Distroless는 이미지의 SHA256 해시를 고정하면 결정론적일 수 있지만 이미지를 수정할 때마다 태그가 재사용되고 어떤 해시가 이전 버전에 해당하는지 확인할 방법이 없기 때문에(수동으로 기록하지 않는 이상) 이는 이상적인 방법이 아니다.

장단점을 고려했을 때 Distroless 이미지를 사용하는 방법은 최종 운영 단계 외에는 Slim과 같은 기본 이미지를 설정하고 운영 단계에서 애플리케이션 관련 디렉터리와 파일을 COPY --chown=1000:1000한 후, 특정 Distroless 이미지의 SHA256 해시로 고정하는 것이다.


Dockerfile

3가지의 방법을 적용한 Dockerfile은 다음과 같다. Distroless 이미지를 SHA256 해시로 고정은 하지 않았다. 적용 전 Docker 이미지의 크기는 서비스마다 조금씩 차이는 있지만 대략 600MB 였다. 하지만 적용 후 280MB로 대략 53% 정도 크기가 줄었다. 하지만 여기서도 분명히 개선의 여지가 있다고 본다. 200MB 밑으로 내려가야 하지 않을까? 이에 대해서도 조금 더 알아보자! 
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
FROM gcr.io/distroless/nodejs18-debian12 AS distroless
FROM node:18.13.0-slim AS base
 
RUN npm install npm@8.19.3 -g
RUN npm install typescript@5.4.5 -g
 
USER node
 
WORKDIR /app
RUN chown -R node:node /app
COPY --chown=node:node package*.json ./
RUN npm ci --include=dev && npm cache clean --force
 
FROM base AS source
 
USER node
 
COPY --chown=node:node . .
RUN npm run build
RUN npm prune --omit=dev
 
WORKDIR /app/public/image/tours
RUN chown -R node:node /app/public/image/tours
 
 
FROM distroless AS prod
 
USER 1000
 
COPY --from=source --chown=1000:1000 /app/node_modules /app/node_modules
COPY --from=source --chown=1000:1000 /app/dist /app/dist
 
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
 
ARG PORT=3000
ENV PORT $PORT
EXPOSE $PORT
 
WORKDIR /app
 
CMD ["dist/src/server.js"]
cs

update: 2024.10.20