Dev.Chan64's Blog

홈으로 가기
Show Cover Slide Show Cover Slide

AWS DevOps Example 프로젝트 회고

이 글은 aws-devops-example 프로젝트의 구조와 설계 과정을 기록한 회고입니다.
CloudFormation과 GitHub Actions를 중심으로, 인프라와 애플리케이션의 배포를 자동화한 실전 사례를 공유하고자 합니다.


왜 이 프로젝트를 시작했는가?

현대적인 서비스 운영은 단순히 코드를 잘 작성하는 것만으로는 부족합니다.
다음과 같은 배경에서 이 프로젝트를 기획하게 되었습니다:


어떤 경험을 담고 싶은가?

이 글은 단순히 실행 결과를 나열하는 문서가 아닙니다.
대신 다음 세 가지 관점에서 실전 경험을 정리하고자 합니다:

  1. 구조화(Structure): 리소스를 어떻게 쪼개고 연결했는가
  2. 자동화(Automation): 어떤 트리거와 흐름으로 배포가 진행되는가
  3. 실패와 학습(Lessons): 무엇이 잘 작동했고, 무엇을 되돌아보게 되었는가

이후 섹션에서는 설계 목표부터 전체 구성, 리소스 분리 방식, 배포 흐름, 시행착오까지 순차적으로 설명합니다.


설계 목표 (Design Goals)

이 프로젝트는 단순한 “배포 자동화 예제”를 넘어서,
“구조화된 인프라 설계와 실행 가능한 자동화”를 목표로 시작되었습니다.


1. 코드로 정의되는 인프라 (Infrastructure as Code)


2. 명확한 스택 분리와 변경 유연성 (Modular & Mutable Stacks)

예:

이러한 구조는 다음과 같은 유연성을 제공합니다:

이런 스택 분리는 단지 구성 요소를 나누는 것이 아니라,
실제 운영 환경에서의 “변경 가능성”을 기준으로 리소스를 구조화하는 데 중점을 두었습니다.


3. GitOps 방식의 자동화 (Declarative Git-based Deployment)

코드 커밋 → GitHub Actions → CloudFormation → AWS 리소스 생성

4. 최소 구조, 명확한 흐름

전체 인프라 구성 (Architecture Overview)

이 프로젝트는 AWS 상에서 다음과 같은 흐름을 따라 구성됩니다:


전체 흐름 요약 (Mermaid)

flowchart TD
ecr --> ecs_taskdef
role --> ecs_taskdef

subnet --> ecs_service
vpc --> subnet --> load_balancer

subgraph alb [ALB Group]
load_balancer --> alb_dns
load_balancer --> tg
load_balancer --> listener
end

alb_dns --> apigw
vpc --> load_balancer
vpc --> securitygroup --> load_balancer
tg --> ecs_service

ecs_cluster --> ecs_service
ecs_taskdef --> ecs_service
vpc --> ecs_service
securitygroup --> ecs_service
ecr --> ecs_service

인프라 세부 계층 구조 (D2)

direction: down

