Canvas 1 Layer 1

아마존 엘라스틱 컨테이너 서비스(ECS) 입문
도커(Docker) 컨테이너 오케스트레이션

들어가며

도커Docker와 같은 컨테이너 가상화 도구를 처음 사용해보면 컨테이너의 매력과 배포의 어려움을 동시에 느끼게 됩니다. 따라서 필연적으로 컨테이너를 적절하게 배치하고 관리할 수 있게 도와주는 컨테이너 오케스트레이션 도구의 필요성을 느끼게됩니다. 도커에서 만든 스웜Swarm, 구글Google의 노하우가 담겨있는 쿠버네티스Kubernetes, 하시코프의 노마드Nomad, 아마존 웹 서비스Amazon Web Service에서 제공하는 엘라스틱 컨테이너 서비스Elastic Container Service 등 다양한 오케스트레이션 도구들이 있습니다.

모두 각자의 장단점을 가지고 있습니다. 이 중에서도 이 글에서 소개할 아마존 엘라스틱 컨테이너 서비스Amazon Elastic Container Service는 클라우드 서비스로 제공되고 있으며 오케스트레이션 시스템 구축을 위한 특별한 준비 없이 사용할 수 있다는 장점이 있습니다. 단, 도커의 모든 기능을 사용하기에는 약간의 제약이 있으며, 세세한 컨트롤이 어렵습니다. 이러한 단점은 요즘 인기를 얻고 있는 쿠버네티스와 자주 비교되곤 합니다. 하지만 ECS도 2015년 릴리즈 이후 서비스 스케일링, 태스크 기반 IAM 지원, 애플리케이션 로드 밸런서 지원, 서비스 디스커버리, AWS 배치AWS Batch 릴리즈, 파게이트Fargate 지원 등 꾸준히 발전하면서 활용성은 상당히 높은 편입니다.

이 글에서는 ECS의 기본 개념들을 소개하고 간단한 nginx 서버를 배포해나가면서, 로드 밸런서와 ECS 서비스를 조합해서 사용하는 방법에 대해서 소개하도록 하겠습니다.

AWS 엘라스틱 컨테이너 서비스 기초

엘라스틱 컨테이너 서비스는 아마존 웹서비스에서 제공하는 매니지드 컨테이너 오케스트레이션 서비스입니다. 이를 사용하기에 앞서 핵심 개념들에 대해서 알아보겠습니다.

도커Docker와 Dockerfile

엘리스틱 컨테이너 서비스라는 이름에서 알 수 있듯이 이 서비스는 컨테이너 가상화를 기반으로 하고 있습니다. 컨테이너 가상화 도구에는 여러가지가 있습니다만 그 중에서 도커를 지원하고 있습니다. 이 글은 도커를 소개하는 글이 아니므로 독자분들이 도커를 어느 정도 사용해봤고, 이미지 생성을 위한 Dockerfile 작성법도 숙지하고 있다고 가정합니다. 도커에 익숙하지 않다면 Subicura 님의 초보를 위한 도커 안내서이나 제가 오래 전에 작성했던 도커 튜토리얼 : 깐 김에 배포까지를 읽어보시기를 추천합니다.

클러스터cluster와 컨테이너 인스턴스cluster instance

클러스터와 클러스터 인스턴스
클러스터와 클러스터 인스턴스

ECS의 가장 기본적인 단위는 클러스터입니다. 클러스터는 도커 컨테이너를 실행할 수 있는 가상의 공간으로 이해할 수 있습니다. 클러스터는 프로젝트나 컨테이너의 성격에 따라서 나눠질 수 있습니다. 예를 들어 프로젝트 Zebra의 컨테이너들은 Zebra 클러스터에서만 실행하고, 프로젝트 Hippo의 컨테이너들은 Hippo 클러스터에서 실행하는 식입니다.

ECS 클러스터는 기본적으로 EC2와 같은 컴퓨팅 자원을 기본적으로 포함하고 있지 않은 논리적인 단위입니다. 따라서 컴퓨팅 자원이 없는 빈 클러스터를 만드는 것도 가능합니다. 그리고 EC2에 ecs-client라는 서비스를 실행해서 특정 클러스터에 연결할 수 있습니다. 이렇게 클러스터에 연결된 EC2 인스턴스를 컨테이너 인스턴스라고 부릅니다. ecs-client는 컨테이너 인스턴스의 자원을 모니터링 및 관리하고, 클러스터로 요청된 컨테이너들을 적절하게 실행하는 역할을 합니다.

태스크 디피니션task definition과 태스크task

태스크 디피니션과 태스크의 관계
태스크 디피니션과 태스크의 관계

ECS에서 컨테이너를 실행하는 최소 단위는 태스크입니다. 태스크는 컨테이너와는 조금 차이가 있습니다. 태스크는 하나 이상의 컨테이너로 구성됩니다. 일반적으로 하나의 필수 컨테이너만으로 구성됩니다. 하지만 필요에 따라 하나의 필수 컨테이너와 n개의 추가적인 컨테이너 조합이 될 수도 있습니다. 이 때 같은 태스크로 실행되는 컨테이너들은 모두 같은 컨테이너 인스턴스에서 실행되는 것이 보장됩니다.

태스크를 실행하려면 태스크 디피니션이 필요합니다. 태스크를 실행할 때 컨테이너 네트워크 모드, 태스크 역할Task Role, 도커 이미지, 실행 명령어, CPU 제한, 메모리 제한 등 다수의 설정이 필요합니다. 컨테이너 오케스트레이션에서는 컨테이너가 필요에 따라서 자동적으로 실행되거나 종료될 수 있습니다. 따라서 매번 이러한 설정들을 지정하기보다는, 미리 설정들의 집합을 하나의 단위로 정의해놓고 사용합니다. 이 단위가 바로 태스크 디피니션입니다. 한 번 태스크 디피니션을 만들면 이 태스크 디피니션을 기반으로 특정 설정을 변경할 수 있습니다. 이렇게 변경된 내용들은 모두 리비전으로 저장됩니다.

또한 태스크는 클러스터에 종속적이지만, 태스크 디피니션은 클러스터에 종속적이지 않다는 중요한 특징이 있습니다.

서비스service

