Posted at
LIFULLDay 24

CloudFormationを使ってECS環境とそのデプロイシステムを作成する

More than 1 year has passed since last update.

こんにちは、LIFULLのchissoです。

この記事は、私が勤務するLIFULLのAdvent Calender1の24日目の記事です。

今日はクリスマス・イブですね。みなさまいかがお過ごしでしょうか。

私はQiitaに2本Advent Calenderの記事を上げています。

もう一つはこちらにAthenaの記事を書いています。

さて、早速ですが本題です。

今年の夏頃、AWSで新規サービスを作成する機会がありました。そこで、CloudFormation(以下CFn)を使って、デプロイシステムとElasticContainerService(以下ECS)のサービスを管理する仕組みを作りました。ベースはawslabsが公開しているコチラのリポジトリです。

当時、Qiitaやクラスメソッドさんのブログに大変お世話になりながらなんとかサービスインにこぎつけたのですが、自分なりに要点やハマりどころなど解説したいと思います。

私が当時調べて取り入れたこと、ハマったことなどつらつらと書くのでかなり長いです。

いざやろうと思われた際に参照してもらえれば幸いです。


はじめに

まずはじめに、なぜCFn・ECSを使ったのか簡単に述べておきます。


CFn

こちらは、AWS内のネットワーク・サービス構成もコードで管理したかった、という点につきます。もう流行りというのも憚れますが、Infrastucture as a Codeというやつですね。メリットとしては、


  1. 変更を追跡可能になる


    • 誰がいつ変更したかわからないSecurity GroupやNetwork ACLがなくなる



  2. 開発環境で作った構成がそのまま本番環境で再現できる


    • AWSコンソールはよくできていますが、ポチポチなど人の作業が発生する以上どうしても作業漏れが発生します



思いつくデメリットは、CFn templateの読み書きが辛いことでしょうか。書くときはひたすらAWSの公式ドキュメントとにらめっこです。ただし、慣れてしまえばドキュメントがよくできているので、結構スラスラ書けるようになっていきます。


ECS

こちらは、実行環境を開発環境と本番環境で揃えたい、という点です。またしても解説不要かと思いますが、dockerを使いたいだけです。

Elastic Beanstalk(EB)とどちらを採用するか結構迷ったのですが、ECRを使わないEBではbuildとdeployが一体化してしまうことが気になり、ECRを使うならECSでいいじゃん、となりました。

今年のre:InventでFargateが発表されましたし、ECSにしておいてよかったなと思っているところです。


前提となる知識

CfnとECSがどんなものか、という導入については、優れた記事がすでにたくさんありますので紹介しながら少しだけ補足します。


Cfn

CFnテンプレートは、jsonとyamlの両方で書くことが可能です。

複数のコンポーネントをCFnで管理しようとすると、1つのテンプレートが肥大化してしまいます。しかしaws cliを使うことで、複数のテンプレートに各コンポーネントを定義することが可能です(統合用のテンプレートが必要となります)。

余談ですが、このaws cloudformation packageコマンドは非常に便利で、lambdaファンクションなども参照してアップロードしてくれます。


ECS

ECSについては、はじめ用語が少しわかりにくいですが、下記の認識で良いと思います。

用語
説明

クラスター
サービスが稼働するEC2インスタンス(群)

サービス
タスクとクラスター、ELB(ターゲットグループ)を紐付ける。EBでいうアプリケーションのような感じ。

タスク
コンテナの集まりで、docker-compose.ymlのイメージ。


CfnでECSのデプロイシステムを構築する

本題に入ります。

ベースはawslabsのリポジトリに公開されているものです。

AWSの公式ブログClassMethodさんの記事で、メリットや概略は既に紹介されているので、各yamlの役割や、私が変更を行ったところについて目的と変更方法、また細かいハマりどころを書いていきます。


全体像

ecs-refarch-continuous-deployment.yaml

