Dockerfile 및 Nginx - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki

1. Dockerfile 작성

  • 굳이 컨테이너화 해야 하는 이유는 무엇인가
    • 간소화된 CI/CD 파이프라인
      • 앱을 도커 컨테이너로 패키징하면 개발부터 프로덕션까지 일관된 환경을 구축할 수 있다
      • 이러한 일관성은 CI/CD 파이프라인을 간소화하고 빌드 및 배포 과정에서 환경별 문제 발생 위험을 줄여준다
    • 간소화된 종속성 관리
      • 도커는 앱의 모든 종속성을 컨테이너 내부에 캡슐화한다. 따라서 로컬에서만 작동되는 문제를 직면할 필요가 없어진다. 모든 팀 구성원과 배포 환경에서 동일한 설정을 사용하므로 원활한 협업이 가능하다
    • 향상된 리소스 관리
      • 컨테이너는 가볍고 효율적이다. 도커 컨테이너는 가상 머신과 달리 호스트 시스템의 커널을 공유하므로 동일한 하드웨어에서 더 많은 컨테이너를 실행할 수 있다.
    • 충돌 없는 격리된 환경
      • 도커는 애플리케이션을 위한 격리된 환경을 제공한다. 이러한 격리를 통해 동일한 시스템에서 서로 다른 프로젝트의 종속성이나 구성 간의 충돌을 방지할 수 있다.

1.1. 도커 파일 핵심 명령어

명령어 질문 역할
FROM 어디서 시작하는가? 기반 이미지 선택
WORKDIR 어디에서 작업하는가? 컨테이너 내부 기본 작업 폴더 설정
COPY 어떤 파일을 넣는가? 로컬 파일을 이미지 내부로 복사
RUN 이미지를 만들 때 무엇을 실행하는가? 패키지 설치, 빌드 등 수행
EXPOSE 앱은 어느 포트를 사용하는가? 컨테이너가 사용하는 포트를 문서화
CMD / ENTRYPOINT 컨테이너가 시작되면 무엇을 실행하는가? 기본 실행 명령 지정

1.2. Frontend Dockerfile 작성

  • 노드 버전은 풀스택 팀에서 버전 선정이 확정되면 추후 변경 예정
# 이 이미지는 가장 최근 LTS 버전으로 시작
FROM node:24.21.0

# 작업 디렉토리 설정
WORKDIR /app

# 의존성 파일 복사
COPY package*.json ./

# 전체 의존성 라이브러리 설치
RUN npm install

# 소스 코드 복사 
COPY . .

# 리액트 앱 빌드
RUN npm run build

# 컨테이너가 사용할 포트 명시
EXPOSE 3000

# 애플리케이션 실행
CMD ["npm", "start"]

1.2. Backend Dockerfile 작성

# Java 21 JDK 및 Gradle이 포함된 베이스 이미지 사용
FROM eclipse-temurin:21-jdk-noble

# 작업 디렉토리 설정
WORKDIR /app

# 소스 코드 및 빌드 설정 파일 복사
COPY . .

# 애플리케이션 빌드
RUN ./gradlew clean bootJar -x test

# 컨테이너가 사용할 포트 명시
EXPOSE 8080

# 애플리케이션 실행
CMD ["java", "-jar", "build/libs/app.jar"]

1.3. AI Dockerfile 작성

FROM python:3.11

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

requirements.txt가 무엇인가?

  • 현재 개발 환경에 pip를 통해 설치된 모든 패키지 목록이 버전과 함께 기록되는 곳이다.
  • 왜 사용하는가?
    • 개별 패키지 설치 과정 없이 명시된 모든 패키지를 명령어로 한 번에 구성하기 위해

1.4. Frontend .dockerignore 작성

# Dependencies
node_modules
.pnpm-store
.yarn

# Local Build Artifacts
dist
build
.next
out

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Environment Variables & Secrets
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local

# Version Control
.git
.gitignore
.gitattributes

