레이블이 CI/CD인 게시물을 표시합니다. 모든 게시물 표시
레이블이 CI/CD인 게시물을 표시합니다. 모든 게시물 표시

목요일

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

수요일

Kubernetes CrashLoopBackOff 원인과 해결 — 재시작 대기 시간(10초~300초)과 kubectl logs --previous

🔍 검색 키워드: Kubernetes CrashLoopBackOff 원인, CrashLoopBackOff 재시작 대기 시간, kubectl logs --previous, restartPolicy Always 백오프, maxContainerRestartPeriod 설정, 쿠버네티스 파드 계속 재시작

요약: CrashLoopBackOff는 컨테이너가 시작 직후 계속 종료되어 kubelet이 재시작 간격을 점점 늘리는 중이라는 상태 표시이며, 그 자체가 원인은 아닙니다. kubectl describe로 컨테이너 상태와 재시작 횟수를 보고, kubectl logs --previous로 직전에 죽은 컨테이너의 로그를 확인해 실제 원인을 찾아야 합니다.

CrashLoopBackOff는 무엇을 뜻하나요?

Kubernetes 공식 문서(Pod Lifecycle)는 이 상태를 "백오프 지연 메커니즘이 현재 적용 중이며, 컨테이너가 실패와 재시작을 반복하는 크래시 루프에 있음을 나타낸다"는 취지로 설명합니다. 같은 문서는 CrashLoopBackOff가 kubectl로 파드를 조회하거나 describe할 때 볼 수 있는 상태 또는 이벤트이며, 컨테이너가 제대로 시작하지 못하고 계속 실패하는 경우에 나타난다고 설명합니다. 즉 CrashLoopBackOff는 에러 이름이 아니라 "재시작을 반복하고 있고 대기 중"이라는 현상입니다.

재시작은 얼마 간격으로 일어나나요?

공식 문서에 따르면 파드의 spec에는 restartPolicy 필드가 있고 값은 Always, OnFailure, Never이며 기본값은 Always입니다. 컨테이너가 종료되면 kubelet이 지수 백오프 지연(10초, 20초, 40초, ...)을 두고 다시 시작하며, 지연은 최대 300초(5분)로 제한됩니다. 컨테이너가 문제없이 10분 동안 실행되면 kubelet은 그 컨테이너의 재시작 백오프 타이머를 초기화합니다.

재시작 횟수대기 시간(기본값)
1번째 이후10초
2번째 이후20초
3번째 이후40초
이후 계속 2배최대 300초(5분)에서 고정

위 표는 공식 문서가 밝힌 규칙(10초에서 시작해 2배씩 증가, 최대 300초)을 정리한 것입니다. 클러스터 버전이나 설정에 따라 값이 다를 수 있습니다.

재시작 대기 시간을 바꿀 수 있나요?

공식 문서의 같은 페이지에는 버전별로 달라진 기능이 두 가지 적혀 있습니다. 문서에 표시된 상태는 이 글을 쓴 시점의 기준이므로, 사용 중인 클러스터 버전에서 지원되는지 반드시 확인하세요.

  • 재시작 지연 단축: Kubernetes v1.33부터 알파이며 기본값은 비활성화입니다. 피처 게이트 ReduceDefaultCrashLoopBackOffDecay를 켜면 지연이 1초에서 시작해 2배씩 늘어나고 최대 60초까지로 줄어듭니다.
  • 최대 지연 설정: Kubernetes v1.35부터 베타이며 기본 활성화입니다. 피처 게이트 KubeletCrashLoopBackOffMax를 쓰면 노드별 kubelet 설정으로 최대 지연을 기본 300초에서 바꿀 수 있습니다.

kubelet 설정의 crashLoopBackOff 항목에서 maxContainerRestartPeriod 필드를 "1s"에서 "300s" 사이로 지정합니다. 공식 문서에 실린 예시는 다음과 같습니다.

# 지연은 10초에서 시작해 2배씩 늘고, 최대 100초
kind: KubeletConfiguration
crashLoopBackOff:
  maxContainerRestartPeriod: "100s"

문서에 따르면 설정한 최대값이 기본 초기값 10초보다 작으면 초기 지연이 그 최대값으로 바뀝니다. 다만 이것은 재시작 간격을 조정할 뿐 크래시의 원인을 해결하지는 않습니다.

CrashLoopBackOff는 어떻게 진단하나요?

1단계: 파드 상태와 재시작 횟수 확인

kubectl get pods
kubectl describe pod ${POD_NAME}

공식 디버깅 문서는 파드 디버깅의 첫 단계로 kubectl describe pods를 실행해 현재 상태와 최근 이벤트를 보라고 안내합니다. 컨테이너가 모두 Running인지, 최근 재시작이 있었는지를 확인하라고 합니다. describe 출력에는 컨테이너 상태(Waiting, Running, Terminated 중 하나)와 재시작 횟수(Restart Count)가 포함되며, 재시작 횟수는 restartPolicy가 Always인 컨테이너의 크래시 루프를 찾는 데 유용하다고 문서에 설명돼 있습니다. 아래쪽 Events 항목의 Reason과 Message도 함께 보세요.

2단계: 직전에 죽은 컨테이너의 로그 확인

공식 문서는 먼저 문제 컨테이너의 로그를 보고, 컨테이너가 이전에 크래시했다면 이전 컨테이너의 크래시 로그를 --previous 옵션으로 볼 수 있다고 안내합니다.

kubectl logs ${POD_NAME} -c ${CONTAINER_NAME}
kubectl logs ${POD_NAME} -c ${CONTAINER_NAME} --previous

CrashLoopBackOff 상태에서는 현재 컨테이너가 막 재시작되어 로그가 비어 있는 경우가 많습니다. 이때 --previous로 직전 실행의 로그를 보는 것이 핵심입니다. 컨테이너가 하나뿐이면 -c 옵션은 생략할 수 있다고 공식 문서에 적혀 있습니다.

3단계: 흔한 원인 후보 점검

아래는 크래시 루프에서 일반적으로 많이 점검하는 항목입니다. 원인은 애플리케이션과 환경마다 다르며, 위 로그와 이벤트로 어느 항목에 해당하는지 확인해야 합니다.

  • 애플리케이션이 시작 중 에러로 종료(설정값 누락, 필수 환경변수 없음, 연결 실패 등)
  • 컨테이너 command나 args가 잘못되어 프로세스가 곧바로 종료
  • 필요한 ConfigMap이나 Secret, 볼륨이 없거나 값이 틀림
  • liveness probe 설정이 너무 엄격해 정상 컨테이너가 반복해서 재시작
  • 메모리 한도 초과로 종료(describe 출력의 종료 이유와 이벤트로 확인)

주의할 점

  • kubectl logs만 보면 재시작 직후의 빈 로그를 보고 원인을 놓치기 쉽습니다. 항상 --previous를 함께 확인하세요.
  • 대기 시간이 최대 5분까지 늘어나므로, 원인을 고친 뒤에도 파드가 곧바로 다시 뜨지 않고 한동안 기다릴 수 있습니다. 급하면 파드를 삭제해 새로 만들 수 있습니다.
  • 피처 게이트나 kubelet 설정으로 재시작 간격을 줄이면 크래시 루프가 더 자주 반복되어 노드에 부하를 줄 수 있습니다. 원인 해결이 먼저입니다.
  • 이 글은 공식 문서를 근거로 정리한 것이며 특정 클러스터에서 직접 실행해 확인한 결과가 아닙니다.

정리표

확인할 것명령 또는 설정알 수 있는 것
파드 상태와 재시작 횟수kubectl describe pod컨테이너 상태, Restart Count, Events
직전 크래시 로그kubectl logs --previous종료 직전 에러 메시지
재시작 간격(기본)10초부터 2배, 최대 300초대기 시간의 상한
최대 간격 조정kubelet의 maxContainerRestartPeriod노드별 상한(1초~300초)

자주 묻는 질문(FAQ)

