레이블이 DevOps인 게시물을 표시합니다. 모든 게시물 표시
레이블이 DevOps인 게시물을 표시합니다. 모든 게시물 표시

목요일

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 같은 타임스탐프 변경 명령으로.

PostgreSQL Connection Pool 고갈 해결하기

🔍 검색 키워드: PostgreSQL connection pool 에러, 커넥션 풀 소진, 데이터베이스 연결 오류, 최대 연결 초과

PostgreSQL Connection Pool 고갈 해결하기

증상

애플리케이션이 실행되다가 갑자기 다음과 같은 에러가 발생한다:

FATAL: sorry, too many clients already

또는:

Error: connect ECONNREFUSED - PostgreSQL connection failed

또는 connection pool 라이브러리에서:

Error: timeout acquiring a connection from the pool
  Error: Client has already been released to the pool

특히 트래픽이 많아지거나 장시간 실행되는 배치 작업 후에 자주 발생한다. 새로운 요청이 들어와도 DB 연결을 못 하고 응답 불가 상태가 된다.

원인

PostgreSQL은 최대 동시 연결 수 제한이 있고 (기본값 100), connection pool은 재사용 가능한 연결 수를 미리 정해둔다. 고갈 현상은 다음 경우에 발생:

  1. 연결을 반환 안 함: 쿼리 후 연결을 pool에 반환하지 않아 계속 증가
  2. 타임아웃 후 좀비 연결: 응답 없는 연결이 pool에 남아있음
  3. Pool 사이즈 설정 과소: 동시 사용자/요청량에 비해 pool이 너무 작음
  4. 쿼리 오래 걸림: 느린 쿼리가 연결을 오래 점유
  5. Connection leak: 예외 발생 시 연결을 close하지 않는 코드

해결방법

1. Connection Pool 설정 최적화

Node.js + pg (node-postgres):

const { Pool } = require('pg');
  
  const pool = new Pool({
    host: 'localhost',
      port: 5432,
        database: 'mydb',
          user: 'postgres',
            password: 'password',
              max: 20,                    // 최대 연결 수 (기본 10)
                idleTimeoutMillis: 30000,   // 30초 idle 후 닫기
                  connectionTimeoutMillis: 2000, // 연결 생성 타임아웃
                  });
                  
                  module.exports = pool;

Python + psycopg2:

import psycopg2.pool
  
  connection_pool = psycopg2.pool.SimpleConnectionPool(
      1,      // minimum connections
          20,     // maximum connections
              database="mydb",
                  user="postgres",
                      password="password",
                          host="localhost"
                          )
                          
                          // 사용
                          conn = connection_pool.getconn()
                          try:
                              // 쿼리 실행
                                  pass
                                  finally:
                                      connection_pool.putconn(conn)

Java + HikariCP:

HikariConfig config = new HikariConfig();
  config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
  config.setUsername("postgres");
  config.setPassword("password");
  config.setMaximumPoolSize(20);          // 최대 연결
  config.setMinimumIdle(5);               // 최소 유휴 연결
  config.setConnectionTimeout(2000);       // 2초 타임아웃
  config.setIdleTimeout(600000);          // 10분 idle 타임아웃
  config.setMaxLifetime(1800000);         // 30분 최대 수명
  
  HikariDataSource dataSource = new HikariDataSource(config);

2. 연결이 제대로 반환되는지 확인

Node.js에서 연결 누수 확인:

const pool = new Pool(config);
  
  pool.on('error', (err, client) => {
    console.error('Unexpected error on idle client', err);
      process.exit(-1);
      });
      
      pool.on('connect', () => {
        console.log('New connection created');
        });
        
        // 항상 try-finally로 연결 반환 보장
        const client = await pool.connect();
        try {
          const result = await client.query('SELECT * FROM users WHERE id = $1', [1]);
            return result.rows;
            } finally {
              client.release();  // 반드시 실행
              }

또는 with 문 활용 (Python):

from contextlib import contextmanager
  
  @contextmanager
  def get_db_connection():
      conn = connection_pool.getconn()
          try:
                  yield conn
                      finally:
                              connection_pool.putconn(conn)
                              
                              // 사용
                              with get_db_connection() as conn:
                                  cursor = conn.cursor()
                                      cursor.execute("SELECT * FROM users WHERE id = %s", (1,))
                                          // 예외 발생해도 자동으로 반환됨

3. 느린 쿼리 최적화

-- 인덱스 추가
  CREATE INDEX idx_users_id ON users(id);
  
  -- 실행계획 확인
  EXPLAIN ANALYZE SELECT * FROM users WHERE status = 'active';
  
  -- 필요 없는 조인 제거, WHERE 조건 최적화
  SELECT u.id, u.name
  FROM users u
  WHERE u.created_at > NOW() - INTERVAL '7 days'
    AND u.status = 'active'
    LIMIT 100;

4. PostgreSQL 서버 설정 확인

# PostgreSQL 최대 연결 수 조회
  psql -U postgres -c "SHOW max_connections;"
  
  # 현재 연결 수 조회
  psql -U postgres -c "SELECT count(*) FROM pg_stat_activity;"
  
  # 연결 상세 정보
  psql -U postgres -c "SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;"

필요하면 postgresql.conf에서:

max_connections = 200   # 기본값 100에서 증가

5. 좀비 연결 정리

-- 유휴 연결 종료
  SELECT pg_terminate_backend(pid)
  FROM pg_stat_activity
  WHERE datname = 'mydb'
    AND state = 'idle'
      AND query_start < now() - interval '30 minutes';
      
      -- 슬로우 쿼리 강제 종료 (주의)
      SELECT pg_terminate_backend(pid)
      FROM pg_stat_activity
      WHERE query_start < now() - interval '5 minutes'
        AND state != 'idle';

정리표

상황 원인 해결법
연결 고갈 후 다시 증가 연결을 close하지 않음 try-finally 또는 context manager로 보장
Pool 타임아웃 pool이 너무 작음 max 크기 증가, idleTimeoutMillis 조정
"too many clients" 에러 PostgreSQL 최대 연결 도달 max_connections 증가 또는 app pool 감소
간헐적 연결 오류 connectionTimeoutMillis 너무 짧음 타임아웃 값 증가 또는 네트워크 확인
메모리 누수 좀비 연결이 메모리 점유 주기적으로 idle 연결 정리 (pg_terminate_backend)

TIP: 프로덕션에서는 반드시 connection pool 크기, 타임아웃, idle 설정을 검토하고, 정기적으로 pg_stat_activity로 모니터링하자.