최신글

ECS 배포 파이프라인에서 고민한 것

반응형

AWS에 컨테이너를 1~2개만 올린다면 EKS는 배보다 배꼽이 큽니다. 애플리케이션 컨테이너 2개를 배포하려고 EKS 클러스터 자체의 유지보수까지 함께 떠안아야 하기 때문입니다. 그래서 배포할 컨테이너가 적고, GPU 관리처럼 kubernetes가 꼭 필요한 상황이 아니라면 운영 관점에서는 ECS가 적절하다고 생각합니다.
 

ECS 파이프라인은 참고할 정형화된 사례가 적다

ECS 파이프라인을 설계하면서 가장 어려웠던 점은 참고할 정형화된 사례가 적다는 점입니다. kubernetes에는 Argo CD, Helm처럼 배포를 잘 운영하기 위한 방법론과 도구가 많습니다.
저는 kubernetes에 정형화된 사례가 많은 이유가 controller에 있다고 생각합니다. kubernetes controller는 선언된 상태와 운영 중인 상태를 계속 비교해 맞춥니다. Argo CD는 이 구조 위에서 Git의 manifest를 클러스터에 적용합니다.

 
ECS에서는 ECS 배포 컨트롤러(deployment controller, 타입 ECS)가 배포를 맡습니다. ECS 배포 컨트롤러는 kubernetes의 Deployment controller처럼 새 버전으로 태스크를 교체합니다. 다만 ECS 배포 컨트롤러가 직접 지원하는 배포 전략은 오랫동안 rolling update뿐이었습니다. blue/green을 하려면 deployment controller 타입을 CODE_DEPLOY로 바꾸고 CodeDeploy를 붙여야 했습니다.
 
ECS 배포 컨트롤러가 blue/green을 직접 지원한 것은 2025년 7월이고, linear와 canary 배포는 2025년 10월에 추가됐습니다. 2025년 11월에는 ECS Express Mode도 나왔습니다. 배포 전략을 ECS 안에서 고를 수 있게 된 지 1년 남짓이라, 이 기능을 전제로 한 방법론과 도구, 사용 사례가 아직 적습니다. 그래서 ECS 파이프라인은 설계하고 구성할 때 직접 정해야 할 것이 많습니다.
 
ECS 배포 전략별 공식 문서와 발표는 다음과 같습니다.

 

설계에서 가장 큰 고민

설계에서 가장 큰 고민은 GitOps를 어디까지 적용할지였습니다. 저는 인프라를 Git으로 운영하면 장점이 많다고 생각합니다. 인프라를 코드로 관리하고 변경 이력을 남길 수 있기 때문입니다.
 
하지만 ECS는 kubernetes에 비해 GitOps 생태계가 활발하지 않습니다. AWS가 제공하는 ECS용 Argo CD 같은 도구가 없어서, 완전한 GitOps를 하려면 방법론을 정하고 도구도 직접 만들어야 합니다. 이 시간을 들일 만큼 중요한 일인지 고민했고, 최소한의 시간으로 GitOps를 적용하는 방법을 골랐습니다.
 
배포와 자동 롤백은 큰 고민 대상이 아니었습니다. ECS 배포 컨트롤러가 있기 때문입니다. ECS 배포 컨트롤러는 새 revision으로 rolling update를 진행하고, circuit breaker를 켜면 실패한 배포를 직전 성공 배포로 되돌립니다.
 

설계 결정: 이미지 태그를 뺀 ECS 설정만 Git으로 관리한다

최소한의 시간으로 ECS 파이프라인을 만들려고 정한 내용은 3가지입니다.

  1. ECS 설정 중 ECR 이미지 태그를 뺀 나머지는 Git으로 관리합니다. ECR 이미지 태그는 파이프라인을 실행할 때 사용자가 입력합니다.
  2. 배포는 ECS API로 하고, 우선 쉘 스크립트로 관리합니다. 쉘 스크립트는 JSON을 읽어 ECS API로 배포합니다. 최초 task definition과 service 생성만 Terraform으로 하고, 생성한 뒤에는 JSON으로 추출해 Git에서 관리합니다.
  3. 파이프라인을 운영하면서 개선이 꼭 필요한 부분이 생기면 그때 개선합니다.

 
