1. 🚨 문제 발생: 잘 되던 파이프라인이 멈췄다

DoEatFit 프로젝트는 dev 브랜치에 코드가 푸시되면 Jenkins가 자동으로 빌드, 테스트 후 원격 서버에 배포하는 CI/CD 파이프라인을 잘 사용하고 있었습니다.

포트폴리오 정리를 하다 GitHub Organization의 이름을 [OLD_ORG_NAME]에서 DoEatFit으로 변경했습니다. 아무생각 없이 한 행동으로 Jenkins 파이프라인은 그날부터 멈췄습니다.

기존 파이프라인 아키텍처 (Private Repo 기반)

  1. Trigger: Private GitHub Repo (dev 브랜치)에 push
  2. CI (Jenkins): Jenkinsfile 실행
    • Checkout
    • Build Docker Image (next build)
    • Push Docker Image to GHCR (GitHub Container Registry)
  3. CD (Jenkins):
    • SSH로 원격 서버([REMOTE_USER]@[REMOTE_IP]) 접속
    • docker pull (from GHCR)
    • docker compose down && docker compose up -d

2. 1차 실패: owner not found

이름 변경 후 첫 파이프라인 실행은 Push Image to GHCR 단계에서 즉시 실패했습니다.

[실패 로그 1]

[Pipeline] { (⬆️ Push Image to GHCR)
+ docker login ghcr.io -u [GHCR_USER] --password-stdin
Login Succeeded
+ docker push ghcr.io/[OLD_ORG_NAME]/doeatfit_front:dev
The push refers to repository [ghcr.io/[OLD_ORG_NAME]/doeatfit_front]
...
denied: not_found: owner not found
[Pipeline] }
ERROR: ❌ 이미지 푸시 실패

2-1. 원인 분석

원인은 명확했습니다. docker login은 제 개인 계정(PAT)으로 성공했지만, 이미지를 푸시하려는 대상인 ghcr.io/[OLD_ORG_NAME]/...라는 소유자(Organization)는 더 이상 존재하지 않았습니다.

GitHub 리포지토리 URL은 이름이 변경되어도 리디렉션을 지원하지만, GHCR(Docker Registry) 경로는 리디렉션을 지원하지 않았습니다.

2-2. 해결 과정

Jenkinsfile 내부에서 이전 조직 이름([OLD_ORG_NAME])을 하드코딩한 모든 부분을 새 조직 이름(DoEatFit)으로 수정해야 했습니다.

// Jenkinsfile
 
pipeline {
    agent any
    
    stages {
        stage('🚚 Checkout') {
            steps {
                // 1. Git 리포지토리 주소 변경
                // (SSH 주소는 리디렉션이 안 될 수 있으므로 명시적으로 변경)
                git credentialsId: '[GIT_SSH_CREDENTIAL_ID]', 
                    url: 'git@github.com:DoEatFit/doeatfit_front.git'
            }
        }
        
        stage('📦 Build Docker Image') {
            steps {
                // 2. Docker 이미지 태그 변경
                sh 'docker build -t ghcr.io/DoEatFit/doeatfit_front:dev .'
            }
        }
        
        stage('⬆️ Push Image to GHCR') {
            // ...
            steps {
                // 3. Docker Push 경로 변경
                sh 'docker push ghcr.io/DoEatFit/doeatfit_front:dev'
            }
        }
        // ... (Deploy to Remote Server 단계)
    }
}
 

3. 2차 실패: manifest unknown

1차 수정을 마치고 다시 빌드를 실행했습니다. Checkout, Build, Push까지 모두 성공(✅)하는 것을 보고 안심했습니다.

하지만 마지막 🚀 Deploy to Remote Server 단계에서 또다시 실패했습니다.

[실패 로그 2]

[Pipeline] { (🚀 Deploy to Remote Server)
+ docker --context remotedocker pull ghcr.io/DoEatFit/doeatfit_front:dev
Status: Downloaded newer image for ghcr.io/DoEatFit/doeatfit_front:dev  <- (Pull 성공??)
...
+ docker --context remotedocker compose up -d
 doeatfit-frontend Pulling 
 doeatfit-frontend Error manifest unknown   <- (Compose 실패!)
Error response from daemon: manifest unknown
[Pipeline] }
ERROR: ❌ 배포 실패

3-1. 원인 분석

로그가 정말 혼란스러웠습니다. docker pull성공했는데, 바로 다음 명령어인 docker compose up이 이미지를 못 찾겠다며 실패했습니다.

한참을 헤매다 원인을 찾았습니다. 문제는 Jenkinsfile이 아니었습니다.

JenkinsfileDeploy 단계는 docker-compose.yaml 파일을 원격 서버로 복사한 뒤 실행합니다. 그런데 이 docker-compose.yaml 파일은 Git 리포지토리에 포함된 것이 아니라, 보안을 위해 Jenkins의 Credentials (Secret file) 기능을 통해 주입되고 있었습니다.

// Jenkinsfile (일부)
stage('🔑 Add Config Files') {
    steps {
        // ...
        withCredentials([file(credentialsId: 'doeatfit-compose-file', variable: 'DOCKER_COMPOSE_FILE')]) {
            // 이 파일이 문제였습니다.
            sh 'cp -p $DOCKER_COMPOSE_FILE docker-compose.yaml'
        }
    }
}

