summary
Docker compose 로 만든 어플리케이션을 AWS 환경에 지속적으로 통합, 배포하는 방법에 대해 알아본다.
AWS 에서 사용하는 서비스는 다음과 같다.
- ECS
- ECR
- Fargate
- CloudFormation
- Code Pipeline
- Code Build
*이전 포스팅에서 만들었던 guest-book 어플리케이션을 가지고 진행한다.
*전체 소스코드는 링크에서 확인할 수 있다.
*reference
concept
AWS 환경에 docker 어플리케이션을 배포하는 컨셉은 다음과 같다.
- 기본 인프라를 구축한다.
- VPC 등 인터넷 관련 인프라
- ECS를 활용한 컨테이너 오케스트레이션 인프라
- Code Pipeline을 구축한다.
- github 레포지토리의 master 브랜치에 변동사항이 생긴다.
- Code Build가 docker의 각 서비스의 이미지를 이미지화 하고, 빌드된 이미지를 ECR에 업로드한다.
- compose 파일을 CloudFormation 파일로 변환한다.
- 기존 어플리케이션 인프라와 비교해 Change Set을 생성한다.
- 수동 승인 과정을 거쳐 ECS에 배포된다.
본 포스팅에서는 첫 번째 항목인 기본 인프라 구축까지 다룬다.
서비스 배포를 진행해보기 이전에 먼저 AWS ECS에 대해서 알아보자.
AWS ECS
Amazon Elastic Container Service(Amazon ECS)는 컨테이너화된 애플리케이션의 손쉬운 배포, 관리 및 조정에 도움이 되는 완전관리형 컨테이너 오케스트레이션 서비스입니다.
ECS를 이해하고 사용하기 위해 ECS의 핵심 개념에 대해 알아보자.
클러스터와 컨테이너 인스턴스

클러스터는 ECS의 가장 기본적인 단위로, 도커 컨테이너를 실행하는 가상의 공간을 의미한다.
클러스터에 포함된 컨테이너는 해당 클러스터 내에서만 실행이 가능하다고 이해하면 쉽다.
클러스터는 컴퓨팅 자원(EC2 와 같은)을 포함하지 않는 논리적 개념으로, 빈 클러스터를 만드는 것도 가능하다.
클러스터는 ecs-client 라는 서비스를 통하여 EC2와 연결될 수 있는데, 이렇게 연결된 ec2 인스턴스를 컨테이너 인스턴스라고 부른다.
ecs-client는 컨테이너 인스턴스를 모니터링하고, 컨테이너를 적절하게 실행하는 역할을 한다.
테스크와 테스크 데피니션

테스크는 ECS에서 컨테이너를 실행하는 최소 단위이다.
테스크는 하나 이상의 컨테이너로 구성되며, 일반적으로는 하나의 필수 컨테이너로 구성된다.
테스크 데피니션은 테스크를 실행할 때의 설정을 지정하는 것으로, 아래의 설정들이 포함된다.
- 컨테이너 컴퓨팅 리소스 (CPU, 메모리 제한 등)
- 컨테이너 네트워크 모드
- 도커 이미지
- 실행 명령어
- 테스크 역할
테스크는 클러스터 안에서 실행되기 때문에 클러스터에 종속적이지만 테스크 데피니션은 그렇지 않다.
서비스