클러스터에는 두 가지 방식으로 태스크를 실행할 수 있습니다. 먼저 첫 번째 방식은 태스크 디피니션으로 직접 태스크를 실행하는 방식입니다. 이 태스크는 곧바로 실행되며 실행이 된 이후에는 더 이상 관리되지 않습니다. 일회성 명령어라면 한 번 실행된 후 종료되며, 데몬 프로세스라면 태스크를 명시적으로 종료할 때까지 컨테이너가 남아있습니다. 직접 태스크를 실행하는 방법은 특별한 이유가 있을 때 외에는 거의 사용되지 않습니다.

두 번째 방법은 서비스를 정의하는 방법입니다. 서비스는 하나의 태스크 디피니션(리비전)과 연결됩니다. 서비스는 크게 리플리카 타입과 데몬 타입이 있습니다.* 데몬 타입으로 실행하는 경우 모든 컨테이너 인스턴스에 해당하는 태스크가 하나씩 실행됩니다. 이 타입은 인스턴스 관리를 위한 용도로 많이 사용됩니다. 리플리카 타입을 사용하면 실행하려는 태스크의 개수Number of tasks를 지정해야합니다. 서비스는 클러스터에서 이 개수만큼 태스크가 실행되도록 자동적으로 관리해줍니다. 리플리카 타입은 웹서버를 비롯한 실제 서비스에서 주로 사용됩니다.

* 데몬 타입은 2018년 6월에 추가되었습니다. Amazon ECS에 데몬 예약 전략 추가를 참고해주세요.

서비스는 클러스터 내에서 태스크를 스케줄링하는 역할을 담당합니다
서비스는 클러스터 내에서 태스크를 스케줄링하는 역할을 담당합니다

EC2와 같은 컴퓨팅 자원을 직접 사용해서 프로세스를 배치하는 경우 서버 운영자가 직접 어떤 인스턴스에 어떤 프로세스를 언제 배치할지 결정해야합니다. 오케스트레이션에서는 이러한 관리를 담당하는 스케줄러가 존재합니다. ECS에서는 서비스가 이러한 스케줄링을 담당하고 있습니다. ECS 서비스는 각 인스턴스들에 설치된 ecs-client에서 수집된 정보를 기반으로 어디에 어떤 태스크를 언제 실행할지 결정합니다. 따라서 리플리카 타입 서비스의 경우, 다수의 컨테이너 인스턴스가 있을 때 언제 어디에서 태스크가 실행될지 알 수 없습니다. 서비스가 직접 태스크 배치 스케줄링을 수행합니다.*

* 이에 대한 제어권이 완전히 박탈되어있는 것은 아닙니다. 이를 제어하는 몇 가지 배치 전략Task Placement이 존재합니다. 하지만 클러스터 오케스트레이션을 도입할 때는 클러스터에 있는 어떤 인스턴스에서 언제라도 태스크가 실행될 수 있다고 의식하고 클러스터를 구성 및 관리하는 것이 중요합니다.

엘라스틱 컨테이너 레지스트리Elastic Conatiner Registry

AWS ECS에서는 엘라스틱 컨테이너 레지스트리ECR, Elastic Container Registry라는 프라이빗 도커 레지스트리 서비스를 제공하고 있습니다. 도커 레지스트리는 도커 이미지를 저장 및 관리하는 서비스입니다. 일반적으로 도커에서 공식적으로 제공하는 도커 허브Docker Hub가 많이 이용됩니다. 이미지를 비공개적로 푸시하거나 풀하는 경우에는 도커 허브의 유료 플랜을 이용하거나 직접 도커 레지스트리 서비스를 운용해야합니다. ECR을 사용해서 프라이빗 도커 레지스트리를 대체할 수 있으며, IAM과 조합함으로서 세세한 권한 관리가 가능합니다.

주요 개념

지금까지 설명한 주요 개념들을 정리하면 다음과 같습니다.

Docker
컨테이너 가상화 도구.
Dockerfile
도커의 이미지 생성 과정을 정의한 DSL 형식으로 작성된 파일.
클러스터
ECS의 가장 기본적인 단위. 서비스나 태스크가 실행되는 공간을 나누는 논리적인 공간.
컨테이너 인스턴스
클러스터에서 속한 인스턴스. 클러스터에 서비스나 태스크 실행을 요청하면 클러스터에 속한 컨테이너 인스턴스 중 하나에서 실행된다.
서비스
태스크를 관리하는 단위. 내부적으로 태스크 실행을 위한 스케줄러를 가지고 있으면 서비스의 정의한 대로 태스크(들)이 실행되는 상태를 유지시키려고 한다.
엘라스틱 컨테이너 서비스
AWS에서 제공하는 프라이빗 도커 레지스트리(이미지 저장소).

ECS 튜토리얼

이 글에서는 세 번의 이터레이션을 통해서 ECS로 서비스를 배포해보겠습니다. 먼저 첫 번째 이터레이션에서는 기본적인 ECS 클러스터를 구성하고 nginx 도커 컨테이너를 ECS 서비스로 배포합니다. 두 번째 이터레이션에서는 로드 밸런서를 연결하고 다수의 태스크를 실행하는 방법에 대해서 알아봅니다. 마지막으로 세 번째 이터레이션에서는 동적 포트를 설정하고 태스크 디피니션과 서비스를 업데이트 해봅니다.

첫 번째 이터레이션: ECS에서 nginx 컨테이너 배포

여기서부터는 AWS 커맨드라인 인터페이스를 사용해 실제로 ECS를 사용하는 방법에 대해서 알아보겠습니다. 이 튜토리얼에서는 도쿄 리전(ap-northeast-1)을 사용합니다. AWS 커맨드라인 인터페이스 사용법에 대해서는 AWS 커맨드라인 인터페이스 기초아마존 웹 서비스 IAM 사용자의 액세스 키 발급 및 관리를 참고해주세요. 또한 AWSCLI의 결과를 필터링하기 위해서 jq를 사용합니다. jq의 자세한 사용법은 커맨드라인 JSON 프로세서 jq: 기초 문법과 작동 원리을 참고해주세요.

클러스터 생성