CrashLoopBackOff는 에러인가요?

에러 자체가 아니라 컨테이너가 반복해서 실패하고 있어 재시작 대기 중이라는 상태입니다. 실제 원인은 컨테이너 로그와 파드 이벤트에서 찾아야 합니다.

CrashLoopBackOff일 때 로그가 비어 있는데 어떻게 하나요?

현재 컨테이너가 막 재시작된 상태일 수 있습니다. kubectl logs에 --previous 옵션을 붙여 직전에 크래시한 컨테이너의 로그를 확인하세요.

재시작 대기 시간은 최대 얼마까지 늘어나나요?

공식 문서 기준 기본값은 10초에서 시작해 2배씩 늘어 최대 300초(5분)입니다. 컨테이너가 10분 동안 문제없이 실행되면 타이머가 초기화됩니다. 최신 버전에서는 kubelet 설정으로 상한을 바꿀 수 있습니다.

restartPolicy를 바꾸면 해결되나요?

restartPolicy는 종료된 컨테이너를 다시 시작할지를 정하는 값이며 크래시의 원인을 고치지는 않습니다. 원인을 해결하지 않고 정책만 바꾸면 증상이 가려질 수 있습니다.

참고 문서: Kubernetes - Pod Lifecycle · Debug Running Pods · Debug Pods

최종 업데이트: 2026-10-26 · 작성: 내멋스레 사는 이야기

Kubernetes ImagePullBackOff 에러 해결법

🔍 검색 키워드: k8s ImagePullBackOff, 쿠버네티스 이미지 풀 실패, Docker 레지스트리 인증, 이미지 태그 오류, Pod 시작 실패

Kubernetes ImagePullBackOff 에러 해결법

증상

Kubernetes Pod를 배포했을 때 다음과 같은 상태에서 멈춘다:

$ kubectl get pods -n production
  NAME                    READY   STATUS             RESTARTS   AGE
  app-deployment-abc123   0/1     ImagePullBackOff   2          5m

상세 확인 시:

$ kubectl describe pod app-deployment-abc123 -n production
  Events:
    Type     Reason                 Age   Message
      ----     ------                 ----  -------
        Normal   Scheduled              5m    Successfully assigned production/app-deployment-abc123 to worker-node-1
          Normal   BackOff                4m    Back-off pulling image "myrepo/app:v1.0"
            Warning  Failed                 4m    Failed to pull image "myrepo/app:v1.0": rpc error: code = Unknown desc = Error response from daemon: unauthorized
              Warning  Failed                 3m    Back-off pulling image

또는 다음과 같은 메시지:

image not found
  image pull rate limit exceeded
  no such image
  invalid reference format

원인

  1. 이미지명 오류: 잘못된 저장소명, 태그 또는 레지스트리 주소
  2. 인증 실패: Private Docker 레지스트리 접근 권한 없음
  3. 네트워크 단절: 워커 노드에서 레지스트리 접근 불가
  4. 레지스트리 다운: Docker Hub, ECR 등 서비스 장애
  5. 이미지 미존재: 푸시되지 않은 이미지 태그
  6. 레이트 제한: Docker Hub 무료 계정 풀 한도 초과
  7. CPU/메모리 부족: 노드 리소스 부족으로 스케줄링 실패

해결 방법

방법 1: 이미지명 및 태그 확인

# 현재 Pod의 이미지 정보 확인
  kubectl get pod app-deployment-abc123 -n production -o yaml | grep image
  
  # 정확한 이미지명 확인 (레지스트리 포함)
  # 형식: [registry]/[repository]/[image]:[tag]
  # 정상 예: docker.io/myrepo/app:v1.0
  # 정상 예: gcr.io/my-project/app:latest
  # 정상 예: ecr.amazonaws.com/123456789.dkr.ecr.us-east-1.amazonaws.com/app:v1.0

Deployment 수정:

apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: app-deployment
      namespace: production
      spec:
        replicas: 3
          selector:
              matchLabels:
                    app: app
                      template:
                          metadata:
                                labels:
                                        app: app
                                            spec:
                                                  containers:
                                                        - name: app
                                                                image: docker.io/myrepo/app:v1.0  # 정확한 이미지명
                                                                        imagePullPolicy: IfNotPresent      # 또는 Always

방법 2: Private 레지스트리 인증 설정

# 1. Docker 자격증명으로 Secret 생성
  kubectl create secret docker-registry regcred \
    --docker-server=gcr.io \
      --docker-username=_json_key \
        --docker-password="$(cat ~/gcr-key.json)" \
          --docker-email=user@example.com \
            -n production
            
            # 2. 또는 기존 docker config 파일 사용
            kubectl create secret generic regcred \
              --from-file=.dockerconfigjson=$HOME/.docker/config.json \
                --type=kubernetes.io/dockercfg \
                  -n production

Deployment에서 사용:

apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: app-deployment
    spec:
      template:
          spec:
                imagePullSecrets:
                      - name: regcred  # 위에서 생성한 Secret 이름
                            containers:
                                  - name: app
                                          image: gcr.io/my-project/app:v1.0

방법 3: 워커 노드 네트워크 확인

# 워커 노드에서 직접 레지스트리 연결 확인
  kubectl debug node/worker-node-1 -it --image=ubuntu
  # Pod 내에서:
  apt-get update && apt-get install -y curl
  curl -I https://gcr.io
  curl -I https://docker.io
  
  # 또는 임시 Pod에서 테스트
  kubectl run test-curl --image=curlimages/curl -it --rm -- \
    curl -v https://gcr.io

방법 4: 로컬에서 이미지 빌드 및 푸시 확인

# 1. 로컬에서 이미지 빌드
  docker build -t myrepo/app:v1.0 .
  
  # 2. 레지스트리에 푸시
  docker push myrepo/app:v1.0
  
  # 3. 푸시된 이미지 확인
  # Docker Hub: https://hub.docker.com/r/myrepo/app
  # GCR: gcloud container images list
  # ECR: aws ecr describe-images --repository-name app
  
  # 4. 로컬에서 이미지 실행 가능 확인
  docker run --rm myrepo/app:v1.0 --version

방법 5: imagePullPolicy 조정

apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: app-deployment
    spec:
      template:
          spec:
                containers:
                      - name: app
                              image: myrepo/app:v1.0
                                      # imagePullPolicy 옵션:
                                              # Always: 매번 레지스트리에서 풀 (기본, 태그 latest 사용 시)
                                                      # IfNotPresent: 로컬에 없을 때만 풀
                                                              # Never: 로컬에서만 사용 (오프라인 환경)
                                                                      imagePullPolicy: Always

방법 6: 디버깅 및 재시도

# Pod 상세 로그 확인
  kubectl logs app-deployment-abc123 -n production --previous
  
  # 이벤트 확인 (시간 역순)
  kubectl get events -n production --sort-by='.lastTimestamp'
  
  # Pod 재생성 (자동 재시도)
  kubectl rollout restart deployment/app-deployment -n production
  
  # 캐시 클리어 후 재배포
  kubectl set image deployment/app-deployment \
    app=myrepo/app:v1.1 \
      -n production
      
      # 또는 현재 이미지로 강제 롤아웃
      kubectl rollout restart deployment/app-deployment -n production

정리표

원인 증상 해결법
이미지명 오류 image not found 정확한 이미지명 확인
인증 실패 unauthorized imagePullSecrets 설정
네트워크 단절 connection timeout 워커 노드 네트워크 확인
레지스트리 장애 service unavailable 레지스트리 상태 확인
태그 미존재 manifest not found docker push 재실행
레이트 제한 rate limit exceeded 인증된 계정으로 전환
노드 리소스 부족 OutOfmemory, OutOfDisk 노드 리소스 확인

팁: 배포 전에 로컬 환경에서 docker push까지 성공하는지 확인하고, 클러스터에는 imagePullPolicy: Always 설정으로 항상 최신 이미지를 가져오도록 설정하면 버전 관리가 쉬워집니다.