클러스터가 테스크를 실행하는 방식은 두 가지가 있다.
첫 번째는 테스트 데피니션으로 직접 테스트를 실행시키는 방법이다.
이 방법으로 실행된 테스크 실행 된 이후 관리되지 않으며, 대부분의 경우 이 방식을 사용하지는 않는다.
두 번째는 서비스를 정의하는 방법이 있다.
서비스에는 크게 두 가지 타입이 존재한다.
- 데몬타입: 모든 컨테이너 인스턴스에 해당하는 태스크가 하나씩 실행됨
- 레플리카 타입: 테스크의 갯수를 지정하고, 서비스가 지정된 갯수만큼 테스크가 실행되도록 관리
레플리카 타입은 테스크 실행 갯수가 보장되기 떄문에 웹서버를 비롯한 실제 서비스에서 사용된다.
레플리카 서비스는 서비스가 직접 테스크 배치 스케쥴링을 진행한다.
이 때 인스턴스들에 설치된 ecs-client에서 수집된 정보를 바탕으로 어디에 어떤 테스크를 실행할 지 결정한다.
엘라스틱 컨테이너 레지스트리
ECR은 프라이빗 도커 이미지를 저장 할 수 있는 레지스트리로, 도커허브의 유료플랜을 대채할 수 있다.
또한 IAM과 조합하여 세세한 관리를 할 수 있는 이점이 있다.
Docker Compose for prod
가장 먼저, 배포 환경에서 사용할 컴포즈 파일(docker-compose.prod.yml)을 생성하자.
x-aws-vpc: ${AWS_VPC}
x-aws-cluster: ${AWS_ECS_CLUSTER}
x-aws-loadbalancer: ${AWS_ELB}
services:
frontend:
image: ${IMAGE_URI}:frontend.${IMAGE_TAG}
environment:
- NODE_ENV=prod
backend:
image: ${IMAGE_URI}:backend.${IMAGE_TAG}
environment:
- NODE_ENV=prod
redis:
image: ${IMAGE_URI}:redis.latest
nginx:
image: ${IMAGE_URI}:nginx.${IMAGE_TAG}
ports:
- 0:80
위 도커 파일을 통해 배포하게 되면 아래와 같은 일이 발생한다.
- 네 개의 ECS 서비스가 생성, Fargate에 배포 된다.
- 외부에서 들어오는 요청을 처리하는 Ingress Rule 을 위함 security group 생성.
- Cloud Map namespace 생성. (namespace 서비스가 컴포즈 서비스 별로 생성된다)
- Application Load Balancer 가 에페메랄 포트를 리스닝 하고, 타겟그룹이 nginx 서비스로 연결시켜 준다.
- Elastic File Share(EFS) 가 생성되고, 레디스 데이터를 저장, backend 서비스에 마운트 됨.
- x-aws-* 변수를 통해 이미 생성된 인프라를 사용.
Infrastructure as Code를 통해 손쉬운 방법으로 관련된 AWS 및 서드 파티 리소스 모음을 모델링하고, 일관된 방식으로 간단히 프로비저닝하고, 수명 주기 전반에 걸쳐 관리할 수 있습니다.
CloudFormation은 인프라를 코드로 관리할 수 있는 AWS의 서비스이다.
인프라를 코드로 관리하면 다음과 같은 이점이 있다.
- 인프라를 코드로 관리하기 때문에 형상을 관리할 수 있다 => 히스토리 및 변경사항 파악에 유리하다.
- 같은 인프라를 빠르게 복제 및 구축할 수 있다.
- CloudFormation 자체의 사용 요금은 없고, CloudFormation을 통해 만들어진 리소스에 대한 요금만 지불하면 된다.
본 포스팅에서는 두 개의 CloudFormation 파일을 생성한다.
첫 번째는 서비스의 인프라 설정에 관한 설정 파일이고, 두 번째는 CI/CD 를 위한 설정파일이다.
먼저 서비스 인프라에 대한 CloudFormation 설정 파일부터 먼저 알아보자.
*CloudFormation 파일의 문법 등은 Template reference를 참고하자.
Docker 서비스 인프라 설정파일
프로젝트의 루트 디렉토리에 infrastructure 라는 폴더를 생성하고, cloudformation.yml 파일을 생성한다.
해당 템플릿에서는 VPC 및 ECS 관련 서비스를 관리한다.
인프라 스트럭쳐의 구조는 아래 그림과 같다.