Jenkins Credential에 저장된 docker-compose.yaml 파일의 내용이 수정되지 않았던 것입니다.

  1. Jenkinsfilepull 명령어는 올바른 새 경로(ghcr.io/DoEatFit/...)를 사용했습니다. (로그의 Pull 성공 부분)

  2. 하지만 docker compose up 명령어는 Jenkins Credential에 저장된 옛날 docker-compose.yaml 파일(ghcr.io/[OLD_ORG_NAME]/...)을 읽으려 시도했습니다.

  3. composepulling을 시도할 때, [OLD_ORG_NAME]이 존재하지 않으므로 manifest unknown 오류가 발생한 것이었습니다.

3-2. 해결 과정

Jenkins 대시보드에서 doeatfit-compose-file Credential(Secret file 타입)을 찾아, 그 내용을 새 조직 이름으로 수정하고 다시 업로드했습니다.

# [수정 전] Jenkins Credential (doeatfit-compose-file)
services:
  doeatfit-frontend:
    image: ghcr.io/[OLD_ORG_NAME]/doeatfit_front:dev
    # ...
 
# [수정 후] Jenkins Credential (doeatfit-compose-file)
services:
  doeatfit-frontend:
    image: ghcr.io/DoEatFit/doeatfit_front:dev
    #...

4. 3차 실패 (Gotcha): “Linux는 대소문자를 구분합니다”

모든 것을 수정했다고 생각했을 때, 마지막 복병을 만났습니다. 2차 실패를 해결하고 다시 빌드를 돌렸는데, 이번엔 Pull 단계부터 오류가 발생했습니다. (로그는 not_found 또는 unauthorized와 유사하게 표시되었습니다)

[실패 로그 3]

+ docker --context remotedocker pull ghcr.io/DoEatFit/doeatfit_front:dev
Error response from daemon: manifest for ghcr.io/DoEatFit/doeatfit_front:dev not found

4-1. 원인 분석

분명 Pushghcr.io/DoEatFit/doeatfit_front:dev로 성공했는데, Pull이 실패했습니다.

원인은 대소문자였습니다.

GitHub Organization 이름은 DoEatFit (CamelCase)입니다. 하지만 GHCR(및 대부분의 Docker Registry 표준)은 경로(소유자 및 이미지 이름)를 모두 소문자(lowercase)로 처리하고 저장합니다.

  • docker push ghcr.io/DoEatFit/... (Push)

    • Jenkins(Linux)가 이 명령을 실행하면, Docker 클라이언트는 이 주소를 소문자로 변환하여 ghcr.io/doeatfit/...로 푸시합니다. (이 과정이 로그에는 잘 보이지 않습니다)
  • docker pull ghcr.io/DoEatFit/... (Pull)

    • 하지만 원격 서버(Linux)에서 pull이나 compose up을 실행할 때, image: 정의에 DoEatFit이라는 대문자가 포함되어 있으면, Docker 데몬은 대소문자를 구분하여 ghcr.io/DoEatFit/...라는 이미지를 찾으려고 시도합니다.

    • 실제로는 ghcr.io/doeatfit/... (소문자)으로 저장되어 있으니, 이미지를 찾지 못하고 manifest not found 오류를 반환한 것입니다.

4-2. 해결 과정

Jenkinsfiledocker-compose.yaml Credential 파일 양쪽 모두의 이미지 경로를 전부 소문자로 통일했습니다.

  • 잘못된 경로 (대소문자 미구분): ghcr.io/DoEatFit/doeatfit_front:dev (X)

  • 올바른 경로 (전부 소문자): ghcr.io/doeatfit/doeatfit_front:dev (O)

5. ✍️ 최종 해결 및 워크플로우 정상화

3번의 실패 끝에 드디어 기존 파이프라인이 정상으로 돌아왔습니다.

graph RL
    A["GitHub Private Repo<br>(dev branch push)"] -->|Webhook| B(Jenkins)
    
    subgraph "CI: Build Server (Jenkins)"
        B --> C{"1. Checkout<br>(from DoEatFit/..)"}
        C --> D{"2. Build Image<br>(to ghcr.io/doeatfit/..)"}
        D --> E{"3. Push to GHCR<br>(ghcr.io/doeatfit/..)"}
    end

    subgraph "CD: Remote Server ([REMOTE_IP])"
        E -->|"SSH ([REMOTE_USER])"| F{"4. Pull from GHCR<br>(ghcr.io/doeatfit/..)"}
        F --> G{"5. Docker Compose Up<br>(image: ghcr.io/doeatfit/..)"}
        G --> H[("🚀<br>배포 성공")]
    end

조직 이름을 변경하는 간단한 작업이 Jenkinsfile, Jenkins Credential, 그리고 Linux(Docker)의 대소문자 정책이라는 세 가지 영역에 걸쳐 연쇄적인 장애를 일으켰습니다.

이번 ISS-163 장애 대응을 통해 CI/CD 파이프라인을 구성하는 요소들이 얼마나 유기적으로 연결되어 있는지, 그리고 명시적인 경로(특히 소문자!) 관리가 얼마나 중요한지 다시 한번 깨닫게 되었습니다.