이미지 태그까지 Git에 두면 새 이미지를 배포할 때마다 JSON을 고치는 커밋이 필요합니다. 수동 롤백이나 circuit breaker 자동 롤백 뒤에는 Git과 ECS를 다시 맞춰야 합니다. 
 

파이프라인은 ECS 배포 컨트롤러에 배포할 내용만 넘긴다

ECS 파이프라인의 핵심은 ECS 배포 컨트롤러입니다. 파이프라인은 ECS 배포 컨트롤러에 배포할 내용을 넘기고, 어떤 배포 전략을 쓸지 정하면 됩니다.
 

최초 생성만 Terraform으로 하고 이후는 JSON으로 관리한다

ECS에 배포할 내용은 Git에 JSON으로 관리합니다. 최초 배포는 Terraform으로 하고, Terraform이 만든 task definition을 JSON으로 추출했습니다. ECR 이미지 태그는 JSON에 두지 않고 뒤에서 설명할 파이프라인 입력으로 받습니다.
 
최초 생성에 Terraform을 쓴 이유는 JSON을 직접 쓰는 것보다 만들기 쉽기 때문입니다. terraform plan으로 생성 전에 오류를 확인할 수 있어서 최초 생성은 Terraform이 더 빠르다고 판단했습니다. Terraform은 최초 생성 뒤 쓰지 않으므로, service가 가리키는 task definition을 Terraform이 되돌리지 않게 ignore_changes를 설정했습니다.

variable "region" {
  type    = string
  default = "ap-northeast-2"
}

variable "service_name" {
  type    = string
  default = "api"
}

variable "task_cpu" {
  type    = number
  default = 512
}

variable "task_memory" {
  type    = number
  default = 1024
}

variable "container_port" {
  type    = number
  default = 8080
}

variable "desired_count" {
  type    = number
  default = 2
}

variable "bootstrap_image" {
  type        = string
  description = "최초 생성에만 쓰는 이미지. 이후 이미지는 파이프라인이 바꾼다"
}

variable "execution_role_arn" {
  type = string
}

variable "subnet_ids" {
  type = list(string)
}

variable "security_group_ids" {
  type = list(string)
}

provider "aws" {
  region = var.region
}

resource "aws_ecs_cluster" "workload" {
  name = "workload"
}

resource "aws_ecs_task_definition" "api_bootstrap" {
  family                   = var.service_name
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.task_cpu
  memory                   = var.task_memory
  execution_role_arn       = var.execution_role_arn

  container_definitions = jsonencode([
    {
      name         = var.service_name
      image        = var.bootstrap_image
      essential    = true
      portMappings = [{ containerPort = var.container_port }]
    }
  ])
}

resource "aws_ecs_service" "api" {
  name            = var.service_name
  cluster         = aws_ecs_cluster.workload.id
  task_definition = aws_ecs_task_definition.api_bootstrap.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = var.subnet_ids
    security_groups = var.security_group_ids
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  lifecycle {
    ignore_changes = [task_definition]
  }
}

 
JSON은 AWS CLI로 추출합니다. service가 지금 쓰는 task definition을 조회한 뒤, AWS가 등록할 때 붙이는 읽기 전용 필드를 지웁니다. 이 필드가 남아 있으면 register-task-definition이 실패합니다. 이미지는 파이프라인 입력으로 채울 자리표시자 __IMAGE__로 바꿉니다. 컨테이너가 하나라고 가정한 스크립트입니다.

#!/usr/bin/env bash
set -euo pipefail
# 사용: ./export-taskdef.sh dev workload api
ENV="$1"
CLUSTER="$2"
SERVICE="$3"

TASKDEF_ARN=$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" \
  --query 'services[0].taskDefinition' --output text)

aws ecs describe-task-definition --task-definition "$TASKDEF_ARN" \
  | jq '.taskDefinition
    | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities,
          .registeredAt, .registeredBy, .deregisteredAt)
    | .containerDefinitions[0].image = "__IMAGE__"
    | del(.. | select(. == [] or . == {}))' \
  > "deploy/taskdef.${ENV}.json"

 