# Docker Specific
Dockerfile
Dockerfile*
docker-compose*
.dockerignore

# OS Metadata & IDEs
.DS_Store
Thumbs.db
.vscode
.idea

1.5. Backend .dockerignore 작성

# Version Control
.git
.gitignore

# Build Tool Directories (Exclude local builds, keep only final artifacts if not using multi-stage)
target/
build/
.gradle/
.mvn/

# IDE and Editor Configuration Files
.idea/
*.iml
.vscode/
.settings/
.classpath
.project
.factorypath

# OS Metadata Files
.DS_Store
Thumbs.db

# Documentation and Local Logs
*.log
README.md
HELP.md

# Build Wrappers (Optional: Ignore if you use pre-installed Maven/Gradle in containers)
mvnw
mvnw.cmd
gradlew
gradlew.bat

1.6. AI .dockerignore 작성

# .dockerignore
.git
.vscode/
__pycache__/
.env
.env.*
*.log
*.pyc
README.md

2. Docker 이미지 최적화

  • 위의 작성한 도커 파일의 문제점

    • 과도하게 큰 베이스 이미지
      • 경량화되지 않은 이미지이기 때문에 풀 버전 이미지에는 리액트 빌드 완료 후에는 전혀 필요없는 Python, 컴파일러 등 리눅스 빌드 도구들이 모두 포함되어 있다.
    • 전체 소스 코드 중 하나라도 수정되면 COPY 단계부터 다시 실행되어 캐시 효과가 사라진다.
    • 이미지가 커지면 빌드 시간이 증가해서 빌드 속도가 느려지고, 이미지를 레지스트리에서 Pull 하는 시간이 길어져서 배포 속도가 저하된다.
      • 또한 Registry에서 많은 저장 공간을 차지하게 되면 저장 비용도 증가한다
    • 불필요한 패키지가 많아 공격 표면이 넓어져서 보안 취약점이 증가한다.
  • 이미지 크기에 영향을 미치는 요소

    • 기본 이미지: ubuntu, alpine, scratch 등 선택에 따라 크기가 크게 달라진다.
    • 설치된 패키지: 패키지의 수와 크기가 전체 크기를 증가시킨다.
    • 불필요한 파일: 실행에 필요하지 않은 파일이나 디렉토리가 이미지를 부풀린다.
    • 이미지 레이어: 레이어 수와 각 레이어의 변경량이 전체 크기에 영향을 미친다.
    • 캐싱 비효율: 캐싱을 효율적으로 사용하지 않으면 이미지 크기가 커질 수 있다.

싱글 스테이지 빌드로 할때 한계점

  • 이미지 용량의 비대화

    • pip 레이어에서 저장된 임시 파일이나 캐시들이 레이어에 포함되어 이미지 용량이 매우 커진다.
    • 이로 인해 CI/CD 환경에서도 이미지 Push, Pull 시간이 늘어나게 되어 배포 시간이 증가한다.
    • 이미지 크기가 커진다는 것은 더 많은 패키지, 라이브러리, 실행도구를 포함한다는 것이다. 불필요한 것들이 많이 포함될 수도 있고, 여러가지 도구만큼 공격 표면이 많아진다고 볼 수 있다.
  • 컨테이너 보안 취약점 노출 위험

    • 런타임 구동에 필요 없는 빌드 유틸리티 및 패키지 설치 도구가 운영 컨테이너에 포함되어 잠재적인 보안 리스크 노출

