이번 게시글에서는 Docker를 단순히 활용하는 것에 만족하지 않고, 이를 실전에서 효율적으로 최적화하고 활용하는 다양한 방법에 대해 정리하려고 한다.
Dockerfile에서 각 명령어는 개별적인 레이어를 생성한다. 이러한 레이어의 누적은 이미지 크기의 증가와 빌드 시간의 연장으로 이어질 수 있으므로, 레이어를 효과적으로 관리하고 최적화하는 것이 중요하다.
Dockerfile 내의 다수의 명령어를 && 연산자를 통해 하나의 RUN 지시어로 통합함으로써, 불필요한 레이어 생성을 방지할 수 있다.
다음은 그 예시이다.
RUN chmod +x ./gradlew
RUN ./gradlew bootJar
chmod +x ./gradlew와 ./gradlew bootJar 명령어는 각각의 RUN 지시어에 의해 별도의 레이어로 생성된다.
RUN chmod +x ./gradlew && ./gradlew bootJar
하지만, 두 명령어를 && 연산자를 사용하여 하나의 RUN 지시어로 통합함으로써, 하나의 레이어만 생성되도록 최적화할 수 있다.
베이스 이미지로 가벼운 Alpine 이미지를 선택함으로써, 이미지 크기를 최소화할 수 있다. Alpine 이미지는 필수적인 도구만을 포함하여 가볍고, 효율적인 이미지 빌드를 가능하게 한다.
다음은 그 예시이다.
FROM eclipse-temurin:17 as builder
# builder 스테이지
FROM eclipse-temurin:17-jre as runtime
# runtime 스테이지에서
builder 스테이지에서는 Eclipse Temurin 17 이미지를 사용하여 애플리케이션을 빌드하며, runtime 스테이지에서는 더 경량화된 JRE 이미지를 사용하여 애플리케이션을 실행하게 된다.
.dockerignore 파일을 사용하여, 빌드 컨텍스트에 포함될 필요가 없는 파일들을 제외함으로써, 빌드 컨텍스트의 크기를 줄이고, 결과적으로 이미지 크기를 최적화할 수 있다.
빌드 컨텍스트는 Docker 이미지가 빌드될 때 Docker 데몬에 전송되는 파일과 디렉터리의 집합을 의미한다. Docker는 빌드를 수행할 때, 해당 컨텍스트 내의 파일들을 기반으로 레이어를 생성한다. 따라서, 불필요한 파일이나 디렉터리가 빌드 컨텍스트에 포함되면, 이미지의 크기가 커지고 빌드 시간이 길어질 수 있다.
.dockerignore 파일은 이러한 불필요한 파일을 빌드 컨텍스트에서 제외하도록 지정할 수 있다. 예를 들어, 소스 코드 관리 시스템(Git)의 메타 데이터나, 로컬 개발 환경에서만 필요한 임시 파일들은 빌드 컨텍스트에 포함될 필요가 없다. 이를 .dockerignore 파일에 명시함으로써, 빌드 과정에서 이들 파일을 자동으로 제외시킬 수 있다.
다음은 예시 코드이다
FROM eclipse-temurin:17 as builder
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
위 코드에서는 COPY 명령어를 여러 번 사용하여 각각의 파일과 디렉터리를 복사하고 있다. 이 경우, 각 COPY 명령어는 Docker 이미지에 새로운 레이어를 추가하게 되어, 불필요하게 많은 레이어가 생성되는 단점이 있다.
이를 개선하기 위해, 다음과 같이 한 번의 COPY . . 명령어로 모든 파일을 복사할 수 있다.
FROM eclipse-temurin:17 as builder
COPY . .
이 방법을 사용하면 레이어가 하나만 생성되어 빌드 속도가 개선될 수 있다. 그러나 이 경우 빌드 컨텍스트에 포함된 모든 파일이 Docker 이미지에 포함될 수 있으며, 필요하지 않은 파일까지 복사될 위험이 있다. 이 문제를 방지하기 위해 .dockerignore 파일을 활용하여 불필요한 파일들을 빌드 컨텍스트에서 제외시키는 것이 좋다.
Docker 빌드를 최적화하기 위해서는 캐싱을 효과적으로 활용하는 것이 중요하다. Docker는 빌드 과정에서 각 명령어의 결과를 캐시로 저장하며, 동일한 명령어를 다시 실행할 때 이전에 생성된 캐시를 재사용한다. 이를 통해 빌드 시간이 단축되고, 빌드 과정의 효율성이 크게 향상된다.
당연하게도, 캐시를 효율적으로 활용하기 위해서는, 변경이 자주 일어나지 않는 파일들을 Dockerfile의 상단에 배치하는 것이 중요하다.
Node.js 프로젝트에서는 package.json과 package-lock.json 파일이 의존성 정보를 담고 있으며, 일반적으로 소스 코드보다 자주 변경되지 않는다. Dockerfile에서 이러한 파일들을 먼저 복사하고 npm install 명령어를 실행하면, 이후 소스 코드가 변경되더라도 이 단계에서 캐시가 재사용된다. 이는 의존성 설치 과정을 반복하지 않도록 하여 빌드 시간을 크게 단축시킨다.
다음은 Node.js의 Dockerfile 예시코드이다.
FROM node:14 AS build
COPY package.json .
COPY package-lock.json .
RUN npm ci
COPY . /app
RUN npm run build
package.json과 package-lock.json 파일을 먼저 복사한 후 npm ci 명령어를 실행하여 의존성을 설치한다. 이때, 소스 코드가 변경되더라도 의존성 설치 과정은 캐시를 활용하여 빌드 시간이 단축된다.
Spring 프로젝트에서도 유사한 방식으로 Docker 빌드 캐시를 활용할 수 있다. Spring 프로젝트의 경우, 일반적으로 build.gradle 또는 pom.xml과 같은 의존성 관리 파일들이 자주 변경되지 않는다. 이 파일들을 먼저 복사하고 의존성 설치를 진행하면, 이후 소스 코드가 변경되더라도 의존성 설치 과정은 캐시를 활용할 수 있다.
다음은 Spring의 Dockerfile 예시코드이다.
FROM gradle7.6.1-jdk11 AS build
COPY build.gradle settings.gradle ./
RUN gradle dependencies --no-daemon
COPY . .
RUN gradle build --no-daemon
build.gradle과 settings.gradle 파일을 먼저 복사한 후, gradle dependencies 명령어를 실행하여 의존성을 다운로드한다. 이때, 소스 코드가 변경되더라도 의존성 설치 과정은 캐시를 활용하여 빌드 시간이 단축된다.
IntelliJ와 Docker를 활용하여 자바 애플리케이션의 개발 환경을 컨테이너로 구성하면, 개발과 테스트 작업을 보다 일관되게 수행할 수 있다. 이를 통해 개발 환경 간의 차이로 인해 발생할 수 있는 문제를 최소화하고, 로컬 환경과 동일한 조건에서 안정적인 개발 및 디버깅이 가능하다.
컨테이너를 활용하여 개발한다면, 사용자의 호스트 환경에 Java가 설치되어 있지 않더라도 Docker 컨테이너 내부에서 모든 필요한 도구가 제공되므로, 개발 환경 설정이 간편해진다. 또한 각 개발자가 동일한 환경에서 작업할 수 있으므로, "내 컴퓨터에서는 잘 동작하지만, 다른 환경에서는 문제가 발생하는" 상황을 효과적으로 방지할 수 있다.
IntelliJ에서 "Run/Debug Configurations" 설정 창을 열고, "Docker"를 선택하여 컨테이너를 활용하여 개발 환경을 설정할 수 있다.
작성한 Dockerfile을 지정하고 필요한 옵션을 설정한다.
설정을 완료한 후 실행하면, Docker 이미지가 빌드되고 컨테이너가 실행된다.
컨테이너 내부에서 JVM을 실행하고 디버깅할 수 있도록 설정하면, 로컬 환경과 동일한 조건에서 테스트가 가능하다.
IntelliJ에서는 Docker 컨테이너 내에서 실행되는 Java 애플리케이션에 대해 원격 디버깅을 설정할 수 있다. 이를 통해 컨테이너 내부의 JVM에 연결하여 디버깅 작업을 수행할 수 있다.
IntelliJ에서 "Run/Debug Configurations" 설정 창을 열고, "Remote JVM Debug"를 선택하여 원격 디버깅 구성을 추가할 수 있다.
JVM 디버깅을 위해 JDWP(Java Debug Wire Protocol)를 사용한다.
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 옵션을 사용하여 JVM을 실행할 때, IntelliJ는 로컬에서 해당 포트(예: 5005)를 통해 컨테이너 내부의 JVM에 연결할 수 있다.