레이블이 컨테이너인 게시물을 표시합니다. 모든 게시물 표시
레이블이 컨테이너인 게시물을 표시합니다. 모든 게시물 표시

목요일

Docker 멀티스테이지 빌드 캐시 최적화하기

🔍 검색 키워드: Docker 멀티스테이지 빌드, Dockerfile 캐시 성능, Docker 레이어 캐싱, 빌드 시간 단축

Docker 멀티스테이지 빌드 캐시 최적화하기

증상

Dockerfile로 이미지를 빌드할 때마다 다음과 같은 문제가 발생한다:

$ docker build -t myapp:latest .
  Step 3/10 : RUN npm install    # 매번 30초 이상 소요
  Step 4/10 : RUN npm run build  # 매번 60초 이상 소요
  ...
  real    2m 15s

코드는 조금만 바뀌었는데도 빌드 시간이 계속 길어진다. CI/CD 파이프라인에서 빌드만 몇 분씩 걸려서 배포 속도가 느리다. 특히 npm, pip 같은 의존성 설치 단계가 매번 전부 다시 실행된다.

원인

Docker는 각 RUN, COPY, ADD 단계를 하나의 레이어로 취급하고, 파일이 변경되면 그 이후의 모든 레이어를 다시 빌드한다. 예를 들어:

FROM node:18
  COPY . /app              # 레이어1: 모든 파일 복사
  WORKDIR /app
  RUN npm install          # 레이어2: 의존성 설치 (레이어1 변경되면 캐시 무효)
  RUN npm run build        # 레이어3: 빌드 (레이어2 변경되면 캐시 무효)

코드를 한 줄만 바꿔서 COPY . /app을 실행하면 npm install, npm run build가 모두 다시 실행된다. 즉, 캐시를 제대로 활용하지 못한다.

멀티스테이지 빌드를 사용하면서도 각 스테이지 간 의존성이 꼬여 있으면 캐시 효율이 떨어진다.

해결방법

1. 의존성 파일을 먼저 복사 (핵심!)

FROM node:18 AS builder
  
  WORKDIR /app
  
  # package.json, package-lock.json만 먼저 복사
  COPY package*.json ./
  
  # 의존성 설치 (코드 변경 시 캐시 유지)
  RUN npm ci
  
  # 이제 코드 복사
  COPY . .
  
  # 빌드
  RUN npm run build
  
  # 런타임 스테이지
  FROM node:18-alpine
  
  WORKDIR /app
  
  # 의존성만 복사 (빌드 아티팩트 불필요)
  COPY --from=builder /app/node_modules ./node_modules
  COPY --from=builder /app/dist ./dist
  COPY package*.json ./
  
  EXPOSE 3000
  CMD ["node", "dist/index.js"]

캐시 효과:

  • package.json 불변 → npm install 캐시 유지
  • 코드만 변경 → COPY . . 부터만 재실행
  • 빌드 시간 30초 → 5초

2. Docker Buildkit으로 고급 캐싱 활용

// syntax=docker/dockerfile:1.4
  
  FROM node:18 AS builder
  
  WORKDIR /app
  
  COPY package*.json ./
  
  // --mount=type=cache로 npm cache 디렉토리 보존
  RUN --mount=type=cache,target=/root/.npm \
      npm ci --prefer-offline
      
      COPY . .
      RUN npm run build
      
      FROM node:18-alpine
      
      WORKDIR /app
      
      COPY --from=builder /app/node_modules ./node_modules
      COPY --from=builder /app/dist ./dist
      COPY package*.json ./
      
      EXPOSE 3000
      CMD ["node", "dist/index.js"]

빌드 명령:

DOCKER_BUILDKIT=1 docker build -t myapp:latest .

효과:

  • npm cache가 빌드 간에 유지되어 더 빠름
  • 오프라인 설치 최적화

3. 불필요한 파일 제외 (.dockerignore)

.git
  .gitignore
  node_modules
  npm-debug.log
  .env
  .env.local
  dist
  build
  coverage
  .DS_Store
  README.md

효과:

  • COPY . . 시 변경되지 않은 파일이 많으면 해시 계산이 더 오래 걸림
  • .dockerignore로 제외하면 캐시 재계산 범위 축소

4. 여러 스테이지에서 캐시 공유

// syntax=docker/dockerfile:1.4
  
  FROM node:18 AS dependencies
  
  WORKDIR /app
  COPY package*.json ./
  
  RUN --mount=type=cache,target=/root/.npm \
      npm ci
      
      // 빌드 스테이지1
      FROM dependencies AS builder1
      
      COPY . .
      RUN npm run build
      
      // 빌드 스테이지2 (테스트)
      FROM dependencies AS tester
      
      COPY . .
      RUN npm test
      
      // 최종 스테이지
      FROM node:18-alpine
      
      WORKDIR /app
      
      COPY --from=builder1 /app/dist ./dist
      COPY --from=dependencies /app/node_modules ./node_modules
      COPY package*.json ./
      
      EXPOSE 3000
      CMD ["node", "dist/index.js"]

