배포 버튼을 누르고 한숨 돌리나 싶었는데, 갑자기 서버가 죽었습니다. 자동 복구 프로세스마저 제대로 작동하지 않아 당혹스러운 상황이었죠. 원인을 파악해보니 예상치 못한 곳에 범인이 숨어 있었습니다.

이번 글에서는 apt-get upgrade -y라는 무심코 던진 명령어 한 줄이 어떻게 연쇄 장애를 일으켰는지, 그리고 Golden AMI 패턴을 통해 이 문제를 근본적으로 어떻게 해결했는지 공유해보려 합니다.


장애 요약

Blue/Green 배포를 마치고 3분 만에 서버가 내려갔습니다. 자동 복구마저 실패하는 바람에 결국 수동으로 docker compose를 실행해 겨우 복구할 수 있었습니다.

무엇이 문제였을까요?
EC2 user-data에 포함된 apt-get upgrade -y가 Docker CE를 24.0.5에서 28.1.1로 메이저 업그레이드해버린 게 화근이었습니다. 이 과정에서 Docker 데몬이 재시작되면서 실행 중이던 컨테이너들이 모두 종료되었습니다.

복구는 왜 실패했을까요?
데몬이 재시작되는 찰나에 docker rm 명령이 실패했고, 이미 지원이 종료(EOL)된 docker-compose v1이 최신 Docker 버전의 --force-recreate 옵션을 제대로 처리하지 못했습니다. 설상가상으로 스크립트에 set -e 설정이 누락되어 있어, 에러가 났음에도 "성공 알림"을 보내는 기현상까지 벌어졌습니다.

어떻게 해결했나요?
Golden AMI 패턴을 도입해 배포 시점에 apt-get upgrade를 수행하지 않도록 구조를 바꿨고, 배포 스크립트 전반을 견고하게 수정했습니다.


들어가기 전에: 기본 개념 정리

장애 원인을 깊이 이해하기 위해 AMI, Docker, 배포 방식에 대해 가볍게 짚고 넘어가겠습니다.

AMI (Amazon Machine Image)

AMI는 EC2 인스턴스의 상태를 그대로 박제한 디스크 이미지라고 생각하면 쉽습니다. 특정 시점에 설치된 프로그램과 설정을 통째로 복사해두는 것이죠.

이 AMI로 새 인스턴스를 만들면 복사 시점의 환경이 그대로 재현됩니다. 매번 프로그램을 새로 설치할 필요가 없습니다.

이 중 Golden AMI는 서비스 운영에 필수적인 Docker, Node.js, pnpm, CloudWatch Agent 등을 미리 설치하고 버전을 꽉 잡아둔 "황금 원본"을 의미합니다. 매번 서버를 켤 때마다 새로 설치할 필요가 없어 속도와 안정성 면에서 유리합니다.

Docker 엔진 vs Docker 이미지

둘의 차이를 명확히 아는 것이 이번 장애를 이해하는 핵심입니다.

Docker 엔진 (Engine)                     Docker 이미지 & 컨테이너
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"컨테이너를 돌려주는 구동 프로그램"        "실행할 소스코드가 담긴 패키지"

비유하자면 '전자레인지'                  비유하자면 '냉동 도시락'
- 한번 설치하면 계속 씁니다.              - 코드가 바뀔 때마다 새로 만듭니다.
- 버전이 자주 바뀔 필요가 없습니다.        - 우리 팀의 최신 소스코드가 들어있습니다.
- AMI에 미리 담아둘 수 있습니다.           - 배포 때마다 새로 빌드해야 합니다.

엔진은 한번 설치하면 계속 쓸 수 있으므로 AMI에 포함시키면 됩니다. 반면 이미지 안에는 소스코드가 들어가기 때문에 배포할 때마다 새로 빌드해야 합니다. 만약 이미지를 AMI에 미리 넣어두면 코드 변경을 반영할 수 없겠죠.

배포 시:
  CodeDeploy가 S3에서 최신 소스코드를 EC2에 복사
    → docker compose up --build
      → Dockerfile 기반으로 새 이미지 빌드 (최신 코드 포함)
        → 그 이미지로 컨테이너 시작

Blue/Green 배포 Flow

기존 서버(Blue)는 그대로 둔 채, 똑같은 새 서버(Green)를 만들어 트래픽을 옮기는 방식입니다. 배포가 실패하면 Green이 헬스체크를 통과하지 못하고, Blue가 그대로 유지됩니다.

