AWS 기술 블로그

팀프레시, Amazon ECS Anywhere 환경에서 Amazon EventBridge와 AWS Lambda를 활용한 배포 프로세스 구성하기

팀프레시는 ‘초연결을 통해 만드는 풍요로운 세상’을 미션으로 물류, 유통, F&B, 플랫폼 사업들을 운영하고 있으며, 최종 소비자에게 판매되기까지 모든 과정을 구축해 팀프레시만의 독창적인 생태계를 만들어 가고 있습니다. 그동안 연 평균 약 200%의 폭발적인 성장속에서 팀프레시는 VISION 2027 ‘그로서리(식료품 및 잡화, grocery) 시장의 독점적 지위 확보’라는 비전을 위해 달려가려 합니다.

팀프레시의 서비스들은 대부분 Amazon Elastic Container Service (ECS) Anywhere 환경에서 서비스 중입니다. 기존에는 Amazon EC2AWS Elastic Beanstalk 환경에서 컨테이너 환경이 아닌 환경으로 서비스를 운영했습니다. 클라우드의 리소스들을 사용중이었지만, 유휴 온프레미스 컴퓨팅 리소스들도 존재 했습니다. 유휴 온프레미스의 리소스를 활용하고자, AWS Direct Connect (DX)로 AWS 클라우드와 온프레미스를 연결하였고, 하이브리드의 형태로 사용함으로써, 추후 완전하게 클라우드로의 마이그레이션을 용이하게 하고자 ECS Anywhere를 선택하게 되었습니다.

Amazon ECS Anywhere

Amazon ECS를 이용하여 사용자는 몇 가지의 간단한 콘솔 작업만으로, 컨테이너 환경을 손쉽게 구성하고 제공되는 리소스를 활용할 수 있습니다. 그러나 Amazon ECS Anywhere의 사용 시에는 여러 중요한 고려 사항들이 있습니다. 이러한 고려 사항들 중에, 주요한 몇 가지는 다음과 같습니다.

  • 서비스 로드 밸런싱은 제공되지 않습니다. 이는 트래픽 분산 및 관리가 필요한 경우에는, 직접 추가적인 방안을 검토해야 함을 의미합니다.
  • 서비스 검색 기능도 지원되지 않습니다. 이는 서비스 간 통신이나 탐색을 위한 대안적 방법을 구현해야 함을 의미합니다.
  • 외부 인스턴스에서 실행되는 태스크는 bridge, host, 또는 none 네트워크 모드를 사용해야 합니다. awsvpc 네트워크 모드는 지원되지 않아, 네트워킹 설정에 있어 제한이 따릅니다.
  • 각 AWS 리전마다 Amazon ECS 서비스 도메인이 존재합니다. 외부 인스턴스로의 트래픽 전송을 가능하게 하려면, 이 서비스 도메인에 대한 접근을 허용하도록 설정해야 합니다.
  • 온프레미스 네트워크와 AWS 클라우드 간의 연결을 위해서는, AWS Direct Connect 연결이 필요합니다. 이는 안정적이고 빠른 네트워크 연결을 위한 핵심 요소입니다.

기존 배포 프로세스

Amazon ECS Anywhere를 사용할 때 애플리케이션으로 인바운드 트래픽 처리가 필요한 상황에서, Elastic Load Balancing (ELB)이 지원되지 않는 것은 중요한 고려 사항입니다. 이 문제를 해결하기 위해, 컨테이너가 프로비저닝될 때 Application Load Balancer (ALB)의 타겟 그룹에 동적으로 등록이 되는 방식으로 접근을 했습니다. 이를 위해 컨테이너 내부에 docker-entrypoint.sh 스크립트를 배치하여, 컨테이너 시작 시 ALB 타겟 그룹에 자동으로 타겟을 등록하고, 필요한 경우 제거할 수 있도록 구성했습니다.

아래는 자세한 기존의 스크립트입니다.

단계 1: ALB의 타겟 그룹에 타겟을 등록

#!/bin/bash
register_targets() { 
    aws elbv2 register-targets \
    --target-group-arn $TG_ARN \
    --targets Id=$TG_IP,Port=$SVC_PORT,AvailabilityZone=$1
}
if [ -e /opt/ecs/common/host_ip ]; then
    TG_IP=`cat /opt/ecs/common/host_ip`
fi
if [ -n "$ECS_CONTAINER_METADATA_URI_V4" ] && [ -n "$TG_ARN" ] && [ -n "$TG_IP" ]; then
    SVC_PORT=`(curl -s $ECS_CONTAINER_METADATA_URI_V4 | jq .Ports[0].HostPort)`
    
    if [ ! -e /opt/ec2/az ]; then
        echo "File does not exist. Applying all zones."
        register_targets "all"
    elif [ ! -s /opt/ec2/az ]; then
        echo "File exists but is empty. Applying all zones."
        register_targets "all"
    else
        AZ_VALUE=`cat /opt/ec2/az`
        echo "File exists and has value. Applying given zone: $AZ_VALUE."
        register_targets $AZ_VALUE
    fi