5. 번들 사이즈 최소화 (캐시 영향 없지만 이미지 크기 감소)

FROM node:18 AS builder
  
  WORKDIR /app
  COPY package*.json ./
  RUN npm ci
  
  COPY . .
  RUN npm run build
  
  // 크기 최소화: dependencies만 복사, devDependencies 제외
  FROM node:18-alpine
  
  WORKDIR /app
  
  COPY --from=builder /app/dist ./dist
  
  // 본번 의존성만 설치 (devDependencies 없음)
  COPY package*.json ./
  RUN npm ci --omit=dev
  
  EXPOSE 3000
  CMD ["node", "dist/index.js"]

정리표

문제 원인 해결법
npm install 매번 재실행 COPY . . 후 RUN npm install package.json만 먼저 복사
캐시가 가끔만 작동 불필요 파일 포함 시 해시 변경 .dockerignore 작성
빌드 시간 여전히 길다 npm cache 미보존 Buildkit + --mount=cache 활용
이미지 크기 커짐 devDependencies 포함 npm ci --omit=dev
멀티스테이지 간 캐시 미공유 각 스테이지가 독립적 dependencies AS 공용 스테이지 생성

TIP: docker build --progress=plain으로 상세 로그 확인, docker image history myapp:latest로 각 레이어 크기 확인 가능. 캐시 강제 무효화는 docker build --no-cache 또는 COPY . . --chown=node:node 같은 타임스탐프 변경 명령으로.

금요일

Docker 포트 충돌 & 컨테이너 연결 에러 완전 정복

🔍 검색 키워드: Docker 포트 충돌, port is already allocated, docker: Error response from daemon, 컨테이너 연결 안됨, bind failed port already in use, docker ps -a, EADDRINUSE 도커

왜 이 에러가 자꾸 나오냐

도커를 쓰다 보면 십중팔구 이 두 가지를 겪는다.

  1. 컨테이너 띄우려는데 포트가 이미 점유됐다고 막힘
  2. 컨테이너는 실행 중인데 앱끼리 통신이 안 됨

둘 다 "왜?"를 이해하면 해결은 5분이다. 모르고 --force나 재시작만 반복하면 하루 날린다.


1. 포트 충돌 에러

에러 메시지 패턴

Error response from daemon: driver failed programming external connectivity on endpoint myapp
(xxx): Bind for 0.0.0.0:8080 failed: port is already allocated
Error starting userland proxy: listen tcp4 0.0.0.0:3306: bind: address already in use

원인 3가지

원인빈도설명
이전 컨테이너가 죽지 않고 포트 점유 중★★★docker stop 안 하고 그냥 터미널 닫은 경우
호스트 프로세스(MySQL, Nginx 등)가 같은 포트 사용★★☆로컬에 MySQL 깔려있는데 3306 쓰려 할 때
이전 컨테이너가 exited 상태로 포트 홀딩★☆☆docker ps엔 안 보이지만 docker ps -a엔 보임

[초보] 단계별 해결법

1단계: 어떤 프로세스가 포트 쓰는지 확인

# macOS / Linux
lsof -i :8080

# Windows (PowerShell)
netstat -ano | findstr :8080

2단계: 도커 컨테이너 확인

# 실행 중인 컨테이너만
docker ps

# 중단된 것 포함 전체
docker ps -a

# 특정 포트 쓰는 컨테이너 찾기
docker ps --filter "publish=8080"

3단계: 점유 중인 컨테이너 정리

# 특정 컨테이너 중지
docker stop <container_id>

# 중지 + 삭제
docker rm -f <container_id>

# exited 상태 컨테이너 일괄 정리
docker container prune

[중급] 포트 매핑 전략

같은 포트를 써야 하는 서비스가 여러 개라면, 호스트 포트를 다르게 매핑한다.

# 호스트 8081 → 컨테이너 내부 8080
docker run -p 8081:8080 myapp

# 여러 포트 동시 매핑
docker run -p 8080:8080 -p 443:443 myapp

docker-compose.yml에서는:

services:
  app:
    image: myapp
    ports:
      - "8081:8080"  # 호스트:컨테이너
  db:
    image: mysql:8
    ports:
      - "3307:3306"  # 로컬 MySQL과 충돌 방지

[고급] 동적 포트 할당

테스트 환경에서 포트 충돌을 원천 차단하는 방법 — 호스트 포트를 도커가 알아서 비어있는 거 잡게 한다.

# 호스트 포트 미지정 → 임의 할당
docker run -p 0:8080 myapp

# 할당된 포트 확인
docker port <container_id> 8080
# 출력 예: 0.0.0.0:49152

Node.js에서 환경변수로 포트 받아 쓰는 패턴:

// app.js
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

2. 컨테이너 간 통신 안 되는 에러

에러 메시지 패턴

Error: connect ECONNREFUSED 127.0.0.1:3306
getaddrinfo ENOTFOUND db

