도커파일(Dockerfile)에 대해 이해하기 위해 공부한 내용을 정리해본다. Dockerfile의 개념, 작성 방법, 최적화, 멀티 스테이지 빌드, 주로 사용되는 인스트럭션까지 전체적으로 정리한다.
Dockerfile이란?
Dockerfile은 도커 이미지를 생성하기 위해 명령어들을 정의한 텍스트(스크립트) 파일이다. 도커 파일에 정의된 명령어들은 이미지의 빌드 과정에서 실행되며, 도커 CLI를 통해 빌드해서 이미지를 생성할 수 있다.
도커파일은 인스트럭션과 명령어(스크립트), 주석으로 구성된다. 도커파일에서 인스트럭션이 이미지에 포함된 각 이미지 레이어가 되며, 인스트럭션을 실행한 결과로 이미지가 만들어진다.
Dockerfile의 장단점
장점
- 일관된 환경과 이식성: 도커 파일을 상용하면 개발, 테스트, 프로덕션 환경에서 동일한 설정을 유지할 수 있다. 그리고 협업에서 팀원들의 개발 환경 의존성문제를 해결해준다.
- 배포 간결성: 도커 이미지는 독립적인 패키지로, 다양한 시스템을 쉽게 배포할 수 있다.
- 확장성 및 유연성: 다양한 기본 이미지를 기반으로 필요한 환경을 빠르게 구축할 수 있다.
단점
- 복잡성 증가: 프로젝트가 커짐에 따라 도커파일을 작성하고 관리하는 과정이 복잡해질 수 있다.
- 도커 학습 곡선 필요: 도커 파일을 작성하고 최적화 하기 위해서는 도커 캐시 및 최적화의 이해가 필요하다.
Dockerfile 만들기
예제에서 사용할 자바스크립트 파일 server.js는 다음과 같다.
const express = require("express");
const PORT = 9090;
const app = express();
app.get("/", (req, res) => {
res.send("Hello World");
});
app.listen(PORT, () => {
console.log(`서버가 동작합니다. http://localhost:${PORT}`);
});
간단한 Dockerfile을 구성해보자.
# 베이스 이미지를 Node.js 16 버전으로 지정한다.
FROM node:16
# 작업 디렉토리를 '/app'으로 설정한다.
WORKDIR '/app'
# 현재 디렉토리의 모든 파일을 컨테이너의 현재 작업 디렉토리로 복사한다.
COPY . .
# 의존성을 설치한다.
RUN npm install
# 'server.js' 스크립트로 애플리케이션을 시작한다.
CMD ["node", "server.js"]
도커파일의 인스트럭션에 대한 설명은 주석으로 남겼다.
도커파일의 인스트럭션은 파일을 작성하면서 다른 인스트럭션들도 사용할 것이고, 다양한 인스트럭션을 후에 설명할 것이다. 그리고 캐싱을 통한 도커파일 최적화까지 알아볼 것이다.
도커 이미지 빌드
이제 작성 도커 파일로 이미지를 한 번 만들 것이다. 이미지를 빌드한다.
docker image build -t sample .
- t 또는 --tag: 이 옵션은 이미지에 지정할 태그를 설정한다. 이 값은 보통 이미지의 버전을 지정할 때 사용한다. 기본값은 latest다.
- 명령어는 현재 디렉토리(.)에 있는 도커파일을 읽어서 이미지 이름은 sample 로 이미지를 빌드한다는 의미다.
build 명령을 실행하면 도커파일 스크립트에 포함된 인스트럭션이 차례되로 실행되면서 그 결과가 출력된다.
도커 이미지를 빌드한 후 이미지를 생성하는 코드를 가져온 것이다. 작성한 인스트럭션이 Step별로 실행됨을 확인할 수 있다. 설명에 불필요한 부분은 생략되어있다.