|- vpc.yaml
|- load_balancer.yaml
|- ecs_cluster.yaml
|- service.yaml
\- deployment-pipeline.yml

ecs-refarch-continuous-deployment.yamlが全体像(Stack)を定義したCFn templateで、その他のyamlがネストしたStackとなっています。

ecs-refarch-continuous-deployment.yaml内に相対パスで各yamlを記載して、上述したaws cloudformation packageを実行すると、各yamlがS3へアップロードされた上でecs-refarch-continuous-deployment.yamlのパスがS3パスへ書き換えられます。

※ awslabsのecs-refarch-continuous-deployment.yamlはリポジトリ内の子templateを別でS3にあげてあり、初めからそちらを参照するように書かれています。

// before

Cluster:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: templates/ecs-cluster.yml
Parameters:
VpcId: !Ref VpcId

// after
Cluster:
Properties:
Parameters:
VpcId:
Ref: VpcId
TemplateURL: https://s3.amazonaws.com/cfn-templates/{hash値}.template
Type: AWS::CloudFormation::Stack

また、この構成の場合ecs-refarch-continuous-deployment.yamlがWeb console(またはAPI)から実行するテンプレートとなり、その際に各種パラメータ(Parametersブロックで定義したもの)を与えます。

ecs-refarch-continuous-deployment.yamlからネストしたテンプレートへのパラメータの引き渡しや、ネストしたテンプレート間での値のやりとりもecs-refarch-continuous-deployment.yaml上で行われることになります。


VPC

https://github.com/awslabs/ecs-refarch-continuous-deployment/blob/master/templates/vpc.yaml

VPC, route table, subnetsなど、ネットワーク周りの基本的な定義を行っています。

私は既存のVPC内にECSクラスタを使いたかったため、こちらは利用しませんでした。

OutputsにSubnetsとVpcIdがある通り、別templateでそれらを利用しますが、既存VPC/Subnetのidが利用可能なので、Parametersブロックに定義して渡すようにしています。


LoadBalancer

https://github.com/awslabs/ecs-refarch-continuous-deployment/blob/master/templates/load-balancer.yaml

LoadBalancerと、紐付けるTargetGroupやSecurityGroupを定義しています。

こちらも、初め利用しようとしていましたが最終的にやめました。

というのも、CFnテンプレートを更新した際に、ELB以外のStackが更新される場合は問題ありませんが、Stackの作り直しやELB Stackの更新が発生すると、ELBのURLが変わってしまいます。

実際にELBをCFnで管理するのであれば、Route53もCFn内で管理して、ELBのエンドポイントに対してCNAMEレコードを作成する必要があると思います。

※ 私が作ったものは社内利用のみだったため、固定されたURLさえあればよく、ELBを固定してパラメータでStackに与えることで対応しました。


ECS Cluster

https://github.com/awslabs/ecs-refarch-continuous-deployment/blob/master/templates/ecs-cluster.yaml

このあと定義する、ECSのサービスを稼働させるインスタンスや、AutoScalingGroupの定義を行っています。(Fargateになればいらなくなるはずのテンプレートです。)


Resoures.SecurityGroup

デフォルトでは、SecurityGroupIngressのIpProtcolに-1が設定されており、ELBのSecurityGroupに対してすべてのポートが解放されています。

http://docs.aws.amazon.com/ja_jp/AWSEC2/latest/APIReference/API_AuthorizeSecurityGroupIngress.html

私は、明示的にポート解放したかったことと、有事の際にsshでインスタンスにログインして調査を行うために、下記の通り変更しました。

SecurityGroupIngress:

# hostポートが動的なため、ELBに対してエフェメラル開放
- SourceSecurityGroupId: !Ref SourceSecurityGroup
IpProtocol: TCP
FromPort: 1024
ToPort: 65535
# ClusterSshBastionSecurityGroupは踏み台サーバーのSecurityGroup
- SourceSecurityGroupId: !Ref ClusterSshBastionSecurityGroup
IpProtocol: TCP
FromPort: 22
ToPort: 22