sequenceDiagram participant LB as 로드 밸런서 (ELB) participant Blue as 기존 인스턴스 (Blue) participant Green as 새 인스턴스 (Green) Note over LB,Blue: 배포 전: 트래픽은 Blue가 처리 중 Green->>Green: ① Golden AMI로 새 인스턴스 생성 Green->>Green: ② CodeDeploy가 소스코드 배포 Green->>Green: ③ 컨테이너 실행 및 서버 가동 LB->>Green: ④ 헬스체크 통과 → 트래픽을 Green으로 전환 LB--xBlue: ⑤ Blue 트래픽 차단 Blue->>Blue: ⑥ Blue 인스턴스 종료 Note over LB,Green: 배포 후: 이제 트래픽은 Green으로!

CodeDeploy와 S3의 역할

CodeDeploy는 개발자가 Push한 코드를 직접 옮기지 않고 S3를 거쳐 가져옵니다. 이때 appspec.yml에 정의된 순서대로 스크립트가 실행됩니다.

  • BeforeInstallbefore_install.sh (기존 컨테이너 정리)
  • Install → S3에서 코드를 서버에 복사 (CodeDeploy 자동 수행)
  • AfterInstallinstall_dependencies.sh (의존성 설치 + 빌드)
  • ApplicationStartapplication_start.sh (Docker 컨테이너 시작)
  • AllowTraffic → ELB 헬스체크 (CodeDeploy 자동 수행)

AMI에서 가져오는 것 vs 배포 시 정리하는 것

이 둘은 대상이 다릅니다.

AMI에서 가져오는 것 (유지)before_install.sh에서 정리하는 것 (제거)
Docker 엔진, Node.js, pnpm 등 런타임 프로그램이전 배포의 Docker 컨테이너, 이미지 캐시 등 배포 산출물

비유하자면 AMI는 주방 시설(오븐, 냉장고)이 갖춰진 가게를 복사한 것이고, before_install.sh이전 영업에서 남은 음식 찌꺼기를 치우는 것입니다.

배포가 중간에 실패해서 같은 인스턴스에 재배포되는 경우에도 이전 시도의 컨테이너가 남아있으므로, 어떤 상황이든 깨끗한 상태에서 시작하기 위한 안전장치입니다.


그날의 기록: 장애 타임라인

10:36  배포 시작. 새 인스턴스 생성이 순조롭게 진행됨
10:47  배포 완료 및 헬스체크 통과
10:50  트래픽 전환 완료. 평소처럼 성공한 줄 알았음
10:53  갑자기 새 인스턴스 사망. ASG가 감지하고 재배포 시도하나 반복 실패
11:19  코드 수정 후 재배포했으나 역시 실패
11:35  직접 접속하여 docker compose(v2) 수동 실행으로 간신히 복구 완료

무엇이 문제였나: 기존 구조의 맹점

장애가 발생한 기존 배포 구조를 분석하고, 7가지 문제점을 하나씩 짚어봅니다.

독립적으로 도는 두 프로세스의 충돌

가장 큰 문제는 새 인스턴스가 뜰 때 부팅 스크립트(user-data)배포 스크립트(CodeDeploy)가 서로의 상태를 모른 채 동시에 달려나간다는 점이었습니다.

sequenceDiagram participant EC2 as 새 EC2 인스턴스 participant CI as 부팅 스크립트 (user-data) participant CD as CodeDeploy (배포 도구) participant Docker as Docker 데몬 par 동시 실행 (서로 간섭) CI->>EC2: apt-get upgrade 실행 CI->>Docker: Docker 24 → 28 강제 업그레이드 Docker-->>Docker: 데몬 재시작 (컨테이너 중단) and CD->>EC2: BeforeInstall 실행 EC2->>Docker: docker stop (성공) EC2-xDocker: docker rm (데몬 재시작 중이라 실패!) CD->>EC2: ApplicationStart 실행 EC2-xDocker: docker-compose v1 에러 (컨테이너 중복) EC2->>EC2: set -e 없음 → 에러 무시 → exit 0 end

핵심 문제: 두 프로세스는 서로의 존재를 모르고 동시에 실행됩니다. apt-get upgrade가 Docker 데몬을 재시작하는 시점과 배포 스크립트가 Docker 명령을 실행하는 시점이 겹치면 연쇄 실패가 발생합니다.