ECS를 사용하려면 먼저 클러스터를 생성해야합니다. 클러스터는 논리적인 구획일 뿐이므로 이름만 있으면 생성이 가능합니다. ecs create-cluster 명령어로 클러스터를 생성합니다.

$ aws ecs create-cluster --cluster-name awesome-ecs-cluster

클러스터가 성공적으로 생성되고 나면 list-clusters 명령어로 클러스터 목록을 확인해봅니다.

$ aws ecs list-clusters | jq '.clusterArns[]'
"arn:aws:ecs:ap-northeast-1:16777216:cluster/awesome-ecs-cluster"

웹 콘솔에서도 클러스터가 생성된 것을 확인할 수 있습니다. 하지만 아무것도 없는 상태입니다.

ECS 클러스터 대시보드: 아직 관련된 리소스가 없습니다
ECS 클러스터 대시보드: 아직 관련된 리소스가 없습니다

클러스터에 컨테이너 인스턴스 추가

클러스터는 논리적인 개념이라서 그 자체로는 실체가 없습니다. 따라서 빈 클러스터로는 어떠한 태스크나 서비스도 실행할 수 없습니다. 클러스터가 요청 받은 작업이 실제로 실행되는 곳은 컨테이너 인스턴스입니다.

웹콘솔에서는 클러스터를 생성할 때 VPCVirtual Private Cloud나 인스턴스들을 함께 생성하는 옵션을 제공하고 있습니다. 하지만 이러한 기능들은 어디까지나 편의를 위한 기능이라 직접 네트워크나 인스턴스를 관리하는 경우 오히려 혼란스러울 수 있습니다.

여기서는 기본 네트워크 환경(Default VPC)에 직접 인스턴스를 생성하고 앞서 생성한 클러스터에 컨테이너 인스턴스로 추가해보겠습니다. 인스턴스가 컨테이너 인스턴스가 되려면 도커와 ecs-agent를 설치하고 적절히 셋업해야만 합니다. 아마존에서는 이러한 준비를 마친 ECS 최적화 이미지를 제공하고 있습니다. 각 리전 별 최신 ECS 최적화 이미지의 AMI는 공식 문서에서 확인할 수 있습니다. 2018년 6월 현재 최신 버전은 amzn-ami-2018.03.a-amazon-ecs-optimized이며, 도쿄 리전의 AMIAmazon Machine Imageami-f3f8098c입니다.

노트
ECS 최적화 이미지

ECS 최적화 이미지는 최신 Amazon Linux AMI에 아래의 소프트웨어들이 설치된 AMI입니다.

  • Amazon ECS 컨테이너 에이전트 최신 버전
  • 최신 Amazon ECS 컨테이너 에이전트에 권장되는 도커 버전
  • Amazon ECS 에이전트를 실행하고 모니터링할 최신 버전 ecs-init 패키지

ECS 최적화 이미지를 사용하더라도 인스턴스가 어떤 클러스터에 속해있는지 지정할 필요가 있습니다. 인스턴스가 초기화될 때 실행되는 userdata를 작성합니다. 다음 내용을 포함한 userdata.sh 파일을 저장합니다.

#!/bin/bash
echo ECS_CLUSTER=awesome-ecs-cluster >> /etc/ecs/ecs.config

다음으로는 시큐리티 그룹을 하나 만들겠습니다. 여기서는 편의상 0.0.0.0/0에 대해 22와 80이 모두 열려있는 시큐리티 그룹을 만들어서 사용하겠습니다. --vpc-id에는 default VPC의 ID를 지정해줍니다.*

* 이 글에서는 기본 VPC를 사용한다고 가정합니다. VPC나 서브넷을 비롯한 네트워크 자원은 웹콘솔의 VPC 대시보드나 AWS 커맨드라인 인터페이스를 사용해서 확인할 수 있습니다. 커맨드라인 인터페이스를 사용하는 방법은 AWS CLI로 기본 VPC의 주요 리소드들 탐색하기를 참고해주세요.

$ aws ec2 create-security-group \
  --group-name public --description public --vpc-id "vpc-12345678" \
  | jq ".GroupId"
"sg-12345678"

인바운드 룰을 추가합니다. --group-id 값은 앞서 생성한 그룹의 ID를 지정합니다.

$ aws ec2 authorize-security-group-ingress \
  --group-id sg-12345678 --protocol tcp --port 80 --cidr 0.0.0.0/0
$ aws ec2 authorize-security-group-ingress \
  --group-id sg-12345678 --protocol tcp --port 22 --cidr 0.0.0.0/0

인스턴스 생성을 위한 준비가 끝났습니다. 이제 run-instance 명령어를 사용해 인스턴스를 실행해보겠습니다.

$ aws ec2 run-instances \
  --image-id ami-f3f8098c \
  --count 2 \
  --instance-type t2.small \
  --security-group-ids <SECURITY_GROUP_ID_1> <SECURITY_GROUP_2> \
  --subnet-id <SUBNET_ID> \
  --iam-instance-profile Name=<IAM_INSTANCE_ROLE> \
  --user-data file://userdata.sh \
  --associate-public-ip-address

옵션이 조금 많습니다. 주요한 부분만 살펴보겠습니다. 먼저 count 옵션으로 인스턴스를 2대 추가했습니다. 시큐리티 그룹 아이디(security-group-ids)에는 위에서 생성한 시큐리티 그룹과 VPC에 기본으로 생성되어 있는 default 시큐리티 그룹과 새로 생성한 시큐리티 그룹을 지정해줍니다. 서브넷 아이디(subnet-id)에도 자신의 계정의 default VPC에 속한 서브넷 중 하나의 ID를 지정해줍니다. 각 리소스의 ID 값은 웹콘솔이나 직접 확인하거나 AWS CLI로 확인할 수 있습니다. user-data에는 앞서 준비한 파일을 지정해주었습니다. 마지막으로 associate-pubilc-ip-adress 옵션을 사용해 퍼블릭 IP를 할당해주었습니다.