도커 이미지 목록 중 sample 이라는 이름으로 생성된 이미지도 확인할 수 있으며, 빌드된 이미지는 도커 허브에서 내려 받은 이미지와 똑같이 사용할 수 있다.
docker image ls "sample"
이제 컨테이너를 생성해서 만든 이미지를 사용한다.
docker container run -d --name sample -p 9090:9090 sample
현재 Dockerfile의 문제점
지금까지 작성한 도커파일은 매우 기본적인 작업만 했으며 여러 문제가 있다.
도커파일을 빌드했었던 이미지를 다시 빌드해서 이미지를 생성할 때 도커엔진은 캐싱을 하는데 위에서 작성한 도커파일은 캐싱을 사용할 수 없다.
도커파일은 인스트럭션이 변경되지 않거나, 인스트럭션에서 사용하는 파일(COPY 명령어에서 복사하는 파일)의 변경이 없으면 캐시를 사용해서 실행한다. 하지만 위 코드에서 모든 파일 수정없이 이미지를 빌드할 때는 상관없지만, 예를 들어 server.js 파일이 수정하거나 새로운 스크립트 파일을 생성했을 때는 캐시를 사용하지 않는다.
다음으로 server.js 파일이 수정돼도 프로젝트의 의존성을 설정하는 package.json 파일은 변경이 없을 수 있다. 지금 상태로는 매번 npm install로 프로젝트의 의존성을 생성하는 작업을 계속 할 것이다.
이 문제점들을 해결해볼 것이다.
Dockerfile 캐시 메커니즘
도커파일이 이미지를 생성할 때 캐싱을 한다고 했는데, 캐싱은 도커파일을 최적화 하는 방법중 하나다.
# 도커파일 예제
1번 인스트럭션(FROM)
2번 인스트럭션
3번 인스트럭션
4번 인스트럭션
5번 인스트럭션
다음과 같은 도커 파일이 있다고 했을 때 5개의 인스트럭션을 가지고 있다. 첫 번째 인스트럭션은 반드시 기반 이미지를 설정하는 FROM 인스트럭션이어야 한다.
기본적인 빌드 과정은 다음과 같다.
도커는 도커파일을 빌드해서 이미지를 생성할 때 각 인스트럭션을 단계별로 실행하고, 각 단계별 결과를 캐시로 저장한다. 그리고 다시 도커파일을 빌드했을 때 동일한 인스트럭션을 만나면 도커는 캐시를 재사용해서 빌드 시간을 단축한다.
이 과정에서 2번 인스트럭션까지는 동일했기 때문에 캐시를 사용했고, 3번 인스트럭션이 달라졌다고 가정한다. 그리고 4, 5번 인스트럭션도 이전과 동일하다고 가정한다. 이 경우에 2번 인스트럭션까지 사용하고 3번 인스트럭션부터는 캐시를 사용할 수 없다. 즉 3, 4, 5번 인스트럭션은 캐시없이 새로 실행하게 된다.
이유는 도커의 캐시 메커니즘은 단계별로 순차적으로 적용되기 때문에, 중간에 변경된 인스트럭션이 있으면 그 이후의 인스트럭션은 캐시를 사용할 수 없고 모두 새로 실행된다.
그 외에도 도커파일을 최적화 하는 방법은 멀티 스테이지 빌드 활용, .dockerignore 파일을 사용해서 불필요한 파일을 이그노어 등 몇가지 방법이 있다.
이제 캐싱을 고려해서 작성한 도커파일의 명령어 순서를 최적화할 것이다.
Dockerfile 캐시 적용 - 명령어 순서 최적화
지금 예제에 사용하는 디렉토리의 구조이다.
├── dockerfile
├── package-lock.json
├── package.json
└── server.js
우선 도커파일 변경전에 이미지를 다시 한 번 빌드해서 캐시를 사용하는 것을 확인해본다.

도커 이미지를 빌드했을 때 캐시를 사용해서 인스트럭션들이 캐시되는 것을 확인할 수 있다. 여기서 server.js 파일을 수정해서 다시 빌드를 해본다. 수정은 공백이나 줄바꿈 정도만 해도 된다.
server.js를 수정하고 다시 이미지를 빌드한다.

