Skip to content

infra: Sprint 06 AWS 운영 인프라 구축 및 ECR digest 기반 자동 배포·롤백 구현 - #19

Open
devikae wants to merge 24 commits into
mainfrom
deploy/aws-ec2
Open

devikae wants to merge 24 commits into
mainfrom
deploy/aws-ec2

Conversation

@devikae

@devikae devikae commented Sep 16, 2026

Copy link
Copy Markdown
Owner

📌 개요 (Overview)

EC2에서 소스를 직접 빌드하던 배포 방식을 ECR 이미지 기반으로 변경했습니다.

GitHub Actions가 프런트엔드와 백엔드 이미지를 빌드해 ECR에 저장하고, EC2는 SSM으로 전달받은 image digest를 pull해 실행합
니다. 이전 digest를 이용한 수동 롤백과 RDS 스냅샷 복원도 실제 환경에서 검증했습니다.

🛠️ 주요 변경 사항 (What Changed)

  • 프런트엔드·백엔드 ECR 저장소 분리

    • snowthing/frontend
    • snowthing/backend
  • GitHub Actions에서 Docker 이미지 빌드 및 ECR push

  • Git commit SHA 기반 이미지 태그 적용

    • candidate-
    • release-
    • stable-
  • EC2 배포 대상을 태그가 아닌 image digest로 고정

  • OIDC로 AWS 배포 역할을 수임하고 SSM Run Command로 배포

  • EC2에서는 이미지 빌드 없이 ECR pull과 컨테이너 실행만 수행

  • 프런트엔드·백엔드 개별 배포 및 헬스체크 적용

  • 배포 실패 시 직전 실행 이미지로 자동 복구

  • release- 또는 stable-를 이용한 수동 롤백 추가

  • 배포 실패 시 다음 정보를 같은 UTC 시각으로 보존

    • docker ps -a
    • 비밀값을 제외한 docker inspect
    • 컨테이너 로그
    • Nginx error log
  • 공식 GitHub Actions를 Node.js 24 기반 주요 버전으로 갱신

  • ECR 수명 주기 정책 적용

    • 태그 없는 이미지: 1일 후 삭제
    • candidate-*: 7일 후 삭제
    • release-*: 최근 3개 보관
    • stable-*: 최근 1개 보관
  • RDS 수동 스냅샷을 별도 DB로 복원하고 레코드 대조

  • 복원 검증이 끝난 임시 RDS 삭제

  • Sprint 06 제출 문서와 복구 증거 추가

💡 핵심 기술 의사결정 및 트레이드오프 (Technical Rationale)

기존 방식은 EC2가 Git 소스를 받은 뒤 직접 이미지를 빌드했습니다. 구성이 단순하지만 EC2의 CPU·디스크·빌드 캐시 상태에 영향
을 받고, 과거 커밋을 다시 빌드한 결과가 당시 배포한 이미지와 같다고 보장하기 어렵습니다.

이번에는 GitHub Actions에서 이미지를 한 번 빌드해 ECR에 저장하고, EC2가 해당 이미지를 실행하도록 변경했습니다. 실제 실행
대상은 태그가 아니라 변경할 수 없는 digest로 지정했습니다. 같은 결과물을 재배포하거나 이전 버전으로 되돌릴 수 있고, EC2는
빌드 작업을 하지 않아도 됩니다.

대신 ECR 저장 비용과 이미지 수명 주기 관리, push·pull IAM 권한, 태그 승격 규칙이 추가됐습니다. 저장 비용을 줄이기 위해
candidate·release·stable 보관 정책을 나누고, IAM 권한은 두 ECR 저장소와 대상 EC2에 필요한 범위로 제한했습니다.

이미지 롤백은 애플리케이션만 되돌리고 DB 스키마와 데이터는 되돌리지 않습니다. 따라서 DB 스키마가 바뀐 배포는 이전 애플리
케이션과 현재 스키마의 호환성을 먼저 확인해야 합니다. 호환되지 않으면 자동 롤백을 멈추고 forward fix 또는 검증한 DB 복원
절차를 선택해야 합니다.

🧪 테스트 및 검증 결과 (Verification & QA)

✅ PR 체크리스트 (Checklist)

  • 프런트엔드·백엔드 CI 통과
  • ECR 이미지 빌드 및 push 확인
  • image digest 기반 EC2 배포 확인
  • 실제 운영 도메인 HTTPS 응답 확인
  • 게시글·리조트 API 200 OK 확인
  • 이전 release 이미지 롤백 확인
  • 최신 release 이미지 재배포 확인
  • 프런트엔드·백엔드 stable 이미지 지정
  • ECR 수명 주기 정책 적용
  • RDS 스냅샷 별도 DB 복원 및 레코드 대조
  • 복원 검증용 RDS 삭제
  • 배포 실패 진단 로그 보존 추가
  • 제출 문서와 검증 증거 갱신