fi

단계 2: ALB의 타겟 그룹에 타겟을 제거

#!/bin/bash
deregister_targets() {
    aws elbv2 deregister-targets \
    --target-group-arn $TG_ARN \
    --targets Id=$TG_IP,Port=$SVC_PORT,AvailabilityZone=$1
    sleep 30
}
if [ -e /opt/ecs/common/host_ip ]; then
    TG_IP=`cat /opt/ecs/common/host_ip`
fi
if [ -n "$ECS_CONTAINER_METADATA_URI_V4" ] && [ -n "$TG_ARN" ] && [ -n "$TG_IP" ]; then
    SVC_PORT=`(curl -s $ECS_CONTAINER_METADATA_URI_V4 | jq .Ports[0].HostPort)`

    if [ ! -e /opt/ec2/az ]; then
        echo "File does not exist. Applying all zones."
        deregister_targets "all"
    elif [ ! -s /opt/ec2/az ]; then
        echo "File exists but is empty. Applying all zones."
        deregister_targets "all"
    else
        AZ_VALUE=`cat /opt/ec2/az`
        echo "File exists and has value. Applying given zone: $AZ_VALUE."
        deregister_targets $AZ_VALUE
    fi
fi

이 방법은 타겟을 수동으로 관리해야 하며, 모든 서비스에 필요한 스크립트 파일을 추가해야 하는등, 번거로움과 유연성 부족이라는 문제점을 내포하고 있습니다. 또한, 타겟을 제거하는 과정에서 30초의 지연(sleep)을 설정해 두었음에도 불구하고, 타겟이 완벽하게 제거되지 않고, 여러 타겟이 동시에 남아 있는 경우가 발생했습니다.

새로운 배포 프로세스

기존 프로세스는 컨테이너 내부에서 호스트의 IP를 가져오기 위한 방법으로 /opt/ecs/common/host_ip 파일을 참조하는 접근 방식을 사용했습니다. 이는 ALB 타겟 그룹에 대상을 추가하기 위해 필수적인 호스트의 IP 정보를 제공하기 위함입니다. 하지만 $ECS_CONTAINER_METADATA_URI_V4를 사용하더라도 컨테이너에서 직접 호스트의 IP를 가져올 수 없기 때문에, 온 프레미스 환경에서 호스트의 IP 정보를 파일에 설정하고, 이를 컨테이너가 마운트하여 사용할 수 있도록 한 것입니다. 이 방식은 컨테이너가 실행되는 환경에 따라 호스트의 IP 정보를 유연하게 제공할 수 있도록 하지만, 다음과 같은 단점이 있습니다.

  1. 확장성 문제: 새로운 클러스터나 컴퓨팅 자원을 추가할 때마다 동일한 설정 작업을 반복해야 합니다. 이는 관리 부담을 증가시키고, 확장 과정에서의 실수 가능성을 높일 수 있습니다.
  2. 환경 의존성: 컨테이너 내부 환경이나 호스트 환경에 변화가 있을 경우, 스크립트를 수정해야 할 필요가 생깁니다. 이는 유지 보수의 복잡성을 증가시킵니다.

Amazon ECS Anywhere의 경우, ECS 서비스의 생명주기 이벤트는 주로 PENDING, RUNNING, STOPPED로 제한됩니다. 이는 AWS 클라우드 환경 내에서 실행되는 일반 ECS 태스크와 비교했을 때, 일부 이벤트 정보가 제한적일 수 있음을 의미합니다.

이러한 문제를 해결하기 위해 고민한 결과, 팀프레시 팀은 Amazon EventBridgeAWS Lambda를 활용하는 방식을 새롭게 검토했습니다. EventBridge에서 특정 이벤트(예: 컨테이너 시작 또는 종료)를 감지하고, 해당 이벤트에 대응하여 Lambda 함수를 트리거합니다. Lambda 함수는 이후 ALB의 타겟 그룹에 타겟을 등록하거나 제거하는 작업을 자동으로 수행합니다.

단계 1: ALB의 타겟 그룹에 타겟을 등록

