CI/CD Docker AWS CodeBuild ECR EC2 DevOps

CodeBuild + ECR + EC2로 가벼운 CI/CD 파이프라인 설계하기

···

저보고 CI/CD 적용하라구요?

저보고 CICD 적용하라구요?

불과 지난주까지만 해도 수동 배포를 하고 있었습니다 😅 그렇다고 완전 노가다였던 건 아닙니다. 간편 배포 스크립트 두 개를 직접 만들어 운용하고 있었으니까요. 물론 제가 만든 스크립트

예시)

스크립트 1: 로컬에서 Docker 이미지 빌드 → 사내 Registry에 push
스크립트 2: 서버에서 Registry에서 pull → 최신 이미지로 컨테이너 재시작

배포가 잦지 않았을 때는 이걸로 충분하다고 생각했고, 실제로 충분했어요. 스크립트 두 개 실행하면 아무리 오래 걸려도 5분이면 끝나니까요. 로컬에서 빌드하고, Registry에 올리고, 서버에서 당겨서 켜는 — 나름 체계적인(?) 수동 배포였죠^^7

그런데 배포가 잦아지기 시작하면서 상황이 달라졌습니다. 환경도 여러 개로 분리되고(dev/prod만 있는 게 아니라 여러 고객사 환경까지), 어떤 서버에는 어떤 버전이 돌아가고 있는지 추적이 안 되고, 롤백은 “이전 이미지 태그 기억나요?” 수준이고…

“아.. 이젠 진짜 CI/CD 적용해야겠다 💀”

그래서 이번 포스트에서는 스크립트 기반 수동 배포 → Bitbucket Pipelines → AWS CodeBuild + ECR까지, 단계적으로 CI/CD를 구축해간 경험을 정리해보겠습니다.


Phase 1: 스크립트 기반 수동 배포 — 최근까지의 이야기

배포 스크립트 두 개가 전부였습니다.

예시)

스크립트 1 (로컬에서 실행):

  • docker build로 이미지 빌드
  • 사내 Docker Registry에 push
  • 태그는 수동으로 버전 입력

스크립트 2 (서버에서 실행):

  • Registry에서 최신 이미지 pull
  • 기존 컨테이너 stop → 새 이미지로 run
  • healthcheck는… 눈으로 확인 😅

배포가 한 달에 한두 번이었을 땐 이게 문제가 없었습니다. 그.런.데

  • 배포가 주 단위로 잦아지니까 매번 스크립트 두 개를 실행하는 게 번거롭고 실수할 확률이 높아짐
  • 환경이 여러 개로 나뉘면서 “이 서버엔 어떤 버전이 깔려 있더라?”가 추적이 안 됨 💀
  • 누가 언제 배포했는지 이력이 없음 💀
  • 롤백? “이전 태그가 뭐였죠?” → Registry 들어가서 확인 → 다시 스크립트 실행 💀

Phase 2: Bitbucket Pipelines로 CI 도입

소스코드를 Bitbucket에서 관리하고 있었기 때문에, 가장 먼저 손이 간 건 Bitbucket Pipelines였습니다. 리포지토리에 bitbucket-pipelines.yml 하나만 추가하면 되니까 진입 장벽이 제일 낮거든요 👍 사실 PoC 단계에서 이미 Bitbucket Pipelines를 사용하고 있었지만, 현재 버전에선 자연스럽게 적용되지 않고 사라졌습니다.. 그것도 제가 했거든요..

CI만 먼저 해결

처음 목표는 “빌드 자동화”였습니다. 코드가 push되면 자동으로 Docker 이미지를 빌드하고 테스트까지 돌리는 것 !

git push (develop)


Bitbucket Pipelines
    ├── Docker build
    ├── 단위 테스트 실행
    └── 빌드 성공/실패 알림

그런데… CD가 문제다

CI는 잘 되는데, 실제 EC2에 배포하는 단계에서 조금 아쉬운 점들이 보이기 시작했습니다.

