GitLab CI/Cd 파이프라인 로그 분석 - build / docker build / deploy

StrayCat·2026년 3월 15일

실제로 파이프라인이 어떻게 동작했는지, 로그를 보며 흐름을 파악해보자.


전체 파이프라인 구조

이번 파이프라인은 세 개의 스테이지(Stage)로 구성되어 있다.

[1단계] build       → Gradle로 Java 소스코드 컴파일 및 JAR 파일 생성
[2단계] docker build → JAR 파일로 Docker 이미지 빌드 후 ECR에 푸시
[3단계] deploy      → ECS 서비스에 새 이미지 배포 명령 전송

각 스테이지는 별도의 Runner(실행 환경) 위에서 독립적으로 동작한다. 그래서 아티팩트(Artifact)를 통해 스테이지 간 파일을 전달하는 것이 핵심이다.


스테이지 1 : build

실행 환경

image: eclipse-temurin:17-jdk-jammy
Runner: runner-nthfetyxq-...

eclipse-temurin:17-jdk-jammy 이미지는 Java 17 JDK가 탑재된 Ubuntu 22.04(Jammy) 기반 컨테이너다. Java 소스를 컴파일하려면 JDK가 필요하기 때문에 이 이미지를 사용한다.

실행 스크립트 분석

# Gradle Wrapper에 실행 권한 부여
# Git에서 clone된 파일은 기본적으로 실행 권한이 없는 경우가 있어 명시적으로 부여
$ chmod +x gradlew

# Gradle 빌드 실행 (clean: 이전 빌드 결과물 삭제, build: 컴파일 + 테스트 + JAR 생성)
$ ./gradlew clean build

Gradle 빌드 과정 상세

Downloading gradle-8.14.4-bin.zip   ← Gradle Wrapper가 지정된 버전 자동 다운로드

> Task :clean UP-TO-DATE             ← 이전 빌드 결과물 없음 (새 환경이므로 스킵)
> Task :compileJava                  ← Java 소스코드(.java) → 바이트코드(.class) 컴파일
> Task :processResources             ← application.yml 등 리소스 파일 복사
> Task :classes                      ← 컴파일된 클래스들 준비 완료
> Task :resolveMainClassName         ← Spring Boot 메인 클래스 탐색
> Task :bootJar                      ← 실행 가능한 Spring Boot 단독 실행 JAR 생성 ★
> Task :jar                          ← 일반 JAR도 함께 생성
> Task :assemble                     ← 모든 산출물 조립 완료
> Task :compileTestJava              ← 테스트 코드 컴파일
> Task :testClasses                  ← 테스트 클래스 준비
> Task :test                         ← JUnit 테스트 실행
> Task :check                        ← 테스트 포함 각종 검증 완료
> Task :build                        ← 전체 빌드 완료

BUILD SUCCESSFUL in 51s

bootJar : Spring Boot 플러그인이 만들어주는 실행 가능한 Fat JAR(모든 의존성 포함)다. java -jar app.jar 한 줄로 서버를 실행할 수 있게 된다.

아티팩트 업로드