iam-instance-profile에는 ECS 클러스터 인스턴스 용 역할을 지정해야합니다. 이미 ECS를 웹 콘솔을 사용해서 사용한 적이 있다면 ECSInstance라는 역할이 생성되어있을 수 있습니다. 이 역할이 없다면 직접 생성해야합니다. 아마존 ECS 클러스터 인스턴스와 서비스용 IAM 역할을 참고해서 역할을 직접 생성해주세요. 생성한 후에 이 역할을 지정해주면 됩니다. 이 속성 값을 arn이 아니라 이름으로 지정한다는 점에 주의가 필요합니다.

인스턴스 생성에는 시간이 조금 걸립니다. 잠시 기다린 후 ECS 클러스터 대시보드에 접속해봅니다.

awesome-ecs-cluster에 2개의 컨테이너 인스턴스가 연결되었습니다
awesome-ecs-cluster에 2개의 컨테이너 인스턴스가 연결되었습니다

클러스터 개요의 맨 오른쪽에 2대의 컨테이너 인스턴스가 등록된 것을 확인할 수 있습니다.

엘라스틱 컨테이너 리포지토리의 이미지 저장소 준비

이제 클러스터에서 실제로 실행한 도커 이미지를 준비해보도록 하겠습니다. 이미지 생성 작업에 앞서 먼저 AWS ECR에 이미지를 저장할 공간을 준비해봅니다. ecr create-repository 명령어로 ECR 저장소를 생성할 수 있습니다.

$ aws ecr create-repository --repository-name awesome-image \
  | jq ".repository | .repositoryUri"
"16777216.dkr.ecr.ap-northeast-1.amazonaws.com/awesome-image"

출력 결과로부터 새로 생성된 저장소의 주소를 확인할 수 있습니다. 도커 이미지를 빌드하고 푸시할 때 이 주소를 사용하기 때문에 출력 결과를 기록해두시기 바랍니다.*

* 이 주소에는 AWS 루트 계정 아이디가 포함되어있으므로 사용자 별로 결과가 다릅니다. 직접 실행한 결과의 repositoryUri를 기록해두세요.

Dockerfile 작성하고 도커 이미지 만들기

richarvey/nginx-php-fpm:1.5.3 이미지를 기반으로 도커 이미지를 작성해보겠습니다. 아래 내용으로 Dockerfile 파일을 작성합니다.

FROM richarvey/nginx-php-fpm:1.5.3
RUN echo '<!DOCTYPE html><html lang="ko">' > /var/www/html/index.php ;\
    echo '<style>html{ margin: 10rem; }</style>' >> /var/www/html/index.php ;\
    echo '<h1>Nginx Demo v0.1</h1>' >> /var/www/html/index.php ;\
    echo '<h2>Hostname: <?php echo gethostname() ?></h2>' >> /var/www/html/index.php ;\
    echo '</html>' >> /var/www/html/index.php

적절한 이름을 임시로 붙여서 이미지를 빌드합니다.

$ docker build -t nacyot/nginx:v0.1 .

테스트를 위해 이미지를 실행해봅니다.

$ docker run -it -p 80:80 nacyot/nginx:v0.1

웹브라우저로 로컬호스트의 80포트에 접속해봅니다.

Nginx 서버에 접속하면 Hostname이 출력됩니다
Nginx 서버에 접속하면 Hostname이 출력됩니다

호스트 네임이 포함된 페이지가 보이는 것을 확인할 수 있습니다. 이 nginx 서버를 ECS로 배포해보겠습니다.

여기서 만든 이미지의 이름을 앞서 기록해둔 repositoryUri로 변경하고 ECR에 푸시해보겠습니다. 먼저 이미지의 이름을 변경합니다. 이미지 이름과 함께 이미지 태그(v0.1)도 확인해주세요.

$ docker tag nacyot/nginx:v0.1 16777216.dkr.ecr.ap-northeast-1.amazonaws.com/awesome-image:v0.1

다음으로 도커 클라이언트에서 ECR에 로그인해야합니다. AWSCLI에서는 ECR 로그인 정보를 얻기 위한 get-login 명령어를 제공하고 있습니다. 아래 명령어는 확인만 하고 실행하지 마세요.

$ aws ecr get-login --no-include-email
docker login -u AWS -p password https://16777216.dkr.ecr.ap-northeast-1.amazonaws.com

출력되는 내용을 복사해서 실행하면 로그인이 됩니다. 하지만 이를 직접 복사해서 실행하는 대신 셸의 $() 문법을 사용해 출력 결과를 곧바로 실행할 수 있습니다.

$ $(aws ecr get-login --no-include-email)
Login Succeeded

Login Succeeded 메시지로 로그인에 성공한 것을 확인할 수 있습니다. 이제 이미지를 푸시해보겠습니다. 앞서 바꾼 이름으로 docker push 명령어를 실행합니다.

$ docker push 16777216.dkr.ecr.ap-northeast-1.amazonaws.com/awesome-image:v0.1
The push refers to repository [16777216.dkr.ecr.ap-northeast-1.amazonaws.com/awesome-image]
...
v0.1: digest: sha256:8c3f7401d12119ae8e41f74a4ffddb3e9dffac60f17bfc6ea9cf26b6c17ac71d size: 1155

ECR 상세 페이지에서 이미지가 정상적으로 저장된 것을 확인해봅니다.

ECR: awsome-image:v0.1이 성공적으로 저장된 걸 확인할 수 있습니다
ECR: awsome-image:v0.1이 성공적으로 저장된 걸 확인할 수 있습니다

태스크 디피니션 준비

이 이미지를 클러스터에서 태스크로 실행하기 위해서는 먼저 태스크 디피니션을 생성해야합니다. 태스크 디피니션 ecs register-task-definition 명령어로 생성할 수 있습니다. register-task-definition 명령어는 많은 옵션을 지원하고 있으므로 관련 설정을 JSON 파일로 작성하면 관리가 용이합니다. 주요 옵션은 CLI 명령어 레퍼런스의 예제를 참고하면 도움이 됩니다. 여기서는 필수적인 옵션들을 위주로 태스크 디피니션 파일을 작성해보겠습니다.

다음 내용을 td-01.json에 저장합니다. 이 파일은 태스크 디피니션을 생성하기 위한 최소한의 옵션들로 구성되어 있습니다.