이 과정에서 Amazon EventBridge를 활용해 Amazon ECS 태스크의 상태 변화를 감지하고, 이를 기반으로 ALB의 타겟 그룹에 태스크를 동적으로 등록하는 자동화 프로세스를 구현하는 방식입니다. 이 접근 방식은 ECS 태스크의 생명 주기를 관리하고 서비스의 가용성을 보장하는 데 중요한 역할을 합니다. 프로세스의 주요 단계는 다음과 같습니다.

  • EventBridge 이벤트 설정
    • sourceaws.ecs를 지정하여 ECS에서 발생하는 이벤트를 대상으로 설정합니다.
    • detail-typeECS Task State Change를 지정하여 태스크 상태 변화 이벤트를 감지합니다.
    • detail 객체 내에서 clusterArn, desiredStatus, lastStatus를 필터링 조건으로 설정하여, 원하는 상태(여기서는 RUNNING)의 태스크는 이벤트에서 태스크의 host, port정보를 확인할 수 있습니다.
{
  "source": ["aws.ecs"],
  "detail-type": ["ECS Task State Change"],
  "detail": {
    "clusterArn": ["<CLUSTER_ARN>"],
    "desiredStatus": ["RUNNING"],
    "lastStatus": ["RUNNING"]
  }
}
  • 이벤트 수신 및 파싱
    • EventBridge에서 수신한 이벤트는 JSON 형식으로 제공되며, 필요한 정보를 파싱하여 추출합니다. 여기서 중요한 정보는 clusterArn, taskArn, service_name, taskDefinitionArn입니다. 이 정보를 통해 해당 태스크와 관련된 세부 사항을 확인할 수 있습니다.
  • ALB 타겟 그룹에 태스크 등록
    • 파싱 된 데이터로 ALB의 타겟을 등록합니다.
    • 태스크의 networkBindings 정보에서 hostPort를 추출합니다. 이 포트 정보는 ALB 타겟 그룹에 태스크를 등록할 때 필요합니다. hostPort는 태스크가 호스트에서 리스닝하고 있는 포트 번호를 나타냅니다.
    • 추출한 정보를 사용하여 ALB의 타겟 그룹에 해당 태스크(정확히는 태스크가 실행되는 호스트의 IP와 호스트 포트)를 동적으로 등록합니다. 이를 통해 트래픽이 적절히 태스크로 라우팅될 수 있도록 합니다.
{
    "version": "0",
    "id": "<ID>",
    "detail-type": "ECS Task State Change",
    "source": "aws.ecs",
    "account": "<ACCOUNT_ID>",
    "time": "2024-02-23T05:31:35Z",
    "region": "<REGION>",
    "resources": [
        "<RESOURCES_ARN>"
    ],
    "detail": {
        "attachments": [],
        "attributes": [
            {
                "name": "ecs.cpu-architecture",
                "value": "x86_64"
            }
        ],
        "clusterArn": "<CLUSTER_ARN>",
        "connectivity": "CONNECTED",
        "connectivityAt": "2024-02-23T05:31:33.459Z",
        "containerInstanceArn": "<CONTAINER_INSTANCE_ARN>",
        "containers": [
            {
                "containerArn": "<CONTAINER_ARN>",
                "lastStatus": "RUNNING",
                "name": "<CONTAINER_NAME>",
                "image": "<IMAGE>",
                "imageDigest": "<IMAGE_DIGEST>",
                "runtimeId": "<RUNTIME_ID>",
                "networkBindings": [
                    {
                        "bindIP": "0.0.0.0",
                        "containerPort": 8080,
                        "hostPort": 35500,
                        "protocol": "tcp"
                    }
                ],
                "taskArn": "<TASK_ARN>",
                "networkInterfaces": [],
                "cpu": "0",
                "memory": "2048"
            }
        ],
        "cpu": "0",
        "createdAt": "2024-02-23T05:31:33.459Z",
        "desiredStatus": "RUNNING",
        "enableExecuteCommand": false,
        "group": "service:sa-test",
        "launchType": "EXTERNAL",
        "lastStatus": "RUNNING",
        "memory": "2048",
        "overrides": {
            "containerOverrides": [
                {
                    "name": "<CONTAINER_NAME>"
                }
            ]
        },
        "pullStartedAt": "2024-02-23T05:31:35.939Z",
        "pullStoppedAt": "2024-02-23T05:31:36.121Z",
        "startedAt": "2024-02-23T05:31:35.753Z",
        "startedBy": "ecs-svc/9731124466056426752",
        "taskArn": "<TASK_ARN>",
        "taskDefinitionArn": "<TASK_DEFINITION_ARN>",
        "updatedAt": "2024-02-23T05:31:35.753Z",
        "version": 2
    }
}

