Classiアドベントカレンダー4日目です。
本日は、ECSを利用して、AWS上でAWSどっぷりのCI/CD環境を準備したときのお話になります。
今年のre:InventでEKSとFargateがリリースされましたが、東京リージョンに来てなかったり、プレビュー段階だったりで、まだしばらくは参考になる部分はありそうかなと^^;
1.背景
- AWS公式ブログ
AWS CodePipeline, AWS CodeBuild, Amazon ECR, AWS CloudFormationを利用したAmazon ECSへの継続的デプロイメント
コンテナやサーバレスアプリのデプロイツールとしてのAWS CloudFormation - Developers.io
CodePipeline, CodeBuildを使ってAmazon ECSへの継続的デプロイメントを試してみた
などで、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の動的ポート機能の組み合わせ

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を使う

デプロイに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パラメータストアを利用する

このあたりは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などとも比べながら、本環境をアップデートしていければよいなと思います。