화요일

k8s OOMKilled 에러 진단 및 해결 완벽 가이드

<div style="font-family:'Noto Sans KR',sans-serif;line-height:1.8;max-width:860px;margin:0 auto">

<p style="color:#666;font-size:14px">🔍 검색 키워드: k8s OOMKilled, Kubernetes 메모리 부족, 파드 종료, 메모리 리소스 설정, 컨테이너 메모리 누수</p>

<h2>k8s OOMKilled 에러 진단 및 해결</h2>

<p>Kubernetes에서 파드가 갑자기 종료되고 재시작되는 문제는 보통 메모리 부족 때문입니다.</p>

<h3>1. 증상: 파드가 자꾸 크래시된다</h3>

<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;overflow-x:auto"><code>$ kubectl get pods

NAME              READY   STATUS    RESTARTS   AGE

my-app-abc123     0/1     CrashLoopBackOff    5      2m

Last State:  Terminated (reason:OOMKilled, exit code:137)</code></pre>

<p>exit code 137은 Linux에서 SIGKILL(9) 신호를 받았다는 뜻입니다.</p>

<h3>2. 원인</h3>

<ul>

<li>메모리 limit이 너무 낮게 설정됨</li>

<li>애플리케이션의 메모리 누수</li>

<li>노드의 물리 메모리 부족</li>

</ul>

<h3>3. 해결 방법</h3>

<h4>방법 1: 메모리 limit 증가</h4>

<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;overflow-x:auto"><code>resources:

  limits:

    memory: 512Mi

  requests:

    memory: 256Mi</code></pre>

<h4>방법 2: HPA로 자동 확장</h4>

<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;overflow-x:auto"><code>apiVersion: autoscaling/v2

kind: HorizontalPodAutoscaler

metadata:

  name: my-app-hpa

spec:

  minReplicas: 2

  maxReplicas: 10

  metrics:

  - type: Resource

    resource:

      name: memory

      target:

        type: Utilization

        averageUtilization: 70</code></pre>

<h3>정리표</h3>

<table border="1" cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">

<tr style="background:#f5f5f5"><th style="text-align:left">확인 항목</th><th style="text-align:left">명령어</th><th style="text-align:left">의미</th></tr>

<tr><td>파드 상태</td><td>kubectl describe pod</td><td>OOMKilled 여부</td></tr>

<tr><td>실제 메모리 사용</td><td>kubectl top pod</td><td>현재 사용량</td></tr>

<tr><td>노드 여유</td><td>kubectl top node</td><td>전체 노드 메모리 상태</td></tr>

</table>

<hr>

<p><strong>팁</strong>: requests는 평상시 사용량의 70~80%, limits는 requests의 1.5~2배로 설정하는 것이 best practice입니다.</p>

</div>

금요일

Kubernetes OOMKilled 에러 완벽 해결 가이드

🔍 검색 키워드: k8s OOMKilled, 쿠버네티스 메모리 부족, 포드 강제 종료, 메모리 리소스 요청, 리미트 설정

Kubernetes OOMKilled 에러 완벽 해결 가이드

1. 증상: OOMKilled 에러 메시지

Pod이 갑자기 재시작되고 로그에 다음과 같이 나타난다:

$ kubectl describe pod my-app-5f8d4c9d7b-xyz12
...
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Killed:       True
Events:
  Type    Reason     Age   Message
  ----    ------     ---   -------
  Normal  Created    2m    Created container app
  Normal  Started    2m    Started container app
  Normal  Killing    1m    Killing container app (killed due to memory limit exceeded)

Pod의 상태가 계속 Pending이거나 CrashLoopBackOff 상태에 빠진다. 다른 Pod들은 정상인데 특정 Pod만 반복적으로 재시작된다.

2. 원인 분석

메모리 리미트 설정 부족

애플리케이션이 사용하는 실제 메모리가 Pod에 할당된 메모리 한계를 초과했다. kubelet이 OOM(Out of Memory) 상태를 감지하면 강제로 Pod을 kill한다.

메모리 누수

애플리케이션 코드에 메모리 누수가 있어서 시간이 지날수록 메모리 사용량이 증가한다. Node의 물리적 메모리도 한계가 있어 OOMKilled 발생.

Node 자원 부족

Node 레벨의 메모리가 부족하면 kubelet은 eviction policy에 따라 Pod들을 순차적으로 종료한다.

사이드카 컨테이너

logging agent, service mesh proxy(Istio sidecar) 등 추가 컨테이너들의 메모리 누적.

3. 해결방법

방법 1: Memory Request/Limit 올바르게 설정

apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
  - name: app
    image: my-app:latest
    resources:
      requests:
        memory: "256Mi"    # Pod 스케줄링 시 보장받는 메모리
      limits:
        memory: "512Mi"    # 절대 초과 불가능한 한계
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 30
      periodSeconds: 10

requests: Scheduler가 Node 할당 결정 시 사용
limits: cgroup을 통해 강제로 제한

방법 2: 메모리 사용량 모니터링

# Pod의 실제 메모리 사용량 확인
$ kubectl top pod my-app-5f8d4c9d7b-xyz12 -n default

NAME                     CPU(cores)   MEMORY(bytes)
my-app-5f8d4c9d7b-xyz12  150m         387Mi

# Node 전체 메모리 상태
$ kubectl top nodes
NAME          CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
worker-node1  1200m        60%    5432Mi          68%

Prometheus + Grafana로 시간대별 메모리 추이를 분석. 대부분 정상이지만 특정 시간에 메모리 스파이크 발생하는지 확인.

방법 3: 메모리 누수 디버깅 (Node.js 예시)

// Node.js 힙 덤프 수집
const heapdump = require('heapdump');

app.get('/heapdump', (req, res) => {
  const fileName = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(fileName, (err, filename) => {
    if (err) return res.status(500).send(err);
    res.send(`Heap dump written to ${filename}`);
  });
});

// Kubernetes manifest에 보안 컨텍스트 추가
securityContext:
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  allowPrivilegeEscalation: false
volumeMounts:
- name: tmp
  mountPath: /tmp
volumes:
- name: tmp
  emptyDir: {}

방법 4: HPA(Horizontal Pod Autoscaler) 설정

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 70

메모리 사용율이 70% 도달하면 자동으로 Pod 개수 증가.

4. 정리표

구분 내용 해결도
증상 Pod이 OOMKilled로 반복 재시작됨 -
원인 1 메모리 limit 설정 부족 ✓ 높음
원인 2 애플리케이션 메모리 누수 ✓ 중간
원인 3 Node 자원 전체 부족 ✓ 중간
원인 4 사이드카 컨테이너 누적 ✓ 낮음
해결 1 Request/Limit 재설정 즉시 효과
해결 2 메모리 모니터링 (top, Prometheus) 원인 파악 필수
해결 3 메모리 누수 디버깅 근본 해결
해결 4 HPA 자동 스케일링 장기 대책

핵심: k8s OOMKilled는 "메모리가 부족하다"는 신호. requests는 넉넉하게, limits는 필요한 만큼만 설정하고, 정기적으로 메모리 프로파일링을 해야 한다.

🔍 검색 키워드: Docker 멀티스테이지 빌드, Docker 캐시 최적화, Dockerfile 성능, Docker 이미지 크기, Docker BuildKit, 컨테이너 최적화

Docker 멀티스테이지 빌드와 캐시 전략으로 빌드 시간 70% 단축

Docker 이미지 빌드는 CI/CD 파이프라인에서 가장 시간이 오래 걸리는 단계입니다. 잘못된 Dockerfile 구조는 매번 모든 의존성을 다시 다운로드하고, 컴파일하며, 불필요한 파일까지 포함시킵니다.

문제 증상

$ docker build -t myapp:latest .
Step 4/15 : RUN npm ci
 ---> cde345cde678 (cache miss) 120.5s
Total: 245.8s  ← 너무 오래 걸림!

# 이미지 크기: 980MB

원인 분석