최상위 객체({})에 태스크 디피니션(TD)의 옵션을 작성합니다. family는 TD의 이름입니다. TD는 기본적으로 다수의 리비전을 가질 수 있기 때문에 이를 패밀리라고 표현합니다. networkMode는 태스크를 실행하는 네트워크 모드입니다. 도커에서 기본적으로 사용하는 bridge 모드로 지정합니다. 그 다음으로는 containerDefinitions에 배열로 하나 이상의 컨테이너를 정의합니다. 여기서는 nginx라는 이름을 가진 하나의 필수(essential) 컨테이너를 정의합니다. image에는 앞서 생성한 이미지를 지정합니다. 마지막으로 포트 맵핑(portMappings)은 도커의 -p 80:80/tcp(호스트:컨테이너/프로토콜) 옵션과 같습니다.

이제 ecs register-task-definition 명령어로 태스크 디피니션을 생성합니다.

$ aws ecs register-task-definition --cli-input-json file://./td-01.json \
  | jq '.taskDefinition | .taskDefinitionArn'
"arn:aws:ecs:ap-northeast-1:16777216:task-definition/ecs_nginx_examaple:1"

새롭게 생성된 태스크 디피니션을 확인할 수 있습니다. 이 ARNAmazon Resource Name에서 맨 뒤의 : 다음 숫자가 태스크 디피니션의 리비전이 됩니다. 리비전은 1부터 TD를 업데이트할 때마다 자동으로 숫자가 증가합니다.*

* 태스크 디피니션을 관리할 때 유의해야할 점이 하나 있습니다. 현재 AWS ECS에서는 TD의 삭제를 지원하지 않습니다. 정확히는 TD의 리비전을 등록 해제Deregister하는 것만 가능합니다. 등록 해제된 리비전은 삭제되지 않고 비활성화된 상태로 계속 남아있습니다. 민감한 정보가 TD에 포함되는 경우 특히 관리에 신경쓸 필요가 있습니다.

nginx 서비스 생성

태스크 디피니션이 준비되었으니 이제 곧바로 서비스를 만들어봅니다. 서비스도 JSON으로 작성합니다. 아래 내용을 service-01.json에 저장합니다.

서비스 이름(serviceName)과 태스크 디피니션(taskDefinition)을 지정하고, 실행할 태스크 개수(desiredCount)를 인스턴스 수만큼 지정해주었습니다. 앞서 태스크 디피니션에서 호스트 포트를 80으로 지정했습니다. 이렇게 지정하는 경우 각 호스트(인스턴스) 별로 컨테이너가 실행되었을 때 80 포트를 점유하게 됩니다. 따라서 인스턴스 수만큼만 태스크를 실행할 수 있습니다. 이 내용을 바탕으로 서비스를 생성합니다.

$ aws ecs create-service \
  --cluster=awesome-ecs-cluster --cli-input-json file://service-01.json

웹 콘솔에서 추가된 클러스터 상세 페이지에 접속해봅니다. 서비스 탭에서 방금 등록한 서비스를 확인할 수 있습니다. 아직 태스크가 실행되지 않은 상태입니다.

ECS 클러스터 대시보드: 아직 태스크가 실행되기 전입니다
ECS 클러스터 대시보드: 아직 태스크가 실행되기 전입니다

서비스는 자동적으로 연결된 태스크 디피니션을 기반으로 컨테이너 인스턴스에서 도커 이미지를 풀 받고 컨테이너를 실행합니다. 잠시 후 리프래시를 해보면 실행중인 태스크 수Running tasks count가 2가 되어있을 것입니다.

ECS 클러스터 대시보드: 태스크가 실행되어 실행중인 태스크 수가 2가 되었습니다
ECS 클러스터 대시보드: 태스크가 실행되어 실행중인 태스크 수가 2가 되었습니다

서비스 이름을 클릭해 상세 페이지로 이동합니다. 상세 페이지에서는 서비스에 관한 보다 구체적인 정보들을 얻을 수 있습니다. 이벤트 페이지Events에서는 서비스에 관련된 로그를 확인할 수 있습니다.

ECS 서비스 대시보드: 이벤트 탭에서 서비스와 관련된 로그를 볼 수 있습니다
ECS 서비스 대시보드: 이벤트 탭에서 서비스와 관련된 로그를 볼 수 있습니다

이벤트에서는 태스크 실행이나 상태에 관한 정보를 얻을 수 있습니다. 특히 태스크 실행에 실패하는 경우 유용한 정보를 얻을 수 있습니다. 위의 예제에서도 설정이 잘못된 경우 태스크가 제대로 실행되지 않을 수 있습니다. 이런 경우 5분 이상 지나도 실행 중인 태스크가 수가 0으로 유지되거나, 1이나 2로 늘어났다가 다시 0으로 줄어들기를 계속해서 반복합니다. 이는 서비스가 태스크 실행을 계속 시도하기 때문입니다. 이런 경우 이벤트 탭에서 어떤 문제가 있는지 확인해야 합니다.

배포된 서비스에 접속

서비스로 태스크가 정상적으로 생성되었다면 배포에 성공한 것입니다. 이제 ECS로 배포된 nginx 서버에 접속해보겠습니다. 클러스터 대시보드에서 ECS 인스턴스ECS Instances 탭을 클릭합니다. 첫 번째 서버를 클릭해서 상세 페이지로 이동합니다.

ECS 클러스터 대시보드의 클러스터 인스턴스 목록
ECS 클러스터 대시보드의 클러스터 인스턴스 목록

컨테이너 인스턴스 상세 페이지에서는 해당 인스턴스의 DNS와 IP를 확인할 수 있습니다. 80 포트로 배포했으니 이 주소에 곧바로 접속이 가능합니다.

ECS 컨테이너 인스턴스 대시보드: 등록된 인스턴스의 상세 정보를 확인할 수 있습니다
ECS 컨테이너 인스턴스 대시보드: 등록된 인스턴스의 상세 정보를 확인할 수 있습니다

웹브라우저의 주소창에 퍼블릭 DNSPublic DNS 주소를 복사해 접속해봅니다. HTTP 기본인 80 포트를 사용하기 때문에 포트 주소는 포함하지 않아도 됩니다.

ECS로 배포한 nginx에 접속한 화면(1). 호스트네임이 출력 됩니다
ECS로 배포한 nginx에 접속한 화면(1). 호스트네임이 출력 됩니다