Lambda 함수는 Amazon ECS에서 실행 중인 태스크의 상태 변화 이벤트를 Amazon EventBridge를 통해 받아 처리하고, 해당 태스크를 ALB의 타겟 그룹에 등록하는 자동화된 프로세스를 구현합니다. 이 과정에서 필요한 AWS 서비스에 대한 권한을 Lambda 함수에 부여해야 합니다. 아래는 각 권한의 설명과 함께 Lambda 함수 코드의 주요 부분에 대한 설명입니다.

(1) Lambda 함수에 필요한 권한

  • ecs:DescribeTasks: ECS 클러스터 내의 작업에 대한 정보를 조회합니다.
  • ecs:DescribeContainerInstances: ECS 클러스터 내의 컨테이너 인스턴스 정보를 조회합니다.
  • ssm:DescribeInstanceInformation: Systems Manager를 통해 EC2 인스턴스 또는 온프레미스 인스턴스에 대한 정보를 조회합니다.
  • elasticloadbalancing:RegisterTargets: ELBv2의 타겟 그룹에 대상을 등록합니다.
  • ecs:DescribeTaskDefinition: ECS 작업 정의에 대한 정보를 조회합니다.

(2) Lambda 함수 구현

import json
import boto3

def lambda_handler(event, context):
    
    events = event["detail"]
    print(json.dumps(event))

    cluster_arn = events["clusterArn"]
    task_arn = events["taskArn"]
    service_name = events["group"].split("service:")[1]
    task_definition_arn = events["taskDefinitionArn"]
    host_port = events["containers"][0]["networkBindings"][0]["hostPort"]
    cluster_name = cluster_arn.split(':')[-1].split('/')[-1]

    ecs_client = boto3.client('ecs')
    elbv2_client = boto3.client('elbv2')
    ssm_client = boto3.client('ssm')
    
    container_instance_arn = get_container_instance_arn(ecs_client, cluster_name, task_arn)
    tg_arn = get_tg_arn(ecs_client, task_definition_arn)
    instance_id = get_instance_id(ecs_client, cluster_name, container_instance_arn)
    instance_ip = get_instance_ip(ssm_client, instance_id)
    alb_call = alb_register_targets(elbv2_client, tg_arn, instance_ip, host_port)

def get_container_instance_arn(ecs_client, cluster_name, task_arn):
    task_response = ecs_client.describe_tasks(cluster=cluster_name, tasks=[task_arn])
    return task_response['tasks'][0]['containerInstanceArn']

def get_instance_id(ecs_client, cluster_name, container_instance_arn):
    container_instance_response = ecs_client.describe_container_instances(
        cluster=cluster_name, 
        containerInstances=[container_instance_arn]
    )
    return container_instance_response['containerInstances'][0]['ec2InstanceId']

def get_instance_ip(ssm_client, instance_id):
    instance_info_response = ssm_client.describe_instance_information(
        Filters=[{
            'Key': 'InstanceIds',
            'Values': [instance_id]
        }]
    )
    return instance_info_response['InstanceInformationList'][0]['IPAddress']

def alb_register_targets(elbv2_client, tg_arn, instance_ip, host_port):
    register_targets_response = elbv2_client.register_targets(
        TargetGroupArn=tg_arn,
        Targets=[
            {
                'Id': instance_ip,
                'Port': host_port,
                'AvailabilityZone': 'all'
            }
        ]
    )

    return register_targets_response

def get_tg_arn(ecs_client, task_definition_arn):
    task_definition = ecs_client.describe_task_definition(taskDefinition=task_definition_arn)
    env_variables = task_definition['taskDefinition']['containerDefinitions'][0]['environment']
    tg_arn_value = next((item['value'] for item in env_variables if item['name'] == 'TG_ARN'), None)
    return tg_arn_value

get_container_instance_arn

  • 작업 설명: 함수는 제공된 ecs_client 객체의 describe_tasks 메서드를 호출합니다. 이 메서드는 지정된 ECS 클러스터 내의 지정된 작업에 대한 정보를 검색합니다.
    • 이 메서드는 두 개의 인수를 사용합니다.
      • cluster: ECS 클러스터 이름입니다.
      • tasks: 작업 ARN 목록입니다. 이 경우에는 제공된 task_arn만 포함합니다.
  • 컨테이너 인스턴스 ARN 추출: 함수는 describe_tasks 메서드의 응답에서 컨테이너 인스턴스 ARN을 추출합니다.
    • task_response 딕셔너리 내 tasks 목록의 첫 번째 요소 ([0])에 액세스합니다.
    • 이 첫 번째 요소에서 작업을 실행하는 컨테이너 인스턴스의 ARN을 보유하는 키 containerInstanceArn의 값을 검색합니다.

