Docker 기초: image, container, layer

1. 개요

Docker를 처음 배우는 많은 사람들이 image와 container를 헷갈리거나 docker build가 두 번째 빌드에서 왜 그렇게 빠른지 이해하지 못합니다. 이 글에서는 세 가지 핵심 개념인 image, container, layer를 설명하고, 실제 Spring Boot 애플리케이션을 위한 image를 만드는 예제를 제시합니다. 이 글을 읽으면 합리적인 Dockerfile을 작성할 수 있고, cache layer를 활용해 빌드 속도를 개선하는 방법을 알게 되며, Java 애플리케이션을 containerize할 때 흔히 나타나는 실수를 피할 수 있습니다.

2. Image란 무엇인가

Image는 애플리케이션을 실행하는 데 필요한 모든 것을 담은 read-only 스냅샷입니다: 기본 운영 체제, runtime(예를 들어 JVM), 라이브러리, 환경 변수, 코드 등입니다. Image는 자동으로 실행되지 않습니다. 이것은 container를 만드는 ‘템플릿’ 같은 것입니다.

Dockerfile로 image를 정의합니다:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

각 줄을 설명하면:

  • FROM: base image를 선택합니다(Temurin의 JRE 21). 추가로 빌드되는 모든 것은 이것을 기반으로 합니다.
  • WORKDIR: container 내 작업 디렉터리를 설정합니다. 이후의 명령들은 이 디렉터리를 기준으로 실행됩니다.
  • COPY: 이미 빌드된 jar 파일(mvn package에서 나온)을 image로 복사합니다.
  • ENTRYPOINT: container가 시작될 때 실행하는 명령입니다.

이 명령으로 image를 빌드합니다:

docker build -t order-service:1.0 .

결과:

[+] Building 3.2s (8/8) FINISHED
 => [1/3] FROM eclipse-temurin:21-jre
 => [2/3] WORKDIR /app
 => [3/3] COPY target/order-service.jar app.jar
 => exporting to image
 => => naming to docker.io/library/order-service:1.0

이 image는 로컬에 저장되며, docker images로 목록을 볼 수 있습니다.

참고: Image는 정적 파일일 뿐입니다. 같은 Dockerfile과 코드로 docker build를 여러 번 실행해도 실행 중인 애플리케이션을 만드는 것은 아닙니다. 실행하려면 image에서 container를 만들어야 합니다.

3. Container란 무엇인가

Container는 image에서 생성된 실행 중인(또는 중지된) instance입니다. Image가 ’클래스’라면 container는 ’객체’입니다. 같은 image에서 여러 개의 container를 만들 수 있으며, 각 container는 프로세스, 네트워크, writable 파일시스템(writable layer)에서 독립적입니다.

Container를 만들고 실행합니다:

docker run -d --name order-svc -p 8080:8080 order-service:1.0
  • -d: detached 모드(백그라운드)에서 실행합니다.
  • --name: container에 이름을 지정하여 쉽게 관리합니다(무작위 ID 대신).
  • -p 8080:8080: 호스트의 port 8080을 container의 port 8080으로 매핑합니다.

실행 중인 container를 확인합니다:

docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS                    NAMES
3f2a9c1b8d4e   order-service:1.0      Up 5 seconds   0.0.0.0:8080->8080/tcp   order-svc

같은 image에서 두 번째 container를 실행할 수 있습니다. 이름과 port만 바꾸면 됩니다:

docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0

두 container는 완전히 독립적입니다. 한 container가 crash 되거나 그 파일시스템에 파일을 쓰면, 다른 container는 영향을 받지 않습니다. 원본 image(order-service:1.0)는 변하지 않습니다. 이것이 image의 read-only 특성입니다.

필요 없을 때 container를 중지하고 삭제합니다:

docker stop order-svc && docker rm order-svc

3.1. Container와 Virtual Machine(VM)의 차이

Container는 호스트와 kernel을 공유하며 VM처럼 전체 운영 체제를 가상화하지 않습니다. 따라서 container는 몇 초 안에 시작됩니다(위의 빌드 로그에서처럼) VM처럼 몇십 초가 걸리지 않으며, 훨씬 적은 리소스를 사용합니다.

4. Layer란 무엇이며 왜 중요한가

Dockerfile의 각 명령(FROM, COPY, RUN, ADD 등)은 별도의 layer를 만듭니다. 이러한 layer들은 겹쳐 있고(union filesystem), Docker는 각 layer를 cache합니다. 이전 빌드와 비교해 layer가 변하지 않으면 Docker는 다시 빌드하는 대신 cache를 다시 사용합니다.

최적화되지 않은 Dockerfile을 살펴봅시다:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]

문제: COPY . .는 전체 소스 코드를 복사하므로, 주석 한 줄만 수정해도 복사됩니다. COPY layer가 변경되면 Docker는 뒤에 있는 RUN mvn package layer를 다시 빌드해야 합니다. 논리 코드가 변하지 않았는데도 매번 몇 분이 걸립니다.

최적화하는 방법은 의존성(자주 바뀌지 않는 부분)을 먼저 분리하고, 코드(자주 바뀌는 부분)를 뒤에 배치하는 것입니다:

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

설명:

  • COPY pom.xml . 그리고 RUN mvn dependency:go-offline: 이 layer는 pom.xml에서 의존성을 추가/제거할 때만 변경됩니다. 대부분의 경우 이 layer는 cache됩니다. 라이브러리 다운로드 단계(보통 가장 오래 걸리는)를 건너뜁니다.
  • COPY src ./src를 뒤에 배치합니다: 코드를 수정할 때 이 layer와 그 뒤의 layer만 다시 빌드되고, 의존성 layer는 cache로 유지됩니다.
  • Multi-stage build(AS build와 FROM ... jre): 최종 stage는 빌드된 jar 파일만 가져오고, JDK와 Maven cache를 포함하지 않습니다. 프로덕션 image가 훨씬 작아집니다.