Uploading artifacts...
build/libs/*.jar: found 2 matching artifact files and directories
→ 201 Created

build/libs/ 디렉토리 안의 JAR 파일 2개 (*.jar 패턴에 매칭)를 GitLab 아티팩트(Artifact) 로 업로드한다. 이 아티팩트는 다음 스테이지인 docker build에서 다운로드되어 사용된다.

아티팩트(Artifact) : CI/CD에서 한 스테이지의 산출물을 다음 스테이지로 전달하는 파일 묶음이다. 각 스테이지는 격리된 환경에서 실행되므로, 아티팩트 없이는 이전 단계에서 만든 파일을 가져올 수 없다.


스테이지 2 : docker build

실행 환경

image: docker:latest
service: docker:dind
Runner: runner-jlguopmmv-...

docker:dind (Docker-in-Docker)는 컨테이너 안에서 Docker 명령어를 실행할 수 있게 해주는 서비스 컨테이너다. GitLab Shared Runner는 자체적으로 Docker 데몬(daemon, 백그라운드 서비스)에 직접 접근할 수 없기 때문에 dind를 별도로 띄운다.

아티팩트 다운로드

Downloading artifacts for build (13499653463)...
→ 200 OK

1단계 build 스테이지에서 업로드한 JAR 파일을 다운로드한다. 이 JAR 파일이 없으면 Docker 이미지를 만들 수 없다.

AWS CLI 설치

# Alpine Linux(docker:latest 기반 OS)에서 패키지 설치
$ apk add --update --no-cache curl py3-pip py3-virtualenv

# Python 가상 환경 생성 (의존성 충돌 방지)
$ python3 -m venv /tmp/venv
$ source /tmp/venv/bin/activate

# AWS CLI 설치
$ pip install awscli
→ awscli-1.44.58 설치 완료

왜 가상 환경(venv)을 쓰는가?
Alpine Linux 3.x 이상에서는 시스템 Python 패키지 공간에 pip로 직접 설치하면 충돌이 발생할 수 있다. --break-system-packages 플래그를 쓰거나, venv를 만들어 격리된 공간에서 설치하는 것이 현재 권장 방식이다.

AWS 자격 증명 설정

# IAM 액세스 키를 AWS CLI에 등록
# $AWS_ACCESS_KEY_ID, $AWS_SECRET_ACCESS_KEY 는 GitLab CI/CD Variables에서 주입된 값
$ aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
$ aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
$ aws configure set region ap-northeast-2   # 서울 리전

챕터 2-9(IAM)에서 만들었던 액세스 키가 여기서 사용된다. 키가 코드에 직접 노출되지 않고 GitLab 환경 변수로 안전하게 주입되는 구조다.

Docker 이미지 빌드

# $DOCKER_IMAGE = 'group-13204911/project-01' (GitLab 그룹/프로젝트명)
$ docker build -t $DOCKER_IMAGE .

빌드 과정 상세:

#1 Dockerfile 읽기
#2 베이스 이미지 메타데이터 확인
    → docker.io/library/eclipse-temurin:17-jdk-jammy (sha256:ce06...)

#4 베이스 이미지 레이어 다운로드 (총 3개 레이어)
    - sha256:b1cb... 29.54MB  ← Ubuntu 베이스 레이어
    - sha256:0fc9... 20.69MB  ← Java 관련 레이어
    - sha256:f25d... 145.63MB ← JDK 본체 레이어
    → DONE 5.9s

#6 [2/2] COPY build/libs/*.jar app.jar
    → 1단계에서 받은 JAR 파일을 이미지 안으로 복사
    → DONE 4.4s

#7 이미지 레이어 저장 완료
    → naming to docker.io/group-13204911/project-01

이 결과로 eclipse-temurin:17-jdk-jammy 위에 JAR 파일 하나만 얹은 간결한 이미지가 완성된다.

ECR 로그인 및 이미지 푸시

# 이미지에 ECR 리포지토리 URI 태그 부착
$ docker tag $DOCKER_IMAGE:latest \
  336805808295.dkr.ecr.ap-northeast-2.amazonaws.com/$DOCKER_IMAGE:$TAG_NAME

# ECR 로그인 (AWS CLI로 임시 토큰 발급 → Docker에 전달)
$ aws ecr get-login-password --region ap-northeast-2 \
  | docker login --username AWS --password-stdin \
  336805808295.dkr.ecr.ap-northeast-2.amazonaws.com
→ Login Succeeded

# ECR에 이미지 푸시
$ docker push 336805808295.dkr.ecr.ap-northeast-2.amazonaws.com/$DOCKER_IMAGE:$TAG_NAME

푸시 과정 상세:

0ec1976e58c7: Pushed   ← JAR 파일 레이어 (애플리케이션 코드)
985435d4caf5: Pushed   ← JDK 관련 레이어
af3ccb261236: Pushed
d59e5cfb705f: Pushed
d075e14ae189: Pushed
6b7908e4c747: Pushed   ← Ubuntu 베이스 레이어

latest: digest: sha256:4d2b3f7d... size: 1581

Docker 이미지는 여러 레이어(Layer)로 구성된다. 각 레이어는 독립적으로 푸시되며, 이미 ECR에 있는 레이어는 재전송하지 않는다(캐시 활용). 이번에는 첫 푸시이므로 전체 레이어가 올라갔다.

aws ecr get-login-password | docker login의 동작 원리:
ECR은 Docker Hub처럼 영구 비밀번호가 없다. 대신 AWS CLI로 12시간짜리 임시 토큰을 발급받아 Docker 로그인에 사용한다. 파이프라인에서 매번 이 과정을 거쳐야 한다.


스테이지 3 : deploy

실행 환경

image: python:3.9-slim
Runner: runner-ykxhnyexq-...

Docker 빌드가 필요 없으므로 가벼운 Python 이미지를 사용한다. AWS CLI만 설치하면 되기 때문이다.

AWS CLI 설치 및 자격 증명

2단계와 동일하게 venv를 생성하고 awscli를 설치한 뒤, IAM 키를 설정한다.

ECS 서비스 업데이트 명령

$ aws ecs update-service \
  --cluster project-cluster \           # 대상 ECS 클러스터
  --service project-01-service \        # 업데이트할 서비스
  --task-definition project-01-task:1 \ # 사용할 태스크 정의 (리비전 1)
  --force-new-deployment                # 이미지 변경 없어도 강제로 새 배포 시작

이 명령어 하나로 ECS에게 "지금 ECR에 있는 최신 이미지로 새 컨테이너를 띄워라"고 지시한다.

ECS 응답 JSON 분석

"launchType": "FARGATE",
"platformVersion": "LATEST",
"platformFamily": "Linux"

Fargate 서버리스 방식으로 실행되며, 플랫폼은 Linux 최신 버전이다.

"deploymentConfiguration": {
  "deploymentCircuitBreaker": {
    "enable": true,   // 배포 실패 시 자동 감지
    "rollback": true  // 실패 시 이전 버전으로 자동 롤백
  },
  "strategy": "ROLLING"  // 롤링 배포 방식
}

롤링 배포(Rolling Deployment) : 구버전 컨테이너를 하나씩 새버전으로 교체하는 방식이다. 서비스 중단 없이 배포할 수 있다. maximumPercent: 200이면 기존 1개 + 신규 1개를 동시에 띄울 수 있고, minimumHealthyPercent: 100이면 항상 최소 1개는 정상 동작 중이어야 한다는 의미다.

Circuit Breaker(서킷 브레이커) : 전기 회로 차단기에서 따온 개념으로, 배포가 연속 실패할 경우 자동으로 멈추고 이전 상태로 되돌리는 안전장치다.

배포 상태 분석 — 에러 발생

로그를 보면 failedTasks 가 기록되어 있고, 이벤트 메시지에 에러가 반복된다:

"message": "was unable to place a task. Reason: CannotPullContainerError:
  pull image manifest has been retried 7 time(s):
  336805808295.dkr.ecr.ap-northeast-2.amazonaws.com/group-13204911/project-01:latest
  not found."
"rolloutState": "FAILED",
"rolloutStateReason": "ECS deployment circuit breaker: tasks failed to start."

에러 원인 분석:

ECS가 ECR에서 이미지를 pull하려고 했는데 "not found" 오류가 발생했다. 이 상황에서 가장 흔한 원인은 다음과 같다.

원인설명
태그 불일치태스크 정의에 설정된 이미지 URI의 태그와 ECR에 실제로 올라간 태그가 다름
ECR 리포지토리명 불일치태스크 정의의 이미지 URL과 ECR 리포지토리 경로가 다름
타이밍 문제docker build 스테이지가 완료되기 전에 deploy 스테이지가 실행된 경우
리전 오류태스크 정의와 ECR 리포지토리가 서로 다른 리전에 있는 경우

이 로그에서는 docker build 스테이지에서 이미지 푸시가 성공했음에도 ECS가 "not found"를 반환하고 있다. 태스크 정의(Task Definition)에 입력된 이미지 URI가 실제 ECR에 올라간 URI와 일치하지 않는 경우가 가장 유력하다.

해결 방법:

# ECS 태스크 정의의 이미지 URI 확인
# 아래 형식이 정확히 일치해야 한다
336805808295.dkr.ecr.ap-northeast-2.amazonaws.com/group-13204911/project-01:latest
#                                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                                                  ECR 리포지토리명과 태그가 동일해야 함

전체 흐름 한눈에 보기

┌─────────────────────────────────────────────────────────┐
│  STAGE 1 : build                                   │
│  image: eclipse-temurin:17-jdk-jammy               │
│  ./gradlew clean build → build/libs/*.jar 생성      │
│  → GitLab 아티팩트로 업로드                           │
└────────────────────┬────────────────────────────────────┘
                     │ 아티팩트 전달 (JAR 파일)
┌────────────────────▼────────────────────────────────────┐
│  STAGE 2 : docker build                            │
│  image: docker:latest + service: docker:dind       │
│  AWS CLI 설치 → IAM 키 설정                          │
│  docker build → JAR를 포함한 이미지 생성              │
│  docker tag → ECR URI로 태그 부착                    │
│  ECR 로그인 → docker push → ECR에 이미지 저장         │
└────────────────────┬────────────────────────────────────┘
                     │ ECR에 이미지 저장 완료
┌────────────────────▼────────────────────────────────────┐
│  STAGE 3 : deploy                                  │
│  image: python:3.9-slim                            │
│  AWS CLI 설치 → IAM 키 설정                          │
│  aws ecs update-service → ECS에 새 배포 지시         │
│  ECS가 ECR에서 이미지 pull → 컨테이너 실행             │
│  ※ 이번 실행에서는 이미지 URI 불일치로 CannotPullContainerError │
└─────────────────────────────────────────────────────────┘

정리 — 이 파이프라인에서 배울 점

이번 파이프라인 로그에서 중요한 학습 포인트 몇 가지를 짚어둔다.

아티팩트의 중요성
각 스테이지는 완전히 격리된 컨테이너 위에서 실행된다. 1단계에서 만든 JAR 파일을 2단계가 알 수 있는 것은 오직 아티팩트 메커니즘 덕분이다.

이미지 일관성 유지
태스크 정의에 입력되는 ECR 이미지 URI는 docker push 명령어에서 사용한 URI와 완전히 동일해야 한다. 사소한 경로나 태그 차이가 CannotPullContainerError를 유발한다.

Circuit Breaker의 동작
ECS가 배포 실패를 자동으로 감지하고 이전 버전으로 자동 롤백을 시도하는 것이 로그에서 확인된다. 이것이 ECS Circuit Breaker의 역할이다. 덕분에 배포 실패가 서비스 다운으로 이어지지 않았다.

deploy 스테이지 자체는 성공(Job succeeded)
aws ecs update-service 명령은 정상적으로 수행되었고 Job은 성공으로 처리되었다. 다만 ECS가 이후에 실제 컨테이너를 띄우는 과정에서 에러가 난 것이다. 이 두 가지를 혼동하지 않는 것이 중요하다.

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글