@devikae
devikae requested a review from yyy9942 September 16, 2026 11:02
@devikae devikae self-assigned this Sep 16, 2026
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: d788ae9c-309a-49e6-9f92-360259637409


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation backend frontend labels Sep 16, 2026

@yyy9942 yyy9942 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굳 전체적으로 잘 해주셨습니다. 조금만 수정해보시죠. 그리고 conflict 나고있는데 main을 머지받거나 리베이스해서 해결해주세요.

byte[] bytes = s3Object.readAllBytes();
String contentType = s3Object.response().contentType();

return new ImageDownloadResponse(bytes, contentType);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지는 S3 공개 URL을 내려줘서 Client에서 S3를 직접 찌르도록 수정해주세요.

PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" --arg image "$IMAGE_URI" --arg region "$AWS_REGION" '{commands: [
"set -e", "cd /home/ubuntu/snowthing",
"git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"",
"git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 이미지를 github.sha 기준으로 만들었는데, EC2에서는 다시 현재 브랜치의 HEAD를 받아오고 있네요.

워크플로가 대기하는 사이 새 커밋이 들어오면 검증한 이미지와 실제 실행하는 compose.prod.yml/배포 스크립트의 버전이 달라질 수 있습니다. digest를 고정한 의미가 여기서 깨질 수 있어요.

브랜치명이 아니라 ${{ github.sha }}를 SSM으로 넘기고, EC2에서도 그 SHA를 명시적으로 fetch/reset해서 이미지와 배포 설정을 하나의 커밋 단위로 맞춰주세요. 프론트 워크플로도 같은 부분을 같이 확인해보면 좋겠습니다.


HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(download.contentType()));
headers.setCacheControl("public, max-age=86400");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기는 인증된 사용자만 이미지를 내려받게 해두었는데 응답 캐시는 public으로 설정되어 있네요.

public이면 공유 프록시나 CDN이 이 응답을 저장했다가 다른 요청에 재사용할 수 있어서, 현재의 비공개 버킷 + 인증 조회 정책과 서로 충돌합니다.

비공개 이미지가 맞다면 private 또는 no-store로 바꿔주세요. 만약 공개 이미지로 운영하려는 의도라면 인증을 왜 요구하는지부터 정책을 다시 정리하고, 공개 가능한 이미지와 비공개 이미지를 구분하는 편이 좋겠습니다.

}

public ImageDownloadResponse getImage(String imageKey) {
if (imageKey == null || imageKey.isBlank() || imageKey.contains("..")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 ..만 막고 있어서, 로그인한 사용자가 키 이름만 알면 images/ 바깥의 객체도 조회할 수 있습니다. 지금 IAM 권한도 버킷 전체 객체에 열려 있어서 애플리케이션 레벨의 경계가 사실상 없는 상태예요.

업로드할 때 만드는 images/{UUID}.{허용 확장자} 형식을 정확히 검증하거나, 더 안전하게는 DB의 이미지 식별자를 받고 서버가 실제 S3 키를 조립하도록 바꿔주세요. images/ 외부 키와 잘못된 UUID/확장자가 거부되는 테스트도 같이 있으면 좋겠습니다.

GetObjectRequest.builder().bucket(bucketName).key(imageKey).build();

ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getRequest);
byte[] bytes = s3Object.readAllBytes();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 방식은 S3 객체 크기를 확인하지 않고 readAllBytes()로 전부 힙에 올립니다. 게다가 ImageDownloadResponse 생성 시 한 번, data() 접근 시 다시 한 번 복사해서 요청 하나당 큰 byte 배열이 최소 3개 생길 수 있어요. 동시 요청이 들어오면 메모리 사용량이 급격히 커질 수 있습니다.

먼저 S3의 contentLength 상한을 검증하고, 가능하면 애플리케이션이 byte 배열을 들고 있지 않도록 스트리밍 응답이나 presigned URL 방식을 검토해주세요. 현재 구조를 유지한다면 다운로드 크기 제한과 대용량 객체 거부 테스트는 꼭 필요해 보입니다.

GetObjectRequest getRequest =
GetObjectRequest.builder().bucket(bucketName).key(imageKey).build();

ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getRequest);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResponseInputStream을 직접 열고 닫지 않고 있습니다. 정상적으로 끝까지 읽은 경우에는 연결이 반환될 수 있지만, 읽는 도중 예외가 발생하면 스트림과 HTTP 연결이 남을 가능성이 있습니다.