문제설명
EC2 접근Bitbucket Pipelines에서 EC2에 접근하려면 SSH 키 관리가 번거롭고, 보안그룹에 따라 막힐 가능성도 있음
AWS 연동ECR에 이미지 푸시하려면 AWS 자격 증명이 필요한데, Bitbucket에 평문으로 둘 수는 없고…
환경 분리dev/prod를 같은 파이프라인에서 관리하려니 복잡도가 올라감
비용Bitbucket Pipelines도 빌드 시간 당 과금인데, Docker 빌드가 무거우니 쓸데없이 비용이 나감
SSH 차단내부 AWS 보안그룹 정책상 SSH 접근이 막힐 예정이라, 외부에서 EC2로 직접 붙는 방식 자체가 곧 불가능해질 상황
인프라 중복이미 AWS 통합 인프라를 쓰고 있는데 Bitbucket에 별도로 비용을 낼 이유가 없음. AWS 안에서 다 해결하는 게 맞겠다 싶었음

결론: PASS


Phase 3: AWS CodeBuild + ECR로 반자동 CI/CD

그래서 도착한 곳이 AWS CodeBuild + ECR + EC2 (SSM) 조합입니다.

왜 CodePipeline + CodeDeploy가 아닌 CodeBuild인가요? 라고 물어보실 수 있는데, 저희 서비스 규모에서는 CodeBuild 하나면 충분했거든요. CodePipeline은 오케스트레이션용인데, 오케스트레이션할 단계가 빌드 → ECR push → EC2 배포 딱 세 개면 굳이 더 복잡한 걸 도입할 필요가 없다고 생각했습니다. 오버엔지니어링 🚫

전체 아키텍처

여기서 중요한 설계 결정이 두 가지 있습니다.

1. 두 개의 리포지토리 분리

리포지토리역할
앱 소스코드 리포 (Bitbucket)애플리케이션 코드, Dockerfile, buildspec.yml
배포 설정 리포서버의 Docker Compose, 운영 스크립트, 환경 변수

소스코드와 배포 설정을 분리하니까, 개발자는 코드만, 운영 담당자는 배포 설정만 신경 쓰면 됩니다. 물론 지금은 제가 다 하고 있습니다만^^7

2. “반자동”이라고 부르는 이유

요구사항이 “완전 자동이 아닌, 반자동” 이었습니다. push를 날리면 바로 배포까지 되는 게 아니라, AWS 콘솔에서 확인 후 빌드를 직접 트리거하고, 그 이후 빌드 → ECR push → EC2 배포까지는 자동으로 흘러가는 구조입니다. CodePipeline은 소스 변경 감지 → 자동 실행이 기본값이라 오히려 이 요구사항에 맞지 않았고요.

이 파이프라인이 100% 자동은 아닙니다.

  • ✅ 자동: Docker build → ECR push → EC2 배포 (빌드 트리거 후)
  • ✅ 자동: EC2에서 이미지 pull → 마이그레이션(필요시) → 컨테이너 재시작
  • ⚠️ 수동: 빌드 트리거, prod 배포 승인, 장애 대응, 롤백 판단

어디까지나 개발자가 트리거를 누르면 알아서 서버까지 반영되는 흐름을 만든 거지, 완전한 무인 배포 시스템을 만든 건 아닙니다. 그래도 수동 배포 시절에 비하면 훨씬 굳굳


Docker Multi-stage Build

파이프라인의 첫 번째 단계는 Docker 이미지 빌드입니다.

Python 애플리케이션이라 multi-stage build로 최종 이미지 크기를 줄였습니다. 핵심 아이디어는 “빌드에 필요한 도구”와 “실행에 필요한 런타임”을 분리하는 거죠.

Stage 1 (builder)          Stage 2 (runtime)
┌──────────────────┐      ┌──────────────────┐
│  빌드 도구 설치    │      │  최소 런타임만    │
│  의존성 컴파일     │ ───▶ │  빌드 결과물 복사  │
│  소스코드 빌드     │      │  non-root로 실행   │
└──────────────────┘      └──────────────────┘
   1.5GB+                    400MB대

신경 쓴 포인트 세 가지:

1. 의존성을 먼저 설치하기 소스코드보다 requirements.txt를 먼저 COPY해서 설치합니다. 이렇게 하면 소스코드가 바뀌어도 의존성 설치 캐시를 재사용할 수 있다고 합니다.

2. non-root user로 실행 컨테이너를 root로 돌리면 혹시 컨테이너가 뚫렸을 때 호스트까지 영향을 받을 수 있습니다.

