Dockerfile로 나만의 이미지 만들기
왜 Dockerfile이 필요한가
앞 장에서는 컨테이너 안에 들어가 파일을 수정해 보았다. 하지만 컨테이너를 삭제하면 그 안에서 바꾼 내용도 함께 사라진다. 이 방식은 실습에는 괜찮지만, 팀원에게 같은 환경을 전달하거나 서버에 배포하기에는 불안정하다.
Dockerfile은 이 문제를 해결한다. Dockerfile은 이미지를 만드는 설명서다. 어떤 운영체제와 런타임을 사용할지, 어떤 파일을 복사할지, 어떤 명령으로 의존성을 설치할지, 컨테이너가 시작될 때 무엇을 실행할지를 텍스트 파일로 기록한다. 즉, 손으로 컨테이너 안을 고치는 대신 실행 환경을 코드로 남긴다.
이 장의 목표는 간단하다. 작은 웹 애플리케이션을 만들고, Dockerfile로 이미지로 패키징한 뒤, 어디서나 같은 명령으로 실행할 수 있게 만드는 것이다.
AI 생성 이미지
Dockerfile의 기본 구조
Dockerfile은 보통 프로젝트 루트에 Dockerfile이라는 이름으로 만든다. 확장자는 붙이지 않는다. 가장 기본적인 흐름은 다음과 같다.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
CMD ["node", "server.js"]각 줄은 이미지 빌드 과정에서 수행할 작업을 의미한다.
FROM은 바탕이 되는 이미지를 정한다. 예제의 node:22-alpine은 Node.js 22가 설치된 가벼운 Linux 기반 이미지다. Dockerfile은 일반적으로 FROM으로 시작한다. 이 이미지 위에 애플리케이션 파일과 설정을 차곡차곡 얹어 새 이미지를 만든다.1
WORKDIR은 이후 명령을 실행할 작업 디렉터리를 정한다. WORKDIR /app을 지정하면 그다음 COPY, RUN, CMD는 기본적으로 /app을 기준으로 동작한다. 디렉터리가 없으면 Docker가 만든다.
COPY는 현재 프로젝트의 파일을 이미지 안으로 복사한다. COPY package*.json ./은 package.json 같은 의존성 관련 파일을 먼저 복사한다. COPY . .은 빌드 컨텍스트 안의 나머지 파일을 현재 작업 디렉터리인 /app으로 복사한다.
RUN은 이미지를 빌드하는 중에 실행할 명령이다. 예제에서는 npm install --omit=dev로 실행에 필요한 패키지를 설치한다. 중요한 점은 RUN은 컨테이너가 시작될 때마다 실행되는 명령이 아니라는 것이다. 이미지가 만들어지는 동안 한 번 실행되고, 그 결과가 이미지 레이어에 저장된다.1
CMD는 컨테이너가 시작될 때 기본으로 실행할 명령이다. 위 Dockerfile로 만든 이미지를 실행하면 기본적으로 node server.js가 실행된다. 사용자가 docker run 뒤에 다른 명령을 붙이면 이 기본값은 바뀔 수 있다.
실습: Node.js 웹 애플리케이션 이미지 만들기
먼저 실습용 디렉터리를 만든다.
mkdir docker-node-demo
cd docker-node-demopackage.json 파일을 만든다.
{
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0"
}
}이제 간단한 웹 서버 파일 server.js를 만든다.
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('Hello from my Docker image!');
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});같은 디렉터리에 Dockerfile을 만든다.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]EXPOSE 3000은 이 이미지가 컨테이너 내부에서 3000번 포트를 사용한다는 표시다. 실제로 내 컴퓨터의 포트와 연결하려면 여전히 docker run -p 옵션이 필요하다. EXPOSE는 포트를 자동으로 열어 주는 명령이 아니라, 이미지 사용자에게 알려 주는 문서에 가깝다.
이제 이미지를 빌드한다.
docker build -t my-node-web:1.0 .여기서 -t my-node-web:1.0은 이미지 이름과 태그를 붙이는 옵션이다. 마지막의 .은 현재 디렉터리를 빌드 컨텍스트로 사용한다는 뜻이다. 빌드 컨텍스트는 Docker가 이미지를 만들 때 접근할 수 있는 파일 묶음이다. COPY가 복사할 수 있는 파일도 이 컨텍스트 안에 있어야 한다.2
이미지가 만들어졌는지 확인한다.
docker image ls my-node-web컨테이너로 실행한다.
docker run --name myweb -d -p 3000:3000 my-node-web:1.0브라우저에서 http://localhost:3000에 접속하거나 다음 명령을 실행한다.
curl http://localhost:3000다음과 같은 응답이 나오면 성공이다.
Hello from my Docker image!로그도 확인해 보자.
docker logs myweb실습이 끝나면 컨테이너를 삭제한다.
docker rm -f myweb이제 중요한 차이가 생겼다. 예전에는 컨테이너 안에서 직접 파일을 고쳤지만, 이제는 Dockerfile, package.json, server.js만 있으면 같은 이미지를 다시 만들 수 있다. 실행 환경이 문서가 아니라 코드가 된 것이다.
빌드 컨텍스트와 .dockerignore
docker build -t my-node-web:1.0 .에서 마지막의 .을 가볍게 넘기면 안 된다. 이 점 하나가 이미지 크기와 보안에 영향을 준다.
Docker는 빌드할 때 컨텍스트 안의 파일을 빌더가 사용할 수 있게 준비한다. 현재 디렉터리를 컨텍스트로 지정하면 그 아래 파일과 하위 디렉터리가 대상이 된다. 그래서 프로젝트 안에 node_modules, 로그 파일, 임시 파일, .env 같은 비밀 설정 파일이 있으면 빌드 과정에 불필요하게 포함될 수 있다.2
이를 막기 위해 .dockerignore 파일을 사용한다. .dockerignore는 Docker 빌드 컨텍스트에서 제외할 파일 목록이다. 프로젝트 루트에 다음 파일을 만든다.
node_modules
npm-debug.log
Dockerfile.*
.git
.gitignore
.env
.env.*
coverage
dist
.DS_Store이 파일은 .gitignore와 비슷한 역할을 한다. node_modules를 제외하면 로컬에 설치된 패키지를 이미지로 복사하지 않는다. 대신 Dockerfile의 RUN npm install --omit=dev가 이미지 안에서 필요한 패키지를 새로 설치한다. 이렇게 해야 내 컴퓨터의 운영체제나 CPU 환경에 맞게 설치된 파일이 컨테이너 안으로 잘못 들어가는 일을 줄일 수 있다.
.env나 .env.*를 제외하는 것도 중요하다. 데이터베이스 비밀번호, API 키 같은 값이 이미지에 들어가면 이미지를 공유하거나 레지스트리에 올릴 때 그대로 노출될 수 있다. 비밀 값은 이미지에 굽는 것이 아니라, 컨테이너 실행 시 환경 변수나 별도의 비밀 관리 방식으로 주입하는 편이 안전하다.
CMD와 ENTRYPOINT의 차이
Dockerfile에서 초보자가 자주 헷갈리는 명령이 CMD와 ENTRYPOINT다. 둘 다 컨테이너 시작 명령과 관련 있지만 역할이 다르다.
먼저 현재 Dockerfile은 다음처럼 끝난다.
CMD ["node", "server.js"]이 이미지를 그냥 실행하면 node server.js가 실행된다.
docker run --rm -p 3000:3000 my-node-web:1.0하지만 docker run 뒤에 다른 명령을 붙이면 CMD는 대체된다.
docker run --rm my-node-web:1.0 node -v이 경우 웹 서버는 실행되지 않고 Node.js 버전만 출력된다. 즉, CMD는 기본값이다. 사용자가 다른 명령을 주면 바뀔 수 있다.
반면 ENTRYPOINT는 컨테이너가 실행할 주 프로그램을 더 강하게 고정할 때 사용한다. 예를 들어 다음처럼 작성할 수 있다.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
ENTRYPOINT ["node"]
CMD ["server.js"]이 구조에서는 ENTRYPOINT ["node"]가 실행 프로그램이 되고, CMD ["server.js"]는 기본 인자가 된다. 따라서 기본 실행은 여전히 node server.js다.
docker build -t my-node-entry:1.0 -f Dockerfile.entrypoint .
docker run --rm -p 3000:3000 my-node-entry:1.0다만 실행할 때 인자를 바꾸면 CMD 부분만 바뀐다.
docker run --rm my-node-entry:1.0 -v이 명령은 내부적으로 node -v처럼 동작한다. ENTRYPOINT는 유지되고, 뒤에 붙인 -v가 새 인자로 전달되는 것이다. ENTRYPOINT 자체를 바꾸고 싶다면 --entrypoint 옵션을 사용한다.
docker run --rm --entrypoint sh my-node-entry:1.0 -c "node -v"실무에서는 보통 웹 애플리케이션 이미지에 CMD ["node", "server.js"]처럼 단순한 기본 실행 명령을 둔다. 반대로 특정 실행 파일을 중심으로 동작하는 CLI 도구 이미지는 ENTRYPOINT와 CMD를 함께 쓰는 경우가 많다.
또 하나 기억할 점이 있다. CMD node server.js처럼 쓰는 방식은 셸 형식이고, CMD ["node", "server.js"]처럼 쓰는 방식은 exec 형식이다. Dockerfile에서는 exec 형식이 더 명확하다. 실행 파일과 인자가 분리되어 있고, 불필요한 셸 해석을 줄일 수 있기 때문이다.1
작은 정리
Dockerfile은 컨테이너 안에서 손으로 하던 작업을 재현 가능한 이미지 빌드 과정으로 바꿔 준다. FROM으로 기반 이미지를 고르고, WORKDIR로 작업 위치를 정하고, COPY로 파일을 넣고, RUN으로 빌드 중 필요한 명령을 실행한다. 마지막으로 CMD나 ENTRYPOINT로 컨테이너 시작 명령을 정한다.
빌드할 때는 마지막 인자인 빌드 컨텍스트를 항상 의식해야 한다. 컨텍스트가 너무 크면 빌드가 느려지고, 민감한 파일이 이미지에 들어갈 위험도 커진다. .dockerignore는 이 문제를 줄이는 기본 장치다.
이제 우리는 애플리케이션 실행 환경을 이미지로 만들 수 있다. 다음 장에서는 이 이미지가 내부적으로 어떤 레이어로 구성되는지, 그리고 Dockerfile을 어떻게 작성해야 빌드가 더 빠르고 효율적이 되는지 살펴본다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.