get_instance_id

  • 컨테이너 인스턴스 설명: 함수는 제공된 ecs_client 객체의 describe_container_instances 메서드를 호출합니다. 이 메서드는 지정된 ECS 클러스터 내의 여러 컨테이너 인스턴스에 대한 정보를 검색합니다.
    • 이 메서드는 두 개의 인수를 사용합니다.
      • cluster: ECS 클러스터 이름입니다.
      • containerInstances: 컨테이너 인스턴스 ARN 목록입니다. 이 경우에는 제공된 container_instance_arn만 포함합니다.
  • EC2 인스턴스 ID 추출: 함수는 describe_container_instances 메서드의 응답에서 EC2 인스턴스 ID를 추출합니다.
    • container_instance_response 딕셔너리 내 containerInstances 목록의 첫 번째 요소 ([0])에 액세스합니다.
    • 이 첫 번째 요소에서 컨테이너 인스턴스를 실행하는 EC2 인스턴스의 ID를 나타내는 키 ec2InstanceId의 값을 검색합니다.

get_instance_ip

  • 인스턴스 정보 설명: 함수는 제공된 ssm_client 객체의 describe_instance_information 메서드를 호출합니다. 이 메서드는 지정된 필터 조건에 따라 인스턴스에 대한 정보를 검색합니다. (EC2가 아닌 외부 인스턴스를 클러스터에 등록할 때 ECS agent 뿐만 아닌 SSM agent와 같이 설치됩니다.)
    • 이 메서드는 필수 인수 하나 (InstanceInformationFilterList) 외에 여러 선택적 인수를 사용합니다. 이 함수에서는 필수 인수로 다음을 사용합니다.
      • InstanceInformationFilterList: 필터 조건 목록입니다. 이 목록은 다음과 같은 단일 필터 조건으로 구성됩니다.
        • Key: ‘InstanceIds’ – 필터링할 EC2 인스턴스의 ID를 지정하는 키입니다.
        • Values: [instance_id] – 필터링 조건으로 사용할 인스턴스 ID 목록입니다. 이 경우에는 제공된 instance_id만 포함합니다.
  • 공개 IP 주소 추출: 함수는 describe_instance_information 메서드의 응답에서 공개 IP 주소를 추출합니다.
    • instance_info_response 딕셔너리 내 InstanceInformationList 목록의 첫 번째 요소 ([0])에 액세스합니다.
    • 이 첫 번째 요소에서 인스턴스의 공개 IP 주소를 나타내는 키 IPAddress의 값을 검색합니다.

alb_register_targets

  • 타겟 등록: 함수는 제공된 elbv2_client 객체의 register_targets 메서드를 호출하여 타겟을 등록합니다.
    • 이 메서드는 두 개의 필수 인수와 몇 가지 선택적 인수를 사용합니다. 이 함수에서는 필수 인수로 다음을 사용합니다.
      • TargetGroupArn: 등록할 타겟 그룹의 ARN입니다.
      • Targets: 등록할 타겟 목록입니다. 이 목록은 다음과 같은 단일 요소로 구성됩니다.
        • Id: 타겟을 식별하는 고유한 식별자입니다. 이 함수에서는 타겟의 IP 주소를 사용합니다.
        • Port: 타겟의 포트 번호입니다.
        • AvailabilityZone: (선택적) 타겟의 가용성 영역입니다. 이 함수에서는 모든 가용성 영역 (all)을 지정합니다.
  • 응답 처리: register_targets 메서드는 ELBv2의 응답을 반환하며, 함수는 이 응답을 그대로 반환합니다. 응답은 타겟 등록 성공 여부와 같은 정보를 포함합니다.

get_tg_arn

  • 작업 정의 설명: 함수는 제공된 ecs_client 객체의 describe_task_definition 메서드를 호출하여 지정된 ARN에 해당하는 작업 정의에 대한 정보를 검색합니다.
  • 환경 변수 추출: 검색된 작업 정의 정보에서 첫 번째 컨테이너 정의(containerDefinitions[0])의 환경 변수 목록 (environment)을 추출합니다.
  • TG_ARN 찾기: 추출된 환경 변수 목록을 반복하며 다음을 수행합니다. (여기서 TG_ARN은 작업 정의에 미리 설정해 주어야 합니다.)
    • 현재 검사하는 환경 변수의 이름이 ‘TG_ARN’인지 확인합니다.
    • 이름이 일치하는 경우 해당 환경 변수의 값 (value)을 반환합니다.
    • 일치하는 이름이 없으면 None을 반환합니다.

단계 2: ALB의 타겟 그룹에 타겟을 제거