상세 문제점 분석

문제 1: 예고 없는 메이저 업데이트

apt-get upgrade -y는 설치된 모든 패키지를 최신 버전으로 업그레이드합니다. Docker도 예외는 아니었죠.

패키지가 업데이트되면 dpkg post-install 스크립트가 systemctl restart docker를 자동 실행합니다. 이것은 Docker의 표준 패키지 동작이며 사용자가 제어할 수 없습니다. 운영 중인 컨테이너 입장에서는 마른하늘에 날벼락인 셈입니다.

프로덕션 apt 히스토리로 확인한 업그레이드 내역:

docker-ce:amd64     (5:24.0.5-1~ubuntu.20.04~focal → 5:28.1.1-1~ubuntu.20.04~focal)
docker-ce-cli:amd64 (5:24.0.5-1~ubuntu.20.04~focal → 5:28.1.1-1~ubuntu.20.04~focal)
containerd.io:amd64 (1.6.22-1 → 1.7.27-1)

비슷한 사례는 업계에서도 심심찮게 보고되고 있습니다:

문제 2: 엉켜버린 스크립트 타이밍

Docker 데몬이 재시작되는 찰나에 docker rm 명령이 날아갔습니다. 소켓 연결이 끊겼으니 permission denied 에러가 나는 건 당연했습니다.

결국 죽어야 할 컨테이너가 좀비처럼 살아남아 이름을 점유하고 있었습니다.

실제 로그:

11:21:03 [stdout]my-app-server              ← docker stop 성공
11:21:03 [stderr]permission denied while trying to connect to the Docker daemon socket
         ...Delete ".../containers/my-app-server": dial unix /var/run/docker.sock:
         connect: permission denied              ← docker rm 실패!

문제 3: 낡은 도구의 한계 (docker-compose v1)

이미 지원이 끝난 docker-compose v1(1.26.2)은 2023-06 말에 EOL이 되었고, Docker Engine 25+ 이후 호환성이 보장되지 않습니다.

기존에 남겨진 컨테이너와 이름이 충돌하자 --force-recreate 옵션이 있었음에도 제 역할을 하지 못하고 뻗어버렸습니다:

Creating my-app-server ... error
ERROR: for my-app-server  Cannot create container for service my-app-server:
Conflict. The container name "/my-app-server" is already in use by container "c659cb8b..."

테스트 서버에서 동일 시나리오를 docker compose v2로 실행했을 때는 --force-recreate가 정상 동작하여 컨테이너를 재생성했습니다.