원인 1: 레이어 캐싱을 무시하는 구조

FROM node:18
COPY . .
RUN npm ci
RUN npm run build

원인 2: 불필요한 파일을 최종 이미지에 포함

# 최종 이미지: node_modules (650MB) + 소스코드 + dist = 900MB

해결 방법

해결책 1: 레이어 순서 최적화

FROM node:18
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

해결책 2: 멀티스테이지 빌드

FROM node:18 AS builder
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

성능 개선 결과

방식빌드 시간이미지 크기
기본 방식245초980MB
멀티스테이지15초180MB

이 전략을 적용하면 빌드 시간을 93% 단축할 수 있습니다.

GitHub Actions "Waiting for a runner to pick up this job" 에러 해결 — 원인과 대처법 총정리

🔍 검색 키워드: GitHub Actions waiting for runner 해결, GitHub Actions job queued 상태, GitHub Actions 러너 대기 멈춤, self-hosted runner stuck, GitHub Actions runner group permission 에러, GitHub Actions 워크플로우 안 돌아감

이 에러, 어떤 상황에서 만나나

어느 날 PR을 올리고 Actions 탭을 보는데 워크플로우가 계속 이 상태다.

Waiting for a runner to pick up this job...

1분, 5분, 10분이 지나도 진행이 없다. 로그를 봐도 아무것도 없다. 그냥 저 메시지만.

처음엔 GitHub 서버 문제인가 싶어서 기다린다. 그러다 재실행 눌러보고, 안 되면 구글링을 시작한다. 이 글은 그 삽질을 줄이기 위한 정리다.

원인은 크게 세 가지

1. GitHub-hosted runner 공급 부족 (일시적)

GitHub에서 제공하는 ubuntu-latest, windows-latest 같은 공유 러너는 글로벌 수요를 함께 쓴다. 트래픽이 몰리는 시간대에는 진짜로 대기열에 걸린다. 특히 2026년 5월 GitHub Actions 장애처럼 플랫폼 자체 이슈일 때도 이 증상이 나온다.

확인 방법: GitHub Status 페이지 확인. Actions 항목에 이슈가 있으면 기다리는 게 답이다.

2. Self-hosted runner가 오프라인이거나 점유 중

Self-hosted runner를 직접 운영하는 경우, runner가 꺼져 있거나, 이미 다른 job을 처리 중이거나, 등록은 됐지만 실제로 연결이 끊긴 상태일 수 있다.

확인 방법: Repository → Settings → Actions → Runners. 상태가 Offline이거나 Idle인지 확인한다.

# runner 서비스 재시작 (Linux)
cd /path/to/actions-runner
./svc.sh stop
./svc.sh start

# 상태 확인
./svc.sh status

3. Runner group 권한 문제

Organization에서 runner group을 사용하는 경우, 특정 repo가 해당 runner group에 접근 권한이 없으면 job이 영구적으로 대기 상태에 빠진다. 에러 메시지는 동일하게 "Waiting for a runner..."라서 원인을 찾기가 까다롭다.

확인 방법: Organization → Settings → Actions → Runner groups → 해당 그룹에서 "Repository access" 설정 확인.

상황별 체크리스트

상황확인 포인트조치
GitHub-hosted runner 사용 중GitHub Status 페이지장애면 대기, 아니면 재실행
Self-hosted runner 사용 중Runner 온라인 상태runner 서비스 재시작
Org runner group 사용 중Repository 접근 권한그룹에 repo 추가
runs-on 라벨 오타workflow yml의 runs-on 값라벨 정확히 입력
동시 job 수 초과Concurrency 설정제한 조정 또는 대기
특정 시간대만 발생GitHub 트래픽 패턴재실행하거나 off-peak 배포

가장 흔한 실수 — runs-on 라벨 오타

Self-hosted runner에 라벨을 my-runner로 등록했는데 workflow에 my_runner라고 쓰는 경우다. 하이픈/언더바 혼용 실수가 생각보다 많다.

# 잘못된 예
jobs:
  build:
    runs-on: my_runner  # 언더바 → 매칭 안 됨

# 올바른 예
jobs:
  build:
    runs-on: my-runner  # 하이픈 → 등록된 라벨과 일치

Runner에 등록된 라벨 목록은 Settings → Actions → Runners에서 확인 가능하다.

Self-hosted runner가 멈춰있을 때 — 완전 재등록

Runner 서비스가 좀비 상태로 남아있으면 재시작이 안 되는 경우도 있다. 이때는 제거 후 재등록이 깔끔하다.

# 1. 기존 runner 제거
cd /path/to/actions-runner
./config.sh remove --token YOUR_REMOVAL_TOKEN

# 2. 새로 등록 (GitHub → Settings → Actions → Runners → New self-hosted runner에서 토큰 발급)
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO --token YOUR_NEW_TOKEN

# 3. 서비스로 등록 및 시작
./svc.sh install
./svc.sh start

Actions Runner Controller (ARC) 사용 중이라면

Kubernetes 위에서 ARC(Actions Runner Controller)를 쓰는 경우, DesiredReplicas=0으로 고정되어 scale-up이 안 되는 버그가 있다. EphemeralRunner가 생성되지 않는 증상이 같이 나온다.

# ARC runner scale set 상태 확인
kubectl get autoscalingrunnerset -n YOUR_NAMESPACE

# ephemeralrunner 상태 확인
kubectl get ephemeralrunner -n YOUR_NAMESPACE

# controller 로그 확인
kubectl logs -n YOUR_NAMESPACE deployment/arc-controller-manager

Controller 재시작으로 해소되는 경우가 많다.

kubectl rollout restart deployment/arc-controller-manager -n YOUR_NAMESPACE

Concurrency 설정으로 인한 대기

workflow에 concurrency 설정이 있으면 같은 그룹의 이전 job이 끝날 때까지 대기한다. 이건 에러가 아니라 의도된 동작이지만, 착각하기 쉽다.

concurrency:
  group: ${{ github.ref }}
  cancel-in-progress: false  # 이게 true면 이전 job 취소, false면 대기

cancel-in-progress: true로 바꾸면 이전 job을 취소하고 새 job이 바로 시작된다.

마무리 — 순서대로 확인하면 대부분 해소된다

  1. GitHub Status 체크 (플랫폼 이슈 먼저 배제)
  2. Runner 온라인 상태 확인
  3. runs-on 라벨 오타 확인
  4. Runner group 권한 확인
  5. ARC 사용 중이면 controller 재시작

이 순서대로 체크하면 "Waiting for a runner..." 에러는 대부분 잡힌다. 플랫폼 장애가 아닌 이상 대기 상태가 10분 이상 지속되면 위 체크리스트를 돌리는 게 빠르다.

GitHub Actions 관련해서 secrets 관련 이슈는 GitHub Actions secrets 환경변수 undefined 에러 해결 글도 참고할 수 있다.

작성일: 2026-06-25

수요일

🔍 검색 키워드: GitHub Actions permission denied 해결, GITHUB_TOKEN permissions 에러, GitHub Actions resource not accessible by integration, GitHub Actions write permission 설정, GitHub Actions contents read write

GitHub Actions 워크플로우를 처음 구성하거나 레포지토리 설정이 바뀐 뒤에 갑자기 이런 에러가 나는 경우가 있다.

Error: Resource not accessible by integration
Error: HttpError: 403 — The requested URL returned error: 403
remote: Permission to owner/repo.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/...': The requested URL returned error: 403

모두 GITHUB_TOKEN의 권한이 부족해서 발생하는 에러다.


증상

  • actions/checkout에서 push 실패
  • GitHub Pages 배포 단계에서 403 에러
  • PR에 코멘트 달기, 라벨 붙이기 등 GitHub API 호출 실패
  • Release 생성, 태그 푸시 등 쓰기 작업에서 403

원인

GitHub는 기본적으로 GITHUB_TOKEN의 권한을 read-only로 설정한다. 이는 2023년 이후 새로 만들어진 레포지토리의 기본 정책이고, 조직 설정에 따라 강제될 수도 있다.


