해당 글은 Docker 공식 문서 를 기반으로 작성되었습니다.

1. 시나리오

Dockerfile은 사용자가 명령줄에서 docker 이미지를 생성하기 위해 사용할 수 있는 모든 명령어가 포함된 텍스트 문서이다. 보통 서비스(컨테이너) 단위당 하나의 Dockerfile을 관리하는 것이 일반적이다. 크게 다음과 같은 순서로 동작한다.

기본 환경 선택 작업 공간 지정 파일 추가 패키지, 라이브러리 설치 통신 포트 지정 컨테이너 시작 시 동작 명령


2. 명령어 소개 + 파일 작성

Dockerfile이 지원하는 명령어는 공식 문서에서 확인 가능하다. 실행 흐름에 따라 자주 사용되는 명령어를 정리하며 작성해보자.

1. FROM

정의

  • 새로운 빌드 단계를 초기화하고 애플리케이션이 돌아갈 기본 이미지를 설정한다.
  • 유효한 Dockerfile은 반드시 이 명령어로 시작해야 한다.
  • 중복해서 사용할 수 있으나 각 FROM명령은 이전 명령에서 생성된 모든 상태를 초기화하므로 이미지 ID를 이용한 관리가 필요하다.

사용 예시

# 형식
FROM [--platform=<platform>] <image>[:<tag>] [AS <name>]
FROM [--platform=<platform>] <image>[@<digest>] [AS <name>]
 
# my
FROM gradle:8.12-jdk17 AS builder

(참고)

  • --platform
    • CPU 아키텍처 강제 지정 옵션 (linux/amd64, linux/arm64 등)
    • 보통은 생략하며, 생략 시 실행 중인 내 컴퓨터의 환경에 맞게 자동으로 선택됨
  • name
    • 해당 빌드 단계에 원하는대로 이름 지정 가능

2. WORKDIR

정의

  • 이후에 실행되는 모든 RUN, CMD, ENTRYPOINT, COPY, ADD 명령어의 작업 디렉토리를 설정한다.1
  • 뒤에 적은 경로가 컨테이너 안에 있다면 해당 위치로 이동하고, 없더라도 에러를 내지 않고 생성한다.
  • 쉽게 mkdir -p(디렉토리 생성)와 cd(이동)를 합쳐놓은 것이라고 생각하면 된다.

사용 예시

# 형식
WORKDIR /path/to/workdir
 
# my
WORKDIR /app

(참고)

  • 작업 공간을 명시적으로 설정하자
    • 별도로 지정하지 않으면 기본적인 작업 디렉터리는 /이다.
    • 하지만 가져다 쓰는 기본 이미지(ex: python, node 등) 개발자가 이미 위치를 설정해 두는 등 예외가 발생할 수 있으니 알 수 없는 디렉터리에서 의도치 않은 작업이 발생하는 것을 방지하기 위해 작업 디렉터리를 꼭 명시적으로 설정하자.

3. COPY & ADD

정의

  • <src>(호스트)에서 새 파일이나 디렉터리를 복사하여 대상 컨테이너 이미지의 지정된 경로 <dest>에 추가한다.
  • COPYADD 명령어는 기능적으로 유사하지만 목적이 약간 다르다.
    • COPY: 빌드 컨텍스트 또는 (멀티 스테이지 빌드라면) 이전 단계의 결과물을 컨테이너로 파일을 복사하는 기본적인 기능을 지원; 쉽게 말해 로컬에 있는 일반적인 소스 코드 복사를 의미한다.
    • ADD: 로컬 파일, 디렉토리는 물론 원격 HTTPS 및 Git URL에서 파일을 직접 다운로드해서 가져오고, 로컬의 tar, gzip 같은 파일을 넣으면 자동으로 압축 해제하는 기능을 지원
  • 대상 디렉터리(<dest>)는 반드시 디렉터리여야 하며 슬래시(/)로 끝나야 한다.2
  • 상위 디렉토리 이동(../)은 이용할 수 없다. 자동으로 무시됨.

사용 예시

# 형식(경로에 공백이 포함된 경우 후자 사용)
ADD [OPTIONS] <src> <src> <src> ... <dest>
ADD [OPTIONS] ["<src>", "<src>", "<src>", ... "<dest>"]
 