컨테이너 A에서 컨테이너 B의 localhost로 접속하려 해서 생기는 문제다. 컨테이너끼리 localhost는 공유하지 않는다. 각자 독립된 네트워크 네임스페이스를 가진다.

원인과 해결법 체크리스트

상황잘못된 접근올바른 접근
컨테이너 A → B 접속localhost:3306컨테이너명 또는 서비스명
docker run 단독 실행네트워크 미지정--network 플래그로 같은 네트워크 사용
docker-compose 사용별도 network 정의같은 compose 파일 내면 자동 연결

[초보] docker-compose로 컨테이너 연결

docker-compose를 쓰면 같은 파일 안의 서비스끼리는 서비스명으로 바로 통신된다. 네트워크 설정 따로 안 해도 된다.

# docker-compose.yml
services:
  app:
    image: node:20
    environment:
      DB_HOST: db        # "localhost" 아니고 서비스명 "db"
      DB_PORT: 3306
    depends_on:
      - db

  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: mydb

Node.js에서 DB 연결:

const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST || 'db',  // 서비스명
  port: process.env.DB_PORT || 3306,
  user: 'root',
  password: 'secret',
  database: 'mydb'
});

Python (SQLAlchemy):

import os
from sqlalchemy import create_engine

DATABASE_URL = (
    f"mysql+pymysql://root:secret@"
    f"{os.getenv('DB_HOST', 'db')}:"
    f"{os.getenv('DB_PORT', '3306')}/mydb"
)

engine = create_engine(DATABASE_URL)

[중급] docker run으로 수동 네트워크 연결

docker-compose 없이 컨테이너 여러 개 연결할 때:

# 1. 공용 네트워크 생성
docker network create mynet

# 2. DB 컨테이너를 해당 네트워크에 붙여 실행
docker run -d \
  --name mydb \
  --network mynet \
  -e MYSQL_ROOT_PASSWORD=secret \
  mysql:8

# 3. 앱 컨테이너도 같은 네트워크로 실행
docker run -d \
  --name myapp \
  --network mynet \
  -e DB_HOST=mydb \
  -p 8080:8080 \
  myapp:latest

Java (Spring Boot) application.yml:

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST:mydb}:${DB_PORT:3306}/mydb
    username: root
    password: secret

[고급] 네트워크 디버깅

# 컨테이너가 어떤 네트워크에 붙어있나
docker inspect myapp | grep -A 20 "Networks"

# 특정 네트워크에 연결된 컨테이너 목록
docker network inspect mynet

# 컨테이너 안에서 직접 핑 테스트
docker exec -it myapp ping mydb

# 컨테이너 안에서 포트 열려있나 확인
docker exec -it myapp nc -zv mydb 3306

# 임시 debug 컨테이너로 네트워크 진단
docker run --rm --network mynet nicolaka/netshoot nmap -p 3306 mydb

Nginx 설정에서 upstream을 컨테이너명으로:

# nginx.conf
upstream backend {
    server app:8080;  # 컨테이너/서비스명
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

트러블슈팅 체크리스트

포트 충돌 발생 시

체크 항목명령어
실행 중인 컨테이너 확인docker ps
중단 포함 전체 확인docker ps -a
호스트 포트 점유 확인lsof -i :PORT (mac/linux)
문제 컨테이너 강제 삭제docker rm -f CONTAINER_ID
불필요한 컨테이너 일괄 정리docker container prune
포트 다르게 재매핑docker run -p HOST:CONTAINER

컨테이너 연결 안 될 때

체크 항목명령어
같은 네트워크인지 확인docker network inspect NETWORK
컨테이너명/서비스명으로 접속하는지 확인localhost → 서비스명
컨테이너 내부에서 핑 테스트docker exec -it APP ping DB
포트 열려있나 확인docker exec -it APP nc -zv DB PORT
컨테이너 로그 확인docker logs CONTAINER_ID
네트워크 재생성 후 재연결docker network create + --network

자주 하는 실수 요약

1. docker stop 대신 터미널만 닫는다
→ 컨테이너는 살아서 포트 계속 점유. docker stop 또는 docker-compose down 습관화.

2. 컨테이너 안에서 localhost로 다른 컨테이너 접근
→ 각 컨테이너는 독립 네트워크. 서비스명이나 컨테이너명으로 접근.

3. exited 컨테이너가 포트 잡고 있는 줄 모름
→ docker ps -a로 exited 포함해서 항상 확인.

4. depends_on만 믿고 DB 준비됐다고 착각
→ depends_on은 컨테이너 시작 순서만 보장. DB가 실제로 ready 상태인지는 별개. healthcheck나 wait 로직 필요.

# healthcheck로 DB 준비 확인
services:
  db:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 10s
      retries: 5

  app:
    depends_on:
      db:
        condition: service_healthy  # DB healthy 확인 후 시작

도커 관련 에러는 대부분 이 두 가지에서 온다. 포트 충돌이면 docker ps -a부터, 연결 안 되면 네트워크와 호스트명부터 확인하면 길을 잃지 않는다.