Published on

GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기

Authors

서론

GitHub Actions로 Docker 이미지를 빌드하고 레지스트리에 푸시한 뒤 배포까지 자동화하면, 어느 순간부터 워크플로가 끝없이 다시 실행되는 “무한 재빌드 루프(infinite rebuild loop)”를 만나기 쉽습니다. 보통은 워크플로가 만든 변경(커밋/태그/릴리스/매니페스트 업데이트) 이 다시 트리거를 발생시키거나, 동일한 이벤트가 다른 워크플로를 연쇄 호출하면서 루프가 만들어집니다.

이 글에서는 Docker 기반 CI/CD에서 루프가 생기는 대표 패턴을 짚고, on: 트리거 설계, 봇 커밋 필터링, 태그/릴리스 이벤트 제어, CD 분리, concurrency와 조건문을 이용한 차단까지 한 번에 정리합니다. (OIDC로 AWS에 배포하는 파이프라인이라면, 인증 실패가 재시도 루프처럼 보이는 케이스도 있으니 필요 시 GitHub Actions OIDC STS 실패 - InvalidIdentityToken도 함께 참고하세요.)

무한 재빌드 루프가 생기는 6가지 전형적 원인

1) 워크플로가 레포에 커밋/푸시를 남긴다

가장 흔합니다. 예를 들어 빌드 후 다음 작업을 자동으로 수행하면 위험합니다.

  • VERSION 파일 증가
  • CHANGELOG.md 업데이트
  • k8s/deployment.yaml 이미지 태그 변경 후 커밋
  • Helm values.yaml 업데이트

이 커밋이 push 트리거를 다시 발생시켜 빌드가 재실행됩니다.

2) pushpull_request를 함께 걸어 중복 실행된다

PR에서 브랜치에 푸시하면 pull_requestpush가 동시에 발생할 수 있습니다. 특히 pull_request에서 머지 커밋을 만드는 설정이나, pull_request_target을 혼용하면 의도치 않은 중복/연쇄가 생깁니다.

3) 태그/릴리스 자동 생성이 또 다른 트리거를 건드린다

빌드가 끝난 뒤 git tag를 만들거나 GitHub Release를 발행하면, on: push: tags: 또는 on: release:가 다시 실행되어 루프가 됩니다.

4) 매니페스트 레포(또는 동일 레포) 업데이트가 CD를 다시 호출한다

GitOps 형태로 매니페스트를 업데이트하는데, 그 변경이 다시 CI를 트리거하면 루프가 됩니다. 단일 레포(monorepo)에서 앱 코드와 배포 매니페스트를 같이 관리할 때 특히 잦습니다.

5) workflow_run/repository_dispatch 연쇄 호출 설계가 꼬인다

  • A 워크플로가 끝나면 B를 workflow_run으로 호출
  • B가 다시 A를 repository_dispatch로 호출

이런 식으로 이벤트 체인이 닫힌 고리를 만들면 “정상 동작처럼 보이는 무한 루프”가 됩니다.

6) 실패 재시도/동시 실행이 루프처럼 보이게 만든다

무한 루프가 아니라도, 다음 상황은 “계속 다시 도는 것”처럼 보입니다.

  • 동일 브랜치에 커밋이 빠르게 누적되어 실행이 계속 쌓임
  • concurrency 미설정으로 이전 실행이 계속 남아 있음
  • 배포 단계에서 인증/권한 오류로 매번 실패 → 사람이 커밋을 다시 올려 재시작 반복

1단계: 트리거 설계를 먼저 고친다 (paths/branches/tags)

가장 비용 대비 효과가 큰 방법은 애초에 트리거가 발화하지 않게 만드는 것입니다.

paths-ignore로 “워크플로가 수정하는 파일”을 제외

예를 들어, CI가 k8s/ 아래 매니페스트를 업데이트한다면, 앱 빌드 워크플로에서 그 경로를 무시하세요.

name: ci
on:
  push:
    branches: ["main"]
    paths-ignore:
      - "k8s/**"
      - "docs/**"
      - "CHANGELOG.md"
  pull_request:
    branches: ["main"]
    paths-ignore:
      - "k8s/**"
      - "docs/**"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "build..."

핵심은 “CI가 건드리는 산출물 경로”를 CI 트리거에서 제외하는 것입니다.

태그 트리거를 쓸 때는 의도를 명확히

릴리스 태그로만 빌드하고 싶다면, 브랜치 푸시와 태그 푸시를 분리합니다.

on:
  push:
    tags:
      - "v*.*.*"

반대로, 브랜치 푸시 빌드만 원한다면 태그는 제외합니다.

on:
  push:
    branches: ["main"]
    tags-ignore:
      - "v*"

2단계: “봇 커밋이 다시 트리거”되는 것을 조건문으로 차단

트리거를 완벽히 통제하기 어렵다면, 워크플로 자체에서 봇 커밋을 무시해야 합니다.

github.actor 기반 차단

Actions가 남긴 커밋은 보통 github-actions[bot]이 주체가 됩니다.

jobs:
  build:
    if: github.actor != 'github-actions[bot]'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "run build"

단, 개인 토큰(PAT)으로 커밋하면 actor가 사람 계정일 수 있으니 커밋 메시지/이메일 기반 차단도 병행하는 편이 안전합니다.

커밋 메시지에 [skip ci]를 강제하고 필터링

워크플로가 만드는 커밋에 [skip ci]를 붙이고, 워크플로 시작 조건에서 이를 감지해 중단합니다.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Stop if skip ci
        if: contains(github.event.head_commit.message, '[skip ci]')
        run: |
          echo "skip"
          exit 0