COPY [OPTIONS] <src> <src> <src> ... <dest>
COPY [OPTIONS] ["<src>", "<src>", "<src>", ... "<dest>"]
 
# my
COPY . .

(참고)

  • 명료성과 예측 가능성을 위해 외부 자료 사용이 아니라면 일반적으로 COPY를 사용한다.

생각해볼 부분

  1. 어디서부터 어디까지 컨테이너로 복사해와야할까?
  2. 설정 파일이나 패키지들은 자주 바뀌지 않으니 저장해두었다가 재사용하고 변경된 파일만 다시 복사해오면 안될까?
  3. 수정 후 첫 빌드 시에만 복사해오고 실행 시에는 실행에 필요한 결과물만 복사해오면 안되나?

4. RUN

정의

  • 뒤에 오는 명령어 실행 후, 실행된 결과를 현재 이미지 위에 새 레이어로 생성한다.
  • docker는 명령어를 실행할 때 두 가지 형태를 제공한다.
    1. Shell form (쉘 형태)
      • 터미널에 입력하는 것과 가장 비슷하게 작성하는 방식
      • RUN apt-get install -y curl
      • 빌드 단계에서 주로 사용
    2. Exec form (실행 형태)
      • JSON 배열 형태로 작성하는 방식
      • RUN ["apt-get", "install", "-y", "curl"]
      • 보통 ENTRYPOINT 혹은 CMD에서 쉘의 간섭 없이 깔끔하게 프로그램을 띄우기 위해 사용

사용 예시

# 형식
# 1. Shell form:
RUN [OPTIONS] <command> ...
# 2. Exec form:
RUN [OPTIONS] [ "<command>", ... ]
 
# my
RUN ./gradlew clean build

생각해볼 부분

마찬가지로 내용이 변경되었을 때만 처음부터 실행하도록 하고, 패키지나 라이브러리들은 이전에 받아둔 결과물을 재사용할 수 있지 않을까?

5. EXPOSE

정의

  • 컨테이너가 런타임 시 사용할 네트워크 포트를 알려준다.
  • 실제로 포트가 외부로 열리는(Publish) 것은 아니고 빌드자와 실행자 사이에서 일종의 메모 혹은 문서 역할을 한다.
  • docker network에 연결된 컨테이너는 특정 포트를 열거나 게시할 필요 없이 모든 포트를 통해 컨테이너 간 서로 통신할 수 있지만,
    호스트 시스템에서 포트 리디렉션을 설정3하려면 실행할 때 docker run -p 80:80/tcp처럼 -p 옵션을 따로 써야한다.

사용 예시

# 형식
EXPOSE <port>[/<protocol>...]
 
# my
EXPOSE 8080

(참고)

  • /protocol
    • TCP 또는 UDP 프로토콜 중 하나를 지정할 수 있다. (default: TCP)
  • EXPOSE를 만든 이유
    • docker 컨테이너 내부에서는 수많은 포트가 열려 있을 수 있지만, 외부와 통신할 수 있는 통로는 명시적으로 선언하고 연결한 것만 사용하여 보안성을 강화
    • 필수적으로 사용하진 않아도 되지만 테스트 때 호스트에 자동 연결된다거나(-P) 오케스트레이션 툴(쿠버네티스 등)이나 클라우드 서비스에서 이 정보를 참고해 자동으로 네트워크 설정을 구성할 수 있기 때문에 권장되는 사항

6. ENTRYPOINT & CMD

  • 두 명령어는 모두 이미지에서 컨테이너 실행 시 어떤 명령이 실행될지 설정한다.
  • Dockerfile에는 두 명령어 중 하나 이상을 지정해야 한다.
  • ENTRYPOINT에는 실행 파일을, CMD에는 기본 옵션을 넣는 패턴이 가장 많이 사용된다.