해결방법

방법 1: 워크플로우 파일에 permissions 추가 (권장)

name: Deploy

on:
  push:
    branches: [main]

# ✅ 필요한 권한만 명시
permissions:
  contents: write      # 코드 push, 태그 생성
  pull-requests: write # PR 코멘트
  pages: write         # GitHub Pages 배포
  id-token: write      # OIDC 토큰

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

특정 job에만 적용하는 것도 가능하다:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read    # 읽기만 필요한 빌드 단계
    steps:
      - uses: actions/checkout@v4
      - run: npm run build

  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      contents: write   # 쓰기 필요한 배포 단계만
      pages: write
      id-token: write
    steps:
      - uses: actions/deploy-pages@v4

방법 2: 레포지토리 설정 변경

레포지토리 → Settings → Actions → General → Workflow permissions 에서 "Read and write permissions"로 변경. 단, 이 방법은 모든 워크플로우에 쓰기 권한을 부여하므로 보안상 권장하지 않는다.


자주 쓰는 permissions 조합

# GitHub Pages 배포
permissions:
  contents: read
  pages: write
  id-token: write

# 릴리즈 생성 + 태그 푸시
permissions:
  contents: write

# PR 자동 코멘트
permissions:
  pull-requests: write
  issues: write

# GitHub Packages / GHCR 푸시
permissions:
  contents: read
  packages: write

# Security 스캔 결과 업로드
permissions:
  security-events: write
  contents: read

사용 가능한 권한 종류

권한 설명
contents 레포 코드 읽기/쓰기, 태그, 릴리즈
pull-requests PR 생성, 코멘트, 라벨
packages GitHub Packages (GHCR)
pages GitHub Pages 배포
id-token OIDC JWT 토큰 (AWS, GCP 인증)
checks 체크 결과 생성/업데이트
security-events 코드 스캐닝 결과

Next.js → GitHub Pages 배포 완성 예시

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./out

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

점검 체크리스트

항목 확인
에러 메시지 403 또는 Resource not accessible인지 확인
워크플로우 permissions 블록 필요한 권한이 명시됐는지
레포 기본 설정 Settings → Actions → Workflow permissions 확인
조직 설정 조직 레벨에서 강제 제한되지 않는지
최소 권한 원칙 필요한 권한만 최소한으로 부여

정리

GITHUB_TOKEN permission denied는 거의 항상 워크플로우 파일에 permissions 블록을 추가하는 것으로 해결된다. 레포 설정을 "Read and write permissions"로 바꾸는 방법도 있지만, 모든 워크플로우에 넓은 권한을 주는 것이라 권장하지 않는다.

필요한 권한을 최소한으로 명시하는 습관을 들이면 보안도 챙기면서 에러도 예방할 수 있다.

Docker Compose healthcheck 에러 해결 — depends_on이 기다려주지 않는 이유

🔍 검색 키워드: Docker Compose depends_on 안 기다림, depends_on service_healthy, Docker Compose healthcheck 설정, 컨테이너 시작 순서 DB 연결 실패, docker compose condition service_healthy, Connection refused 컨테이너 기동 순서

요약: Docker Compose의 depends_on은 기본적으로 컨테이너가 "시작"되는 순서만 보장하고, 안의 서비스가 "준비"될 때까지는 기다리지 않습니다. 의존 대상에 healthcheck를 정의하고 depends_on에 condition: service_healthy를 지정하면 healthy 상태가 된 뒤에 다음 컨테이너가 시작됩니다.

depends_on을 썼는데 왜 DB 연결이 실패하나요?

앱 컨테이너가 시작 직후 DB에 접속하다가 Connection refused 또는 "could not connect to server" 같은 에러로 죽는 경우가 많습니다. depends_on: [db]만 쓰면 Compose는 db 컨테이너를 먼저 "시작(start)"할 뿐, db 프로세스가 포트를 열 때까지 기다리지 않기 때문입니다. 공식 문서도 짧은 형식의 depends_on은 서비스가 준비될 때까지 기다리지 않는다고 설명합니다.

직접 재현해 본 결과

이 글의 출력은 아래 환경에서 직접 실행한 결과입니다. 실제 DB 이미지를 새로 내려받지 않고, "시작 후 8초 뒤에 5432 포트를 여는 서버"(Java ServerSocket)로 늦게 준비되는 DB를 흉내 냈습니다. PostgreSQL이나 MySQL로 재현한 것은 아니며, 기동 순서의 동작 원리를 확인하는 용도입니다.

  • 환경: macOS 27.0.1, Docker 29.7.2, Docker Compose v5.4.0
  • 이미지: eclipse-temurin:8-jdk (openjdk 1.8.0_502)

1) depends_on만 쓴 경우 (실패)

services:
  db:
    image: eclipse-temurin:8-jdk
    working_dir: /w
    volumes: ["./:/w"]
    entrypoint: ["sh","-c","javac Server.java && java Server"]  # 8초 뒤 5432 오픈
  app:
    image: eclipse-temurin:8-jdk
    depends_on: [db]
    entrypoint: ["bash","-c","date +%T; if (echo > /dev/tcp/db/5432) 2>/dev/null; then echo 'app: connected to db'; else echo 'app: connection to db FAILED'; fi"]
$ docker compose -p hcbad up --abort-on-container-exit
app-1  | 03:06:36
app-1  | app: connection to db FAILED
app-1 exited with code 0

db 컨테이너는 시작됐지만 포트가 아직 열리지 않았으므로 app의 접속이 실패했습니다.

2) healthcheck + condition: service_healthy (성공)

services:
  db:
    image: eclipse-temurin:8-jdk
    working_dir: /w
    volumes: ["./:/w"]
    entrypoint: ["sh","-c","javac Server.java && java Server"]
    healthcheck:
      test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/5432'"]
      interval: 2s
      timeout: 2s
      retries: 10
  app:
    image: eclipse-temurin:8-jdk
    depends_on:
      db:
        condition: service_healthy
    entrypoint: [...]  # 위와 동일
$ docker compose -p hcgood up --abort-on-container-exit
 Container hcgood-db-1 Healthy
 Container hcgood-app-1 Starting
app-1  | 03:06:55
app-1  | app: connected to db

db가 Healthy가 된 것을 확인한 뒤에야 app이 Starting 상태로 넘어갔고, 접속에 성공했습니다. (위 두 출력의 시각은 서로 다른 실행의 값이라 비교 대상이 아닙니다.)

3) healthcheck 없이 service_healthy를 쓴 경우 (에러)

의존 대상에 healthcheck가 없으면 up 실행 시점에 아래 에러로 중단됩니다. 직접 실행해 확인한 메시지입니다.

Container hcno-db-1 Error dependency db failed to start
dependency failed to start: container hcno-db-1 has no healthcheck configured

실제 DB에는 어떤 healthcheck를 쓰나요?

위 재현은 TCP 포트 확인이었고, 실제 서비스에서는 해당 DB의 전용 명령으로 준비 상태를 확인하는 것이 일반적입니다. 아래는 많이 쓰는 형태의 예시이며, 이 글에서 직접 실행해 확인한 것은 아닙니다. 이미지와 계정 값은 환경에 맞게 바꾸세요.

# PostgreSQL 예시
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 5s
  timeout: 3s
  retries: 10

# MySQL 예시
healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  interval: 5s
  timeout: 3s
  retries: 10

healthcheck 옵션은 어떻게 정하나요?

  • interval: 검사 주기. 너무 길면 시작이 늦어지고, 너무 짧으면 부하가 늘어납니다.
  • timeout: 한 번의 검사가 이 시간을 넘으면 실패로 처리합니다.
  • retries: 연속 실패가 이 횟수를 넘으면 unhealthy가 됩니다. DB가 늦게 뜨는 환경이면 넉넉히 두세요.
  • start_period: 초기 기동 시간 동안은 실패를 세지 않는 유예 시간입니다. 기동이 오래 걸리는 서비스에 유용합니다.

