Tomorrow Will Be A Better Day

Published

- 10 min read

CloudFormationで、ECSのCI/CD環境を構築した際のハマりどころ 〜CodePipeline,CodeBuild,KMSも添えて〜

img of CloudFormationで、ECSのCI/CD環境を構築した際のハマりどころ 〜CodePipeline,CodeBuild,KMSも添えて〜

Classiアドベントカレンダー4日目です。
本日は、ECSを利用して、AWS上でAWSどっぷりのCI/CD環境を準備したときのお話になります。

今年のre:InventでEKSとFargateがリリースされましたが、東京リージョンに来てなかったり、プレビュー段階だったりで、
まだしばらくは参考になる部分はありそうかなと^^;

1.背景

などで、AWS公式でもECS環境下のCloudFormation(以下、CFn)を使ったデプロイ方法が紹介されています。
とはいえ、現実の要件でCFnで実装しようとすると、デフォルト設定だと失敗したり、
ドキュメントだけだと、GUIで設定できる部分がCFnでの書き方がわからかったりして、いくつかハマった内容があったので、
3種類ぐらいの特徴を抜粋して書いてみようと思います。

2.TL;DR

ECSを使うなら、

  • ALBとECSの動的ポート機能を組み合わせる
  • IAM Role,KMS,SSMパラメータストアを組み合わせる
  • CodePipelineで複数リポジトリからのコード取得を行う

これらの機能を全部CFnでやろうとすると、一部aws-cliなどを使う必要がありますが、
ひとまずDevとOpsでうまく権限を分担したCI/CD環境を構築できるのではないかなと思います。

3.特徴解説

3-1. ALBとECSの動的ポート機能の組み合わせ

qiita_ecs_port.png

EC2へ割り当てるSecurityGroupは、ECSの動的ポート機能を利用するため、インバウンドのTCPポートを開放しておきます。

   ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
       Properties:
        VpcId: !Ref VpcId
       GroupName: sample
       GroupDescription: "ALB Serurity Group"
       SecurityGroupIngress:
            -
                CidrIp: 0.0.0.0/0
                IpProtocol: tcp
                FromPort: 443
                ToPort: 443
EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
       Properties:
        VpcId: !Ref VpcId
       GroupName: sample
       GroupDescription: "EC2 Serurity Group"
       SecurityGroupIngress:
            -
                SourceSecurityGroupId: !Ref ALBSecurityGroup
                IpProtocol: tcp
                FromPort: 0
                ToPort: 65535

ECSの動的ポートを有効にするため、PortMappingsの設定でホストのポートを0に設定します。

   ECSTask:
  Type: 'AWS::ECS::TaskDefinition'
  Properties:
    Family: sample
    NetworkMode: bridge
    ContainerDefinitions:
      - Name: sample
        Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRName}:${ImageTag}'
        Cpu: 2
        Memory: 128
        PortMappings:
          - ContainerPort: 80
            HostPort: 0
        Essential: true
        Ulimits:
          - Name: nofile
            SoftLimit: 65535
            HardLimit: 65535
        Environment:
          - Name: TZ
            Value: Asia/Tokyo
        LogConfiguration:
          LogDriver: awslogs
          Options:
            awslogs-group: sample
            awslogs-region: !Sub ${AWS::Region}
            awslogs-stream-prefix: !Ref ImageTag
  Service:
    Type: 'AWS::ECS::Service'
    Properties:
      ServiceName: sample
      Cluster: !Ref ECSCluster
      DesiredCount: 1
      TaskDefinition: !Ref ECSTask
      Role: !Ref ECSServiceRole
      PlacementStrategies:
        - Type: spread
          Field: instanceId
      LoadBalancers:
        - ContainerName: sample
          ContainerPort: 80
          TargetGroupArn: !Ref ALBTargetGroup

注意点

複数のEC2でECSを運用するのであれば、PlacementStrategiesの設定を行っておかないと、
random配置ECSのタスクが一つのホストだけに偏ってしまったりすることがあります。

3-2. DevとOpsで別gitリポジトリを運用しつつ、CodePipelineのデプロイフェーズでCFnのChangeSetを使う

qiita_codepipeline.png

