- Backend
- Tech
20분걸리던 배포가 4분만에 끝나버렸습니다.
팀 배포 속도감, 효율 폭발!!!
2025.09.18
들어가며
안녕하세요, 라프텔 백엔드 엔지니어 페디입니다 :)
라프텔은 대한민국을 너머 동남아 해외 유저들에게도 확장하며 빠르게 성장하는 스트리밍 서비스입니다.
하지만 배포에 매번 20분 이상이 걸리면서 새로운 기능 테스트와 장애 대응이 지연되는 문제가 있었습니다.
이 글에서는 배포 속도를 20분대 → 4-6분대로 단축한 과정을 공유해볼 예정이고, 크게 다음 3가지 과정을 소개해보려고 합니다.
Docker 이미지 빌드 최적화
ECS Capacity Provider 구조 개선
이미지 사이즈 축소
저와 비슷한 환경에서 ECS + Django를 사용하는 분들이라면, 제가 겪은 시행착오가 도움이 되길 바라며… 🙏
그럼 시작해보겠습니다!
병목 분석
배포 파이프라인의 세 가지 병목을 진단하고, 각 단계에서 적용한 개선과 전·후 소요 시간 및 감소율(%)을 정리한 표입니다.
구간 | 전(before) | 후(after) | 변화(Δ) | 감소율 |
|---|---|---|---|---|
Docker 이미지 빌드 (Checkout & Build) | 약6분 40초 | 약 1분 20초 | -5분 20초 | -80% |
DB 마이그레이션 실행 (단일 실행 태스크) | 약 1분 | 약 1분 | x | x |
ECS Blue/Green 배포 (서비스 교체) | 약 18분 | 약 2분 30초 | -15분 30초 | -86% |
총 배포 시간 | 20~25분 | 4~6분 |
배포시 병목이 되는 구간은 총 3군데였습니다.
Docker 이미지 빌드
데이터베이스 마이그레이션 실행
ECS Blue/Green 배포
1. 비효율적인 Docker 빌드
요약: CI 과정중 가장 무거운 작업은 pip 패키지 설치 과정이었어요.
Docker에서 Layer란 무엇일까요?
파일 시스템 변경 사항(Snapshot) 을 누적 저장한 구조입니다.
Dockerfile의 각 명령어 (RUN, COPY, ADD, 등) 가 하나의 Layer를 생성합니다.
Docker Layer의 특징
우선 Docker의 명령(Instruction)(FROM/WORKDIR/RUN/COPY/…)은 불변 레이어를 만들어요. 여기서, 어떤 레이어의 입력이 한 글자라도 바뀌면, 해당 레이어와 이후 레이어의 캐시가 전부 무효가 됩니다.
그래서 이상적으로는 변경이 많은 레이어일수록 아래쪽에 배치해야하고, 변경이 적을수록 위쪽에 두는 게 캐시 구조상 유리해요.
requirements.txt 를 미리 복사하기
그리고, COPY . . 이 부분은 조금 특별해요. 바로 현재 디렉토리가 변경되면 전체 캐시 미스가 일어나게 되는데요, 보통 소스 코드 변경은 필수로 일어나다보니 배포시 COPY . . 에서는 필연적으로 캐시 미스가 발생합니다.
여기서 중요한 것은 파이썬은 패키지 설치 과정에서 특히 많은 시간을 차지하는데요, 위 예시에서는 COPY . . 뒤에 오기 때문에 패키지 설치 또한 캐시 미스가 발생한다는 점입니다.
그래서 이를 개선해서 requirements.txt 만 먼저 복사해서 패키지를 설치하면, 의존성이 변경되지 않는 이상 캐시가 히트되도록 설계할 수 있어요.
레이어를 최소화하기 (?)
RUN 안에 모든걸 때려넣으면 사실 조금이나마 이미지가 압축이 되고 레이어를 최소화할 수 있지만, 요즘에는 Docker 자체에서 최적화가 잘 되어있어서 그 효과는 미미하거든요.
그래서 그런 최적화를 할 필요 없이 가독성과 유지보수성을 챙기는 것이 더 중요합니다.
(하지만 묶으면 좋은 경우가 있습니다… 그것은 바로)
apt-get upate는 예외
중요한 점은 빌드 안정성이에요. 위 예시에서 apt-get update 와 apt-get install … 을 분리하면 update가 캐시되어 오래된 패키지 인덱스를 참조할 수 있어서, 404나 Hash sum 불일치와 같은 오류가 생길 수 있습니다.
그래서 apt-get 을 하나의 레이어에 묶어서 안정성도 챙겼습니다.
Github Actions 캐시
앞에서 캐시 레이어를 개선했는데, 막상 CI에서 캐시를 활용하지 못하는 상황이 있었는데요,
Actions에서 GHA 캐시는 빌드 캐시 메타데이터를 저장합니다.
docker/build-push-action 의 cache-from/to: type: gha 는 리모트 레이어를 재사용 가능하게 해서 CI간의 빌드가 콜드스타트이더라도, 빠르게 실행된다는 장점이 있습니다.
직접 캐시 백엔드를 운영할 수 있지만, 운영 난이도나 리소스를 고려해서 github의 캐시 (GHA) 를 활용했습니다.
그렇게 Checkout - 이미지 빌드 완료까지 1분대 달성!
Multi-stage 빌드 효과는 크지 않았어요.
파이썬에서 Multi-stage 빌드는 큰 영향을 주지 않았어요.
분명 몇십 MB를 아껴주긴 했지만, 그 효과가 크진 않아서 Dockerfile의 가독성을 높이기 위해 Multi-stage 빌드는 적용 후 롤백을 결정했습니다.
위: 멀티스테이지 빌드 적용 X, 아래: 멀티스테이지 빌드 적용 O
2. ECS의 EC2 Capacity Provider의 병목
요약: binpack과 동시 배포(Blue/Green + 롤링), 그리고 사전 Migration 서비스의 일시적인 용량 선점 및 인스턴스 재사용이 겹치며 배포가 지연되는 현상이 있었어요. EC2 기반으로도 최적화 및 해결책은 있었으나, 운영 복잡도를 낮추고 배포 안정성을 높이기 이해 Fargate 전환을 선택했어요.
당시 EC2 Capacity Provider의 구성 및 문제점
🚧
ECS 클러스터: EC2 기반 Capacity Provider를 공유하는 구조.
문제는 Blue/Green + 롤링 업데이트가 동시에 일어나면 리소스가 2배 필요해진다는 점이었죠.
예를 들어,
Service A: Task 4개 필요 (Blue/Green)
Service B: Task 1개 필요 (롤링 업데이트)
추가로 Migration Task 1개 실행
이렇게 되면 순간적으로 5개 이상의 인스턴스가 필요합니다.
하지만 CPU/메모리 여유에 따라 여러 태스크가 같은 인스턴스에 공존할 수 있는 binpack 전략을 쓰고 있었고,
Task의 예약된 vCPU/메모리 값이 인스턴스 용량과 1:1에 가깝게 맞춰져 여유 슬롯이 실질적으로는 1개로 유지됐던 상황이었습니다. → 1Task = 1인스턴스
Service B가 자리를 차지하면 Service A는 필요한 만큼의 인스턴스를 확보하지 못해 배포가 지연되는 것이죠.
시도 1: Warm Pool 도입
처음에는 Warm Pool을 도입했으나 인스턴스 부팅 시간만 줄어들 뿐 ECS 서비스 용량 문제는 해결되지 않았습니다. 서비스간 자원 경쟁과는 큰 의미가 없던 것이죠.
Hibernated 옵션을 쓰면 RAM 상태까지 보존 가능하지만, 결국 용량 선점 문제는 그대로였습니다.
💡
Warm Pool은 인스턴스 "부팅 지연"에는 효과가 있지만,
"서비스 간 리소스 경쟁" 문제는 해결하지 못했습니다.
Warm Pool이란?
Auto Scaling Group(ASG)에서 빠른 부팅을 위한 pre-initialized 인스턴스 풀입니다.
Hibernated로 인스턴스 상태를 세팅하면 Compute 비용은 없고, EBS/EIP 등만 과금되어서 비용 효율적으로 사용할 수 있습니다.
시도 2: 부분 Fargate로 전환
이번에는 1회성 Task(마이그레이션 등)만 별도의 Capacity Provider/ASG 대신
Fargate에서 실행하도록 분리해봤습니다. 의도는 본 서비스와 자원 경쟁을 차단해서, 서비스 배포에는 항상 충분한 인스턴스를 확보하도록 하자는 것이었죠.
하지만 결과적으로 문제는 여전히 해결되지 않았습니다.
이유는 자원을 잠식하는 주체는 1회성 Task뿐만 아니라 기존 서비스 A,B 끼리도 서로 리소스를 두고 경쟁하고 있었기 때문입니다.
즉, 1회성 Task를 Capacity Provider에서 분리해도 전체 병목은 여전히 남아 있던 것이죠.
그 외 해결 가능한 옵션
제가 헷갈렸던 부분은 AWS 문서/가이드에서 targetCapacity를 낮추라는 가이드는 보통 다수 태스크가 공유하는 인스턴스 환경을 전제로 하는 것이었어요.
사실 저의 케이스에서 targetCapacity를 낮추는 건 의미가 없었어요. 리소스를 꽉꽉 채워서 95% 이상 쓰기 때문에, targetCapacity를 70%든 100%든, 결과적으로 한 인스턴스에 Task 1개만 들어가고 예약률은 이미 95%+ 였기 때문이죠.
그래서 결국 빈자리를 만들려면 새 인스턴스를 미리 켜두는 것 밖에 방법이 없었습니다. (MinSize/DesiredCapacity 상향)
결정: 전체 Fargate로 전환 - 2분대 배포 달성…!
라프텔 백엔드 팀에서 궁극적으로 추구하는 배포 안정성 확보와, 운영 리소스 최소화를 달성하기 위해 EC2 Capacity Provider 최적화 및 지속적인 운영은 Fargate에 비해 큰 이점이 없다고 판단했어요.
ECS Launch Type을 Fargate로 전환한 결과, 최대 18분까지 걸리던 시간을 평균 2분 30초대로 컨테이너 배포를 완료할 수 있게 되었습니다.
3. 큰 이미지 사이즈: slim 이미지를 쓰자
JSON을 서빙하는 서버에서 python 이미지는 다소 무겁습니다.
python:3.11을 python:3.11-slim으로 변경해서 약 400MB를 아낄 수 있었어요.
위: python:3.11 이미지 기반, 아래: python:3.11-slim 이미지 기반
물론 python alpine 이미지도 고려했지만, 팀 내에선 Debian계열이 익숙했기에, slim 이미지를 선택했어요.
4. 정리
Docker 빌드 최적화와 캐시 활용으로 이미지 빌드 시간을 1분대로 단축
ECS EC2 기반에서 Fargate 전환으로 평균 2분 30초 배포 달성
Slim 이미지 등으로 용량 최적화 → 최종 20분 → 4~6분 배포 성공 ⭐
마무리하며…
처음엔 이유를 찾느라 고생을 했지만, 결국은 “단순한 게 답이구나” 라는 교훈을 얻어갑니다. 그리고 이런 팀 철학이 작은 팀으로도 높은 트래픽을 받는 서비스를 운영할 수 있는 이유라고 생각했구요.
결과적으로 배포가 빨라지니 팀 전체의 속도감도 확 달라졌구요. 팀원들의 만족도가 높아진 것 같아요.
아쉬운 점은 배포가 진행되며 스몰토크를 하곤 했는데 지금은 배포만 하고 바로 자리로 돌아가는 것입니다.
(은 농담이고 라프텔에서는 업무중에도 잡담을 많이 하고 티타임이라는 훌륭한 문화가 있기에 아쉽지 않습니다 ㅎㅎ 👍)