웹 상에서 정상적으로 접속 가능한 것을 확인할 수 있습니다. 이 페이지를 새로고침하더라도 호스트 이름은 변경되지 않습니다.

같은 방법으로 두 번째 인스턴스의 퍼블릭 DNS를 확인하고 접속해봅니다.

ECS로 배포한 nginx에 접속한 화면(2). 호스트네임이 다릅니다
ECS로 배포한 nginx에 접속한 화면(2). 호스트네임이 다릅니다

첫 번째 인스턴스와는 호스트 이름이 다른 것을 확인할 수 있습니다. 역시 새로고침을 하더라도 호스트 이름은 달라지지 않습니다. 여기까지 ECS를 사용해 서비스를 배포하는 방법에 대해서 알아보았습니다.

두 번째 이터레이션: 애플리케이션 로드 밸런서와 연동

애플리케이션 로드 밸런서Application Load Balancer는 트래픽을 경로나 호스트를 기준으로 지정된 타깃 그룹Target Group으로 보냅니다. 타깃 그룹은 기본적으로 트래픽을 분배받는 인스턴스들과 연결되어 있습니다만, ECS 서비스를 사용하면 인스턴스가 아닌 태스크와 직접 연결될 수 있습니다. 두 번째 이터레이션은 첫 번째 이터레이션에서 만든 ECS 서비스에 애플리케이션 로드 밸런서에 연결해보겠습니다.

서비스와 연동할 애플리케이션 로드 밸런서와 타깃 그룹 생성

서비스에 로드 밸런서를 연결하기 위해서는 먼저 로드 밸런서와 타깃 그룹을 준비해야합니다. 로드 밸런서와 타깃 그룹은 독립적으로 생성할 수 있습니다. 로드 밸런서를 생성할 때는 이름, 서브넷, 시큐리티 그룹을 지정해야 합니다. 서브넷은 default VPC에 속한 모든 서브넷을 지정하고*, 시큐리티 그룹은 앞서 인스턴스 생성을 하면서 만들었던 시큐리티 그룹과 VPC의 default 시큐리티 그룹을 지정해줍니다.

* 로드 밸런서는 안정적으로 트래픽을 분산한다는 목적이 있기 때문에 최소한 2개 이상의 서브넷을 지정해야한다는 점에 주의가 필요합니다. 인스턴스를 모두 하나의 서브넷에 배포했더라도 2개 이상의 서브넷을 지정해야합니다.

$ aws elbv2 create-load-balancer \
  --name awesome-load-balancer \
  --subnets subnet-12345678 subnet-23456789 subnet-34567890 \
  --security-groups sg-12345678 sg-23456789 \
  | jq '.LoadBalancers[0] | .LoadBalancerArn' 
"<LOAD_BALANCER_ARN>"

다음으로 타깃 그룹을 생성합니다. 타깃 그룹의 프로토콜과 포트는 연결된 인스턴스를 향한 경로를 나타냅니다. 프로토콜(--protocol)은 HTTP, 포트(--port)는 80을 지정해줍니다. 그리고 로드 밸런서와 달리 타깃 그룹에서는 서브넷이 아닌 VPC ID를 지정합니다.

$ aws elbv2 create-target-group \
  --name awesome-target-group \
  --protocol HTTP --port 80 --vpc-id vpc-12345678 \
  | jq '.TargetGroups[0] | .TargetGroupArn'
"<TARGET_GROUP_ARN>"

마지막으로 로드 밸런서에서 트래픽을 받아 타깃 그룹으로 보내주는 리스너를 생성합니다. 리스너도 프로토콜과 포트를 가지고 있습니다. 타깃 그룹과는 달리 로드 밸런서가 직접 트래픽을 받는 프로토콜(--protocol)과 포트(--port)를 의미합니다. 리스너는 지정한 경로로 들어오는 트래픽을 특정 타깃 그룹으로 다시 보내주는 역할을 합니다. 따라서 최초에 트래픽을 받는 로드 밸런서(--load-balancer-arn)와 이를 다시 전달 받는 타깃 그룹을 지정해주어야합니다. 타깃 그룹은 --default-actions 옵션으로 지정합니다. 이 옵션은 Type=forward,TargetGroupArn=<TARGET_GROUP_ARN> 형식으로 지정합니다.

$ aws elbv2 create-listener \
  --protocol HTTP --port 80 \
  --load-balancer-arn='<LOAD_BALANCER_ARN>' \
  --default-actions 'Type=forward,TargetGroupArn=<TARGET_GROUP_ARN>'

이것으로 로드 밸런서 준비를 마쳤습니다.

ECS 서비스에 애플리케이션 로드 밸런서 연결하기

로드 밸런서가 준비 되었으니 이제 ECS 서비스에 연결해보겠습니다. 로드 밸런서로부터 서비스의 태스크까지 연결되는 기본적인 구조는 아래와 같습니다.

ECS 서비스와 로드 밸런서를 연동하는 구조도
ECS 서비스와 로드 밸런서를 연동하는 구조도

로드 밸런서의 리스너가 받은 트래픽은 타깃 그룹으로 보내집니다. 이 트래픽은 다시 타깃 그룹에 등록된 인스턴스로 보내집니다. 이 때 타깃 그룹에 등록된 인스턴스에 대해서 타깃 그룹에 지정한 포트를 호출합니다. 따라서 기본적으로 타깃 그룹의 포트는 태스크 디피니션의 호스트 포트와 같아야만 동작하지만, 실제 동작은 조금 다릅니다. 타깃 그룹의 포트와 무관하게 태스크 디피니션의 호스트 포트가 타깃 그룹의 인스턴스 포트로 등록됩니다. 즉, ECS 서비스를 사용해 타깃 그룹에 인스턴스를 등록하는 경우 타깃 그룹의 포트는 무시 됩니다. 따라서 타깃 그룹의 포트를 전혀 다른 값으로 지정해도 로드 밸런서는 동작합니다. 로드 밸런서부터 태스크까지 트래픽이 전달되는 포트들이 헷갈리기 때문에 타깃 그룹의 포트는 없다고 생각하는 편이 좋습니다. 또한 서비스에 지정하는 컨테이너 포트는 태스크 디피니션의 컨테이너 포트와 같아야합니다. 그럼 실제로 로드 밸런서를 연결해보겠습니다.