3. slim 베이스 이미지 풀 이미지를 쓰면 빌드 도구까지 다 포함돼서 이미지가 불필요하게 커집니다. slim 이미지로 최소한의 런타임만 포함하는 게 배포 속도에도, ECR 저장 비용에도 좋습니다.


AWS CodeBuild로 빌드 자동화

CodeBuild가 buildspec.yml을 읽어서 전체 빌드/배포 과정을 실행합니다.

CodeBuild 프로젝트 설정

CodeBuild 프로젝트 생성할 때 중요한 설정들:

  • 소스: Bitbucket webhook 연결 (main/develop 브랜치 푸시 시 자동 트리거)
  • 환경: AWS 관리형 이미지 (Ubuntu)
  • Privileged mode: 반드시 활성화 — CodeBuild 컨테이너 안에서 Docker build를 해야하므로 (Docker-in-Docker)
  • 서비스 역할: ECR push 권한, SSM Parameter 읽기 권한 필요
  • 환경 변수: ENV_PREFIX(dev/prod), EC2_HOST 등을 콘솔에서 주입

Privileged mode를 켜지 않으면 Docker build 단계에서 permission denied 에러가 납니다. 저도 처음에 이걸 놓쳐서 한참 헤맸습니다ㅠ CodeBuild 에러 로그에서 Cannot connect to the Docker daemon 같은 메시지가 보이면 일단 이 설정부터 확인하세요.

buildspec.yml의 4단계

install — AWS CLI를 준비합니다. 이후 SSM을 통해 EC2에 명령을 전달해야 합니다.

pre_build — 여기서 중요한 작업이 세 가지는 아래와 같습니다.

  1. 브랜치를 감지해서 환경을 판단
  2. 커밋 해시를 기반으로 이미지 태그를 생성
  3. ECR 로그인 + 이전 빌드의 latest 이미지를 pull해서 캐시로 준비

builddocker build를 실행합니다. --cache-from 옵션으로 pre_build에서 받아온 캐시 이미지를 활용해서 빌드 시간을 줄일 수 있습니다.

post_build — 빌드된 이미지를 ECR에 push하고, AWS SSM으로 EC2에 명령을 전달해서 배포합니다.


ECR 이미지 태깅 전략

매 빌드마다 이미지에 두 개의 태그를 붙이는 게 이 파이프라인의 핵심 설계입니다.

my-app:dev-a1b2c3d4   ← 버전 태그 (롤백용, 영구 보존)
my-app:dev-latest      ← 최신 태그 (항상 최신 빌드를 가리킴)

브랜치별 환경 분리

예시)

브랜치접두어태그 예시
mainprodprod-20260415-a1b2c3d4
developdevdev-a1b2c3d4

main 브랜치엔 날짜를 추가해서 언제 배포된 건지 한누에 보이게 했습니다. prod 환경은 이력 추적이 중요하기 때문입니다!

이중 태깅이 왜 필요한가?!

  • latest 태그: 배포 스크립트에서 항상 latest를 pull하도록 하면, 매번 태그를 수동으로 바꿀 필요가 없습니다.
  • 버전 태그: 롤백할 때 필요합니다. 이전 버전의 태그는 ECR에 그대로 남아있으니까, 서버에서 태그만 이전 버전으로 바꿔주면 됩니다.

롤백은?

문제가 생기면 서버의 이미지 태그 설정을 이전 버전으로 되돌리고 재배포하면 됩니다. ECR에 과거 태그들이 다 남아있기 떄문입니다. 이미지 하나당 약 400MB이긴 하지만, ECR 비용이 워낙 저렴해서 몇 달 치는 그냥 둬도 괜찮습니다.

단, DB 마이그레이션이 포함된 배포라면 이미지만 되돌린다고 해결되지 않을 수 있습니다. 스키마 변경이 있었다면 migration rollback도 함께 고려해야 합니다.


EC2 배포 — 반자동의 핵심

배포 설정 리포지토리

서버에 별도 리포지토리를 두고, 여기서 Docker Compose로 전체 서비스 스택을 관리합니다:

  • docker-compose.yml: 앱, DB 등 전체 서비스 정의
  • 운영 스크립트: up/down/pull/migrate/status 등을 하나로 묶은 셸 스크립트
  • 환경 변수 파일: DB 비번, API 키 등은 .env로 관리
  • 이미지 버전 파일: ECR에서 pull할 이미지 태그를 지정