주의할 점

  • service_healthy 조건은 시작 시점의 순서만 다룹니다. 이후 DB가 재시작되거나 일시적으로 끊기는 상황까지 막아 주지 않으므로, 앱 쪽에도 재시도(retry) 로직을 두는 편이 안전합니다.
  • 의존 대상에 healthcheck가 없으면 위 3)의 에러가 납니다. 반드시 healthcheck를 정의하세요.
  • healthcheck 명령은 컨테이너 안에서 실행되므로 그 안에 해당 명령(pg_isready 등)이 있어야 합니다.

정리표

설정보장하는 것결과(직접 재현)
depends_on: [db]db 컨테이너를 먼저 시작app 접속 실패
condition: service_healthy + healthcheckhealthy 상태가 된 뒤 시작app 접속 성공
condition: service_healthy, healthcheck 없음-no healthcheck configured 에러

자주 묻는 질문(FAQ)

depends_on만으로 DB가 준비될 때까지 기다리게 할 수 없나요?

없습니다. 짧은 형식의 depends_on은 시작 순서만 지정합니다. 준비 완료까지 기다리려면 의존 서비스에 healthcheck를 정의하고 condition: service_healthy를 사용하세요.

wait-for-it.sh 같은 스크립트가 아직 필요한가요?

Compose의 healthcheck와 service_healthy로 대부분 대체할 수 있습니다. 다만 Compose 밖에서 실행되는 앱이나 시작 이후 재연결까지 다뤄야 한다면 앱 자체의 재시도 로직이 더 근본적인 해결입니다.

healthcheck가 계속 unhealthy로 나오면 어떻게 확인하나요?

docker inspect로 컨테이너의 Health 로그를 확인하면 검사 명령의 출력과 종료 코드를 볼 수 있습니다. 명령이 컨테이너 안에 없거나 포트·계정이 틀린 경우가 흔한 원인입니다.

service_healthy를 썼는데도 가끔 접속이 실패합니다.

healthcheck가 "DB 프로세스 응답"만 확인하고 앱이 쓰는 계정·스키마까지 준비됐는지는 확인하지 못하는 경우입니다. 초기화 스크립트가 끝난 뒤에 healthy가 되도록 검사 명령을 조정하거나, 앱에 재시도를 추가하세요.

참고 문서: Docker Docs - Control startup and shutdown order in Compose · Compose file reference - depends_on

최종 업데이트: 2026-10-06 · 작성: 내멋스레 사는 이야기

화요일

GitHub Actions secrets 환경변수 비어있음 해결 — secret not available 원인 분석

🔍 검색 키워드: GitHub Actions secrets undefined, GitHub Actions 환경변수 비어있음, secrets not available 해결, GitHub Actions secret 적용 안 됨, CI 시크릿 설정, GitHub Actions secret empty

증상: 분명히 설정했는데 값이 없다고 한다

GitHub Actions 워크플로우는 돌리면 이런 상황이 생긴다.

Error: API_KEY is undefined
Error: Cannot read properties of undefined (reading 'length')

아니면 시크릿 값이 빈 문자열로 들어오거나, 더 황당하게는 배포가 그냥 조용히 실패한다. Secrets 탭에서 분명히 등록했는데 워크플로우가 못 읽는다.

CI 처음 세팅할 때, 또는 레포를 포크하거나 환경(Environment)을 새로 만들었을 때 이 문제를 자주 만난다.

원인 분류

1. 시크릿 이름 대소문자 불일치

가장 흔한 실수. Secrets UI에서 API_KEY로 등록했는데 워크플로우에서 ${{ secrets.api_key }}로 참조하면 빈 값이 온다. 시크릿은 대소문자 구분한다.

# 잘못된 예
env:
  API_KEY: ${{ secrets.api_key }}  # 실제 이름이 API_KEY면 못 읽음

# 올바른 예
env:
  API_KEY: ${{ secrets.API_KEY }}

2. Environment 시크릿인데 job에 environment 지정 안 함

GitHub에서 환경(Environment)을 별도로 만들어서 거기에 시크릿을 등록했다면, job에서 그 environment를 명시해야 한다. 안 하면 해당 시크릿은 아예 조회 자체가 안 된다.

# 잘못된 예 — environment 지정 없이 env 시크릿 접근 시도
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo ${{ secrets.PROD_DB_URL }}  # production environment의 시크릿이면 빈 값

# 올바른 예
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # ← 이게 있어야 environment 시크릿 접근 가능
    steps:
      - run: echo ${{ secrets.PROD_DB_URL }}

3. Fork PR에서는 시크릿 접근이 차단된다

외부 기여자가 fork해서 올린 PR의 워크플로우는 보안 정책상 시크릿에 접근할 수 없다. pull_request 이벤트 트리거를 쓰면 이 제한이 적용된다.

Warning: Context access might be invalid: secrets

의도적인 제한이다. 악의적인 코드가 PR로 들어와서 시크릿을 탈취하는 걸 막기 위한 것.

4. 시크릿 참조 문법 오류

# 틀린 문법들
${{ secret.API_KEY }}       # secrets가 아니라 secret (오타)
${{ secrets[API_KEY] }}     # 대괄호 안에 따옴표 필요
${{ env.secrets.API_KEY }}  # env와 secrets 혼용

# 올바른 문법
${{ secrets.API_KEY }}
${{ secrets[env.SECRET_NAME] }}  # 동적 키 참조는 이렇게

해결 방법

Step 1. 시크릿 이름 확인

# GitHub CLI로 현재 등록된 시크릿 목록 확인
gh secret list

# environment 시크릿 확인
gh secret list --env production

워크플로우 YAML의 이름과 정확히 일치하� 체크.

Step 2. 시크릿이 실제로 전달되는지 테스트

값을 직접 출력하면 안 된다 (마스킹됨). 대신�길이나 존재 여부를 확인:

steps:
  - name: Check secrets
    run: |
      if [ -z "${{ secrets.API_KEY }}" ]; then
        echo "API_KEY is EMPTY"
      else
        echo "API_KEY is set (length: ${#API_KEY})"
      fi
    env:
      API_KEY: ${{ secrets.API_KEY }}

Step 3. Environment 시크릿 설정