VPC, Public and Private Subnets 그리고 Amazon ECS cluster 를 AWS best practices 에 따라 생성한다.
AWSTemplateFormatVersion: '2010-09-09'
Description: ECS Cluster in a new VPC
Parameters:
VpcCidr:
Description: CIDR Range for the VPC
Type: String
Default: 10.0.0.0/16
AllowedPattern: ([0-9]{1,3}\.){3}[0-9]{1,3}($|/(16|24))
PublicSubnetOneCidr:
Description: CIDR Range for public subnet one
Type: String
Default: 10.0.1.0/24
AllowedPattern: ([0-9]{1,3}\.){3}[0-9]{1,3}($|/24)
PublicSubnetTwoCidr:
Description: CIDR Range for public subnet two
Type: String
Default: 10.0.2.0/24
AllowedPattern: ([0-9]{1,3}\.){3}[0-9]{1,3}($|/24)
PrivateSubnetOneCidr:
Description: CIDR Range for private subnet one
Type: String
Default: 10.0.3.0/24
AllowedPattern: ([0-9]{1,3}\.){3}[0-9]{1,3}($|/24)
PrivateSubnetTwoCidr:
Description: CIDR Range for private subnet two
Type: String
Default: 10.0.4.0/24
AllowedPattern: ([0-9]{1,3}\.){3}[0-9]{1,3}($|/24)
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsHostnames: true
EnableDnsSupport: true
InstanceTenancy: default
Tags:
- Key: Name
Value: !Sub '${AWS::StackName}'
PublicSubnetOne:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 0
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !Ref PublicSubnetOneCidr
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'public-1'
- Key: subnet-type
Value: 'Public'
PublicSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 1
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !Ref PublicSubnetTwoCidr
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'public-2'
- Key: subnet-type
Value: 'Public'
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId:
Ref: VPC
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'public-routetable'
PublicSubnetOneRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId:
Ref: PublicRouteTable
SubnetId:
Ref: PublicSubnetOne
PublicSubnetTwoRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId:
Ref: PublicRouteTable
SubnetId:
Ref: PublicSubnetTwo
PublicInternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'internet-gateway'
PublicInternetGatewayAssociation:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref 'VPC'
InternetGatewayId: !Ref 'PublicInternetGateway'
PublicSubnetDefaultRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId:
Ref: PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId:
Ref: PublicInternetGateway
PublicEIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'nat-gw-eip'
PublicNatGW:
Type: AWS::EC2::NatGateway
Properties:
AllocationId:
Fn::GetAtt:
- PublicEIP
- AllocationId
SubnetId:
Ref: PublicSubnetOne
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'nat-gw'
PrivateSubnetOne:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 0
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !Ref PrivateSubnetOneCidr
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'private-1'
- Key: subnet-type
Value: 'Private'
PrivateSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 1
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !Ref PrivateSubnetTwoCidr
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'private-2'
- Key: subnet-type
Value: 'Private'
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId:
Ref: VPC
Tags:
- Key: Name
Value: !Join
- "-"
- - !Sub '${AWS::StackName}'
- 'private-routetable'
PrivateSubnetDefaultRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId:
Ref: PrivateRouteTable
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId:
Ref: PublicNatGW
PrivateSubnetOneRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId:
Ref: PrivateRouteTable
SubnetId:
Ref: PrivateSubnetOne
PrivateSubnetTwoRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId:
Ref: PrivateRouteTable
SubnetId:
Ref: PrivateSubnetTwo
# Security Group for GuestBookApp
GuestBookAppSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow Access to Web Port from anywhere
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: "0.0.0.0/0"
VpcId: !Ref VPC
# Application Load Balancer
GuestBookAppLoadbalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: !Sub "${AWS::StackName}-alb"
Type: "application"
Scheme: "internet-facing"
SecurityGroups:
- !Ref GuestBookAppSecurityGroup
Subnets:
- !Ref PublicSubnetOne
- !Ref PublicSubnetTwo
# ECS Resources
ECSCluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Join ["-",[!Sub '${AWS::StackName}', 'cluster']]
ClusterSettings:
- Name: containerInsights
Value: enabled
CapacityProviders:
- FARGATE
- FARGATE_SPOT
DefaultCapacityProviderStrategy:
- CapacityProvider: FARGATE
Weight: 1
- CapacityProvider: FARGATE_SPOT
Weight: 2
# ECS Task Execution Role
ECSTaskExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [ecs-tasks.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
ManagedPolicyArns:
- 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy'
# These output values will be available to service templates to use.
Outputs:
VpcId:
Description: The ID of the VPC that this stack is deployed in
Value: !Ref 'VPC'
ClusterName:
Description: The name of the ECS cluster
Value: !Ref 'ECSCluster'
LoadbalancerId:
Description: The GuestBook App Loadbalancer Arn
Value: !Ref 'GuestBookAppLoadbalancer'
LoadbalancerEndpoint:
Description: The GuestBook App Loadbalancer Endpoint
Value: !Join
- ""
- - "http://"
- !GetAtt 'GuestBookAppLoadbalancer.DNSName'
Docker 서비스 인프라 생성
infrastructure/cloudformation.yml 템플릿을 통해 지정한 AWS 리소스를 CLI를 통해 생성해보자.
- AWS CLI가 설치되어 있지 않다면 링크를 참고하여 설치하자.(버전 2의 설치를 권장한다)
infrastructure 디렉토리로 이동 한뒤, 아래 명령어를 실행하자.
stach-name은 원하는 이름으로 설정해도 좋다. (스택이름이 너무 길면 생성에 실패할 수 있는 점 주의하도록 하자)
aws cloudformation create-stack \
--stack-name guest-book-compose-infra \
--template-body file://cloudformation.yaml \
--capabilities CAPABILITY_IAM

실행 후 AWS 콘솔에서 확인해 보면 위 그림과 같이 AWS 리소스가 성공적으로 생성된 것을 알 수 있다.
*만약 실패한다면 이벤트로그를 살펴보자.
end
이번 포스팅을 통해 AWS ECS의 기본 개념에 대해 알아보고,
도커 컨테이너 배포를 위한 인프라를 CloudFormation 템플릿을 통해 설정하고 실행해 보았다.
다음 포스팅에서는 지속적 통합, 배포환경을 구성해보도록 하겠다.