배포 순서

CodeBuild가 post_build 단계에서 AWS SSM으로 EC2에 명령을 전달해 실행하는 배포 순서:

1. 이미지 태그 업데이트 (새 버전으로)
2. ECR에서 최신 이미지 pull
3. DB 마이그레이션 (마이그레이션 파일이 변경된 경우만)
4. 앱 컨테이너만 재시작 (DB 등 인프라는 그대로)

여기서 포인트는 앱 컨테이너만 재시작한다는 겁니다. Docker Compose의 --no-deps 옵션을 쓰면 앱 컨테이너만 내렸다 올리고, DB나 캐시 같은 인프라 서비스는 그대로 둘 수 있습니다. 전체 스택을 내렸다 올리면 서비스 중단 시간이 길어지니까요.

DB 마이그레이션은 조건부

마이그레이션은 매번 실행하는 게 아니라, 마이그레이션 파일이 변경되었을 때만 실행합니다. CI 단계에서 git diff로 마이그레이션 파일 변경을 감지해서 플래그를 넘기고, EC2 배포 스크립트에서 이 플래그에 따라 마이그레이션 실행 여부를 결정합니다.

불필요한 마이그레이션 실행을 줄이니까 배포 시간도 단축되고, 실수로 마이그레이션이 꼬일 리스크도 줄어들죠 💪


삽질기 🪏

1. Bitbucket Pipelines → CodeBuild 전환할 때 삽질

Bitbucket Pipelines에서 CodeBuild로 넘어가면서 제일 헤맨 건 환경 변수 관리였습니다. Bitbucket엔 변수를 UI에서 편하게 넣을 수 있는데, CodeBuild는 환경 변수 주입 방식이 달라서요.

특히:

  • 시크릿 관리: Bitbucket엔 변수를 UI에서 편하게 넣을 수 있는데, CodeBuild에선 AWS SSM Parameter Store에서 가져오도록 buildspec을 구성해야 했음
  • AWS 자격 증명: CodeBuild의 서비스 역할에 ECR 권한을 직접 붙여야 해서 IAM 정책을 새로 만들어야 했음
  • webhook 연동: Bitbucket webhook을 CodeBuild에 연결하는 설정이 처음이라 삽질 알고보니 CodeBuild에 권한 부여하면 웹훅 없어도 그냥 되는거였습니다..

결국 SSM Parameter Store로 민감 정보를 관리하는 방식으로 정착했습니다. buildspec.yml에서 parameter-store를 통해 안전하게 값을 가져올 수 있거든요. 평문으로 어딘가에 적어둘 필요가 없으니 보안상 훨씬 괜찮죠.

2. Docker 캐시를 안 쓰면 빌드가 10분

처음에 캐시 없이 docker build만 했는데, 매 빌드마다 의존성 설치부터 다시 하니까 10분 (?)이 걸렸습니다. CodeBuild는 빌드 시간별로 과금이니까 이게 돈이죠…

이전 빌드의 latest 이미지를 캐시로 쓰게 바꾸니까 소스코드만 바뀐 경우 2~3분으로 줄었습니다. 핵심은 세 가지:

  1. pre_build에서 이전 latest 이미지를 pull (|| true로 첫 빌드 대비)
  2. build에서 --cache-from으로 캐시 지정
  3. Dockerfile에서 의존성 설치를 소스코드 COPY보다 먼저 배치

이것만 해도 빌드 시간이 확 줄어들었습니다.

3. .env 바꾸고 restart만 하면 반영이 안 된다

Docker Compose에서 .env 파일을 수정한 후 docker compose restart만 하면 변경사항이 반영되지 않습니다. restart는 기존 컨테이너를 재시작하는 거지, 새 설정으로 컨테이너를 다시 만드는 게 아니거든요.

# ❌ 이렇게 하면 .env 변경이 안 반영됨
docker compose restart my-app

# ✅ down → up 으로 컨테이너를 새로 만들어야 함
docker compose down
docker compose up -d

이거 때문에 “분명히 .env 바꿨는데 왜 적용이 안 되지??” 하고 30분 날린 적이 있습니다 😅 배포 스크립트를 짤 때 이걸 고려해서 restart가 아니라 down → up을 쓰도록 만들어야 합니다.