デプロイにCFnを利用することで、デプロイの実行記録の管理やCFnで記載された部分のインフラ部分のテストを行いつつ、デプロイをすることが可能になります。
また、Sourceフェーズで、CFnの内容やEC2のASGやAMI設定の管理を行うOps管轄リポジトリと、Dockerコンテナ化するアプリロジックが含まれているDev管轄リポジトリを分割することで、運用フェーズに入ったときにDevとOpsで独立して、デプロイを行うことができます。

   CodePipeline:
    Type: "AWS::CodePipeline::Pipeline"
    Properties:
        Name: sample
        ArtifactStore:
            Type: S3
            Location: sample
        RoleArn: !Ref BuildRole
        Stages:
            -
                Name: Source
                Actions:
                    -
                        Name: AppSource
                        RunOrder: 1
                        ActionTypeId:
                            Category: Source
                            Owner: ThirdParty
                            Version: 1
                            Provider: GitHub
                        Configuration:
                            Owner: !Ref GithubOwner
                            Repo: !Ref GithubAppRepo
                            Branch: !Ref GithubAppBranch
                            OAuthToken: !Ref GithubToken
                        OutputArtifacts:
                            - Name: AppSource
                    -
                        Name: InfraSource
                        RunOrder: 1
                        ActionTypeId:
                            Category: Source
                            Owner: ThirdParty
                            Version: 1
                            Provider: GitHub
                        Configuration:
                            Owner: !Ref GithubOwner
                            Repo: !Ref GithubInfraRepo
                            Branch: !Ref GithubInfraBranch
                            OAuthToken: !Ref GithubToken
                        OutputArtifacts:
                            - Name: InfraSource
            -
                Name: Build
                Actions:
                    -
                        Name: CodeBuild
                        RunOrder: 1
                        InputArtifacts:
                            - Name: AppSource
                        ActionTypeId:
                            Category: Build
                            Owner: AWS
                            Version: 1
                            Provider: CodeBuild
                        Configuration:
                            ProjectName: !Ref CodeBuild
                        OutputArtifacts:
                            - Name: Build
            -
                Name: CreateChangeSet
                Actions:
                    -
                        Name: CreateChangeSet
                        RunOrder: 1
                        InputArtifacts:
                            - Name: InfraSource
                            - Name: Build
                        ActionTypeId:
                            Category: Deploy
                            Owner: AWS
                            Version: 1
                            Provider: CloudFormation
                        Configuration:
                            ChangeSetName: Deploy
                            ActionMode: CHANGE_SET_REPLACE
                            StackName: !Sub ${AWS::StackName}
                            Capabilities: CAPABILITY_NAMED_IAM
                            TemplatePath: !Sub "Source::sample.yml"
                            ChangeSetName: !Ref CFnChangeSetName
                            RoleArn: !Ref BuildRole
                            ParameterOverrides: !Sub |
                                {
                                    "ImageTag": { "Fn::GetParam" : [ "Build", "build.json", "tag" ] },
                                    "AppName": "${AppName}",
                                    "OwnerName": "${OwnerName}",
                                    "RoleName": "${RoleName}",
                                    "StageName": "${StageName}",
                                    "VpcId": "${VpcId}"
                                }
            -
                Name: Deploy
                Actions:
                    -
                        Name: Deploy
                        ActionTypeId:
                            Category: Deploy
                            Owner: AWS
                            Version: 1
                            Provider: CloudFormation
                        Configuration:
                            ActionMode: CHANGE_SET_EXECUTE
                            ChangeSetName: !Ref CFnChangeSetName
                            RoleArn: !Ref BuildRole
                            StackName: !Sub ${AWS::StackName}

注意点

  • CodePipelineのキックは、PRがマージされたタイミングなので、(一応、CodePipelineにはTestフェーズもあるが)マージ前のテストなどはCircleCIとかに任せた方がよいかも
  • ParameterOverridesで上書きするパラメータは、CFnのParametersに設定している項目に応じて設定する
  • Sourceフェーズで持ってこれるリポジトリは2つまで。コンテナビルドに持ってくるのがもっとある場合、CodeBuild内でこちらの記事のように、githubから引っ張ってきて、ビルドするなどの対応が必要になりそう

3-3. CodeBuildでDockerイメージを作る際、KMSとSSMパラメータストアを利用する

qiita_codebuild.png

このあたりはAWSの恩恵をフルに受けている部分かなと。
RDSのパスワードや秘密鍵など、gitリポジトリ内で管理したくない情報は、SSMパラメータストアを使って、Dockerイメージを作成するときに環境変数を埋め込みます。

   CodeBuild:
  Type: AWS::CodeBuild::Project
  Properties:
    Name: sample
    Source:
      Type: CODEPIPELINE
    ServiceRole: !Ref BuildRole
    Artifacts:
      Type: CODEPIPELINE
    Environment:
      Type: LINUX_CONTAINER
      ComputeType: BUILD_GENERAL1_SMALL
      Image: 'aws/codebuild/docker:1.12.1'
      EnvironmentVariables:
        - Name: AWS_DEFAULT_REGION
          Value: !Sub ${AWS::Region}
        - Name: AWS_ACCOUNT_ID
          Value: !Sub ${AWS::AccountId}
        - Name: IMAGE_REPO_NAME
          Value: !Ref ECRRepoName