ECS 서비스에 로드 밸런서에 연결하는 것은 서비스를 만들 때만 가능합니다. 따라서 기존 서비스에는 로드 밸런서를 붙일 수 없고, 삭제하고 새로 만들어야만 합니다. 먼저 기존 서비스의 --desired-count을 0으로 지정해서 서비스를 업데이트 한 후, 서비스를 삭제합니다.

$ aws ecs update-service \
  --cluster=awesome-ecs-cluster --service=awesome-service --desired-count=0
$ aws ecs delete-service \
  --cluster=awesome-ecs-cluster --service=awesome-service

service-01.json 파일에서 loadBalancersrole 옵션을 추가해 service-02.json으로 저장해줍니다.

containerName은 태스크 디피니션에 지정한 컨테이너 이름을 지정해줍니다. containerPort도 태스크 디피니션의 portMappings에 지정한 containerPort와 같은 포트를 지정해야합니다. 이 파일을 사용해 서비스를 다시 생성합니다. 또한 desiredCount를 4로 늘려봅니다.

aws ecs create-service --cluster=awesome-ecs-cluster --cli-input-json file://service-02.json

ECS 대시보드에서 서비스의 상태를 확인해봅니다. 시간이 조금 지나면 2개의 태스크가 실행되는 것을 확인할 수 있습니다.

웹 콘솔에서 EC2 대시보드의 타깃 그룹 메뉴에 접속해봅니다. awesome-target-group의 타깃Targets 탭을 확인해보면 2개의 타깃(인스턴스)가 등록된 것을 확인할 수 있습니다.

타깃 그룹 대시보드: awesome-target-group에 2개의 인스턴스가 등록되었습니다
타깃 그룹 대시보드: awesome-target-group에 2개의 인스턴스가 등록되었습니다
중요
왜 4개가 아니라 2개만 실행될까?
ECS 클러스터 대시보드: 실행하고자 하는 개수는 4개지만 실행된 개수는 2개입니다
ECS 클러스터 대시보드: 실행하고자 하는 개수는 4개지만 실행된 개수는 2개입니다

실행하고자 하는 개수Desired tasks를 4로 지정했지만 4대가 실행되지는 않습니다. 서비스의 상세 페이지에서 이벤트 탭으로 이동합니다.

ECS 서비스 대시보드의 이벤트 탭: 포트 충돌로 태스크가 실행되지 못 하고 있습니다
ECS 서비스 대시보드의 이벤트 탭: 포트 충돌로 태스크가 실행되지 못 하고 있습니다

마지막 이벤트를 읽어보면 포트가 이미 점유되어있어서 태스크를 실행하지 못 한 것을 알 수 있습니다. 앞서도 이야기했지만 태스크 디피니션에서 고정된 호스트 포트를 사용할 경우 하나의 인스턴스에서는 정확히 특정 태스크를 1개만 실행할 수 있습니다. 그 인스턴스에서 같은 태스크를 실행하려고 하면 포트 충돌이 일어납니다. 따라서 현재 인스턴스가 2대이기 때문에 ecs_nginx_examaple 태스크 디피니션을 기반으로 2개의 태스크만을 실행할 수 있습니다. 이 문제는 다음 이터레이션에서 해결해보겠습니다.

로드 밸런서로 배포된 서비스 확인

새로운 서비스를 로드 밸런서로 배포했으니 이번에는 로드 밸런서 DNS에 직접 접속해보겠습니다. 로드 밸런서 DNS 주소는 웹 콘솔이나 AWSCLI를 사용해서 확인할 수 있습니다.

$ aws elbv2 describe-load-balancers --names awesome-load-balancer \
  | jq '.LoadBalancers[0] | .DNSName'
"awesome-load-balancer-1869312546.ap-northeast-1.elb.amazonaws.com"

웹 브라우저에서 이 주소에 접속해봅니다.

ECS와 ELB로 배포한 nginx 컨테이너 접속한 화면(1)
ECS와 ELB로 배포한 nginx 컨테이너 접속한 화면(1)

정상적으로 접속이 됩니다. 새로고침도 해봅니다.

ECS와 ELB로 배포한 nginx 컨테이너 접속한 화면(2). 같은 주소지만 호스트가 다릅니다
ECS와 ELB로 배포한 nginx 컨테이너 접속한 화면(2). 같은 주소지만 호스트가 다릅니다

현재 ELB에는 2개의 태스크가 연결되어있기 때문에 같은 주소에서 새로고침을 해보면 호스트 네임이 달라지는 것도 확인할 수 있습니다. 로드 밸런서 연결까지 무사히 마쳤습니다.

세 번째 이터레이션: 동적 포트를 적용 및 서비스 업데이트

마지막 이터레이션입니다. 앞에서는 컨테이너를 실행할 때 호스트 포트를 80 고정 포트로 실행해서 인스턴스마다 하나의 태스크만을 실행할 수 있었습니다. 고정된 포트를 사용하는 대신 컨테이너가 실행될 때 호스트 포트를 동적으로 할당할 수 있다면 포트 충돌을 피할 수 있습니다. 따라서 하나의 인스턴스에서 다수의 같은 태스크를 실행하는 것도 가능합니다. 동적 포트의 구성은 다음 그림과 같습니다.

로드 밸런서와 ECS 서비스를 동적 포트로 연결한 구조도
로드 밸런서와 ECS 서비스를 동적 포트로 연결한 구조도

태스크 디피니션의 호스트 포트를 0으로 지정함으로써 동적 포트가 활성화됩니다. 이제 서비스를 통해서 태스크가 실행될 때 호스트 포트가 동적으로 지정됩니다. 따라서 하나의 인스턴스에서 다수의 같은 태스크가 실행되더라도 호트스 포트의 충돌이 일어나지 않습니다. 타깃 그룹에서는 80 포트가 지정되어있지만 앞서 설명했듯이 이 포트는 무시됩니다. 각 태스크에 동적으로 할당된 포트로 타깃 그룹에 인스턴스가 등록됩니다.