4. 마이그레이션과 롤백은 별개 문제

DB 마이그레이션이 포함된 배포는 이미지만 되돌린다고 롤백이 안 됩니다.

예를 들어:

  1. 마이그레이션으로 컬럼 추가 → 성공
  2. 새 코드에 버그 발견
  3. 이전 이미지로 롤백 → 하지만 DB에는 이미 새 컬럼이 있음

이전 코드는 새 컬럼을 모르니까 에러가 날 수도 있고, 반대로 새 컬럼이 NOT NULL이면 이전 코드에서 INSERT 할 때 에러가 납니다.

그래서 마이그레이션은 항상 backward-compatible하게 작성하는 게 중요합니다:

  • 컬럼 추가 → NULLABLE로
  • 컬럼 삭제 → 사용하지 않는 표시만 먼저, 실제 삭제는 다음 배포에서

이런 마이그레이션 전략은 CI/CD 파이프라인 설계할 때 미리 고민해둬야 합니다. 파이프라인은 만들었는데 롤백을 못 하면 의미가 없으니까요 😅

5. Privileged mode 안 켜서 삽질한 이야기 (한 번 더)

이건 진짜 어이없는 실수인데, CodeBuild에서 Privileged mode를 안 켜고 buildspec.yml을 돌렸다가 Cannot connect to the Docker daemon 에러를 만났습니다.

CodeBuild 자체가 Docker 컨테이너 안에서 돌아가는데, 그 안에서 또 docker build를 하려면 Docker-in-Docker가 가능해야 하거든요. 그래서 Privileged mode를 켜야 하는데, 이걸 CodeBuild 프로젝트 설정의 깊숙한 곳에 있어서 처음엔 몰랐습니다…

CodeBuild 프로젝트 생성 시 Environment 설정에서 “Enable privileged mode” 체크박스를 꼭 확인하세요. 이것 때문에 빌드가 계속 실패하면 CloudWatch 로그를 보면서 Docker daemon 관련 에러가 있는지 확인하시길.


돌아보면

수동 → Bitbucket Pipelines → CodeBuild, 무엇이 바뀌었나

스크립트 수동 배포Bitbucket PipelinesCodeBuild + ECR
빌드로컬에서 스크립트 실행자동 ✅자동 ✅
이미지 관리사내 Registry에 push사내 Registry에 pushECR에 푸시 ✅
배포서버에서 스크립트 실행수동반자동 ✅
롤백이전 태그 찾아서 스크립트 재실행이전 태그 찾아서 스크립트 재실행태그만 바꿔서 재배포 ✅
이력 추적없음 (슬랙 메시지가 전부)빌드 이력만이미지 태그 + ECR 이력 ✅
환경 분리수동으로 태그/서버 구분약간 자동브랜치 기반 자동 분리 ✅

스크립트 수동 배포 할 때는 롤백 자체가 “이전 태그가 뭐였더라 → Registry에서 찾기 → 스크립트 다시 실행”이라서 10분이 넘게 걸렸는데, 지금은 이전 이미지 태그로 바꿔서 재배포면 1~2분이면 끝납니다.

아직 부족한 것들

물론 완벽하진 않습니다.

항목설명
배포 후 헬스체크CodeBuild가 배포 후 앱이 정상 응답하는지 자동 확인 (아직 못함)
알림 연동배포 성공/실패를 Slack이나 Discord로 알림
무중단 배포지금은 stop-and-start라 배포 순간에 짧은 다운타임 있음 (사실 필요하진 않음)
prod 배포 승인prod 배포 전 수동 승인 단계 (지금은 main에 push하면 바로 감)

그래도 “git push 하고 한 번에 빌드부터 배포까지”가 가능해진 것만으로도, 수동 배포 시절에 비하면 천지차이입니다

인프라 직접 만져보니까 백엔드 개발자로서 시야가 확실히 넓어진 느낌입니다. “코드만 잘 짜면 되지”가 아니라, 코드가 어떻게 서버에 닿는지를 알고 나니까 아키텍처 설계할 때 고려하는 것들도 달라지더라고요

이 정도면 CI/CD… 일단은 합격 아닐까요? 🫡