docker buildするときに、—build-argに秘匿情報として環境変数を引き渡し、できあがったイメージをECRにpushする。

   version: 0.2
phases:
    pre_build:
        commands:
            - $(aws ecr get-login --region $AWS_DEFAULT_REGION)
            - IMAGE_TAG="${CODEBUILD_RESOLVED_SOURCE_VERSION}"
            - DB_PASSWORD=$(aws ssm get-parameters --names rds_pass --with-decryption --query "Parameters[0].Value" --output text)
    build:
        commands:
            - docker build --build-arg DB_PASSWORD="${DB_PASSWORD}" -t "${IMAGE_REPO_NAME}:${IMAGE_TAG}" .
            - docker tag "${IMAGE_REPO_NAME}:${IMAGE_TAG}" "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}"
    post_build:
        commands:
            - docker push "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}"
            - printf '{"tag":"%s"}' "${IMAGE_TAG}" > build.json
artifacts:
    files:
        - build.json
    discard-paths: yes
(snip)
ARG DB_PASSWORD
ENV DB_PASSWORD=${DB_PASSWORD}
(snip)

実運用する際は、IAM Roleを使う権限も意識して、KMSのKeyを利用するIAM UserやIAM Roleを設定する。

   KMSKey:
  Type: 'AWS::KMS::Key'
  Properties:
    Description: sample-key
    KeyPolicy:
      Version: '2012-10-17'
      Id: 'key-default-1'
      Statement:
        - Sid: 'Allow use of the key'
          Effect: 'Allow'
          Principal:
            AWS: !GetAtt BuildRole.Arn
          Action:
            - 'kms:DescribeKey'
            - 'kms:Decrypt'
          Resource: '*'

注意点

  • SSMパラメータにおける、SecureString型の値登録

3-3.でSSMパラメータストアで暗号化する際、SecureString型はCFnに対応していない。

そのため、aws-cliで設定することにした。TerraformはSecureString型に対応しているので、CFn側でも対応して欲しいところ…

   $aws ssm put-parameter --name rds-pass --value PASSWORD --type SecureString --key-id hogehoge

4. その他の雑多なハマりどころ

4-1. ECSのAMIのデフォルト設定

  • EBSのストレージタイプのデフォルトがHDD
    LaunchConfigurationのBlockDeviceMappingsで、gp2を明示的に指定してあげる。
  • WillReplace用のシグナルを送るcfn-signalが未インストール
    UserDataの中で記載しておく。シグナルを送るタイミングは、どこまでAMIに手を入れるかによって変更する。
   LaunchConfig:
  Type: 'AWS::AutoScaling::LaunchConfiguration'
  Properties:
    AssociatePublicIpAddress: true
    KeyName: sample
    IamInstanceProfile: sample
    ImageId: ami-e4657283
    SecurityGroups:
      - !Ref SecurityGroup
    InstanceType: t2.micro
    BlockDeviceMappings:
      - DeviceName: '/dev/xvda'
        Ebs:
          VolumeType: gp2
          VolumeSize: 30
    UserData:
      Fn::Base64: !Sub |
        #!/bin/bash
        echo ECS_CLUSTER=${ECSClusterName} >> /etc/ecs/ecs.config
        sudo yum install -y aws-cfn-bootstrap
        sleep 60
        /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource AutoScalingGroup --region ${AWS::Region}
AutoScalingGroup:
  Type: 'AWS::AutoScaling::AutoScalingGroup'
  Properties:
    LaunchConfigurationName: sample
    DesiredCapacity: 2
    MaxSize: 3
    MinSize: 2
    VPCZoneIdentifier:
      - !Ref PublicSubnet1
      - !Ref PublicSubnet2
  CreationPolicy:
    ResourceSignal:
      Count: 1
      Timeout: PT5M
  UpdatePolicy:
    AutoScalingReplacingUpdate:
      WillReplace: true

5.まとめ

もう少しきれいな書き方がありそうだけど、実運用でよくある要件の参考程度になれば幸いです。
EC2のASGまわりの設定は、従来のECSだとこのような形で大分インフラ側を意識しないといけない構成です。
今後、re:Inventで発表されたEKSやFargateなどとも比べながら、本環境をアップデートしていければよいなと思います。