Eloy's devlog
docker   docker-compose   AWS   deploy   CI   CD   ECS   ECR   Fargate   CloudFormation

AWS ECS에 docker 컨테이너를 배포 및 관리 - 1

summary

Docker compose 로 만든 어플리케이션을 AWS 환경에 지속적으로 통합, 배포하는 방법에 대해 알아본다.

AWS 에서 사용하는 서비스는 다음과 같다.

*이전 포스팅에서 만들었던 guest-book 어플리케이션을 가지고 진행한다.

*전체 소스코드는 링크에서 확인할 수 있다.

*reference

concept

AWS 환경에 docker 어플리케이션을 배포하는 컨셉은 다음과 같다.

  1. 기본 인프라를 구축한다.
    • VPC 등 인터넷 관련 인프라
    • ECS를 활용한 컨테이너 오케스트레이션 인프라
  2. Code Pipeline을 구축한다.
    1. github 레포지토리의 master 브랜치에 변동사항이 생긴다.
    2. Code Build가 docker의 각 서비스의 이미지를 이미지화 하고, 빌드된 이미지를 ECR에 업로드한다.
    3. compose 파일을 CloudFormation 파일로 변환한다.
    4. 기존 어플리케이션 인프라와 비교해 Change Set을 생성한다.
    5. 수동 승인 과정을 거쳐 ECS에 배포된다.

본 포스팅에서는 첫 번째 항목인 기본 인프라 구축까지 다룬다.

서비스 배포를 진행해보기 이전에 먼저 AWS ECS에 대해서 알아보자.

AWS ECS

Amazon Elastic Container Service(Amazon ECS)는 컨테이너화된 애플리케이션의 손쉬운 배포, 관리 및 조정에 도움이 되는 완전관리형 컨테이너 오케스트레이션 서비스입니다.

ECS를 이해하고 사용하기 위해 ECS의 핵심 개념에 대해 알아보자.

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

ecs-cluster-and-container-instance

클러스터는 ECS의 가장 기본적인 단위로, 도커 컨테이너를 실행하는 가상의 공간을 의미한다. 클러스터에 포함된 컨테이너는 해당 클러스터 내에서만 실행이 가능하다고 이해하면 쉽다.

클러스터는 컴퓨팅 자원(EC2 와 같은)을 포함하지 않는 논리적 개념으로, 빈 클러스터를 만드는 것도 가능하다. 클러스터는 ecs-client 라는 서비스를 통하여 EC2와 연결될 수 있는데, 이렇게 연결된 ec2 인스턴스를 컨테이너 인스턴스라고 부른다. ecs-client는 컨테이너 인스턴스를 모니터링하고, 컨테이너를 적절하게 실행하는 역할을 한다.

테스크와 테스크 데피니션

ecs-task-and-task-definition

테스크는 ECS에서 컨테이너를 실행하는 최소 단위이다. 테스크는 하나 이상의 컨테이너로 구성되며, 일반적으로는 하나의 필수 컨테이너로 구성된다.

테스크 데피니션은 테스크를 실행할 때의 설정을 지정하는 것으로, 아래의 설정들이 포함된다.

테스크는 클러스터 안에서 실행되기 때문에 클러스터에 종속적이지만 테스크 데피니션은 그렇지 않다.

서비스

ecs-service

클러스터가 테스크를 실행하는 방식은 두 가지가 있다. 첫 번째는 테스트 데피니션으로 직접 테스트를 실행시키는 방법이다. 이 방법으로 실행된 테스크 실행 된 이후 관리되지 않으며, 대부분의 경우 이 방식을 사용하지는 않는다.

두 번째는 서비스를 정의하는 방법이 있다. 서비스에는 크게 두 가지 타입이 존재한다.

레플리카 타입은 테스크 실행 갯수가 보장되기 떄문에 웹서버를 비롯한 실제 서비스에서 사용된다. 레플리카 서비스는 서비스가 직접 테스크 배치 스케쥴링을 진행한다. 이 때 인스턴스들에 설치된 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

위 도커 파일을 통해 배포하게 되면 아래와 같은 일이 발생한다.

infra structures by CloudFormation

Infrastructure as Code를 통해 손쉬운 방법으로 관련된 AWS 및 서드 파티 리소스 모음을 모델링하고, 일관된 방식으로 간단히 프로비저닝하고, 수명 주기 전반에 걸쳐 관리할 수 있습니다.

CloudFormation은 인프라를 코드로 관리할 수 있는 AWS의 서비스이다. 인프라를 코드로 관리하면 다음과 같은 이점이 있다.

본 포스팅에서는 두 개의 CloudFormation 파일을 생성한다. 첫 번째는 서비스의 인프라 설정에 관한 설정 파일이고, 두 번째는 CI/CD 를 위한 설정파일이다.

먼저 서비스 인프라에 대한 CloudFormation 설정 파일부터 먼저 알아보자.

*CloudFormation 파일의 문법 등은 Template reference를 참고하자.

Docker 서비스 인프라 설정파일

프로젝트의 루트 디렉토리에 infrastructure 라는 폴더를 생성하고, cloudformation.yml 파일을 생성한다. 해당 템플릿에서는 VPC 및 ECS 관련 서비스를 관리한다. 인프라 스트럭쳐의 구조는 아래 그림과 같다.

infrastructure-diagram

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를 통해 생성해보자.

infrastructure 디렉토리로 이동 한뒤, 아래 명령어를 실행하자. stach-name은 원하는 이름으로 설정해도 좋다. (스택이름이 너무 길면 생성에 실패할 수 있는 점 주의하도록 하자)

aws cloudformation create-stack \
    --stack-name guest-book-compose-infra \
    --template-body file://cloudformation.yaml \
    --capabilities CAPABILITY_IAM

infrastructure-deploy-result

실행 후 AWS 콘솔에서 확인해 보면 위 그림과 같이 AWS 리소스가 성공적으로 생성된 것을 알 수 있다.

*만약 실패한다면 이벤트로그를 살펴보자.

end

이번 포스팅을 통해 AWS ECS의 기본 개념에 대해 알아보고, 도커 컨테이너 배포를 위한 인프라를 CloudFormation 템플릿을 통해 설정하고 실행해 보았다.

다음 포스팅에서는 지속적 통합, 배포환경을 구성해보도록 하겠다.