2.1. Docker 이미지 최적화 전략

  1. 더 작은 베이스 이미지 선택
    • 베이스 이미지 선택은 이미지 크기에 가장 큰 영향을 미치는 결정이다. alpine이나 scratch 같은 경량 이미지를 사용하면 크기를 크게 줄일 수 있다.
    • 임시 파일, 의존성등을 최종 런타임 이미지에서 나눠, 최종 이미지 용량을 크게 줄여 레지스트리 Push/Pull 시간 단축으로 인한 배포 파이프라인 가속화
  2. 레이어 수 최소화
    • Dockerfile의 RUN, COPY, ADD 명령어는 각각 새로운 레이어를 생성한다. 명령어를 하나로 합치면 레이어 수를 줄일 수 있다.
  3. 멀티스테이지 빌드 활용
    • 멀티스테이지 빌드는 단일 Dockerfile에서 여러 FROM 문을 사용하여 빌드 환경과 런타임 환경을 분리한다. 빌드에만 필요한 도구(컴파일러, 빌드 패키지 등)가 최종 이미지에 포함되지 않으므로 이미지 크기를 크게 줄일 수 있다.
    • 소스 코드가 수정되어도 의존성 설치 레이어는 캐시를 재사용함으로써 불필요한 패키지 재다운로드 시간을 제거하여 이미지 빌드 시간을 줄였다.
  4. 불필요한 파일 제거
    • 패키지 설치 후 남은 캐시와 임시 파일을 같은 RUN 명령어 내에서 제거해야 한다. (별도 레이어로 제거하면 이전 레이어에 파일이 남아 효과가 없다.)
  5. 캐시 효율적으로 활용
    • 변경이 잦은 파일(소스 코드)은 뒤에, 변경이 드문 파일(의존성 목록)은 앞에 배치하여 캐시 히트율을 높인다.
  6. .dockerignore 파일 활용
    • 불필요한 파일이 빌드 컨텍스트에 포함되지 않도록 .dockerignore 파일을 작성한다.
  7. BuildKit 캐시 마운트 활용하기
    • build.gradle 파일에 작은 변경(주석 추가, 라이브러리 버전 수정 등)만 생겨도 의존성을 다운로드하는 레이어의 캐시가 깨지고, 모든 의존성을 처음부터 다시 받아와야 한다. 이 과정은 수 분이 소요될 수 있는 매우 비싼 작업이다.
      • 이 문제를 해결하기 위해 BuildKit은 RUN 명령어에 --mount=type=cache 옵션을 제공한다. 이 옵션은 Docker 호스트의 캐시 디렉토리를 빌드 컨테이너의 특정 경로에 마운트하여, RUN 명령어가 실행되는 동안 해당 디렉토리의 내용을 유지하고 다음 빌드에서 재사용할 수 있게 해준다.
    • build.gradle 파일이 변경되어 레이어 캐시가 깨지더라도, Gradle은 캐시 마운트에 남아있는 파일들을 재사용하므로 의존성을 다시 다운로드하는 시간을 거의 없앨 수 있다.

non-root 전환으로 보안취약점 보완

2.2. Frontend Dockerfile 수정

ARG NODE_VERSION=24.21.0-alpine

# =========================================
# 1: 리액트 애플리케이션 빌드 스테이지
# =========================================

# 경량화된 Node.js 이미지를 빌드를 위해 사용
FROM node:${NODE_VERSION} AS builder
 
# 작업 디렉토리 설정
WORKDIR /app
 
# Docker 레이어 캐싱을 최적화하기 위해 먼저 종속성 매니페스트를 복사
COPY package.json package-lock.json ./
 
# 프로젝트 의존성 설치 (Docker BuildKit 캐시 마운트를 활용)
RUN --mount=type=cache,target=/root/.npm npm ci
 
# 나머지 소스코드를 컨테이너로 복사
COPY . .
 
# 리액트 앱 Build
RUN npm run build
 
# =========================================
# 2: 서빙용 정적 파일 스테이지
# =========================================

# 앱 실행을 위해 새로운 이미지를 시작
FROM node:${NODE_VERSION} AS runner
 
# Node.js가 프로덕션 모드로 실행되도록 한다
ENV NODE_ENV=production
 
# 작업 디렉토리 설정
WORKDIR /app
 
# 빌더 단계에서 생성된 프로덕션 파일만 복사
COPY --link --from=builder /app/dist ./dist
 
# 정적 빌드를 제공하는 데 필요한 패키지(고정 버전)만 설치(전역 설치나 개발 종속성은 설치X)
RUN --mount=type=cache,target=/root/.npm npm install serve@^14.2.6 --omit=dev
 