Service

https://github.com/awslabs/ecs-refarch-continuous-deployment/blob/master/templates/service.yaml

ECSのサービス、タスクを定義しています。実際のアプリケーション周りの設定はここで行います。

例えば、コンテナで設定したい環境変数はEnvironmentブロックで定義します。


containerのログをCloudWatchに送る

こちらのスニペットにある通りですが、下記の設定を行うことでコンテナのログをCloudWatchへ送ることができます。

Resources:

CloudWatchLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub ${AWS::StackName}
RetentionInDays: 14 # 2週間

TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub ${AWS::StackName}
ContainerDefinitions:
- LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Sub ${AWS::StackName}
awslogs-region: ap-northeast-1
awslogs-stream-prefix: hogehoge
# その他propertyは一旦省略

LogGroupはなんでもよいですが、私はStackNameにしました。既存のものを流用するのであれば、上記のCloudWatchLogsのブロックは不要です。


DeploymentPipeline

https://github.com/awslabs/ecs-refarch-continuous-deployment/blob/master/templates/deployment-pipeline.yaml

今回の肝になる部分です。

Github -> CodePipeline -> CodeBuild -> ECS

の流れを自動化するCFn stackをつくります。

概ねサンプルのままで動きますが、ハマりどころが多かったです。


Pipelineブロックのイメージ

私が当時ほとんどCodePipelineを使ったことがなかったためでもありますが、templateだけ見ているとイメージがかなり掴みづらかったので、少し解説します。


1. Pipeline.Stages[0](Name: Source)

Githubからソースをクローンしてきます。

そして、ArtifactStoreで定義したLocationに{なにかのhash値}.zip(※)という名前で保存します。

正直、このサンプル(というかCFnの仕様?)の一番意味がわからないところです。

このName: SourceのOutputArtifactsはAppで、Name: BuildのStage(CodeBuild)に対してInputArtifactとしてAppを渡しています。

awslabsのyamlだと、そのInputArtifactを無視して、別ブロックで定義されたCodeBuildProjectsに記載の通りArtifactとしてBucketを直参照した上で、${ArtifactBucket}/source.zipを参照してCodeBuildを実行しています。

でも、source.zipなんてS3にはないんです、、、

CodeBuildのドキュメントには、



  • source-location: 必須値 (CODEPIPELINE に source-type を設定しない場合)。指定されたリポジトリタイプのソースコードの場所。


    • Amazon S3 では、ビルド入力バケット名の後に、スラッシュ (/) が続き、ソースコードとビルド仕様 (例: bucket-name/object-name.zip) を含む ZIP ファイルの名前が続きます。これは ZIP ファイルがビルド入力バケットのルートにあることを前提としています。(ZIP ファイルがバケット内のフォルダにある場合は、代わりに bucket-name/path/to/object-name.zip を使用してください)。




と記載されていますが、object-nameってなんやねん:joy:

でもこのままでうまく動きます。source.zip以外にしてみたことはないので、どうなってるのかはよくわかりません、、


2. Pipeline.Stages[1](Name: Build)

githubから取得したソースを元に、CodeBuildProjectブロックに記載の通りにdocker buildを実行します。そしてできあがったコンテナをECRにpushします。

はまりどころというわけでもないですが、私が一瞬こんがらがった点を少し。冷静になってみると当たり前なんですが、CodeBuildProjectのEnvironmentブロックは、docker buildを行うdockerコンテナ上の環境変数です。dockerfile内で使えるENVや、最終的なdockerコンテナ(タスク)に渡される環境変数ではありません。Dockerfileで外から変数を受取りたい場合、build.commandsのdockerコマンドに--build-args HOGE=hogeなどとargsを渡してください。


3. Pipeline.Stages[2](Name: Deploy)