Lambda 함수는 Amazon ECS에서 실행 중인 태스크가 정상적으로 종료될 때 해당 태스크를 Application Load Balancer (ALB)의 타겟 그룹에서 제거하는 프로세스를 자동화합니다. Amazon EventBridge를 통해 태스크의 상태 변경 이벤트를 수신하고, 태스크가 "desiredStatus": "STOPPED", "lastStatus": "RUNNING" 상태로 전환될 때, 해당 태스크를 타겟 그룹에서 제거하는 작업을 수행합니다. 이 프로세스는 서비스의 가용성 관리와 리소스 최적화에 중요한 역할을 합니다.

{
  "source": ["aws.ecs"],
  "detail-type": ["ECS Task State Change"],
  "detail": {
    "clusterArn": ["<CLUSTER_ARN>"],
    "desiredStatus": ["STOPPED"],
    "lastStatus": ["RUNNING"]
  }
}

여기서 "desiredStatus": "STOPPED", "lastStatus": "RUNNING" 으로 필터링하는 이유는, 구버전의 태스크가 정상적으로 종료되었을 때 태스크의 host, port를 알 수 있기 때문입니다.

{
    "version": "0",
    "id": "<ID>",
    "detail-type": "ECS Task State Change",
    "source": "aws.ecs",
    "account": "<ACCOUNT_ID>",
    "time": "2024-02-23T05:31:35Z",
    "region": "<REGION>",
    "resources": [
        "<RESOURCES_ARN>"
    ],
    "detail": {
        "attachments": [],
        "attributes": [
            {
                "name": "ecs.cpu-architecture",
                "value": "x86_64"
            }
        ],
        "clusterArn": "<CLUSTER_ARN>",
        "connectivity": "CONNECTED",
        "connectivityAt": "2024-02-23T05:31:33.459Z",
        "containerInstanceArn": "<CONTAINER_INSTANCE_ARN>",
        "containers": [
            {
                "containerArn": "<CONTAINER_ARN>",
                "lastStatus": "RUNNING",
                "name": "<CONTAINER_NAME>",
                "image": "<IMAGE>",
                "imageDigest": "<IMAGE_DIGEST>",
                "runtimeId": "<RUNTIME_ID>",
                "networkBindings": [
                    {
                        "bindIP": "0.0.0.0",
                        "containerPort": 8080,
                        "hostPort": 35500,
                        "protocol": "tcp"
                    }
                ],
                "taskArn": "<TASK_ARN>",
                "networkInterfaces": [],
                "cpu": "0",
                "memory": "2048"
            }
        ],
        "cpu": "0",
        "createdAt": "2024-02-23T05:31:33.459Z",
        "desiredStatus": "["STOPPED"]",
        "enableExecuteCommand": false,
        "group": "service:sa-test",
        "launchType": "EXTERNAL",
        "lastStatus": "RUNNING",
        "memory": "2048",
        "overrides": {
            "containerOverrides": [
                {
                    "name": "<CONTAINER_NAME>"
                }
            ]
        },
        "pullStartedAt": "2024-02-23T05:31:35.939Z",
        "pullStoppedAt": "2024-02-23T05:31:36.121Z",
        "startedAt": "2024-02-23T05:31:35.753Z",
        "startedBy": "ecs-svc/9731124466056426752",
        "taskArn": "<TASK_ARN>",
        "taskDefinitionArn": "<TASK_DEFINITION_ARN>",
        "updatedAt": "2024-02-23T05:32:31.753Z",
        "version": 2
    }
}

(1) Lambda 함수에 필요한 권한

  • elasticloadbalancing:DeregisterTargets: 이 권한은 ALB의 타겟 그룹에서 타겟을 제거할 수 있게 해주며, 기존 Lambda 함수에서 사용된 권한에 추가되어야 합니다.

(2) Lambda 함수 구현

regist target 람다의 차이점은 타겟을 등록하는 함수가 타겟을 제거해 주는 함수로만 변경됩니다. 권한은 기존 람다 권한에서 elasticloadbalancing:DeregisterTargets 권한만 추가하면 됩니다.

import json
import boto3

def lambda_handler(event, context):

    events = event["detail"]
    print(json.dumps(event))
    
    host_port = events["containers"][0]["networkBindings"][0]["hostPort"]
    cluster_arn = events["clusterArn"]
    task_arn = events["taskArn"]
    task_definition_arn = events["taskDefinitionArn"]
    cluster_name = cluster_arn.split(':')[-1].split('/')[-1]

    ecs_client = boto3.client('ecs')
    elbv2_client = boto3.client('elbv2')
    ssm_client = boto3.client('ssm')
    
    container_instance_arn = get_container_instance_arn(ecs_client, cluster_name, task_arn)
    tg_arn = get_tg_arn(ecs_client, task_definition_arn)
    instance_ip = get_instance_ip(ecs_client, cluster_name, container_instance_arn)
    instance_ip = get_instance_ip(ssm_client, instance_ip)
    alb_deregister = alb_deregister_targets(elbv2_client, tg_arn, instance_ip, host_port)
  
