이미지 빌드, 레이어와 캐시 레이어, 멀티 스테이지 빌드를 알아보자.
대부분의 경우 이미지 빌드는 Dockerfile을 기반으로 이미지를 빌드합니다.
docker build .
docker build 명령은 위와 같으며, 명령의 마지막 .은 빌드 컨텍스트의 경로나 URL을 제공합니다.
이 위치에서 빌더는 Dockerfile과 다른 참조된 파일을 찾게 됩니다.
빌드를 실행하면, 빌더는 필요한 경우 기본 이미지를 가져오고, Dockerfile에 지정된 명령어들을 실행합니다.
하지만 위와 같이 실행하면 이미지의 이름이 없고, 출력에서 이미지의 ID를 제공합니다.
예를 들면, 다음과 같습니다.
docker build .
[+] Building 3.5s (11/11) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 308B 0.0s
=> [internal] load metadata for docker.io/library/python:3.12 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/6] FROM docker.io/library/python:3.12 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 123B 0.0s
=> [2/6] WORKDIR /usr/local/app 0.0s
=> [3/6] RUN useradd app 0.1s
=> [4/6] COPY ./requirements.txt ./requirements.txt 0.0s
=> [5/6] RUN pip install --no-cache-dir --upgrade -r requirements.txt 3.2s
=> [6/6] COPY ./app ./app 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:9924dfd9350407b3df01d1a0e1033b1e543523ce7d5d5e2c83a724480ebe8f00 0.0s
이미지에 이름을 부여할 수 있습니다.
이미지 전체 구조는 다음과 같습니다.
[HOST[:PORT_NUMBER]/]PATH[:TAG]
HOST: 이미지가 저장된 레지스트리의 주소입니다.
예를 들어, Docker의 공식 레지스트리는 docker.io입니다. 이 부분이 생략되면 Docker는 기본적으로 docker.io를 사용합니다.
PORT_NUMBER: 레지스트리 서버의 포트 번호입니다.
이는 주로 사설 레지스트리를 사용할 때 필요하며, 대부분의 공용 레지스트리는 이를 생략합니다.
PATH: 이미지의 경로로, 레지스트리 내에서 이미지를 찾기 위한 경로를 제공합니다.
일반적으로 이는 [NAMESPACE/]REPOSITORY 형식을 따릅니다. 여기서 NAMESPACE는 사용자나 조직의 이름이고, REPOSITORY는 이미지의 저장소 이름입니다. NAMESPACE가 지정되지 않으면 기본적으로 library가 사용됩니다.
TAG: 이미지의 버전을 식별하는 태그입니다.
예를 들어, 개발 중인 버전은 dev, 안정적인 릴리즈는 latest 등으로 태깅할 수 있습니다. 태그가 지정되지 않으면 기본적으로 latest가 사용됩니다.
docker build -t YOUR_DOCKER_USERNAME/concepts-build-image-demo .
이미지 빌드 시 -t
플래그로 이미지 이름을 지정할 수 있습니다.
docker image tag my-username/my-image another-username/another-image:v1
위 명령어로 이미 빌드된 이미지 이름을 변경할 수 있습니다.
이미지 생성을 위해 docker build
명령을 실행할 때, Docker는 Dockerfile의 각 명령을 실행하며, 지정된 순서대로 각 명령에 대해 레이어를 생성합니다.
각 명령에 대해 Docker는 이전 빌드에서 해당 명령을 재사용할 수 있는지 확인합니다.
이미 비슷한 명령을 실행한 적이 있다면 Docker는 해당 명령을 다시 실행하지 않고 대신, 캐시된 결과를 사용합니다.
빌드 캐시를 효과적으로 사용하면 이전 빌드의 결과를 재사용하고 불필요한 작업을 생략함으로써 더 빠른 빌드를 달성할 수 있습니다.
이미지는 여러 레이러로 구성되어 있으며, 각 레이어는 한 번 생성되면 변경할 수 없습니다.
이미지의 각 레이어는 파일 시스템 변경사항들(추가, 삭제, 수정)을 포함하고 있습니다.
예를 들면, 다음과 같습니다.
제일 하단이 베이스 레이어, 제일 상단이 제일 최신 레이어입니다.
이러한 구조는 이미지간에 레이어를 재사용할 수 있게 해서 유용합니다.
예를 들면, 다른 Python 애플리케이션을 생성하고자 할 때, 같은 Python 베이스 레이어를 활용할 수 있습니다. 이는 빌드 속도를 높이고, 이미지를 배포하는데 필요한 저장 공간을 줄이는 데 도움이 됩니다.
레이어를 통해 다른 사람의 이미지 베이스 레이어를 재사용하여 확장할 수 있고, 애플리케이션에 필요한 데이터만 추가할 수 있습니다. 이렇게 하면 기존의 유용한 리소스를 효과적으로 활용하면서 필요한 부분만 새롭게 구성할 수 있습니다.
이미지 레이어링이 가능한 이유는 파일을 저장하고 관리하는 특별한 방식 덕분에 가능합니다.
레이어 다운로드: 컨테이너의 각 레이어는 다운로드된 후 컴퓨터의 특정 폴더에 저장됩니다.
유니언 파일 시스템 생성: 컨테이너를 실행할 때, 이 레이어들은 서로 겹쳐져서 '유니언 파일 시스템'이라는 새로운 통합된 폴더 뷰를 형성합니다. 이것은 여러 레이어가 하나처럼 보이게 만듭니다.
컨테이너의 루트 설정: 컨테이너가 시작되면, 이 새로운 통합된 폴더가 컨테이너의 '루트 디렉토리'가 되며 chroot를 사용합니다. 여기서 모든 파일 작업이 이루어집니다.
변경 가능한 공간: 유니언 파일 시스템이 생성될 때, 이미지 레이어 외에도 실행 중인 컨테이너를 위해 특별히 생성된 디렉토리가 있습니다. 이를 통해 컨테이너는 파일 시스템 변경을 수행할 수 있으며, 원래의 이미지 레이어는 그대로 유지됩니다. 이러한 방식은 동일한 기반 이미지에서 여러 컨테이너를 실행할 수 있게 해줍니다.
이런 방식으로, 컨테이너는 각각 독립적으로 작동할 수 있으며, 한 이미지로부터 여러 컨테이너를 효율적으로 관리하고 실행할 수 있습니다.
docker image history getting-started
이미지 내의 각 레이러를 생성하는데 사용한 명령을 볼 수 있습니다.
$ docker image history getting-started
IMAGE CREATED CREATED BY SIZE COMMENT
5b92fd4138ba 45 hours ago EXPOSE map[3000/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 45 hours ago CMD ["node" "src/index.js"] 0B buildkit.dockerfile.v0
<missing> 45 hours ago RUN /bin/sh -c yarn install --production # b… 81.5MB buildkit.dockerfile.v0
<missing> 45 hours ago COPY . . # buildkit 89.8MB buildkit.dockerfile.v0
<missing> 45 hours ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["node"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ENTRYPOINT ["docker-entry… 0B
<missing> 2 weeks ago /bin/sh -c #(nop) COPY file:4d192565a7220e13… 388B
<missing> 2 weeks ago /bin/sh -c apk add --no-cache --virtual .bui… 5.57MB
<missing> 4 weeks ago /bin/sh -c #(nop) ENV YARN_VERSION=1.22.19 0B
<missing> 4 weeks ago /bin/sh -c addgroup -g 1000 node && addu… 114MB
<missing> 4 weeks ago /bin/sh -c #(nop) ENV NODE_VERSION=18.20.2 0B
<missing> 3 months ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 3 months ago /bin/sh -c #(nop) ADD file:37a76ec18f9887751… 7.38MB
각 줄은 이미지의 한 레이어를 나타냅니다.
가장 하단에 기본 레이어가 있고, 가장 상단에 최신 레이어가 있습니다. 각 레이어의 크기를 빠르게 확인할 수 있어 큰 이미지를 진단하는데 도움이 됩니다.
레이어 캐싱을 이용하면 이미지의 빌드 시간을 줄이는데 사용할 수 있습니다.
캐시 사용을 극대화하고 재빌드를 피하기 위해 캐시 무효화가 어떻게 작동하는지 이해하는 것이 중요합니다.
다음은 캐시가 무효화될 수 있는 몇 가지 상황의 예입니다:
RUN 명령의 커맨드에 대한 변경은 해당 레이어를 무효화합니다. Docker는 Dockerfile의 RUN 커맨드에 어떤 수정이 있는지 감지하고 빌드 캐시를 무효화합니다.
COPY 또는 ADD 명령을 사용하여 이미지에 복사된 파일에 대한 변경입니다. Docker는 프로젝트 디렉토리 내 파일의 변경을 모니터링합니다. 내용 변경이든 속성 변경이든(예: 권한), Docker는 이러한 수정을 캐시 무효화의 트리거로 간주합니다.
한 레이어가 무효화되면 그 이후의 모든 레이어도 무효화됩니다. 기본 이미지나 중간 레이어를 포함한 이전 레이어가 변경으로 인해 무효화된 경우, Docker는 그것에 의존하는 후속 레이어도 무효화되도록 보장합니다. 이는 빌드 과정을 동기화하고 일관성 없는 결과를 방지합니다.
Dockerfile을 작성하거나 편집할 때 불필요한 캐시 미스를 주의하여 빌드가 가능한 한 빠르고 효율적으로 실행되도록 해야 합니다.
이미지 히스토리를 보면 Dockerfile에 작성했던 각 명령어가 이미지 내에서 새로운 레이어로 나타나있는 것을 볼 수 있습니다.
이미지를 변경했을 때 yarn 의존성을 다시 설치했어야 했는데, 빌드할 때마다 같은 의존성을 계속 설치하는 것은 비효율적입니다.
이 문제를 해결하기 위해 Dockerfile을 수정하여 의존성을 캐싱할 수 있도록 해봅시다.
Node.js 앱은 package.json 파일에 의존성 라이브러리들이 정의되어 있기 때문에, package.json 파일만 먼저 복사한 다음, 의존성을 설치하고 나머지를 복사하도록 해봅시다.
이렇게 하면 package.json 파일에 변경이 있을 때만 yarn 의존성을 다시 설치하게 됩니다.
# syntax=docker/dockerfile:1
FROM node:18-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]
.dockerignore 파일은 프로젝트 파일 중 컨테이너에 복사할 때 제외할 파일 및 폴더를 설정합니다.
여기서는 두 번째 COPY 단계에서 node_modules 폴더의 복사를 생략하게 됩니다. node_modules를 생략하지 않으면 RUN 단계에서 생성한 node_modules를 덮어쓸 수 있습니다.
docker build -t getting-started .
<!-- <title>Todo App</title> -->
<title>The Awesome Todo App</title>
docker build -t getting-started .
빌드 시간이 확연히 단축된 것을 확인할 수 있습니다.
이전에 캐시된 레이러를 사용하기 때문입니다. 이렇게 레이커 캐싱을 이용해 이미지를 업데이트하고 그 업데이트를 푸시, 풀하는 속도를 높일 수 있습니다.
전통적인 빌드에서는 모든 빌드 명령이 순차적으로 단일 빌드 컨테이너에서 실행됩니다(종속성 다운로드, 코드 컴파일, 애플리케이션 패키징).
이 모든 레이어는 최종 이미지에 포함됩니다. 이 방식은 불필요한 무거운 이미지를 빌드하게 되는데, 이 문제를 해결하기 위해 멀티 스테이지 빌드가 등장했습니다.
멀티 스테이지 빌드는 Dockerfile에 여러 단계를 도입하며, 각각은 특정 목적을 가집니다.
마치 빌드의 다른 부분을 동시에 여러 다른 환경에서 실행할 수 있는 것과 같습니다. 빌드 환경을 최종 런타임 환경에서 분리함으로써 이미지 크기를 상당히 줄이고 보안 공격의 위험을 줄일 수 있습니다. 이는 빌드에 큰 종속성이 있는 애플리케이션에 특히 유리합니다.
멀티 스테이지 빌드는 모든 유형의 애플리케이션에 권장됩니다.
인터프리터 언어인 JS, Ruby, Python 등의 경우, 하나의 단계에서 코드를 빌드하고 최소화한 후, 줄어든 런타임 이미지로 생산 준비 파일을 복사할 수 있습니다. 이는 배포를 위한 이미지를 최적화합니다.
컴파일 언어인 C, Go, Rust 등의 경우, 하나의 단계에서 컴파일을 수행하고, 컴파일된 바이너리를 최종 런타임 이미지로 복사할 수 있습니다. 최종 이미지에 컴파일러 전체를 번들링할 필요가 없습니다.
멀티 스테이지 빌드는 이미지를 생성하기 위해 여러 단계의 빌드를 사용하는 도구입니다.
멀티 스테이지 빌드 방식으로 빌드를 하게되면, 빌드 시간 의존성과 런타인 의존성을 분리할 수 있고, 앱 실행에 필요한 것만 포함하여 전체 이미지 크기를 줄일 수 있습니다.
예를 들어,
# syntax=docker/dockerfile:1
FROM node:18-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]
위 dockerfile에서 RUN 명령어를 추가하여 통해 yarn build를 수행합니다.
새로운 FROM 명령어를 작성하여 런타임에 사용할 node 환경을 선택하고, 이전 빌드 스테이지에서 생성한 빌드 파일을 복사하여 실행할 수 있습니다.
페이어 캐싱을 통해 이미지 빌드 시간을 단축하고, 멀티 스테이지 빌드를 통해 이미지 크기를 축소할 수 있는 방법에 대해 알아봤습니다.
https://docs.docker.com/build/building/context/#dockerignore-files
https://docs.docker.com/reference/dockerfile/
https://docs.docker.com/build/guide/
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/