동적 포트는 태스크 디피니션에서 활성화할 수 있습니다. 태스크 디피니션을 업데이트하고 이 새로운 리비전으로 서비스를 업데이트하는 방법을 알아보겠습니다.

태스크 디피니션 업데이트

td-02.json 파일을 만들고 다음 내용을 저장합니다.

td-01.json과 달라진 점은 거의 없습니다. hostPort를 80에서 0으로 지정했을 뿐입니다. 이 설정 하나면 동적 포트가 적용됩니다. TD의 경우 업데이트할 때도 처음 생성할 때와 마찬가지로 register-task-definition 명령어를 사용합니다. 이는 엄밀히 말해 TD를 업데이트 하는 것이 아니라 같은 TD 패밀리에 새로운 리비전을 등록하는 작업이기 때문입니다.

$ aws ecs register-task-definition --cli-input-json file://./td-02.json \
  | jq '.taskDefinition | .taskDefinitionArn'
"arn:aws:ecs:ap-northeast-1:16777216:task-definition/ecs_nginx_examaple:2"
노트
에페메랄 포트Ephemeral Port

동적 포트를 사용하면 에페메랄 포트 중에서 임의로 포트가 결정됩니다. 에페메랄 포트는 임시로 사용될 수 있는 포트의 범위로 리눅스의 경우 32768부터 61000입니다. 따라서 인스턴스 사이에서 로드 밸런서의 요청을 받기 위해 이 범위에 대한 시큐리티 그룹이나 NACL 설정이 오픈되어 있어야합니다. 기본적으로 VPC의 default 시큐리티 그룹을 로드 밸런서와 인스턴스에서 가지고 있으면 모든 포트에 대해서 통신이 가능합니다. 따라서 여기서는 추가적인 작업이 필요하지 않습니다. 하지만 이 포트 범위에 대한 시큐리티 그룹 설정을 빼먹는 일은 동적 포트를 설정하면서 빠지기 쉬운 함정 중 하나입니다. 실제 프로덕션 환경을 구성할 때는 이 포트 전체에 대해서 접속이 가능해야만 동적 포트가 정상적으로 작동한다는 것을 의식할 필요가 있습니다.

서비스 업데이트

업데이트한 태스크 디피니션을 바탕으로 서비스를 업데이트합니다. update-service 명령어를 사용합니다.

$ aws ecs update-service --cluster awesome-ecs-cluster --service awesome-service --task-definition ecs_nginx_examaple:2

정상적으로 업데이트 되었다면 서비스 상세 페이지에서 실행하고자 하는 개수Desired conut실행중인 개수Running count가 4개로 바뀐 것을 확인할 수 있습니다.

ECS 서비스 대시보드: 실행중인 태스크 개수가 4개가 되었습니다
ECS 서비스 대시보드: 실행중인 태스크 개수가 4개가 되었습니다

태스크Tasks 탭을 들으가 보면 하나의 인스턴스 당 2개의 태스크가 실행되고 있는 것을 알 수 있습니다.

ECS 클러스터 대시보드의 전체 태스크 목록
ECS 클러스터 대시보드의 전체 태스크 목록

이것으로 동적 포트 적용이 완료되었습니다.

로드 밸런서 및 배포 결과 확인

타깃 그룹에 등록된 ECS 태스크를 확인해보겠습니다. awesome-target-group의 상세 페이지에서 타깃Targets 탭을 열어봅니다.

타깃 그룹 대시보드: 4개의 인스턴스가 등록된 것을 확인할 수 있습니다
타깃 그룹 대시보드: 4개의 인스턴스가 등록된 것을 확인할 수 있습니다

스크린샷에서는 4개의 인스턴스가 등록되어있는 것을 알 수 있습니다. 하나의 인스턴스가 2번씩 등록되어있습니다. 인스턴스 별로 각각 32784, 32785 포트를 사용하고 있습니다. 따라서 인스턴스와 포트 조합으로 인스턴스가 중복 등록될 수 있습니다.

일반적으로 인스턴스 단위의 로드 밸런싱을 하는 경우 고정된 포트를 사용해서 인스턴스 당 하나의 포트에 연결하는 것과는 달리 컨테이너 오케스트레이션에서는 하나의 인스턴스를 최대한 활용합니다. 따라서 리소스 제한과 같은 옵션을 적극적으로 사용한다면 좀 더 효율적으로 컨테이너를 사용하는 것이 가능해집니다.

이제 ELB에 접속해봅니다. 새로고침할 때마다 호스트 네임이 변경되는 것을 확인할 수 있습니다. 호스트 네임은 도커 컨테이너 안에서 정해지므로 컨테이너 수만큼 서로 다른 호스트 이름을 확인할 수 있습니다.

ELB와 ECS 동적 포트로 배포한 nginx 컨테이너. 새로고침할 때마다 호스트 네임이 바뀝니다.
ELB와 ECS 동적 포트로 배포한 nginx 컨테이너. 새로고침할 때마다 호스트 네임이 바뀝니다.

여기까지 ECS 서비스와 로드 밸런서를 사용해서 컨테이너를 배포하는 방법에 대해서 알아보았습니다.

리소스 정리

다음 리소스들은 요금이 발생할 수 있으므로 사용하지 않는다면 삭제해주세요.

아래의 리소스들도 사용하지 않는다면 삭제해주세요.

마치며

이 글에서는 ECS의 가장 기본적인 사용법만을 살펴보았습니다. ECS는 AWS를 사용하고 컨테이너 도입을 고려하고 있다면 가장 쉽게 사용할 수 있는 조합입니다. 또한 ALB나 타깃 그룹의 동적 포트 지원과 같은 기능은 명백히 ECS를 고려해서 만들어진 기능입니다. 이외에도 AWS 배치나 라우트53의 서비스 디스커버리 역시 ECS를 고려해서 만들어진 서비스들입니다. ECS는 단순해보이지만 의외로 활용범위가 넓습니다. 또한 아마존에서 공식적으로 매니지드 쿠버네티스 서비스인 EKS를 발표하면서 ECS에서 넘어가기도 수월해진 상황입니다. 관심이 생기셨다면 한 번 시도해보시기를 바랍니다 😄

더 읽을거리