레이블이 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 해결하기

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이 기다려주지 않는 이유

화요일

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 에러 원인과 대처법

금요일

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