Infra: {
  label: "AWS Infrastructure"

  INET: "Internet"

  Traffic: {
    label: "Traffic Management"
    Route53: "Route 53"
  }

  CDN: {
    label: "CDN"
    CloudFront: "CloudFront"
  }

  API: {
    label: "API Gateway"
    APIGateway: "API Gateway"
  }

  NetworkLayer: {
    label: "Network Layer"

    VPC: {
      label: "VPC Network"

      IGW: "Internet Gateway"
      RouteTable: "Route Table"
      SecurityGroup: "Security Group"

      IGW -> RouteTable
      RouteTable -> SecurityGroup

      Subnets: {
        label: "Subnets"
        Subnet1: "Subnet 1"
        Subnet2: "Subnet 2"
        Subnet3: "Subnet 3"
      }

      LoadBalancers: {
        label: "Load Balancers"

        LB1: {
          label: "Load Balancer 1"
          Listener1: "ELB Listener 1"
          TargetGroup1: "Target Group 1"
          Listener1 -> TargetGroup1
        }

        LB2: {
          label: "Load Balancer 2"
          Listener2: "ELB Listener 2"
          TargetGroup2: "Target Group 2"
          Listener2 -> TargetGroup2
        }

        LB3: {
          label: "Load Balancer 3"
          Listener3: "ELB Listener 3"
          TargetGroup3: "Target Group 3"
          Listener3 -> TargetGroup3
        }
      }

      SecurityGroup -> Subnets
      LoadBalancers.LB1.TargetGroup1 -> Subnets.Subnet1
      LoadBalancers.LB2.TargetGroup2 -> Subnets.Subnet2
      LoadBalancers.LB3.TargetGroup3 -> Subnets.Subnet3
    }
  }

  Application: {
    label: "Application Layer"

    ECS: {
      label: "ECS Cluster"

      SRV1: {
        label: "ECS Service 1"
        Task1: "ECS Task 1"
        Frontend: "Frontend"
        Task1 -> Frontend
      }

      SRV2: {
        label: "ECS Service 2"
        Task2: "ECS Task 2"
        API: "API Service"
        Task2 -> API
      }

      SRV3: {
        label: "ECS Service 3"
        Task3: "ECS Task 3"
        Data: "Data Service"
        Task3 -> Data
      }
    }
  }

  NetworkLayer.VPC.Subnets.Subnet1 -> Application.ECS.SRV1.Task1
  NetworkLayer.VPC.Subnets.Subnet2 -> Application.ECS.SRV2.Task2
  NetworkLayer.VPC.Subnets.Subnet3 -> Application.ECS.SRV3.Task3

  DATA: {
    label: "Data Platform"

    IoT: {
      IoTCore: "IoT Core"
      IoTRules: "IoT Rules"
      IoTCore -> IoTRules
    }

    CW: "Cloud Watch"
    IoT.IoTRules -> CW

    Storage: {
      S3: "S3 Bucket"
      DynamoDB
    }
  }

  AUTH: {
    label: "Authentication"
    CognitoUserPool: "Cognito User Pool"
    CognitoIdentityPool: "Cognito Identity Pool"
  }

  Application -> DATA
  Application -> AUTH

  INET -> Traffic.Route53 -> CDN.CloudFront -> API.APIGateway -> NetworkLayer.VPC.LoadBalancers
  INET -> NetworkLayer.VPC.IGW
}

리소스 구성 상세 (Resource Stack Breakdown)

이 프로젝트는 리소스를 기능 단위로 분리하여 총 네 개의 주요 스택으로 구성되어 있습니다:

  1. 네트워크 스택: VPC, Subnet, SecurityGroup 등 기반 구조
  2. 애플리케이션 스택: ECS Cluster, Task Definition, Service, IAM Role
  3. 로드 밸런서 스택: ALB, Listener, Target Group
  4. API Gateway 스택: ALB와 연결되는 API Gateway 구성

4.1 네트워크 스택

포함 템플릿:

주요 리소스:

설계 포인트:


4.2 애플리케이션 스택

포함 템플릿:

주요 리소스:

설계 포인트:


4.3 로드 밸런서 스택

포함 템플릿:

주요 리소스:

설계 포인트:


4.4 API Gateway 스택

포함 템플릿:

주요 리소스:

설계 포인트:


이러한 분리는 배포 단위 유연성과 유지보수 편의성을 동시에 제공합니다.
특정 리소스만 수정할 때 전체를 재배포할 필요 없이, 해당 스택만 GitHub Actions에서 선택적으로 실행할 수 있습니다.


GitHub Actions 기반 배포 자동화 (CI/CD)

이 프로젝트는 수동 CLI 없이도 인프라를 배포할 수 있도록,
GitHub Actions를 중심으로 완전 자동화된 배포 흐름을 구성했습니다.


워크플로우 구성

각 스택은 별도의 GitHub Actions 워크플로우 파일로 관리되며, 다음과 같은 규칙을 따릅니다:

스택 워크플로우 파일 트리거 조건
VPC vpc-stack.yml workflow_dispatch, 또는 Push
ALB alb-stack.yml VPC 스택 완료 후 자동 실행
ECS ecs-stack.yml ALB 스택 완료 후 자동 실행
API Gateway apigw-stack.yml 수동 실행 또는 ECS 완료 후 연결 가능

트리거 구조 (의존성 연결)

워크플로우 간에는 다음과 같은 순서로 자동 실행되도록 설정되어 있습니다:

vpc-stack
   ↓ (성공 시)
alb-stack
   ↓
ecs-stack
   ↓
apigw-stack (선택적 연결)

이 구조는 다음을 가능하게 합니다:


환경 변수 및 시크릿 관리

모든 AWS 인증 정보 및 파라미터는 GitHub 저장소의 Secrets에 저장하고, Actions 내에서 참조합니다.

예시:

env:
  AWS_REGION: $
  AWS_ACCOUNT_ID: $

Secrets 항목:


수동 실행 (workflow_dispatch)