긴급성: Docker Engine 29.0.0에서 최소 API 버전이 1.44로 상향될 예정이며, 이 경우 v1의 모든 명령이 거부됩니다 (docker/compose #13389). 다음 업그레이드에서 v1은 완전히 사용 불가해질 수 있습니다.

문제 4: "침묵"하는 에러 핸들링

스크립트에 set -e가 없어서 명령어가 실패해도 꿋꿋이 다음 줄로 넘어갔습니다:

#!/bin/bash
# ← set -e 없음!

docker-compose -f ... up -d --force-recreate --build    # 실패해도 계속 진행
docker exec my-app-server pm2 stop ... || true           # 실패, 무시
docker exec my-app-server pm2 start ...                  # 실패, 무시

echo "===== PM2 start done. ====="                      # 실패해도 출력
# "배포 완료" 알림 전송                                    # 실패해도 전송!

마지막 줄의 "알림 전송"이 성공하니 CodeDeploy는 전체 과정을 '성공'으로 오판했습니다.

서버는 죽었는데 배포 성공 알림이 오는 아이러니한 상황이었습니다.

문제 5: 죽은 코드

application_start.sh에서 docker compose up -d 후 5초 뒤 docker exec pm2 stop/start를 실행하지만, 이 시점에 컨테이너는 아직 pnpm install 중이므로 두 명령 모두 실패합니다.

이후 pm2-runtime이 정상 시작되면서 어차피 정상 동작합니다. 사실상 아무것도 하지 않는 죽은 코드였습니다.

before_install.shinstall_dependencies.sh.env hash 저장/비교 로직도 마찬가지로 변수를 할당만 하고 비교하는 로직이 없는 죽은 코드였습니다.

이 죽은 코드들은 직접 장애를 일으키지는 않았지만, set -e를 추가하면 pm2 startCannot find module 'dotenv' 에러로 정상 배포도 실패하게 됩니다. 따라서 set -e 추가와 죽은 코드 제거는 반드시 동시에 적용해야 합니다.

문제 6: CloudWatch Agent 매번 설치 + amd64 하드코딩

install_dependencies.sh에서 매 배포마다 CW Agent를 wget으로 다운로드/설치하고 있었고, amd64가 하드코딩되어 ARM 인스턴스에서는 설치 자체가 실패했습니다.

문제 7: docker-compose.prod.yamlversion: "3.7"

Docker Compose v2에서 이 필드는 obsolete로 무시됩니다. 직접 장애를 일으키지는 않지만, v2 전환 완료를 코드에서 명확히 하기 위해 제거했습니다.

연쇄 실패 흐름 한눈에 보기

%%{init: {"theme":"default", "themeVariables":{"fontSize":"17px"}, "flowchart":{"nodeSpacing":30,"rankSpacing":40}}}%% flowchart LR A["upgrade -y"] --> B["Docker 업그레이드"] --> C["데몬 재시작"] C --> D["rm 실패"] --> E["컨테이너 잔류"] --> F["v1 실패"] F --> G["set -e 없음"] --> H["거짓 성공"] --> I["수동 복구"] style A fill:#ff6b6b,color:#fff,font-size:17px,padding:14px style B fill:#fff,stroke:#ccc,font-size:17px,padding:14px style C fill:#ff6b6b,color:#fff,font-size:17px,padding:14px style D fill:#fff,stroke:#ccc,font-size:17px,padding:14px style E fill:#fff,stroke:#ccc,font-size:17px,padding:14px style F fill:#ff6b6b,color:#fff,font-size:17px,padding:14px style G fill:#fff,stroke:#ccc,font-size:17px,padding:14px style H fill:#ff6b6b,color:#fff,font-size:17px,padding:14px style I fill:#fff,stroke:#ccc,font-size:17px,padding:14px

해결책: Golden AMI로 구조 혁신

단순히 옵션 한두 개 고치는 걸로는 부족했습니다. 시스템이 예측 가능하게 동작하도록 Golden AMI를 도입했습니다.

Golden AMI란

Docker, Node.js, pnpm, CloudWatch Agent 등 필요한 소프트웨어가 사전 설치된 AMI를 만들어두고, 이 AMI로 인스턴스를 생성하는 패턴입니다. AWS Well-Architected Framework REL08-BP04 "Deploy using immutable infrastructure"에서 권장하고 있습니다.

Golden AMI 적용 후의 깔끔한 흐름

sequenceDiagram participant EC2 as 새 EC2 인스턴스 (Golden AMI) participant CI as 부팅 스크립트 (user-data) participant CD as CodeDeploy (배포 도구) participant Docker as Docker 데몬 (사전 설치) Note over EC2: Docker 28.1.1, Node 20, pnpm, CW Agent 사전 설치됨 EC2->>CI: user-data 시작 (최소화됨) CI->>EC2: mkdir + CW Agent 시작만 Note over CI: apt-get upgrade 없음! 수 초 만에 완료 EC2->>CD: CodeDeploy Agent 시작 CD->>EC2: ① BeforeInstall EC2->>CI: cloud-init status --wait (이미 완료) EC2->>Docker: compose down + stop/rm (5회 재시도) EC2->>EC2: 디렉토리→파일 충돌 정리 EC2->>Docker: docker system prune CD->>EC2: ② Install (S3 → 코드 복사) CD->>EC2: ③ AfterInstall EC2->>EC2: CW Agent 설정 + 알람 생성 EC2->>EC2: pnpm install + build CD->>EC2: ④ ApplicationStart EC2->>Docker: compose v2 up --force-recreate --build EC2->>Docker: sleep 5 + docker ps (크래시 감지) EC2->>EC2: 배포 완료 알림 CD->>EC2: ⑤ AllowTraffic (헬스체크 통과)

왜 Golden AMI인가

항목기존 방식Golden AMI (개선)
버전 관리배포 시점에 apt-get upgrade가 무엇을 건드릴지 알 수 없음AMI 생성 시 고정 (예측 가능)
부팅/배포 충돌두 프로세스가 동시 실행, 타이밍에 따라 실패user-data 최소화로 경합 원천 제거
CW Agent매번 다운로드/설치 (amd64 하드코딩)AMI에 사전 설치, 설정만 변경
배포 속도apt-get upgrade + 설치에 수 분 소요부팅 즉시 준비 완료
보안 패치통제 불가능한 시점에 적용AMI 리빌드 시 검증 후 적용

apt-mark hold만으로는 부족한 이유:

apt-mark hold로 Docker 패키지를 고정할 수는 있지만, 여전히 apt-get upgrade가 실행되고 cloud-init/CodeDeploy race condition이 남습니다.

Golden AMI는 apt-get upgrade 자체를 제거하여 두 문제를 모두 해결합니다. 단, AMI 내에서도 apt-mark hold을 추가 적용하여 AMI 리빌드 시 실수로 Docker가 업그레이드되는 것을 방지합니다 (defense in depth).

live-restore를 적용하지 않은 이유

Docker의 live-restore: true 옵션은 데몬 재시작 시 컨테이너를 유지하는 기능이지만, 이번 아키텍처에서는 적용하지 않았습니다:

  1. 메이저 업그레이드에서 미보장 — patch 릴리스에서만 지원, 메이저 업그레이드 시 컨테이너가 중단됨
  2. 로깅 드라이버와 충돌 — 데몬 중단 시 FIFO 로그 버퍼가 포화되어 컨테이너가 블로킹될 수 있음 (공식 문서 참고). awslogs 드라이버는 기본적으로 blocking mode라 이 문제가 더 심각
  3. Blue/Green 배포와 중복 — 새 인스턴스를 만들어 교체하므로, 기존 인스턴스의 Docker 데몬 재시작 시 컨테이너 유지가 불필요

구체적인 개선 코드

각 배포 스크립트를 어떻게 수정했는지, 왜 그렇게 수정해야 했는지를 설명합니다.

1. 부팅 스크립트 (user-data) 최적화

위험한 upgrade는 과감히 덜어내고, 필요한 설정만 최소한으로 남겼습니다.

#!/bin/bash
# Golden AMI: Docker, Node.js, pnpm, CloudWatch Agent 사전 설치됨
# apt-get upgrade 불필요 — 보안 패치는 AMI 리빌드 시 적용
mkdir -p /home/ubuntu/app/logs
if [ -f /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json ]; then
  sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
    -a fetch-config -m ec2 \
    -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s
fi

2. 더 꼼꼼해진 before_install.sh

프로세스 간의 경합을 막기 위해 '대기'와 '재시도' 로직을 추가했습니다.

#!/bin/bash
set -euo pipefail # 하나라도 실패하면 즉시 중단!

# 부팅 프로세스가 끝날 때까지 최대 120초간 기다려줍니다.
timeout 120 cloud-init status --wait 2>/dev/null || true

# 깔끔하게 다 내리고 시작합니다.
docker compose -f /path/to/docker-compose.prod.yaml down 2>/dev/null || true

# 혹시 모를 좀비 컨테이너는 5번까지 끈질기게 지웁니다.
docker stop my-app-server 2>/dev/null || true
for i in $(seq 1 5); do
  docker rm my-app-server 2>/dev/null && break
  echo "docker rm 재시도... ($i/5)"
  sleep 3
done

# Docker가 존재하지 않는 파일을 마운트할 때 디렉토리로 생성하는 문제 정리
DEPLOY_DIR="/home/ubuntu/app"
for f in .npmrc package.json turbo.json pnpm-workspace.yaml pnpm-lock.yaml; do
  if [ -d "$DEPLOY_DIR/$f" ]; then
    echo "디렉토리→파일 충돌 정리: $DEPLOY_DIR/$f"
    rm -rf "$DEPLOY_DIR/$f"
  fi
done

# 찌꺼기 파일과 이미지를 정리해 빌드 환경을 깨끗하게 만듭니다.
docker system prune -f

왜 이렇게 동작해야 하는가:

  • cloud-init status --wait: cloud-init이 끝나야 Docker 데몬이 안정 상태입니다. AWS 공식 문서에서도 이 race condition을 인정하고 대안을 제시합니다.
  • docker compose down: 컨테이너 + 네트워크를 한번에 정리합니다. docker stop/rm보다 깔끔합니다.
  • docker rm 재시도: compose down이 실패해도 개별 컨테이너를 제거할 수 있습니다. 5회 재시도, 3초 간격.
  • 디렉토리→파일 충돌 정리: Docker가 존재하지 않는 호스트 파일을 bind mount하면 파일 대신 디렉토리를 생성합니다. 다음 배포에서 CodeDeploy가 이 위치에 파일을 복사하려 하면 에러가 발생합니다.
  • docker system prune -f에 방어(|| true)를 추가하지 않은 이유: Docker 데몬이 안 떠있으면 여기서 실패하는데, 어차피 이후 docker compose up도 실패하므로 일찍 실패하는 게 낫습니다.

3. install_dependencies.sh — nvm과 set -u의 순서

#!/bin/bash
set -eo pipefail

# nvm은 내부적으로 미정의 변수를 참조하므로 nvm 로딩 완료 후 set -u 활성화
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
set -u

nvm.sh는 내부적으로 NVM_NODEJS_ORG_MIRROR 등 미정의 변수를 참조합니다. set -u가 활성화된 상태에서 nvm을 로딩하면 unbound variable 에러로 스크립트가 중단됩니다.

실제로 첫 배포에서 이 문제로 실패했습니다. nvm 로딩이 끝난 후 set -u를 활성화하여 해결했습니다.

CloudWatch Agent — 설치 제거, 설정만 유지:

# 기존: 매번 wget + dpkg 설치 (amd64 하드코딩)
# 변경: Golden AMI에 사전 설치되어 있으므로 설정/시작만 수행
if [ -f /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl ]; then
  sudo tee /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json > /dev/null <<'CWEOF'
  { ... }
CWEOF
  sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
    -a fetch-config -m ec2 \
    -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s
fi

CW Agent fetch-config에는 방어(|| true)를 추가하지 않았습니다. 만약 실패하면 메트릭 수집 자체가 안 되어 모니터링이 무의미해지기 때문입니다. 모니터링 없이 서버가 도는 것보다 배포 실패로 문제를 바로 인지하는 게 더 안전합니다.

반면 CloudWatch 알람 put-metric-alarm에는 방어를 추가했습니다. 알람 생성은 외부 API 호출이므로 네트워크 장애로 실패할 수 있고, 1개 실패해도 나머지는 동작하므로 이것 때문에 배포를 중단시키는 건 과합니다.

4. 내실을 다진 application_start.sh

성공했다는 거짓말을 하지 않도록, 실제로 잘 떴는지 확인하는 절차를 넣었습니다.

#!/bin/bash
set -euo pipefail

# Docker Compose v2로 안전하게 실행
docker compose -f /path/to/docker-compose.prod.yaml up -d --force-recreate --build

# 5초만 기다렸다가 정말 살아있는지 확인해봅시다.
sleep 5
if ! docker ps --filter "name=my-app-server" --filter "status=running" -q | grep -q .; then
  echo "컨테이너가 시작 직후 죽었습니다. 로그를 확인하세요."
  docker logs my-app-server --tail 20 2>/dev/null || true
  exit 1 # 여기서 1을 뱉어야 롤백이 작동합니다.
fi

# 배포 완료 알림 (webhook)
# IMDSv2 토큰 발급 후 메타데이터 조회 (IMDSv1보다 안전)
IMDS_TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" 2>/dev/null || echo "")
INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $IMDS_TOKEN" \
  http://169.254.169.254/latest/meta-data/instance-id 2>/dev/null || echo "unknown")
WEBHOOK_URL=$(grep '^WEBHOOK_URL=' /path/to/.env | cut -d'=' -f2- || echo "")

if [ -n "$WEBHOOK_URL" ]; then
  curl -s -H "Content-Type: application/json" -d "$PAYLOAD" "$WEBHOOK_URL" > /dev/null 2>&1 \
    && echo "배포 완료 알림 전송 성공" \
    || echo "배포 완료 알림 전송 실패 - 무시"
fi

왜 이렇게 동작해야 하는가:

  • docker compose up -d는 detached 모드에서 컨테이너 시작 요청 후 즉시 exit 0을 반환합니다. 컨테이너가 1초 후 크래시해도 exit 0입니다. 5초 후 docker ps로 실제 running 상태를 확인하고, 크래시했으면 exit 1 → CodeDeploy 배포 실패 → 자동 롤백.
  • Webhook URL의 || echo "": grep은 매치가 없으면 exit 1을 반환합니다. set -o pipefail 하에서 파이프라인이 실패하면 스크립트가 중단되므로 방어가 필요합니다.
  • set -euo pipefail: 이제 docker compose up이 실패하면 스크립트가 즉시 중단되고 CodeDeploy가 자동 롤백을 트리거합니다. 거짓 성공 알림이 더 이상 발생하지 않습니다.

5. docker-compose.prod.yaml

- version: "3.7"
  services:
    my-app-server:

Docker Compose v2에서 version 필드는 obsolete로 무시됩니다.


방어 계층: 하나가 뚫려도 다음이 막아준다

수정 후에는 동일한 장애가 발생해도 6단계에 걸쳐 차단됩니다.

flowchart TD trigger["Docker 새 버전이 APT 저장소에 올라옴"] trigger --> L1{"[1단계] Golden AMI - user-data에 apt-get upgrade 없음"} L1 -->|"원천 차단"| safe["배포 정상 완료"] L1 -->|"만약 AMI 리빌드 시 실수로 업그레이드되었다면"| L1b L1b{"[1-b] apt-mark hold - Docker 패키지 고정"} L1b -->|"업그레이드 차단"| safe L1b -->|"만약 user-data가 변경되었다면"| L2 L2{"[2단계] cloud-init status --wait - user-data 완료 대기"} L2 -->|"race condition 방지"| safe L2 -->|"만약 데몬이 아직 올라오지 않았다면"| L3 L3{"[3단계] docker rm 재시도 - 5회, 3초 간격"} L3 -->|"컨테이너 제거 성공"| safe L3 -->|"만약 rm이 끝내 실패했다면"| L4 L4{"[4단계] docker compose v2 --force-recreate"} L4 -->|"강제 재생성 성공"| safe L4 -->|"만약 그래도 실패했다면"| L5 L5{"[5단계] set -euo pipefail - 스크립트 즉시 종료"} L5 -->|"CodeDeploy 실패 감지 → 자동 롤백"| rollback["이전 인스턴스로 롤백(거짓 성공 알림 없음)"] L5 -->|"만약 up은 성공했지만 컨테이너가 크래시했다면"| L6 L6{"[6단계] sleep 5 + docker ps - 크래시 감지 → exit 1"} L6 --> rollback style L1 fill:#2ecc71,color:#fff style safe fill:#2ecc71,color:#fff style rollback fill:#f39c12,color:#fff

Golden AMI 유지보수

Golden AMI는 생성 시점의 패키지 버전이 고정됩니다. 보안 패치를 적용하려면 분기별로 AMI를 리빌드해야 합니다:

# 1. 현재 Golden AMI로 임시 EC2 인스턴스 시작

# 2. SSH 접속 후 보안 패치 적용
sudo apt-mark unhold docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo apt-get update && sudo apt-get upgrade -y
docker --version   # Docker 버전이 올라갔다면 테스트 서버에서 먼저 검증
sudo apt-mark hold docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras

# 3. 새 AMI 생성 (AWS 콘솔)
# 4. Launch Template에서 AMI ID 업데이트
# 5. 임시 인스턴스 종료

마치며: 우리가 배운 교훈

이번 장애는 단 하나의 큰 실수라기보다, 사소한 문제들이 톱니바퀴처럼 맞물려 발생한 사고였습니다. 개별적으로는 사소한 문제들이 동시에 맞물리면서 복구까지 1시간이 걸렸습니다.

Golden AMI 도입으로 apt-get upgrade 자체를 제거하고, 6단계 방어 계층을 구축하여 같은 장애가 재발하더라도 여러 지점에서 차단됩니다.

이번 일을 통해 얻은 핵심 원칙 4가지를 정리하며 글을 마칩니다.

  1. 운영 서버의 부팅 스크립트에서 apt-get upgrade는 금물입니다. 어떤 변화가 생길지 예측할 수 없기 때문입니다.
  2. 수명이 다한(EOL) 도구는 빨리 보내주어야 합니다. docker-compose v1은 Docker 28에서 이미 동작하지 않고, Docker 29에서 완전히 거부됩니다.
  3. 스크립트의 에러 핸들링(set -euo pipefail)은 필수입니다. 실패를 성공으로 포장하는 것만큼 위험한 일은 없습니다.
  4. 프로세스 간의 레이스 컨디션을 항상 고려해야 합니다. cloud-init status --wait로 동기화하거나, Golden AMI로 user-data를 최소화하는 여유가 안정성을 만듭니다.

비슷한 환경에서 배포 자동화를 고민하시는 분들께 이 기록이 작은 도움이 되길 바랍니다.


참고 자료

AWS

Docker

cloud-init

유사 장애 사례

Shell