레포 Settings → Environments → 환경 선택 → Environment secrets에서 등록했다면:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # 반드시 명시
    steps:
      - name: Deploy
        env:
          DB_URL: ${{ secrets.DB_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: ./deploy.sh

environment 이름은 Environments 탭에 있는 이름과 정확히 일치해야 한다.

Step 4. Fork PR 시크릿 접근 처리

내부 PR이라면 pull_request_target 이벤트 사용을 고려할 수 있다. 단, pull_request_target은 base 브랜치의 코드를 실행하므로 PR 코드를 checkout해서 실행하면 보안 취약점이 된다.

# 안전한 패턴 — PR 코드는 테스트만 하고, 배포는 main 병합 후에
on:
  pull_request:
    # 시크릿 없이 테스트만
  push:
    branches: [main]
    # 여기서 시크릿 써서 배포

Step 5. Organization 시크릿 접근 권한

조직(organization) 레벨 시크릿은 레포별로 접근 권한이 따로 있다. 조직 Settings → Secrets → 해당 시크릿 → Repository access에서 해당 레포가 포함되어 있는지 확인.

상황별 체크리스트

증상원인조치
시크릿 값이 빈 문자열이름 대소문자 불일치gh secret list로 정확한 이름 확인
environment 시크릿 못 읽음job에 environment 미지정environment: 필드 추가
Fork PR에서만 실패보안 정책으로 차단시크릿 없이 동작하도록 CI 설계 변경
조직 시크릿 못 읽음레포 접근 권한 없음Organization 설정에서 레포 추가
로컬에서는 되는데 CI에서만환경변수 주입 누락env: 블록에서 명시적 주입 확인

자주 하는 실수 — 디버깅 시 값 출력하려다 마스킹에 막히는 경우

# 이렇게 하면 *** 로 마스킹되어 아무 의미 없음
- run: echo ${{ secrets.API_KEY }}

# 디버깅 목적이면 이렇게
- run: |
    echo "Length: ${#MY_SECRET}"
    echo "First char: ${MY_SECRET:0:1}"
  env:
    MY_SECRET: ${{ secrets.API_KEY }}

시크릿 값 자체를 로그에 출력하는 건 GitHub이 자동 마스킹한다. 길이나 첫 글자 정도로 존재 여부 확인하는 게 현실적인 디버깅 방법이다.

실무 팁: 시크릿 관리 패턴

# 공통 시크릿은 레포 레벨에
# 환경별 시크릿(prod DB URL 등)은 environment 레벨에 분리

jobs:
  test:
    runs-on: ubuntu-latest
    # environment 없음 — 레포 레벨 시크릿만 접근
    steps:
      - run: npm test
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}  # 레포 레벨 시크릿

  deploy-prod:
    runs-on: ubuntu-latest
    environment: production   # environment 레벨 시크릿 접근
    needs: test
    steps:
      - run: ./deploy.sh
        env:
          DB_URL: ${{ secrets.DB_URL }}        # production environment 시크릿
          API_KEY: ${{ secrets.API_KEY }}      # production environment 시크릿

시크릿을 환경별로 분리하면 실수로 개발용 키를 프로덕션에 쓰는 사고를 막을 수 있다.

정리

GitHub Actions 시크릿이 안 읽히는 케이스는 이름 대소문자 불일치, environment 미지정, fork PR 보안 정책 세 가지가 대부분을 차지한다. gh secret list로 정확한 이름 확인하고, environment 시크릿이면 job에 environment: 명시하는 것만 체크해도 80%는 해결된다.

관련 글: GitHub Actions Node.js 빌드 실패 해결 — CI 에러 원인과 대처법

토요일

GitHub Actions Node.js 빌드 실패 완전 정리 — npm ci, 캐시, 의존성 에러 해결

🔍 검색 키워드: GitHub Actions npm ci 실패, npm ci can only install packages when your package.json and package-lock.json are in sync, npm ci package-lock.json 없음, EBADENGINE GitHub Actions, setup-node cache 설정, npm ci vs npm install CI

요약: GitHub Actions에서 npm ci가 실패하는 가장 흔한 원인은 package.json과 package-lock.json이 서로 맞지 않거나, lock 파일이 저장소에 없거나, Node 버전이 engines 조건과 맞지 않는 경우입니다. 로컬에서 npm install로 lock 파일을 갱신해 함께 커밋하고, workflow의 Node 버전을 package.json과 맞추면 해결됩니다.

npm ci는 npm install과 무엇이 다른가요?

npm ci는 CI처럼 재현 가능한 설치가 필요한 환경을 위한 명령입니다. package-lock.json을 기준으로 의존성을 정확히 설치하고, lock과 package.json이 맞지 않으면 아예 실패합니다. npm install은 이런 불일치를 조용히 고치면서 lock 파일을 수정하는 점이 다릅니다. 그래서 로컬에서는 멀쩡한데 CI에서만 실패하는 일이 생깁니다.

직접 재현한 결과

아래 출력은 로컬에서 직접 실행한 결과이며 GitHub Actions 러너에서 실행한 것은 아닙니다. 같은 npm 명령이므로 에러 메시지는 동일하게 나타납니다. 레지스트리에서 패키지를 내려받지 않도록 로컬 경로 의존성(file:)으로 재현했습니다.

  • 환경: macOS 27.0.1, Node.js v20.20.2, npm 10.8.2

1) package.json과 lock 파일이 맞지 않는 경우

lock을 만든 뒤 package.json에 의존성을 추가하고 lock은 갱신하지 않은 상태입니다.

$ npm ci
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json or npm-shrinkwrap.json are in sync. Please update your
npm error lock file with `npm install` before continuing.
npm error
npm error Missing: dep2@2.0.0 from lock file

마지막 줄의 Missing 항목이 어떤 패키지가 lock에 없는지 알려 줍니다.

2) lock 파일이 저장소에 없는 경우

$ npm ci
npm error code EUSAGE
npm error
npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or
npm error later to generate a package-lock.json file, then try again.

.gitignore에 package-lock.json이 들어 있거나 커밋을 빠뜨린 경우입니다.

3) Node 버전이 engines 조건과 맞지 않는 경우

package.json에 engines.node가 >=22로 지정돼 있는데 Node 20으로 실행한 경우입니다. 엄격 모드(--engine-strict)를 켰을 때의 결과입니다.

$ npm ci --engine-strict
npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm: demo@1.0.0
npm error notsup Required: {"node":">=22"}

직접 확인한 결과, engine-strict 없이 실행하면 같은 상황에서 npm warn EBADENGINE 경고만 출력되고 설치는 종료 코드 0으로 계속됩니다. 이 경우 실패는 이후 단계(빌드·테스트)에서 나타나므로 원인을 놓치기 쉽습니다.

어떻게 해결하나요?

lock 파일을 갱신해서 커밋하기

npm install
git add package.json package-lock.json
git commit -m "chore: sync package-lock.json"

갱신 후 npm ci를 다시 실행하면 정상 설치됩니다. 직접 확인한 결과, 같은 프로젝트에서 npm install로 lock을 갱신한 뒤 npm ci가 dep, dep2를 모두 설치했습니다.

workflow의 Node 버전을 맞추기

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 22
      cache: npm
  - run: npm ci
  - run: npm test

node-version은 package.json의 engines나 .nvmrc와 같은 값을 쓰는 것이 안전합니다. 위 workflow는 일반적으로 쓰는 형태의 예시이며 이 글에서 실제 러너로 실행해 확인하지는 않았습니다.

setup-node의 cache 옵션과 lock 파일은 어떤 관계인가요?

actions/setup-node의 cache: npm 옵션은 lock 파일(package-lock.json 등)의 해시를 캐시 키로 사용합니다. 그래서 lock 파일이 없거나 하위 폴더에 있는 모노레포에서는 cache-dependency-path로 위치를 지정해야 합니다. 이 동작은 setup-node 공식 문서 기준 설명이며, 직접 재현하지는 않았습니다.

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm
    cache-dependency-path: apps/web/package-lock.json

주의할 점

  • npm ci는 node_modules를 먼저 삭제하고 설치합니다. 직접 확인한 결과, node_modules에 넣어 둔 임의의 파일이 npm ci 실행 후 사라졌습니다.
  • CI에서 npm install로 바꿔서 에러를 넘기면 lock이 조용히 바뀌어 빌드 재현성이 깨집니다. 원인을 고치는 쪽이 낫습니다.
  • 로컬과 CI의 npm 메이저 버전이 다르면 lock 파일 형식(lockfileVersion)이 달라질 수 있습니다. 팀에서 Node와 npm 버전을 통일하세요.

정리표

증상(에러 메시지)원인해결
EUSAGE ... are in sync / Missing: ... from lock filepackage.json과 lock 불일치npm install 후 lock 커밋
EUSAGE ... existing package-lock.jsonlock 파일 없음lock 생성·커밋, .gitignore 확인
EBADENGINE Unsupported engineNode 버전이 engines와 불일치setup-node의 node-version 수정

자주 묻는 질문(FAQ)

로컬에서는 되는데 GitHub Actions에서만 npm ci가 실패하는 이유는 무엇인가요?

로컬에서는 npm install이 lock 불일치를 조용히 고치지만, CI의 npm ci는 불일치를 오류로 처리하기 때문입니다. lock 파일을 갱신해서 커밋하면 해결됩니다.

CI에서 npm install을 써도 되나요?

동작은 하지만 권장하지 않습니다. 설치 결과가 lock과 달라질 수 있어 재현성이 떨어집니다. 일반적으로 CI에서는 npm ci를 쓰고, 실패하면 원인을 고치는 방식을 권합니다.