ENTRYPOINT

  • 컨테이너를 ==실행 파일로 사용할 때는 ENTRYPOINT를 사용==해야 한다.
  • 컨테이너가 실행될 때 반드시 실행되어야 하는 기본 실행 파일이다. (ex: java, python, git, nginx)
  • docker는 ENTRYPOINT 또한 두 가지 형태를 제공하지만 이 명령어는 특별히 실행 형태를 더 권장한다.
    1. Exec form (권장)
      • 프로그램이 컨테이너 내에서 직접 실행되어 PID 1로 실행됨
      • Linux 신호를 직접 받아 안전하게 실행 가능
        • ex) docker stop을 눌렀을 때 종료 신호(SIGTERM)를 직접 받아서 안전하게 꺼짐
      • CMD에 쓴 내용을 인자로 그대로 받아들임
    2. Shell form
      • /bin/sh -c 실행 후 프로그램이 쉘 내부에서 실행되어 쉘의 자식으로 뜸
      • 쉘이 신호를 가로막기 때문에 직접 받을 수 없음
        • ex) docker stop 을 명령해도 신호를 못 받고 10초 후 강제 종료(SIGKILL) 되는 문제 발생
      • CMD에 쓴 기본 인자들이 무시됨

CMD

  • 실행 중인 컨테이너에 대한 기본 옵션 값을 제공하는 데 사용된다. (ex: --version, help)
  • 기본값에는 실행 파일이 포함될 수도 있고, 생략할 수도 있다.
    • 실행 파일을 생략하는 경우에는 ENTRYPOINT 명령어도 함께 지정해야 한다.
  • Dockerfile에는 CMD 명령어가 하나만 있을 수 있다. (두 개 이상 나열 시 마지막 명령어만 유효)
  • 컨테이너를 다른 인수로 실행하면 CMD는 무시된다.
    • FROM ubuntu
      ENTRYPOINT ["top", "-b"]
      CMD ["-c"]
      위의 경우 기본적으로 top -b -c 실행 사용자가 실행 시 뒤에 -H를 붙이면 -c는 무시됨 top -b -H 실행
  • docker는 CMD 또한 두 가지 형태를 제공하지만 ENTRYPOINT와 결합했을 때 서로 다른 형식을 사용한다면 의도치 않게 쉘 명령어가 파라미터로 전달되는 이상한 결과가 나올 수 있기 때문에 똑같이 실행 형식을 사용하는 것이 표준이다.

사용 예시

# 형식
# 1. Exec form(권장):
ENTRYPOINT ["executable", "param1", "param2"]
# 2. Shell form:
ENTRYPOINT command param1 param2
 
# 1. Exec form:
CMD ["executable","param1","param2"]
# 2. Exec form(ENTRYPOINT의 기본 매개변수로 사용; 권장):
CMD ["param1","param2"]
# 3. Shell form:
CMD command param1 param2
 
# my
ENTRYPOINT ["java", "-jar", "build/libs/app.jar"]

(참고)

  • 이미 CMD가 실행될 상태이더라도 그 후에 실행될 Dockerfile에 ENTRYPOINT 명령어가 새롭게 정의된다면 기존 베이스 이미지에 있던 CMD자동으로 삭제(초기화) 된다.
    만약 인자가 필요하다면 현재 Dockerfile에서 CMD를 다시 써줘야 한다.

3. 전체 코드

위 명령어 조건에 맞추어 내 프로젝트를 기준으로 기본적인 Dockerfile을 작성했다.

# 1. 빌드 환경 설정 (Java 17 버전 사용)
FROM gradle:8.12-jdk17 AS builder
WORKDIR /app
 
# 2. 전체 소스 코드 복사
COPY . .
 
# 3. 컨테이너 내부에서 직접 자바 파일을 컴파일 & 빌드
RUN ./gradlew clean build
 
# 3. 실행할 포트 문서화 (Spring Boot 기본값 8080)
EXPOSE 8080
 
# 4. 빌드된 jar 파일을 실행 (보통 'build/libs/프로젝트명-SNAPSHOT.jar' 경로에 생성됨)
ENTRYPOINT ["java", "-jar", "build/libs/app.jar"]
# CMD 생략 가능

현재 작성한 Dockerfile은 이미지가 빌드되어 실행되도록 하는 가장 기본적인 구성으로 작성되어 있다. 다음 글에서는 이미지의 빌드 효율을 높이거나 용량을 최적화 하는 등 다양한 설정을 변경하여 직접 이미지를 만들며 비교해보겠다.

Footnotes

  1. 도커의 각 명령어는 독립적으로 실행되기 때문에 1번 줄에서 디렉토리 이동을 하더라도, WORKDIR을 사용한게 아니라면 다음 줄이 실행될 때는 다시 원래 위치로 돌아간다.

  2. 오류는 발생하지 않으나, 의도치 않은 작업 발생 가능

  3. 컨테이너의 포트를 실제 내 로컬(호스트)와 연결