# 보안 강화를 위해 루트 권한을 낮춘다
USER node
 
# 컨테이너가 사용할 포트 명시
EXPOSE 3000
 
# 정적 빌드를 호스팅하는 프로세스를 시작
CMD ["npx", "serve", "-s", "dist", "-l", "3000"]

2.3. Backend Dockerfile 수정

# =========================================
# 1: 스프링부트 애플리케이션 빌드 스테이지
# =========================================

# Java 21 JDK 및 Gradle이 포함된 베이스 이미지 사용
FROM eclipse-temurin:21-jdk-noble AS builder

# 작업 디렉토리 설정
WORKDIR /app

# Gradle 래퍼 및 빌드 설정 파일만 먼저 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./

# 의존성만 먼저 다운로드 (레이어 캐싱 + BuildKit 캐시 마운트 활용)
# build.gradle이 바뀌어 이 레이어 캐시가 깨지더라도, 캐시 마운트에 남은
# 의존성 파일을 재사용하므로 매번 처음부터 다시 받지 않는다.
RUN --mount=type=cache,target=/root/.gradle \
    ./gradlew dependencies --no-daemon || true

# 실제 소스 코드 복사 및 빌드
COPY src src
RUN ./gradlew clean bootJar -x test --no-daemon

# =========================================
# 2: 실행 스테이지 - 최종 이미지 용량 최소화
# =========================================

# 실행에는 JDK가 아닌 JRE만 필요하므로 더 가벼운 이미지 사용
FROM eclipse-temurin:21-jre-alpine AS runner

# 작업 디렉토리 설정
WORKDIR /app

# 보안을 위해 root가 아닌 일반 실행 사용자 생성 및 전환
RUN adduser -D springuser
USER springuser

# 빌드 스테이지에서 생성된 jar 파일만 추출하여 복사
# (app.jar 대신 실제 생성되는 빌드 결과물 이름을 유연하게 매칭)
COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8080

# 애플리케이션 실행
ENTRYPOINT ["java", "-jar", "app.jar"]

2.4. AI Dockerfile 수정

# 멀티 스테이지 빌드일때

# 1. 빌드 스테이지
FROM python:3.11 AS builder

WORKDIR /app

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 2. 런타임 스테이지
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 
ENV PYTHONUNBUFFERED=1 
ENV PATH="/opt/venv/bin:$PATH"

RUN groupadd -r app && useradd -r -g app appuser

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
COPY . .

USER appuser

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

2.5. alpine 이미지를 썼을 때 발생할 수 있는 장점과 단점

장점

  1. 최소한의 용량
    • 기본 Alphine 이미지의 크기는 약 5MB에 불과하여 Ubuntu나 Debian과 같은 다른 배포판보다 훨씬 작다. 이러한 최소 크기는 빠른 다운로드 속도, 컨테이너 시작 시간 단축, 디스크 공간 사용량 감소로 이어져 Docker를 사용하는 클라우드 네이티브 애플리케이션에 이상적이다
  2. 보안
    • 보안에 중점을 둔 Alpine Linux는 보안을 최우선으로 고려하여 설계되었다. 규모가 큰 유틸리티 대신 경량화된 유틸리티를 사용하여 공격 표면을 최소화한다. 이는 잠재적인 취약점을 줄여 컨테이너 보안에 좋은 선택이 된다.