이전에 캐시됐던 COPY 인스트럭션과 RUN 인스트럭션이 캐시되지 못한 것을 확인할 수 있다. 이제 이 부분을 최적화해본다.
우선 생성했던 이미지를 정리하고 시작한다.
# sample 이미지 삭제
docker image rm -f sample
# 도커 시스템에서 사용하지 않는 데이터 정리(캐시를 정리하기 위함)
docker image prune
예제에서 package.json 파일은 프로젝트의 메타 데이터, 모든 의존성 목록이 정의되어 있다. 이 파일은 의존성을 추가하지 않는다면 변경되지 않을 것이다. 하지만 server.js는 비즈니스 로직이 추가됨에 따라 코드가 변경될 여지가 크다. 이 두 파일을 COPY 하는 부분을 수정해서 캐시를 최적화한다.
# 수정된 도커 파일
FROM node:16
WORKDIR /usr/src/app
# 소스코드 변경 시 종속성은 유지될 수 있기 때문에 다시 복사하지 않기 위해 선 복사
COPY package*.json .
RUN npm install
# 나머지 파일을을 복사
COPY . .
CMD ["node", "server.js"]
도커파일에서 수정된 부분을 주석으로 설명했다. 파일에서 package*.json을 선 복사하기 때문에, 앞으로 package.json 파일이 변경되지 않는다변 인스트럭션에 캐시가 적용되서 이미지를 빌드할 것이다.
우선 server.js를 변경하지 않고 캐시된 로그를 확인한다. 이미지를 두 번 빌드하고 결과 코드를 확인해본다.

결과를 확인했을 때 도커가 모든 이미지 레이어를 캐시하고 있는 것을 확인할 수 있다.
이제 server.js 파일을 수정하고 다시 빌드한다.