try-with-resources로 감싸서 성공/실패와 상관없이 닫히도록 보장해주세요. 예외 발생 시에도 close되는지 테스트까지 있으면 좋겠습니다.

@RequiredArgsConstructor
public class ImageService {

private static final long MAX_FILE_SIZE = 5 * 1024 * 1024L; // 5MB

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서비스에서는 최대 5MB까지 허용한다고 정의했는데, spring.servlet.multipart.max-file-sizemax-request-size 설정이 보이지 않습니다. 그러면 Spring의 multipart 파서가 서비스 검증보다 먼저 요청을 잘라서 1MB 초과~5MB 이하 파일도 실패할 수 있습니다.

애플리케이션 설정의 multipart 제한과 이 상수를 같은 값으로 맞추고, 프레임워크에서 발생하는 업로드 크기 초과 예외도 FILE_SIZE_EXCEEDED로 변환해주세요. 경계값 통합 테스트도 추가하면 정책이 확실해질 것 같습니다.

DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$SOURCE_TAG" --query 'imageDetails[0].imageDigest' --output text)
MANIFEST=$(aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'images[0].imageManifest' --output text)
aws ecr put-image --repository-name "$REPOSITORY" --image-tag "$STABLE_TAG" --image-manifest "$MANIFEST" >/dev/null
mapfile -t RELEASE_TAGS < <(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$DIGEST" --query 'imageDetails[0].imageTags[?starts_with(@, `release-`)]' --output text | tr '\t' '\n')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 stable로 승격할 때 같은 digest에 붙어 있는 모든 release-* 태그를 삭제하고 있습니다. 서로 다른 커밋이라도 이미지 내용이 같으면 같은 digest를 가질 수 있어서, 다른 커밋의 정상적인 롤백 지점까지 같이 사라질 수 있어요.

우선은 사용자가 선택한 SOURCE_TAG만 제거하는 편이 안전해 보입니다. 여러 release 태그를 정리하려는 목적이라면 digest 기준 일괄 삭제보다는 보관 개수/기간을 포함한 명시적인 retention 정책으로 분리해주세요. 백엔드·프론트에 중복된 stable 처리도 함께 확인하면 좋겠습니다.

-- ====================================================================
-- Snowthing Production Initial Schema (MySQL 8.0)
-- Version: 001
-- Description: 운영 RDS 최초 스키마 생성 스크립트 (비파괴 CREATE TABLE IF NOT EXISTS)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

운영 프로필은 ddl-auto: validate인데, 현재 배포 흐름에서는 이 SQL을 RDS에 적용하는 단계가 연결되어 있지 않은 것 같습니다. 지금 RDS는 수동으로 구성해서 동작하더라도, 다음 컬럼/인덱스 변경부터는 새 애플리케이션이 스키마 검증 단계에서 바로 종료될 수 있어요.

초기 생성 SQL만 두기보다 Flyway/Liquibase 같은 버전 마이그레이션이나 별도 migration job을 배포 선행 단계로 연결해주세요. 자동 롤백까지 고려하면 이전 이미지와 새 스키마가 잠시 공존할 수 있도록 expand/contract 방식도 같이 정리할 필요가 있습니다.

echo "=== docker inspect (secrets excluded) ==="
docker inspect --format 'image={{.Config.Image}} status={{.State.Status}} running={{.State.Running}} exit_code={{.State.ExitCode}} error={{.State.Error}} started_at={{.State.StartedAt}} finished_at={{.State.FinishedAt}} restart_count={{.RestartCount}} network_mode={{.HostConfig.NetworkMode}}' "$CONTAINER_NAME" || true
echo "=== container logs (last 100 lines) ==="
docker logs --tail 100 --timestamps "$CONTAINER_NAME" 2>&1 || true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 컨테이너 로그와 Nginx 오류 로그 전체를 tee로 stdout에도 내보내고 있습니다. 이 stdout은 SSM 응답으로 수집되고, 워크플로에서 StandardOutputContent를 그대로 출력하므로 장애 시 사용자 정보·토큰·요청값·DB 오류 내용이 Actions 로그까지 올라갈 수 있어요.

원본 진단 로그는 EC2 파일에만 저장하고, Actions에는 저장 경로와 민감정보를 제거한 요약만 출력하는 편이 안전합니다. 로그 파일 권한과 보관 기간도 같이 정해주세요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend ci-cd database documentation Improvements or additions to documentation frontend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants