💭

[AWS] ECS on EC2 スポットインスタンス + CloudFront で SSL付きサービスを立ち上げてみた

2022/06/20に公開

モチベーション

EC2 スポットインスタンス超安いらしい。目的とするサービスは数分くらい止まっても問題なし。
CloudFront 使うと ACM で SSL つけられるらしい。
(ELBでもSSLできるが高い。またECSインスタンス1つしか使わないのでLoadBalancer機能は過重)
やってみた。

立ち上げるサービス概要

  • Docker コンテナ
  • 主にモバイル端末から画像を受け取り、画像処理して後方サービス(別EC2)に投げる
  • 画像処理に AWS 外の他サービスを使う
  • クライアントへの処理状況通知に、WebSocket を使う
  • 特定施設でのみ利用。1日アクセス数 50 ~ 100 程度

コスト

EC2 (スポットインスタンス) t3a.micro -> $0.0029/1時間 = $2.088/月
CloudFront data transfer 10GB/月 -> $1.74/月
(その他もろもろあるかもしれないが無視できる範囲?)

Total $3.824/月 = 500円くらい?

図(ひどいので後で直す)

事前準備

  • Route53 でドメイン準備。ACM で証明書も用意しておく。
  • CloudFront のオリジンとなる ElasticIP を一つ作っておく。
  • ECR に Docker イメージ入れておく。

CloudFormation 要所

Reseruces から抜粋、一部改変。

VPC周り

普通に VPC 作成、InternetGateway をアタッチ。
VPC には後で使うため Name タグを付与。

ecs.yaml
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
      - Key: Name
        Value: !Sub ${TagApp}-vpc

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
      - Key: Name
        Value: !Sub ${TagApp}-igw

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

Subnet

ECS インスタンス1つ動かすだけなので、パブリックサブネット1つだけ。
InternetGateway に抜ける route を設定。
(パブリックサブネットのセキュリティリスク?よく理解していない)

ecs.yaml
  PublicSubnet1RouteTable:
    Type: AWS::EC2::RouteTable
    DependsOn: AttachGateway
    Properties:
      VpcId: !Ref VPC
      Tags:
      - Key: Name
        Value: !Sub ${TagApp}-public-subnet-1-route-table

  PublicSubnet1Route:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      RouteTableId: !Ref PublicSubnet1RouteTable
      GatewayId: !Ref InternetGateway

  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicSubnet1RouteTable

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    DependsOn: AttachGateway
    Properties:
      CidrBlock: 10.0.1.0/24
      MapPublicIpOnLaunch: true
      VpcId: !Ref VPC
      AvailabilityZone: !Ref AZ1
      Tags:
      - Key: Name
        Value: !Sub ${TagApp}-public-subnet-1

ECS クラスタ

普通に ECS クラスタを作成

ecs.yaml
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties: 
      ClusterName: !Sub ${TagApp}-cluster
      ClusterSettings: 
      - Name: containerInsights
        Value: enabled

セキュリティグループ

SpotFleet(ECSインスタンス)で使う用。依存が多いのでecs.yaml内で定義。
インはSSH(22)とDockerコンテナがexposeしているポート(8080)をオープン。
アウトは全オープン。

ecs.yaml
  SpotInstanceSecurityGroup:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: Spot Instance Security Group
      VpcId: !Ref VPC
      SecurityGroupIngress:
      - CidrIp: !Ref SourceCidr
        FromPort: 22
        ToPort: 22
        IpProtocol: tcp
      - CidrIp: !Ref SourceCidr
        FromPort: 8080
        ToPort: 8080
        IpProtocol: tcp
      SecurityGroupEgress:
      - CidrIp: !Ref SourceCidr
        FromPort: 0
        ToPort: 0
        IpProtocol: '-1'

CloudFront

GUI で作ったものを yaml へ変換しただけ。精査していない...
事前に作った Elastic IP を Origin に設定。
ViewerCertificate -> AcmCertificateArn に事前に作った ACM の ARN を設定
あと、WebSocket通すために Policy 何か調整したかも...

cloudfront.yaml
  CloudFrontDistribution:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig: 
        Aliases: 
        - !Ref Domain
        Origins: 
        - 
          ConnectionAttempts: 3
          ConnectionTimeout: 10
          CustomOriginConfig: 
            HTTPPort: !Ref ContainerPort
            HTTPSPort: 443
            OriginKeepaliveTimeout: 5
            OriginProtocolPolicy: "http-only"
            OriginReadTimeout: 30
            OriginSSLProtocols: 
            - "TLSv1"
            - "TLSv1.1"
            - "TLSv1.2"
          DomainName: !Ref ElasticIpDomain
          Id: !Ref ElasticIpDomain
          OriginPath: ""
        DefaultCacheBehavior: 
          AllowedMethods: 
          - "HEAD"
          - "DELETE"
          - "POST"
          - "GET"
          - "OPTIONS"
          - "PUT"
          - "PATCH"
          CachedMethods: 
          - "HEAD"
          - "GET"
          Compress: true
          CachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
          OriginRequestPolicyId: "216adef6-5c7f-47e4-b989-5492eafa07d3"
          ResponseHeadersPolicyId: "eaab4381-ed33-4a86-88ca-d9558dc6cd63"
          SmoothStreaming: false
          TargetOriginId: !Sub "ec2-xxx-xxx-xxx-xxx.${AWS::Region}.compute.amazonaws.com"
          ViewerProtocolPolicy: "redirect-to-https"
        Comment: ""
        PriceClass: "PriceClass_200"
        Enabled: true
        ViewerCertificate: 
          AcmCertificateArn: !Ref AcmCertificateArn
          CloudFrontDefaultCertificate: false
          MinimumProtocolVersion: "TLSv1.2_2021"
          SslSupportMethod: "sni-only"
        Restrictions: 
          GeoRestriction: 
            RestrictionType: "none"
        HttpVersion: "http2"
        DefaultRootObject: ""
        IPV6Enabled: true

SpotFleet

スポットインスタンスを1つ確保するための Spot Fleet。
UserData: のスクリプトで aws-cfn-bootstrap をインストール。
Metadata -> AWS::CloudFormation::Init -> commandsECS_CLUSTER にクラスタ名を入れて ECS クラスタに参加させている。
また、aws ec2 associate-address ... で Elastic IP 紐づけ自動化。
スポットインスタンスが終了して再度別インスタンス立ち上がるときも同じEIPを使って起動するため、CloudFront の Origin はこの Elastic IP を指定すればよい。

下記以外に IAM 系リソースあるが割愛。

spotfleet.yaml
  SpotFleet:
    Type: AWS::EC2::SpotFleet
    DependsOn:
    - SpotFleetRole
    - SpotFleetInstanceProfile
    Properties:
      SpotFleetRequestConfigData:
        AllocationStrategy: diversified
        IamFleetRole: !GetAtt SpotFleetRole.Arn
        LaunchSpecifications:
        - IamInstanceProfile:
            Arn: !GetAtt SpotFleetInstanceProfile.Arn
          ImageId: !FindInMap [ AWSInstanceTypeToAMI, !Ref InstanceType, AMI ]
          InstanceType: !Ref InstanceType
          KeyName: !Ref KeyName
          Monitoring:
            Enabled: true
          SecurityGroups:
          - GroupId: !Ref SpotInstanceSecurityGroupId
          SubnetId: !Ref Subnet1Id
          UserData:
            Fn::Base64: !Sub |
              #!/bin/bash
              echo ECS_CLUSTER=${TagApp}-cluster >> /etc/ecs/ecs.config
              amazon-linux-extras install epel -y
              yum -y update
              yum -y install aws-cfn-bootstrap aws-cli jq unzip
              aws configure set default.region ${AWS::Region}
              /opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource SpotFleet
        TargetCapacity: !Ref SpotFleetTargetCapacity
        TerminateInstancesWithExpiration: 'true'
    Metadata:
      AWS::CloudFormation::Init:
        config:
          files:
            "/etc/cfn/cfn-hup.conf":
              mode: 000400
              owner: root
              group: root
              content: !Sub |
                [main]
                stack=${AWS::StackId}
                region=${AWS::Region}
            "/etc/cfn/hooks.d/cfn-auto-reloader.conf":
              content: !Sub |
                [cfn-auto-reloader-hook]
                triggers=post.update
                path=Resources.SpotFleet.Metadata.AWS::CloudFormation::Init
                action=/opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource SpotFleet
          commands:
            com1-setup-ecs-cluster:
              command: !Sub "echo ECS_CLUSTER=${TagApp}-cluster >> /etc/ecs/ecs.config"
            com2-associate-eip:
              command: !Sub "aws ec2 associate-address --instance-id `/usr/bin/curl -s http://169.254.169.254/latest/meta-data/instance-id` --allocation-id ${ElasticIpId} --allow-reassociation"
          services:
            sysvinit:
              cfn-hup:
                enabled: true
                ensureRunning: true
                files:
                - /etc/cfn/cfn-hup.conf
                - /etc/cfn/hooks.d/cfn-auto-reloader.conf

ECS タスク / サービス

LaunchType は Fargate ではなく EC2。
ELB 使わないので、ネットワークモードは bridge。
ECR にある Docker イメージ使う。

service.yaml
  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    DependsOn: 
    - TaskExecutionRole
    - TaskRolePolicyForS3
    Properties:
      Family: !Sub ${TagApp}-task-def
      RequiresCompatibilities: [ EC2 ]
      Cpu: 256
      Memory: 512
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      ContainerDefinitions:
      - Name: !Sub ${TagApp}-container
        Image: !Ref ECRImage
        Essential: true
        PortMappings:
        - ContainerPort: !Ref ContainerPort
          HostPort: !Ref ContainerPort
          Protocol: tcp
        Environment:
        - Name: PORT
          Value: !Ref ContainerPort
	# ... 省略 ...
	
  Service:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !Ref ECSClusterArn
      ServiceName: !Sub ${TagApp}-service
      TaskDefinition: !Ref TaskDefinition
      SchedulingStrategy: REPLICA
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 100
      DesiredCount: !Ref DesiredCount
      LaunchType: EC2

起動

ecs.yaml、spotfleet.yaml、cloudfront.yaml、service.yaml の順にCloudFormation で スタック作成。
無事サービス起動できた。

今後

しばらく動かしてみてコストが目論見通りかチェック。
セキュリティ強化。
自前 NGINX 挟んで LoadBalancing?

Discussion