쉘 스크립트는 revision을 등록하고 service를 갱신한다

쉘 스크립트는 AWS CLI로 ECS API를 호출해 ECS 배포 컨트롤러에 배포를 요청합니다. kubernetes에서 kubectl이 API server로 manifest를 보내는 것과 같은 역할입니다.
 
쉘 스크립트의 로직을 이해하려면 ECS 리소스를 이해해야하는데 kubernetes 리소스와 비교하면 이해하기 쉽습니다. ECS는 manifest를 task definition으로 관리합니다. task와 task definition은 다른 리소스이므로 구분해서 부릅니다. task definition은 한 번 등록하면 수정할 수 없고, 내용을 바꿔 다시 등록하면 같은 family에 새 revision 번호가 붙습니다. 등록한 revision을 service에 지정해야 배포가 시작됩니다.

task definitionDeployment의 Pod template이미지, 환경변수, CPU, 메모리 정의
task definition revisionDeployment revision(ReplicaSet)등록할 때마다 번호가 1씩 늘고, 등록한 revision은 수정할 수 없음
taskPod실행 중인 컨테이너 묶음
serviceDeployment태스크 개수를 유지하고 새 revision으로 교체

 
쉘 스크립트는 task definition을 등록할 때 Git의 JSON과 사용자가 넘긴 ECR 이미지 태그를 합칩니다. 

 
쉘 스크립트의 단계는 6개입니다.

  1. 입력 확인: IMAGE_TAG가 비어 있거나 ECR에 없으면 바로 실패합니다.
  2. JSON 렌더링: taskdef.${ENV}.json의 __IMAGE__를 이미지 digest로 바꿉니다.
  3. revision 등록: register-task-definition으로 새 revision을 만들고 app_version 태그를 붙입니다.
  4. service 갱신: update-service로 service가 새 revision을 가리키게 합니다. 태스크 교체는 ECS 배포 컨트롤러가 합니다.
  5. 안정화 대기: wait services-stable로 rolling update가 끝날 때까지 기다립니다.
  6. 결과 확인: circuit breaker가 롤백해도 안정화 대기는 성공합니다. 그래서 PRIMARY 배포가 방금 등록한 revision인지 확인합니다.
#!/usr/bin/env bash
set -euo pipefail

: "${ENV:?ENV를 입력하세요. 예: dev}"
: "${CLUSTER:?CLUSTER를 입력하세요}"
: "${SERVICE:?SERVICE를 입력하세요}"
: "${REPO:?REPO를 입력하세요. ECR 리포지토리 이름}"
: "${IMAGE_TAG:?IMAGE_TAG를 입력하세요. 예: v1.4.0}"

TASKDEF_FILE="deploy/taskdef.${ENV}.json"

# 1. 입력 확인
DIGEST=$(aws ecr describe-images --repository-name "$REPO" --image-ids imageTag="$IMAGE_TAG" \
  --query 'imageDetails[0].imageDigest' --output text) \
  || { echo "ECR에 ${REPO}:${IMAGE_TAG} 이미지가 없습니다"; exit 1; }
REPO_URI=$(aws ecr describe-repositories --repository-names "$REPO" \
  --query 'repositories[0].repositoryUri' --output text)

# 2. JSON 렌더링
jq --arg img "${REPO_URI}@${DIGEST}" '.containerDefinitions[0].image = $img' \
  "$TASKDEF_FILE" > taskdef.rendered.json

# 3. revision 등록
TASKDEF_ARN=$(aws ecs register-task-definition --cli-input-json file://taskdef.rendered.json \
  --tags key=app_version,value="$IMAGE_TAG" \
  --query 'taskDefinition.taskDefinitionArn' --output text)

# 4. service 갱신
aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" \
  --task-definition "$TASKDEF_ARN" > /dev/null

# 5. 안정화 대기
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE"