def get_container_instance_arn(ecs_client, cluster_name, task_arn):
    task_response = ecs_client.describe_tasks(cluster=cluster_name, tasks=[task_arn])
    return task_response['tasks'][0]['containerInstanceArn']

def get_instance_ip(ecs_client, cluster_name, container_instance_arn):
    container_instance_response = ecs_client.describe_container_instances(
        cluster=cluster_name, 
        containerInstances=[container_instance_arn]
    )
    return container_instance_response['containerInstances'][0]['ec2InstanceId']

def get_instance_ip(ssm_client, instance_ip):
    instance_info_response = ssm_client.describe_instance_information(
        Filters=[{
            'Key': 'InstanceIds',
            'Values': [instance_ip]
        }]
    )
    return instance_info_response['InstanceInformationList'][0]['IPAddress']

def alb_deregister_targets(elbv2_client, tg_arn, instance_ip, host_port):
    deregister_targets_response = elbv2_client.deregister_targets(
        TargetGroupArn=tg_arn,
        Targets=[
            {
                'Id': instance_ip,
                'Port': host_port,
                'AvailabilityZone': 'all'
            }
        ]
    )
    return deregister_targets_response

alb_deregister_targets

  • 타겟 등록 해제: 함수는 제공된 elbv2_client 객체의 deregister_targets 메서드를 호출하여 타겟을 등록 해제합니다.
    • 이 메서드는 두 개의 필수 인수와 몇 가지 선택적 인수를 사용합니다. 이 함수에서는 필수 인수로 다음을 사용합니다.
      • TargetGroupArn: 등록 해제할 타겟 그룹의 ARN입니다.
      • Targets: 등록 해제할 타겟 목록입니다. 이 목록은 다음과 같은 단일 요소로 구성됩니다.
        • Id: 타겟을 식별하는 고유한 식별자입니다. 이 함수에서는 타겟의 IP 주소를 사용합니다.
        • Port: 타겟의 포트 번호입니다.
        • AvailabilityZone: (선택적) 타겟의 가용성 영역입니다. 이 함수에서는 모든 가용성 영역 (all)을 지정합니다.
  • 응답 처리: deregister_targets 메서드는 ELBv2의 응답을 반환하며, 함수는 이 응답을 그대로 반환합니다. 응답은 타겟 등록 해제 성공 여부와 같은 정보를 포함합니다.

단계 3: Graceful shutdown 구현하기

위 2단계에서 구현한 Lambda 함수를 통해 구버전 태스크를 대상에서 제거를 한다면, graceful 하게 종료되지 못하고 오히려 task의 종료가 deregister_targets 보다 먼저 종료되게 됩니다. 따라서 시스템이 종료 신호(SIGTERM)를 받았을 때 즉각적으로 종료하는 것이 아니라, 일정 시간 동안 대기하면서 실행 중인 작업을 완료하고 리소스를 정리해야 합니다. 이 방법은 서비스의 가용성을 유지하면서 업데이트나 재배포 과정에서 발생할 수 있는 중단 시간을 최소화하는 데 필요합니다.

(1) docker-entrypoint.sh 스크립트 생성

docker-entrypoint.sh 스크립트는 다음 단계로 구성됩니다:

  1. SIGTERM 신호 처리: trap 명령어를 사용하여 SIGTERM 신호를 감지하고 gracefulshutdown 함수를 실행합니다. 이는 컨테이너가 종료 신호를 받았을 때 호출됩니다.
  2. gracefulshutdown 함수: 이 함수는 SIGTERM 신호를 받았을 때 실행됩니다. 함수 내에서는 먼저 현재 실행 중인 프로세스(애플리케이션)의 종료를 시작하고, sleep 100 명령으로 100초 동안 대기합니다. 이 대기 시간은 애플리케이션에 충분한 시간을 제공하여 현재 처리 중인 요청을 완료하고 필요한 정리 작업을 수행할 수 있도록 합니다. 대기 시간이 지난 후에는 kill -15 "$PID" 명령으로 실제 프로세스를 종료하고 컨테이너의 실행을 정상적으로 마칩니다.
  3. 스크립트는 exec "$@" & 명령으로 전달된 인수(예: 애플리케이션 실행 명령)를 백그라운드에서 실행하고, 실행된 프로세스의 PID를 $PID 변수에 저장합니다. wait $PID 명령은 백그라운드 프로세스의 종료를 기다립니다. 이 구조는 컨테이너가 애플리케이션 프로세스와 함께 실행되도록 하며, SIGTERM 신호에 의해 gracefulshutdown 함수가 호출되어 graceful 종료 절차를 시작합니다.