ECRのリポジトリがタグ付きで更新されているので、ConfigurationにそのURIなど含めることでいい感じにタスク定義が更新され、service.ymlで定義したスタックが更新されます。(多分)

多分、と書いたのは、実は私は別の方法をとっています。

ここのConfigurationの記述、探したかぎりドキュメントが見つからず、、

- Name: Deploy

Actions:
- Name: Deploy
ActionTypeId:
Category: Deploy
Owner: AWS
Version: 1
Provider: ECS
Configuration:
ClusterName: !Ref Cluster
ServiceName: !Ref Service
FileName: images.json
InputArtifacts:
- Name: BuildOutput
RunOrder: 1

私は複数のコンテナをTaskDefinitionに含めており、1つのURIでは対応できませんでした。images.jsonをいい感じに書き換えればうまくいくのか、、わからなかったため、下記の方法をとりました。


複数コンテナを含むタスクをCodePipelineからアップデートする

やったことは、service.yamlをdeployment-pipeline.yamlにネストさせて、DeployアクションでStackの更新を行うようにしました。

- Name: Deploy

Actions:
- Name: Deploy
ActionTypeId:
Category: Deploy
Owner: AWS
Version: 1
Provider: CloudFormation
Configuration:
ChangeSetName: Deploy
ActionMode: CREATE_UPDATE
StackName: !Sub "${AWS::StackName}-Service"
Capabilities: CAPABILITY_NAMED_IAM
# githubからcloneしたファイルをパス指定
TemplatePath: App::cfn_templates/packaged_service.yml
RoleArn: !GetAtt CloudFormationExecutionRole.Arn
ParameterOverrides: !Sub |
{
"Tag" : { "Fn::GetParam" : [ "BuildOutput", "build.json", "tag" ] },
"DesiredCount": "${TaskDesiredCount}",
"Cluster": "${Cluster}",
"TargetGroup": "${TargetGroup}",
"EcrRepositoryPrefix": "${EcrRepositoryPrefix}",
"Environment": "${Environment}",
"S3Bucket": "${S3Bucket}"
}
InputArtifacts:
- Name: App
- Name: BuildOutput
RunOrder: 1

軽く解説を。


  • 事前にservice.yamlはaws cloudformation packageコマンドでpackageしておきます。

  • テンプレートはgithubから取得したリポジトリに含んでいるため、InputArtifact経由でのパス指定を行います。

  • ECR Repositoryのprefixと、buildした際のタグをservice.yamlにパラメータとして与えます


    • service.yaml側では下記のような形で、タグを含むURIを指定しておきます。



TaskDefinition:

Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub ${AWS::StackName}
ContainerDefinitions:
- Name: hoge
Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${EcrRepositoryPrefix}/hoge:${Tag}
            - Name: fuga
Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${EcrRepositoryPrefix}/fuga:${Tag}

これで、CodePipelineからCloudFormationのCREATE_UPDATEが行われ、めでたくTaskDefinitionの更新->Serviceの更新という順でDeployが行われます。

はまりどころとしては、ParameterOverridesのブロックが、1000文字しか使えません。もろもろ変数展開されたあとだと、結構簡単に1000文字超えて、

Please provide a valid JSON with maximum length of 1000 characters.

って言われます。

Forumにこんなissueがある程度で、(当時)ドキュメントは見つかりませんでした。

私は悩んだ末、はじめParameterOverridesに書いていた値を、yaml_vaultを使ってリポジトリ内で暗号化して管理するようにしました。


おわりに

すごくながくなりました。疲れました。

細かい所見直せてないかもしれませんが、クリスマスイブにこれ以上は悔しいので一度公開します。

このあたりは後日編集されるかもしれません。。

いろいろと個人的な感情が入っていますが、CloudFormationで環境作ってみたいなーという方の参考になれば幸いです。

えっ、Deploy周りはそんなことしないでCircle CI使えって?あーあー聞こえないー。

(一応今回はAWS内で完結することを目標にしてこの形に落ち着いています)