개발 환경에서는 전체 스택을 수동으로 실행할 수 있도록 workflow_dispatch를 설정해두었습니다.

이 구조는 로컬 개발 환경에 의존하지 않고,
버전 관리된 YAML 파일만으로 인프라를 통제할 수 있는 GitOps 운영 방식을 실현합니다.


배포 흐름 요약 (Deployment Flow)

이 프로젝트는 GitHub Actions와 CloudFormation을 통해
인프라 구성부터 애플리케이션 실행까지 완전 자동화된 흐름을 갖습니다.


전체 흐름

GitHub에 코드 Push
       ↓
GitHub Actions 워크플로우 실행
       ↓
CloudFormation 스택 생성/업데이트
       ↓
VPC → ALB → ECS → API Gateway 순으로 배포
       ↓
ECS Task 실행 상태 확인 및 ALB 연결
       ↓
최종적으로 API Gateway Endpoint에서 서비스 접근 가능

실제 흐름 예시

  1. 사용자가 main 브랜치에 코드 변경을 Push
  2. GitHub Actions가 .github/workflows/vpc-stack.yml을 실행
  3. 이후 workflow_run 조건에 따라 alb-stack.yml, ecs-stack.yml 순차 실행
  4. ECS Task가 등록된 Target Group과 연결되며, ALB가 트래픽을 분산 처리
  5. 마지막으로 API Gateway에서 ALB를 대상으로 연결해 외부 요청을 수신

배포 후 검증


요약하면

이 구조는 다음과 같은 특성을 가집니다:


시행착오 및 개선 사례 (What Went Wrong & Fixed)

프로젝트를 진행하면서 단순히 코드만 작성한 것이 아니라,
AWS 리소스 간의 의존성과 시차, 권한 문제로 다양한 시행착오를 겪었습니다.
이 섹션에서는 그중 의미 있었던 사례들을 정리합니다.


  1. GitHub Actions 트리거 순서 꼬임
    문제: workflow_run 트리거가 예상대로 동작하지 않음
    원인: 이전 워크플로우 이름 불일치 또는 conclusion 조건 누락
    해결 방법:
on:
  workflow_run:
    workflows: ["vpc-stack"]
    types:
      - completed

이름이 정확히 일치해야만 다음 워크플로우가 실행됨


2. ECS Service와 TargetGroup 헬스체크로 인한 CloudFormation 대기 지연

문제: CloudFormation 스택 생성 시, ECS 서비스 생성 단계에서
TargetGroup의 헬스체크 결과를 기다리느라 스택 진행이 수 분간 지연

원인: ECS Service는 TargetGroup과 연결된 후, 최소 하나 이상의 Task가
헬시(Healthy) 상태가 되어야만 “CREATE_COMPLETE” 상태로 넘어감

영향:

해결 방법:

HealthCheckPath: "/health"
Matcher:
  HttpCode: "200"

3. ECR 배포 후 ECS TaskDefinition이 갱신되지 않음 (latest 태그 사용)

문제: ECR에 새로운 이미지를 Push했지만, ECS 서비스가 이전 이미지로 계속 실행됨

원인:
ECS의 TaskDefinition은 image 필드에 :latest 태그를 명시하더라도,
CloudFormation은 repository:latest URI가 같다면 변경사항이 없다고 판단하여
새로운 리비전을 생성하지 않음 → 배포 누락 발생

임시 해결 방법: force-new-deployment 사용

CI 워크플로우(push-ecr-hello)에 다음 스크립트를 추가하여,
ECR 이미지 Push 후 ECS 서비스에 강제로 재배포되도록 구성함:

aws ecs update-service \
  --cluster dev-ecs-cluster \
  --service dev-ecs-service-hello \
  --force-new-deployment \
  --region $AWS_REGION

향후 개선 방향: Immutable Tag 전략
이 방식은 매 배포마다 강제 재시작이 필요하고,
CloudFormation으로는 추적이 되지 않으므로 다음 개선이 필요합니다:

예시 전략:

image: ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/dev-hello:$

교훈:
latest는 로컬 테스트에는 편리하지만,
프로덕션 배포에서는 정확한 버전 추적과 변경 감지가 어려움.
CI/CD 파이프라인에는 가급적 immutable tag 전략을 도입해야 한다.


다음 단계

1. 멀티 서비스/멀티 환경 구성 확장

2. 모니터링 및 알림 연동


마치며

이 글이 비슷한 목표를 가진 분들께 작은 힌트가 되길 바랍니다.


홈으로 가기
태그: 프로젝트