#!/bin/bash
function gracefulshutdown {
  echo "stop process :: "$PID
  sleep 100
  kill -15 "$PID" && echo "Shutting down!"
}
trap gracefulshutdown SIGTERM
echo "start process..."
exec "$@" &
PID="$!"
wait $PID

(2) 애플리케이션 Docker 파일 구성하기

  1. 스크립트 파일(docker-entrypoint.sh)을 컨테이너 내부로 복사합니다.
  2. 실행 권한을 부여합니다.
  3. ENTRYPOINT 지시어를 사용하여 스크립트를 컨테이너의 진입점으로 설정합니다.
COPY docker-entrypoint.sh /usr/local/bin/ 
RUN chmod +x /usr/local/bin/docker-entrypoint.sh 
ENTRYPOINT ["docker-entrypoint.sh", "application"]

마무리

팀프레시 팀은 ECS Anywhere와 Lambda를 활용하여 대규모 비용 절감을 실현한 사례는, 컨테이너 기반 서비스 운영에 있어 모범사례와 같은 접근 방법을 보여줍니다. 이러한 접근 방법은 클라우드 인프라 관리의 효율성과 비용 효율성을 극대화하는 동시에, 서비스의 안정성과 유연성을 유지할 수 있게 합니다.

주요 성과 및 시사점

  • 비용 절감: 월 $4,742.2에 달하는 비용을 람다 함수 사용 비용인 $1 미만으로 줄이면서, 대규모 인프라 비용 절감을 실현했습니다. 이는 클라우드 리소스의 효율적 사용과 자동화를 통해 달성한 결과입니다. 결과적으로 약 월 $4,700의 비용을 절약할 수 있었습니다.
  • 자동화 및 안정성: EventBridge와 Lambda를 활용한 자동화 프로세스는 ECS 태스크의 생명 주기 관리를 단순화하고, 서비스의 가용성과 안정성을 향상시켰습니다.
  • 정보 추출 및 처리: 컨테이너 인스턴스 정보와 타겟 그룹 ARN을 추출하고, SSM Agent를 이용해 EC2 인스턴스의 IP 주소를 확인했습니다.
  • Graceful Shutdown 구현: 컨테이너의 graceful shutdown 절차를 구현함으로써, 배포 중 발생할 수 있는 서비스 중단 시간을 최소화하고, 사용자 경험을 개선했습니다.

향후 계획 및 전략

  • 클라우드 전환 가속화: 완전한 클라우드 환경으로의 마이그레이션을 통해 더 높은 확장성, 유연성 및 비용 효율성을 계속 추구할 계획입니다.
  • 테스트 및 검증 강화: 더 다양한 사용 사례와 시나리오에 대한 테스트를 통해, 시스템의 안정성과 성능을 지속적으로 향상시킬 계획입니다.
  • 자동화된 배포 파이프라인 구축: CI/CD 접근 방식을 도입하여 개발 및 배포 프로세스의 효율성과 속도를 향상시킬 예정입니다.
  • 문서화 : 프로젝트와 관련된 문서를 지속적으로 업데이트할 계획입니다.

이러한 계획과 전략은 팀프레시 팀이 지속적으로 서비스 품질을 개선하고, 비즈니스 요구에 더욱 민첩하게 대응할 수 있는 기반을 마련해 줄 것입니다. 특히, 클라우드 기술과 자동화를 극대화하는 방향은, 향후 디지털 전환을 가속화하는 데 중요한 역할을 할 것으로 기대됩니다.

SungRyul Eom

SungRyul Eom

팀프레시의 솔루션 아키텍트 팀을 이끌고 있으며, 비즈니스 전략을 근간으로 하여 일관성있고 효율적이며 확장 가능한 아키텍처 설계를 늘 고민하고 있습니다.

MinHyeok Park

MinHyeok Park

팀프레시의 전반적인 클라우드 시스템들을 관리하고 있으며, 자동화와 시스템 아키텍처를 보다 안정적이고  효율적으로 관리하는데 관심이 많습니다.

YoungMin Kim

YoungMin Kim

팀프레시의 전반적인 DevOps 영역을 담당하고 있습니다. 서비스에 발생한 문제 해결 및 성능 개선과 개발자들의 생산성 향상을 위한 방법, 구조 설계에 관심이 많습니다.

Jungseob Shin

Jungseob Shin

신정섭 Solutions Architect는 다양한 분야의 Backend 서버 개발 경험을 바탕으로 Startup 고객이 비즈니스 목표를 달성하도록 AWS 서비스의 효율적인 사용을 위한 아키텍처 설계 가이드와 기술을 지원하는 역할을 수행하고 있습니다. 컨테이너와 서버리스 기술에 관심이 많습니다.