단점

  1. 성능 이슈
    • musl libc가 glibc보다 사용하는 스레드 스택크기가 작다. 또한 musl libc는 메모리 할당(malloc) 및 관리 방식이 glibc와 다르다. 흔히 잘못 알려진 사실은 Alpine Linux가 Ubuntu나 Debian 같은 다른 배포판에 비해 성능이 떨어진다는 것인데, 이는 주로 musl libc구현 방식 때문이다. 하지만 특정 애플리케이션이나 작업 부하의 경우, 특히 멀티스레딩이나 최적화된 C 라이브러리에 많이 의존하는 경우 Alpine에서 성능이 약간 저하될 수 있다.
    • 이는 시스템을 실제로 운영하며 성능 상의 문제를 체크해야 한다
  2. 라이브러리 이슈
    • Ubuntu나 Debian은 표준 C 라이브러리로 glibc를 사용하지만, Alpine은 경량화된 musl libc를 사용한다
      • Python의 pandas, numpy, cryptography나 Node.js의 sass처럼 C 언어로 작성된 핵심 기능(Binary Wheels)이 포함된 라이브러리들은 glibc를 기준으로 빌드되어 있다. Alpine에서는 이 바이너리를 그대로 쓸 수 없어 컨테이너를 빌드할 때 소스 코드를 처음부터 직접 컴파일해야 한다.
      • 결과적으로 빌드 시간이 수십 분 이상으로 늘어나며, 컴파일을 위해 gcc, g++, make 같은 무거운 빌드 도구를 추가로 설치해야 하므로 이미지 용량이 커져 Alpine의 장점이 사라진다.
    • 이러한 호환성의 문제는 주로 파이썬을 사용하는 애플리케이션에서 발생하는데 일부 파이썬 패키지의 설치 문제나 런타임 오류로 이어질 수 있다.

결론 : alpine 이미지 사용

3. Docker 이미지 최적화 전후 비교

아직 프론트, 백엔드, AI 코드가 구현되지 않은 상태라 LLM을 활용하여 기능을 구현한 Repo를 이용하여 테스트를 진행했습니다

1. 이미지 크기 / 최초 빌드 시간 (최적화 전, 후)

서비스 이전 이후 크기 감소 빌드 시간
Frontend 1.71GB 244MB -85.7% 34s → 12s (-64.7%)
Backend 1.03GB 341MB -66.9% 41s → 35s (-14.6%)
AI 1.70GB 315MB -81.5% 40s → 13s (-67.5%)

2. 백엔드 반복 배포 시 재빌드 시간 (BuildKit 캐시 마운트 효과)

Dockerfile 재빌드 시간
naive (단일 스테이지) 38s
optimized, 캐시 마운트 추가 (--mount=type=cache,target=/root/.gradle) 10s (naive 대비 -73.7%)
  • 이미지 크기는 캐시 마운트를 넣어도 341MB로 동일하다
  • 캐시 마운트는 크기가 아니라 순수하게 빌드 시간에만 영향을 준다

Docker 멀티스테이지 빌드 + Alpine 베이스 이미지 전환으로 이미지 크기 평균 78% 감소 (Frontend 1.71GB→244MB, Backend 1.03GB→341MB, AI 1.7GB→315MB), BuildKit 캐시 마운트 도입으로 의존성 변경 없는 재배포 시 빌드 시간 73% 단축 (38s→10s)

4. Nginx


# upstream backend {
#	  server 127.0.0.1:8080
# }

# upstream frontend {
# 	server 127.0.0.1:3000
# }

# upstream ai {
#   server 127.0.0.1:8000
# }

server {
	listen 80;
	server_name my_domain_name;
	
	
	  proxy_http_version 1.1;
	  proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
	
	
	location / {
		proxy_pass http://frontend; # docker network를 쓰기 때문에 docker container이름 쓰기
	}

	location /api {
		proxy_pass http://backend; # docker network를 쓰기 때문에 docker container이름 쓰기
	}
	
	
	location /ai {
		proxy_pass http://ai; # docker network를 쓰기 때문에 docker container이름 쓰기
	}
}
=

upstream

  • 부하 분산, 트래픽 제어와 같은 옵션을 제공한다.
    • weigth (예시 weigth = 3)
      • 다른 서버 대비 3배 더 자주 사용된다.
    • 예시 max_fails = 3, fail_timeout=30s;
      • 서버는 30초 동안 응답하지 않는 상태가 3번 지속되면 죽은 것으로 간주하고 요청을 보내지 않는다.