# 6. 결과 확인
read -r PRIMARY STATE <<< "$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" \
  --query "services[0].deployments[?status=='PRIMARY'] | [0].[taskDefinition,rolloutState]" \
  --output text)"

if [ "$PRIMARY" != "$TASKDEF_ARN" ] || [ "$STATE" != "COMPLETED" ]; then
  echo "배포 실패: PRIMARY=${PRIMARY}, rolloutState=${STATE}"
  exit 1
fi
echo "배포 완료: ${SERVICE} ${IMAGE_TAG} (${TASKDEF_ARN})"

 

CodeBuild가 쉘 스크립트를 실행하고 이미지 태그는 환경변수로 받는다

쉘 스크립트를 실행하는 방법은 여러 가지지만 CodeBuild를 사용했습니다. 쉘 스크립트의 목적이 배포이고, 이 목적에 맞는 AWS 리소스가 CodeBuild라고 판단했습니다.
 
CodeBuild에 값을 넘기는 기본 방법은 환경변수입니다. 환경변수 값은 세 곳에서 정할 수 있습니다.

  • 프로젝트 설정이나 buildspec의 env.variables: 서비스 이름처럼 고정된 값
  • 빌드를 시작할 때 덮어쓰기(start-build --environment-variables-override): 실행할 때마다 바뀌는 값
  • buildspec의 env.parameter-store, env.secrets-manager: SSM Parameter Store, Secrets Manager에 저장한 값

 
이 중 IMAGE_TAG는 실행할 때마다 바뀌는 필수 입력이라 빌드를 시작할 때 덮어쓰는 방식으로 넘깁니다. 쉘 스크립트는 이 환경변수를 ECR 이미지 태그로 사용합니다. 환경변수와 별개로 --source-version으로 배포할 Git 브랜치나 커밋을 지정할 수도 있습니다. 아래 그림은 Codebuild가 실행될 때 실제 받은 환경변수 예입니다.

 
CodeBuild는 AWS CLI로 ECS API를 호출합니다. 호출에 필요한 권한은 CodeBuild 서비스 role에 줍니다. 이 쉘 스크립트에는 ecr:DescribeImages, ecr:DescribeRepositories, ecs:RegisterTaskDefinition, ecs:TagResource, ecs:UpdateService, ecs:DescribeServices 권한과, task definition에 지정한 role을 넘기기 위한 iam:PassRole 권한이 필요합니다.

 

서비스가 늘면 CodeBuild는 공통으로 두고 CodePipeline을 서비스마다 생성

ECS 서비스가 2개 이상이면 CodeBuild 프로젝트 하나를 공통으로 쓸 수 있습니다. 쉘 스크립트가 서비스 이름 같은 값을 환경변수로 받으므로, 서비스마다 다른 값만 넘기면 됩니다.
 
실행할 때마다 Codebuild 환경변수를 직접 입력하기가 번거로우면, 서비스마다 CodePipeline을 만들어 공통 CodeBuild를 실행합니다. 서비스 이름처럼 고정된 값은 CodePipeline의 CodeBuild action 환경변수에 두고, 이미지 태그는 CodePipeline 실행 변수로 받아 넘깁니다. Jenkins 같은 외부 도구로 CodeBuild를 실행해도 됩니다. 여러 서비스를 동시에 배포하면 CodeBuild 동시 빌드 한도를 확인합니다.

 

ECR 이미지 빌드와 push는 소스 저장소에 맞춘다

ECR 이미지 빌드와 push는 소스 저장소에 맞춰 구성합니다. GitHub이면 GitHub Actions, GitLab이면 GitLab Runner를 쓰고, CodeBuild로 구성해도 됩니다.
 

실습

실습은 저의 github Pull request로 대체합니다.
- https://github.com/choisungwook/portfolio/pull/1341
 

파이프라인 개선

파이프라인을 개선한다면, ECS모든 것을 git기준으로 배포하도록 수정할 수 있습니다. git기준으로 하려면 아래 문제가 해결되야 합니다.
- ECS 배포 컨트롤러가 롤백이 일어나면, git에 내용도 롤백이 반영하는 프로세스가 존재해야함

반응형