그리고 커밋을 만들 때:

git commit -m "chore: update manifest [skip ci]"

주의: pull_request 이벤트에서는 head_commit.message가 비어 있을 수 있습니다. 이 경우 github.event.pull_request.title 또는 API로 커밋 메시지를 조회하는 방식이 필요합니다.

3단계: CI와 CD를 “이벤트 관점에서” 분리한다

무한 루프의 근본 원인은 하나의 워크플로가 빌드도 하고 배포도 하면서 레포 상태를 다시 바꾸는 구조에 있는 경우가 많습니다.

권장 패턴은 다음 중 하나입니다.

  1. CI(이미지 빌드/푸시) 는 코드 변경(push, pull_request)에만 반응
  2. CD(배포) 는 “이미지 태그가 확정된 이벤트”에만 반응
    • 예: GitHub Release 발행
    • 예: 수동 workflow_dispatch
    • 예: 별도 매니페스트 레포에 PR 생성

예시: CI는 SHA 태그로 이미지 푸시만

name: ci-docker
on:
  push:
    branches: ["main"]

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        id: meta
        run: echo "tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"

      - name: Build and push
        run: |
          docker build -t ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }} .
          echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }}

이 워크플로는 레포에 어떤 변경도 남기지 않으므로 루프 가능성이 낮습니다.

예시: CD는 수동 실행으로만 배포

name: cd-deploy
on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: "Image tag (SHA)"
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "Deploying image tag ${{ inputs.image_tag }}"

이렇게 분리하면 “배포가 레포를 바꿔서 CI가 다시 도는” 구조 자체가 사라집니다.

4단계: 매니페스트 업데이트가 필요하다면 ‘별도 레포’ 또는 PR 기반으로

GitOps를 하고 싶다면, 앱 레포에서 매니페스트를 직접 커밋하기보다 다음을 권합니다.

  • 배포 매니페스트 전용 레포를 따로 두고 거기에 PR을 생성
  • 또는 같은 레포라도 k8s/ 변경은 CI 트리거에서 제외(paths-ignore)

PR 기반으로 남기면 사람이 병합할 때만 배포가 진행되어, 자동 루프를 원천 차단할 수 있습니다.

5단계: concurrency로 “같은 브랜치의 중복 실행”을 끊는다

루프가 아니라도, 커밋이 연속으로 들어오면 빌드가 줄줄이 쌓여 비용이 급증합니다. 이때는 최신 실행만 남기고 이전 것을 취소합니다.

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

특히 Docker 빌드는 시간이 길어 체감 효과가 큽니다.

6단계: workflow_run 체인을 쓴다면 “단방향”만 허용

workflow_run은 편하지만, 잘못 연결하면 고리가 생깁니다. 원칙은 단 하나입니다.

  • A → B는 가능
  • B → A는 금지

예시로, CI가 끝나면 CD가 실행되도록 하되, CD는 절대 CI를 다시 부르지 않습니다.

name: cd
on:
  workflow_run:
    workflows: ["ci-docker"]
    types:
      - completed

jobs:
  deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploy"

Docker 빌드 관점에서 자주 하는 실수: 태그 전략과 캐시

무한 루프와 직접 관련은 없지만, 루프를 “재빌드 폭탄”으로 키우는 실수들이 있습니다.

  • latest만 푸시: 무엇이 배포됐는지 추적이 어려워 재시작/재배포가 반복됨
  • 매번 캐시 무효화: 변경이 적어도 빌드가 항상 오래 걸림

권장:

  • 불변 태그(예: GITHUB_SHA) + 선택적으로 latest 병행
  • BuildKit/docker/build-push-action의 캐시 사용
- name: Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:${{ github.sha }}
      ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

캐시가 안정화되면 “한 번의 트리거 실수”가 나더라도 피해가 줄어듭니다.

체크리스트: 지금 내 루프는 어디서 시작됐나?

다음 질문에 “예”가 나오면 그 지점이 루프의 시발점입니다.

  1. 워크플로가 git commit/git push를 수행하는가?
  2. 워크플로가 태그/릴리스를 생성하는가?
  3. on: pushon: pull_request가 동시에 같은 브랜치에 걸려 있는가?
  4. 매니페스트/버전 파일을 같은 레포에서 자동 수정하는가?
  5. workflow_run/repository_dispatch가 서로를 다시 호출하는 구조인가?
  6. 동일 브랜치에서 실행이 과도하게 쌓이는가? (concurrency 미사용)

결론

GitHub Actions에서 Docker 기반 CI/CD 무한 재빌드 루프는 대부분 “워크플로가 만든 변경이 다시 워크플로를 깨운다”는 단순한 구조에서 시작합니다. 해결은 복잡한 트릭보다 트리거를 좁히고(paths/branches/tags), 봇 커밋을 차단하며(if/skip), CI와 CD를 이벤트 관점에서 분리하는 것이 정석입니다.

특히 레포에 커밋을 남기는 자동화(버전/매니페스트 업데이트)는 루프의 가장 큰 원인이므로, 가능하면 PR 기반 또는 별도 레포로 분리해 ‘닫힌 고리’를 만들지 않는 설계를 권합니다. 배포가 AWS OIDC를 사용한다면 인증 실패가 반복 실행으로 오해될 수 있으니, 문제 양상이 비슷할 때는 GitHub Actions OIDC STS 실패 - InvalidIdentityToken도 함께 확인해 보세요.