image의 layer를 다음 명령으로 확인합니다:

docker history order-service:1.0
IMAGE          CREATED        CREATED BY                      SIZE
a1b2c3d4e5f6   3 minutes ago  ENTRYPOINT ["java" "-jar"...]   0B
7f8e9d0c1b2a   3 minutes ago  COPY /app/target/order-ser…     45.2MB
...

각 줄은 layer에 해당하며, 추가된 크기가 표시됩니다. jar를 COPY 하는 layer가 대부분의 용량을 차지합니다. 이것은 애플리케이션의 실제 코드를 포함하기 때문에 합리적입니다.

4.1. 다시 빌드할 때 Layer cache가 작동하는 방식

Java 클래스 하나만 수정한 후 다시 빌드한다고 가정해봅시다:

docker build -t order-service:1.1 .
 => [build 1/5] FROM eclipse-temurin:21-jdk
 => CACHED [build 2/5] COPY pom.xml .
 => CACHED [build 3/5] RUN mvn dependency:go-offline
 => [build 4/5] COPY src ./src
 => [build 5/5] RUN mvn package -DskipTests
 => [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar

두 줄의 CACHED는 Docker가 의존성 다운로드를 건너뛴다는 것을 보여줍니다. 처음의 COPY . . 방식과 비교하면 빌드가 훨씬 빠릅니다.

참고: Docker는 파일 내용과 명령 순서를 기반으로 cache를 비교하며, timestamp에 기반하지 않습니다. COPY나 RUN의 순서를 변경하면 해당 지점부터 모든 layer가 무효화됩니다(실제 내용이 이전과 같아도).

5. 자동 테스트로 검증

service를 containerize할 때, build된 image가 healthcheck를 제대로 통과하는지 확인하고 싶을 것입니다. Testcontainers 라이브러리를 사용하여 container가 시작되고 올바른 응답을 반환하는지 확인하는 테스트를 작성할 수 있습니다.

의존성을 추가합니다:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

테스트를 작성합니다:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;

import static org.assertj.core.api.Assertions.assertThat;

class OrderServiceContainerTest {

    @Test
    void containerShouldRespondOnHealthEndpoint() {
        try (GenericContainer<?> container =
                new GenericContainer<>("order-service:1.0")
                        .withExposedPorts(8080)
                        .waitingFor(Wait.forHttp("/actuator/health"))) {

            container.start();

            assertThat(container.isRunning()).isTrue();
            assertThat(container.getMappedPort(8080)).isPositive();
        }
    }
}
  • GenericContainer는 image order-service:1.0에서 임시 container를 만들고, try 블록이 종료될 때 자동으로 정리됩니다.
  • waitingFor(Wait.forHttp(...)): 테스트는 health endpoint가 2xx를 반환할 때까지 대기하므로 container가 준비되었다고 간주하며, race condition을 피합니다.
  • getMappedPort(8080): Docker는 호스트의 임의 port로 매핑하므로, 테스트는 실제 port를 얻어 필요하면 호출할 수 있습니다.

이 테스트를 실행하면 Mục 2에서 빌드한 image가 단지 빌드에 성공한 것이 아니라 실제로 실행된다는 것을 확인합니다. 많은 사람들이 건너뛰는 검증 단계입니다.

6. 일반적인 실수

  • 정리할 때 image와 container를 혼동: docker rm으로 container를 삭제해도 image는 남아 있고 용량을 차지합니다. Image를 삭제하려면 docker rmi를 사용하세요.
  • 의존성 설치 전에 전체 소스를 COPY: Mục 4에서 설명했듯이 cache layer의 이점을 잃게 되어 불필요하게 느려집니다.
  • JRE runtime을 위해 너무 무거운 base image 사용(예: 전체 ubuntu): 프로덕션 image가 부풀어 오르고 pull/deploy 시간이 증가합니다. eclipse-temurin:21-jre 같은 최소한의 JRE image나 호환되는 경우 -alpine 버전을 사용하세요.
  • tag를 변경하지 않고 새 image 빌드: latest tag를 재사용하면 프로덕션 오류 발생 시 rollback이 어려워집니다. 정확히 어느 image가 실행 중인지 알 수 없기 때문입니다.

multi-stage build가 필요 없는 경우: CI(예: GitHub Actions)에서 jar를 미리 빌드한 후 docker build를 실행한다면, Dockerfile에서 build stage가 필요 없습니다. Mục 2의 예처럼 이미 있는 jar 파일을 COPY하기만 하면 됩니다. Multi-stage는 Docker가 source에서 코드를 직접 컴파일하도록 하려는 경우에만 필요합니다.

7. 결론

Image는 정적 템플릿이고, container는 그 image에서 실행되는 instance이며, layer는 Docker가 지능적으로 cache하여 빌드를 더 빠르게 하는 겹쳐진 레이어들입니다. Dockerfile에서 layer의 순서를 명확히 이해하면 됩니다. 덜 자주 바뀌는 부분(의존성)을 앞에, 자주 바뀌는 부분(소스 코드)을 뒤에 배치하는 것이 빌드 시간을 최적화하는 가장 간단한 방법입니다. 이 기초 위에서 여러 service를 관리하기 위한 Docker Compose나 distroless base를 사용한 image 크기 최적화 같은 고급 주제로 나아갈 수 있습니다.

dockerdevopscontainerimagespring-boot