server.js를 수정하고 이미지 다시 빌드했을 때 이번에는 COPY . . 인스트럭션 부분만 캐시를 하지 않고 나머지를 모두 캐시한 것을 확인할 수 있다.
이처럼 이미지 레이어의 캐싱을 고려해서 인스트럭션의 순서를 지정해서 도커파일 스크립트를 최적화하는것은 중요한 개념이다.
Dockerfile 멀티 스테이지 빌드
한 가지 더 생각해볼 사항이 있다. 현재 예제에서는 프로젝트를 빌드하고 배포하지 않았지만, 실제로 배포까지 진행했을 때 우리는 프로젝트의 배포 파일만 있으면 될 것 같다. 예를 들어 스프링부트 프로젝트를 배포할 때는 빌드 파일인 jar 파일만 있으면 된다. 그런데 현재는 빌드하기 위해 필요한 Gradle까지 필요한 상황이다.
이 때 사용할 수 있는 것이 멀티스테이지 빌드다.
멀티 스테이지 빌드란?
멀티 스테이지 빌드는 하나의 도커파일 안에서 여러 개의 FROM 인스트럭션을 사용해서 여러 스테이지를 정의하고, 각 스테이지에서 필요한 작업을 진행 후 최종적으로 필요한 산출물을 빌드해서 가져오는 방식이다. 중간 단계에서 생성된 파일들은 최종 이미지에 포함시키지 않을 수 있다.
멀티 스테이지 빌드의 장점은 다음과 같다.
- 빌드 단계에서 사용된 임시 파일이나 도구를 최종 이미지에 포함시키지 않기 때문에, 최종 이미지 크기를 감소시켜 배포 시간을 줄일 수 있다.
- 빌드 도구, 라이브러리, 소스 코드 등이 최종 이미지에 포함되지 않기 때문에 외부의 공격 표면이 줄어들어 보안성이 올라간다.
- 도커파일의 각 인스트럭션은 자신만의 캐시를 갖기 때문에 성능이 향상된다.
- 빌드 단계와 런타임 단계를 명확히 분리하기 때문에 Dockerfile이 더 읽기 쉽고 관리하기 쉬워진다.
- 빌드 단계에서 사용되는 환경과 배포 환경을 분리할 수 있다. 이는 개발과 배포 환경 간의 차이를 줄이고, 애플리케이션의 일관성있게 유지할 수 있다.
멀티 스테이지 빌드 예제 작성
이번 예제는 스프링 부트를 사용할 것이다. 스프링 부트 코드는 공개하지 않았지만 어떤 프로젝트를 사용해도 상관 없다.
# Gradle 8.11.1과 JDK 17을 사용하여 빌드 환경을 설정한다.
FROM gradle:8.11.1-jdk17 AS build
# 작업 디렉토리를 /app로 설정한다.
WORKDIR /app
# gradle 폴더를 컨테이너의 작업 디렉토리로 복사한다.
# gradlew, build.gradle과 settings.gradle 파일을 컨테이너의 작업 디렉토리로 복사한다.
COPY gradle ./gradle
COPY gradlew build.gradle settings.gradle ./
# 프로젝트의 의존성을 다운로드한다
RUN ./gradlew dependencies
# 나머지 소스 파일들을 컨테이너의 /app 디렉토리로 복사한다
COPY . .
# 테스트를 제외하고 프로젝트를 빌드한다
RUN ./gradlew build -x test
# 최종 스테이지 빌드
# openjdk:17-jdk-slim 이미지를 기반으로 실행 스테이지를 설정한다
FROM openjdk:17-jdk-slim
# 작업 디렉토리를 /app으로 설정한다
WORKDIR /app
# 빌드 스테이지에서 생성된 .jar 파일을 실행 스테이지의 /app 디렉토리로 복사한다
COPY --from=build /app/build/libs/*.jar project.jar
# 컨테이너가 9090 포트를 노출하도록 설정한다
EXPOSE 9090
# 컨테이너가 시작될 때 실행할 기본 명령을 설정한다
ENTRYPOINT ["java", "-jar", "project.jar"]
각 인스트럭선에 대한 설명은 주석으로 남겨두었고, 몇 가지 달라진 것들 위주로 설명을 할 것이다.
- FROM 인스트럭션이 두 개인 것과 첫 번째 FROM 인스트럭션
FROM gradle:8.11.1-jdk17 AS build은 이름을 build로 지정했다는 것이다. COPY --from=build /app/build/libs/*.jar project.jar에서 --from=build 부분은 우리가 build라고 이름 붙인 스테이지에서 생성한/app/build/libs/*.jar파일을 실행 스테이지의 작업 디렉토리에서project.jar라는 이름으로 복사한다는 것이다.- EXPOSE 인스트럭션을 사용해서 포트를 지정했는데, 이 부분은 실제 컨테이너를 실행할 때 반영되지는 않는다. 보통 문서화 목적으로 사용하고 우리가 컨테이너를 실행할 때는 포트를 지정해줘야 한다.
- ENTRYPOINT 인스트럭션이 사용된 부분이 있다. ENTRYPOINT 인스트럭션을 사용한 이유는 CMD 인스트럭션은 컨테이너를 시작할 때
docker container run에 명령을 지정하면 덮어씌워져 무시될 수 있기 때문이다.
이제 이미지를 빌드하고 컨테이너를 실행하면 된다. 그리고 컨테이너 실행 후 쉘로 접속해보면 작업 디렉토리에는 project.jar 파일만 존재하는 것을 확인할 수 있다.
# 이미지 빌드
docker image build -t spring-project .
# 컨테이너 실행
docker container run -d --name spring-project -p 9090:9090 spring-project
# 컨테이너 쉘 접속
docker container exec -it spring-project bash
지금까지 Dockerfile 멀티 스테이지 빌드를 알아봤는데, 보통 단일 스테이지 빌드를 사용하기 보다는 멀티 스테이지 빌드를 사용할 일이 많을 것이다.
Dockerfile 인스트럭션
지금까지 예제를 통해서 사용된 인스트럭션과 몇 가지의 인스트럭션을 설명한다.
FROM
- 형식:
FROM [이미지]:[태그] - 베이스 이미지를 지정한다. 도커파일의 스테이지는 베이스 이미지를 기반으로 작동하므로 가장 먼저 등장해야 한다. 태그를 생략하면 latest를 기본으로 사용한다.
WORKDIR
- 형식:
WORKDIR [경로] - 명령어가 실행될 작업 디렉토리를 설정한다. RUN, CMD, ENTRYPOINT, COPY 등 모든 명령어들이 설정된 디렉토리에서 실행된다.
COPY
- 형식:
COPY [옵션] [호스트 경로]... [목적지 경로] - 호스트 파일 시스템에서 파일을 컨테이너 파일 시스템으로 복사한다. 호스트 파일의 경로는 도커 파일이 있는 곳을 기준으로 상대 경로를 사용한다. 대상 경로는 컨테이너 내의 절대 경로를 사용한다.
- 옵션으로는
--chown=user:group,--from=stage-name등이 있다.
RUN
- 형식:
RUN [명령] - 이미지 빌드 과정에서 명령을 실행하는 데 사용되며 여러번 사용할 수 있다. 주로 의존 모듈 설치, 소스 컴파일 등에 작업을 수행한다. 너무 잦은 사용을 하면 이미지 레이어를 생성하기 때문에 이미지가 커질 수 있다.
CMD
- 형식:
CMD ["실행파일", "파라미터1", "파라미터2"] - 컨테이너가 시작될 때 실행할 기본 명령어를 지정한다. 도커파일 내에 여러번 사용할 수 있지만 마지막에 사용한 CMD만 적용된다.
- 이미지를 빌드하고
docker container run echo "hello world"와 같이 도커 실행 명령어에 명령을 지정하면 CMD 인스트럭션은 무시될 수 있으므로 주의해야 한다.
ENTRYPOINT
- 형식:
ENTRYPOINT ["실행파일", "파라미터1", "파라미터2"] - 컨테이너가 시작될 때 항상 실행되어야 하는 명령을 정의한다. CMD와 달리
docker container run echo "hello world"와 같이 도커 실행 시 명령을 지정해도 무시되지 않고 항상 실행된다.
VOLUME
- 형식:
VOLUME ["경로"] - 컨테이너에서 사용할 볼륨을 지정한다. 주로 데이터를 저장할 곳을 외부에 두기 위해서 사용한다.
EXPOSE
- 형식:
EXPOSE [포트] - 컨테이너가 외부와 통신하기 위해 열어둘 포트를 지정한다. 포트는 실제 적용되지 않고 문서화에 주로 사용된다. 컨테이너에서 실제로 포트를 매핑하려면
docker container run -p [호스트 포트]:[컨테이너 포트]를 사용해야 한다.
ENV
- 형식:
ENV KEY=VALUE - 컨테이너에서 사용할 환경 변수를 설정한다.
docker container run -e 키=값으로 지정한 환경 변수가 우선시 되는 것을 주의해야 한다.
ADD
- 형식:
ADD [호스트 경로] [대상 경로] - 파일이나 디렉토리를 이미지에 추가한다. COPY와 유사하지만, 추가적으로 URL에서 파일을 다운로드하거나, tar 파일을 자동으로 압축 해제하는 기능이 있다.
USER
- 형식:
USER [username]:[group] - 명령어가 실행될 사용자를 설정한다. 기본적으로는 root 사용자로 명령어가 실행되지만, USER 명령어를 통해 다른 사용자로 전환할 수 있다. 보안상의 이유로 root 계정을 사용하지 않을 것이라면 변경할 수 있다.
이 외에도 여러 인스트럭션이 있지만 주로 사용되는 것들만 설명했다. 필요한 것들은 실제 Dockerfile을 작성할 때 찾아서 사용하면 좋을 것 같다.
'DevOps' 카테고리의 다른 글
| 젠킨스 파이프라인으로 깃허브 SSH 연동 (0) | 2024.12.23 |
|---|---|
| 도커 젠킨스를 깃허브 SSH로 연동 (0) | 2024.12.16 |
| Jenkins Github 리포지토리 SSH 연결 에러(stderr: No ED25519 host key...) (0) | 2024.12.14 |
| cron 배치 작업 시 중복 실행 문제 방지 (0) | 2024.10.07 |
| [Apache] 아파치 서버 SSL, 리버스 프록시 설정 (0) | 2024.04.26 |