package-lock.json은 커밋해야 하나요?

애플리케이션 프로젝트라면 커밋하는 것이 일반적입니다. npm ci가 lock 파일을 필요로 하기 때문입니다. npm 공식 문서도 npm ci는 기존 lock 파일이 있어야 동작한다고 설명합니다.

EBADENGINE이 경고로만 나오고 설치가 되는데 문제가 있나요?

설치는 되지만 요구 Node 버전과 다른 환경에서 빌드·테스트가 예기치 않게 실패할 수 있습니다. workflow의 node-version을 engines 조건에 맞추는 것이 안전합니다.

참고 문서: npm Docs - npm ci · actions/setup-node

최종 업데이트: 2026-10-07 · 작성: 내멋스레 사는 이야기

화요일

GitHub Actions OOM 에러 해결: JavaScript heap out of memory 완전 정복

🔍 검색 키워드: GitHub Actions JavaScript heap out of memory, FATAL ERROR Reached heap limit Allocation failed, max-old-space-size GitHub Actions, NODE_OPTIONS 힙 메모리 늘리기, Node.js heap out of memory 빌드, GitHub Actions 빌드 메모리 부족

요약: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"는 Node.js 프로세스가 V8 힙 상한을 넘겨 메모리를 더 확보하지 못해 강제 종료될 때 나옵니다. workflow의 env에 NODE_OPTIONS=--max-old-space-size=값을 지정해 상한을 올리거나, 빌드 자체의 메모리 사용량(소스맵, 병렬 작업 등)을 줄여서 해결합니다.

JavaScript heap out of memory는 왜 발생하나요?

Node.js는 V8 엔진의 힙(old space) 메모리에 상한을 둡니다. 빌드 도구(webpack, tsc, 번들러 등)가 큰 프로젝트를 처리하다가 이 상한을 넘으면 가비지 컬렉션으로도 공간을 확보하지 못해 프로세스를 중단합니다. 로컬에서는 괜찮다가 GitHub Actions에서만 실패하는 경우는 보통 러너 환경과 Node 버전, 프로젝트 크기가 달라서입니다.

직접 재현한 결과

아래 출력은 로컬에서 직접 실행한 결과이며 GitHub Actions 러너에서 실행한 것은 아닙니다. 빌드 도구 대신, 배열을 계속 늘리는 간단한 스크립트로 힙 상한을 넘기는 상황을 만들었습니다. 빠르게 재현하려고 상한을 64MB로 낮췄습니다.

  • 환경: macOS 27.0.1, Node.js v20.20.2, RAM 24GB
// leak.js
const a = [];
while (true) a.push(new Array(1e5).fill('x'));
$ node --max-old-space-size=64 leak.js
... Mark-Compact 64.2 (98.1) -> 64.2 (98.1) MB, ...
    allocation failure; scavenge might not succeed
FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory

종료 코드: 134 (Abort trap: 6)

가비지 컬렉션(Mark-Compact)을 해도 사용량이 줄지 않다가 상한에 닿아 프로세스가 중단됐습니다. 종료 코드는 134였습니다.

힙 상한은 어떻게 확인하고 바꾸나요?

v8 모듈의 getHeapStatistics()로 현재 상한(heap_size_limit)을 확인할 수 있습니다. 직접 확인한 결과는 다음과 같습니다.

$ node -e "console.log(require('v8').getHeapStatistics().heap_size_limit/1024/1024)"
4144   # 이 컴퓨터(RAM 24GB, Node 20)의 기본값(MB)

$ NODE_OPTIONS=--max-old-space-size=2048 node -e "..."
2096   # NODE_OPTIONS로 지정한 값이 적용됨

두 가지를 확인했습니다. 첫째, NODE_OPTIONS로 지정한 값이 실제로 적용됩니다. 둘째, 이 컴퓨터는 기본 상한이 이미 약 4GB라서 --max-old-space-size=4096을 줘도 값이 4144로 같았습니다. 기본 상한은 Node 버전과 시스템 메모리에 따라 달라지므로, 숫자를 무작정 키우기 전에 현재 상한을 먼저 확인하세요. 러너의 기본 상한과 사용 가능한 메모리는 직접 확인하지 못했으며 GitHub 공식 문서의 러너 사양을 확인해야 합니다.

어떻게 해결하나요?

1) NODE_OPTIONS로 힙 상한 올리기

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      NODE_OPTIONS: --max-old-space-size=4096
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build

위 workflow는 일반적인 형태의 예시이며 실제 러너로 실행해 확인하지는 않았습니다. 값은 러너의 실제 메모리보다 크게 잡으면 안 됩니다. 상한이 물리 메모리를 넘으면 힙 오류 대신 운영체제가 프로세스를 강제 종료할 수 있습니다.

2) 빌드 스크립트에만 적용하기

{
  "scripts": {
    "build": "NODE_OPTIONS=--max-old-space-size=4096 vite build"
  }
}

모든 Node 프로세스가 아니라 빌드 한 곳에만 적용하고 싶을 때 씁니다. Windows 러너를 쓴다면 cross-env 같은 도구가 필요합니다.

3) 메모리 사용량 자체를 줄이기

  • 프로덕션 소스맵 생성을 끄거나 줄입니다. 소스맵은 빌드 메모리를 많이 쓰는 대표적인 옵션입니다.
  • 병렬 처리 수를 줄입니다(예: 번들러의 worker 수, 테스트의 --maxWorkers).
  • 타입 검사와 번들을 한 프로세스에서 같이 돌리고 있다면 단계를 나눕니다.
  • 한 번에 처리하는 파일이 너무 크지 않은지 확인합니다.

주의할 점

  • 상한을 올리는 것은 증상을 미루는 방법일 수 있습니다. 메모리 누수가 원인이면 값을 키워도 시간이 조금 늦어질 뿐 다시 실패합니다.
  • GitHub Actions 로그에서 같은 단계가 "Killed"나 종료 코드 137로 끝나면 힙 오류가 아니라 운영체제 수준의 메모리 부족일 가능성이 있습니다. 이 경우는 위 재현과는 다른 증상이며 직접 재현하지는 않았습니다.
  • Node 버전이 바뀌면 기본 힙 상한이 달라질 수 있으므로, setup-node의 node-version을 고정하세요.

정리표

증상원인해결
FATAL ERROR ... heap out of memory (종료 코드 134)V8 힙 상한 초과NODE_OPTIONS=--max-old-space-size 지정
상한을 올려도 계속 실패메모리 누수 또는 과도한 사용소스맵·병렬 수·작업 분할 점검
Killed / 종료 코드 137OS 수준 메모리 부족(문서 기준)상한을 낮추거나 러너 사양 변경

자주 묻는 질문(FAQ)

--max-old-space-size는 단위가 무엇인가요?

메가바이트(MB)입니다. 직접 확인한 결과, 2048을 지정하면 heap_size_limit가 약 2096MB로 표시됩니다.

NODE_OPTIONS는 어디에 넣어야 하나요?

job 또는 step의 env에 넣거나 package.json의 스크립트 앞에 붙입니다. 모든 Node 프로세스에 적용되므로 필요한 범위로만 지정하는 것이 좋습니다.

로컬에서는 되는데 GitHub Actions에서만 실패하는 이유는 무엇인가요?

Node 버전, 시스템 메모리, 프로젝트 의존성 상태가 다를 수 있기 때문입니다. 힙 상한을 확인하는 명령을 workflow에 한 번 출력해 비교해 보세요.

상한을 크게 올리면 항상 해결되나요?

아닙니다. 누수가 원인이면 다시 실패하고, 값이 물리 메모리보다 크면 OS가 프로세스를 종료할 수 있습니다.

참고 문서: Node.js CLI options · GitHub-hosted runners

최종 업데이트: 2026-10-08 · 작성: 내멋스레 사는 이야기

금요일

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부터, 연결 안 되면 네트워크와 호스트명부터 확인하면 길을 잃지 않는다.