テクノロジカルマーケティング部の橋本です。 肩書的には分析基盤開発・保守を担当するエンジニアですが、近頃は基盤開発に限らず、データアナリストが推進するデータ活用施策をエンジニアの立場でサポートしており、施策実行のために必要となる周辺システムの開発も行っています。仕事とあらば、できる事はなんでもやります。
そんな中で、最近バッチ処理構築のお手伝いをすることが多かったのですが、そこで利用したCloudFormationを使ったコンテナビルド用パイプラインの取り回しが良かったので、こちらでご紹介しようと思います。
CloudFormation(以下、CFn)は、AWS謹製のInfrastructure as Codeのフレームワーク、およびマネージドサービスの名前です。特徴としては
- AWSのサービスを記述するのに便利なAPI
- 宣言的記述
- 拡張多数(SAMなど)
と、Terraformの様に複雑なロジックや制御はできませんが、AWSで小〜中規模サービスを展開するには便利です。
背景
ディレクターやアナリストでもコードを書く!
弊社では「営業でもSQLを使う」という文化がありますが、アナリストや一部のディレクターはSQLだけでなくモデリング・スコアリングのためにRやPythonなどのコードを書いたりしています。これらのコードで有効性が期待されるものについては、そのまま実環境で試してみたいというニーズが当然のようにあり、そういう場合はバッチ処理の中でこれらのコードを動かすことになります。Cronで定時にキックするタイプの小粒なバッチ処理で、ワークフローエンジンを使う程ではないにしろ、定時にサービス各所からデータを集め、ディレクターやアナリストのロジックで加工・スコアリング等の処理を行い、それをプロダクトのDBに書き戻してサービス内で使う・・・といったものです。私がお手伝いしていたのは、そのコードが動く環境構築と基礎づくりで、それに対してディレクターやアナリストからPull Requestが飛んでくる・・・そんな状況でした。
でも、デプロイとか環境構築が・・・
ただ、それ専用にサーバを用意して、都度デプロイしたり保守運用するとエンジニアの手数の方が追いつかず・・・という場面がしばしばありました。コードが書けるとは言え、流石に非エンジニアの人達に本番サーバを操作する権限を与えて・・・というのも危険ですし、ランタイムのビルドやライブラリ依存関係が・・・アップデートが・・・と、それをディレクターやアナリストにやってもらうのも無理があります。都度デプロイ作業の手順書を・・・なんてやってられません。また、この状況だとアナリストも気軽にバッチ処理を追加したいと言い出しづらく、試行錯誤の回数が制限されてしまう可能性があります。データ活用においてこの状況は致命傷になりかねません。 ということで、アナリストやディレクターがPoCレベルの実装を安全に実環境でテストでき、かつ、エンジニアの負荷をかけずに済むように簡単・安全にデプロイできるしくみを作りたいというのが今回の背景にあったニーズです。
つくるもの・できるようになること
上記背景を受けて、Githubへのpushによってキックされ、変更入りのイメージがECRに自動でpushされる素朴なコンテナCDパイプラインを作ってみました。これをCronキックの都度イメージの更新を確認してサーバで走らせれば、ロジックの変更もランタイムのアップデートも、Githubにpushするだけで変更が本番環境に適用されます。
今回つくるパイプラインは以下の構成で、AWSのリソースはCFnテンプレートとして記述していきます。
こうすることで、
- バッチ処理のコード、ロジックはすべてGithubで管理できる
- コーダーはGithubの所定のブランチへ変更をpushすればよいだけになる
- CronサーバにはdockerとAWS CLIがインストールされ、クレデンシャルが配置されていればOK
- 他は特にいらない
- バッチ処理毎にランタイムやライブラリも自由に選べ、アップデートも比較的気楽にできる
- パイプラインはCFnテンプレートで記述されているので
- 同じ仕組みの横展開、撤収が容易にできる
- 部署間でのエンジニアへの技術協力、仕組みの譲渡が容易にできる
などなど、とても便利です。特に、
- コーダーの関心がGithubまでで完結し、デプロイ、リリースを気にしなくてよくなる
- ランタイム環境の影響範囲がコンテナに閉じる
- Infrastructure as Codeとして、仕組みがポータブルに受け渡しできる
といった辺りのメリットが好評でした。
用意するもの
- パイプラインを構築する AWSアカウントと、Administrator権限を持ったIAMユーザ
- rootユーザでの操作は危険なのでやらないこと
- Githubアカウントとリポジトリ
- バッチ処理を走らせるCronサーバ
- dockerとAWS CLIがインストールされていること
- デプロイユーザのクレデンシャルがインストールされていること
CFnによって作成されるリソースは
- CodeBuildプロジェクト
- ECRリポジトリ
- IAMロール
- CodeBuildにアタッチする
- CloudWatch Logs ロググループ
- CodeBuildのログを出力する
となります。それから別途、
- IAMユーザ(Cronサーバのデプロイユーザ)
を作成しておく必要があります。
実装
CFn
今回のテンプレートは、全てyaml形式で書いていきます。
先ず、nested stackの大枠から。
AWSTemplateFormatVersion: "2010-09-09" Description: Build Pipeline for xxxx Container Resources: EcrRepos: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: "https://s3.amazonaws.com/hoge-cloudformation-templates/xxxx/ecr-repos.yml" BuildLogs: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: "https://s3.amazonaws.com/hoge-cloudformation-templates/xxxx/build-logs.yml" IAMRoles: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: "https://s3.amazonaws.com/hoge-cloudformation-templates/xxxx/iam-roles.yml" DependsOn: - EcrRepos - BuildLogs CodeBuildProjects: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: "https://s3.amazonaws.com/hoge-cloudformation-templates/xxxx/codebuild.yml" DependsOn: IAMRoles
今回のスタックは2段にネストされており、スタック全体をまとめる上の階層のスタックがコレです。書いてあるとおり、それぞれ
- ECRリポジトリ
- CloudWatch Logsロググループ
- IAMロール
- CodeBuildプロジェクト
の、テンプレートが呼び出されています。スタック全体を作成・更新する際には、このyaml(main.yml)を
aws cloudformation deploy --template ./main.yml --stack-name xxxx-build --capabilities CAPABILITY_NAMED_IAM
のようにして呼び出して使うことになります。この時、各サブスタックのテンプレートとなるyamlは、予めS3の所定のバケットに配置されていなければなりません。こんな感じでupload.sh
を作り、それをテンプレート更新の都度、aws cloudformation deploy
の前に使う必要があるので注意です。これを忘れてしまうと、テンプレートが更新されないままdeployが走ってしまいます。
BUCKET=s3://hoge-cloudformation-templates/xxxx aws s3 cp ecr-repos.yml ${BUCKET}/ecr-repos.yml aws s3 cp build-logs.yml ${BUCKET}/build-logs.yml aws s3 cp iam-roles.yml ${BUCKET}/iam-roles.yml aws s3 cp codebuild.yml ${BUCKET}/codebuild.yml
S3のバケット名は任意のものを使って下さい。
依存関係をもつスタックにはDependsOn
属性が設定されており、依存するスタックの名前が書かれています。これが設定されているスタックは、依存するスタックが作成された後に作成が開始されます。上記スタックが依存している内容は、後ほど具体的に確認していきます。
次に、ECRリポジトリのスタック(ecr-repos.yml)を見ていきます。
AWSTemplateFormatVersion: "2010-09-09" Description: ECR Repository for xxxx Resources: xxxxECR: Type: "AWS::ECR::Repository" Properties: RepositoryName: xxxx Outputs: xxxxECRArn: Value: !GetAtt xxxxECR.Arn Export: Name: xxxx-ECR xxxxECRName: Value: !Ref xxxxECR Export: Name: xxxx-ECR-Name
Resources
フィールドには、作成されるリソースについて記述されています。ここではxxxx
という名前のECRリポジトリを作成しています。
Outputs
フィールドには、このスタックについて他のスタックから外部参照される変数の名前と値が書かれています。例えば、xxxxECRArn
フィールドの記述によって、CFnにxxxx-ECR
という論理名の変数が作成され、その値は!GetAtt
関数でxxxxECR.Arn
の値が設定されます。これは、xxxxECR
リソース(ECRリポジトリ)のARN(AWS Resource Names)になっていて、このスタックの作成が完了すると、外部のスタックからは!ImportValue
関数と論理名(xxxx-ECR
)でこのECRリポジトリのARNにアクセスできるようになります。こんな感じです。
- !ImportValue xxxx-ECR
この外部参照の使い方は、後に紹介するスタックでまた確認します。
次は、CodeBuildがログを排出するCloudWatch Logs ロググループのスタックです。
AWSTemplateFormatVersion: "2010-09-09" Description: CloudWatchLogs for xxxx CodeBuild Resources: xxxxBuildLogs: Type: "AWS::Logs::LogGroup" Properties: LogGroupName: xxxx-build RetentionInDays: 60 Outputs: xxxxBuildLogsArn: Value: !GetAtt xxxxBuildLogs.Arn Export: Name: xxxx-CloudWatchLogs-BuildLogGroup xxxxBuildLogsName: Value: !Ref xxxxBuildLogs Export: Name: xxxx-CloudWatchLogs-BuildLogGroup-Name
こちらもecr-repos.yml
と、ほぼ同様の構成です。
次に、CodeBuildプロジェクトにアタッチする各種権限(IAMロール)のスタックです。このスタックでは、作成したIAMロールに、以下のポリシーをアタッチしています。
- ビルドしたイメージを格納するECRリポジトリの読み書き権限
- ビルド時のログを格納するCloudWatchLogsログストリームを、
xxxx-CloudWatchLogs-BuildLogGroup
ロググループに作成する権限 xxxx-CloudWatchLogs-BuildLogGroup
ロググループのログストリームに、ログを書き込む権限
AWSTemplateFormatVersion: "2010-09-09" Description: IAM Role for xxxx CodeBuild Resources: RoleCodeBuild: Type: "AWS::IAM::Role" Properties: RoleName: xxxx-codebuild AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: "codebuild.amazonaws.com" Action: "sts:AssumeRole" Policies: - PolicyName: xxxx-codebuild-role PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "ecr:GetDownloadUrlForLayer" - "ecr:BatchGetImage" - "ecr:BatchCheckLayerAvailability" Resource: - !ImportValue xxxx-ECR - Effect: Allow Action: - "ecr:CompleteLayerUpload" - "ecr:InitiateLayerUpload" - "ecr:UploadLayerPart" - "ecr:PutImage" Resource: - !ImportValue xxxx-ECR - Effect: Allow Action: - "ecr:GetAuthorizationToken" Resource: - "*" - Effect: Allow Action: - "logs:CreateLogStream" - "logs:PutLogEvents" Resource: - !ImportValue xxxx-CloudWatchLogs-BuildLogGroup - !Join - ":" - - !ImportValue xxxx-CloudWatchLogs-BuildLogGroup - "log-stream:*" Outputs: RoleARN: Value: !GetAtt RoleCodeBuild.Arn Export: Name: xxxx-Role-CodeBuild
iam-roles.yml
では、ecr-repos.yml
とbuild-logs.yml
で作成した論理名の変数を!ImportValue
で参照しています。こうすることで、予め作成されたリソースの属性値を参照し、スタックの定義に用いることができます。この論理名は、テンプレートが実行される前にCFnに定義されている必要があり、順番を違える(CFnに論理名が未定義のままテンプレートを実行してしまう)と参照エラーになってスタックの作成が止まってしまいます。main.yml
でDependsOn
属性を設定したのはこのためです。
最後に、CodeBuildプロジェクトのスタックです。
AWSTemplateFormatVersion: "2010-09-09" Description: CodeBuild Project for xxxx Container Resources: xxxxCodeBuild: Type: "AWS::CodeBuild::Project" Properties: Name: !ImportValue xxxx-ECR-Name Artifacts: Type: NO_ARTIFACTS Environment: Type: LINUX_CONTAINER Image: "aws/codebuild/docker:18.09.0" ComputeType: BUILD_GENERAL1_SMALL EnvironmentVariables: - Name: IMAGE_REPO_NAME Type: PLAINTEXT Value: !ImportValue xxxx-ECR-Name - Name: AWS_ACCOUNT_ID Type: PLAINTEXT Value: !Ref "AWS::AccountId" ServiceRole: !ImportValue xxxx-Role-CodeBuild Source: Type: GITHUB GitCloneDepth: 1 Location: "https://github.com/path/to-your-project-repo" Triggers: Webhook: true LogsConfig: CloudWatchLogs: Status: ENABLED GroupName: !ImportValue xxxx-CloudWatchLogs-BuildLogGroup-Name
これで、AWSにコンテナビルドのパイプラインが作成されます。
Githubリポジトリ
ビルドの対象になるGithubリポジトリのコードには、CodeBuildが実行するビルドの手順を記したbuildspec.yml
を、リポジトリのルートに配置します。こんな感じです。
version: 0.2 phases: pre_build: commands: - echo Logging in to Amazon ECR... - $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION) build: commands: - echo Build started on `date` - echo Building the Docker image... - IMAGE_TAG=`echo $CODEBUILD_WEBHOOK_TRIGGER | awk -F/ '{print $2}' | sed 's/master/latest/'` - docker build -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: - echo Build completed on `date` - echo Pushing the Docker image... - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
これで、CodeBuildでビルドされたイメージが、CFnで設定したECRリポジトリにpushされます。
Cronサーバ
Cronサーバでは、実行の都度、docker pull
した後にdocker run
します。こんな感じのシェルスクリプトを毎回実行します。
export AWS_CONFIG_FILE=/home/xxxx/.aws/config export AWS_SHARED_CREDENTIALS_FILE=/home/xxxx/.aws/credentials IMAGE="xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx:latest" $(aws ecr get-login --region ap-northeast-1 --no-include-email) docker pull ${IMAGE} docker run --rm --env-file=prod.env ${IMAGE}
クレデンシャルは、デプロイユーザを作成してアクセスキー、シークレットアクセスキーを発行して下さい。CFnでパイプラインのスタックを作成した後、下記の要領でテンプレートを実行し、IAMユーザを作成します。
AWSTemplateFormatVersion: "2010-09-09" Description: IAM Users and Group for xxxx Resources: DevelopersGroup: Type: "AWS::IAM::Group" Properties: GroupName: xxxx-developers Policies: - PolicyName: xxxx-developers-policy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "ecr:BatchCheckLayerAvailability" - "ecr:GetDownloadUrlForLayer" - "ecr:GetRepositoryPolicy" - "ecr:DescribeRepositories" - "ecr:ListImages" - "ecr:DescribeImages" - "ecr:BatchGetImage" Resource: - !ImportValue xxxx-ECR - Effect: Allow Action: - "ecr:GetAuthorizationToken" Resource: - "*" DeployUser: Type: "AWS::IAM::User" Properties: Groups: - !Ref DevelopersGroup UserName: xxxx_deploy
一応グループも作ったので、後からここへIAMユーザを追加することもできます。
作業手順
- Githubリポジトリを作り、
buildspec.yml
をmasterブランチのルートディレクトリに入れておく - CodeBuildにOAuthを通しておく(次の項に記載)
- CFnテンプレートを適用する
- CodeBuildのbranch filterを設定しておく(「ちょっとだけ便利にしておく」に記載)
- デプロイユーザのIAMユーザを作り、クレデンシャルを発行しておく
- デプロイユーザのクレデンシャルを使って、Cronをサーバに設定する
使用方法
使用前に
CFnの操作はAdministrator権限のIAMユーザで行う
CFnがテンプレートにより実行するAWSへの操作については、CFnを実行したユーザの権限が引き継がれます。適切な権限がないとリソースの作成、変更に失敗するので、基本的にはAdministrator権限のユーザでしましょう。もしAdministrator権限が強力過ぎるとか、得られないような場合には、リソース管理に必要なPolicyを適切にアタッチしたユーザでCFnの操作をしなければなりません。
CodeBuildにデプロイユーザのGithubアカウントでOAuthを通しておく
CodeBuildがGithubにアクセスする際のアカウントを設定します。残念ながらこれはWebコンソールからしか実行できないようです。1回だけ設定しておけば、接続を解除しない限り再設定は不要です。
先ず、Administrator権限のIAMユーザで、CodeBuildを開いて、「ビルドプロジェクトを作成する」をクリック。
「送信元」カードの「ソースプロバイダ」を、「Github」にして、「リポジトリ」の項目で「OAuthを使用して接続する」を選択し、「GitHubに接続」ボタンを押す。
するとGitHubのドメインにリダイレクトされるので、デプロイユーザアカウントで、OAuth認証を行う。
ここまでの手順でCodeBuildの画面に戻ってくるので、そのままビルドプロジェクト作成をキャンセルすればOKです。
テンプレートを配置するバケットを作成しておく
任意のバケット名で作成して下さい。これも、最初に1度だけ行えばOKです。
aws s3 mb s3://hoge-cloudformation-templates/
スタック作成・更新
ここまでくれば、あとはコマンド一発です。ポチッとな。
./upload.sh aws cloudformation deploy --template ./main.yml --stack-name xxxx-build --capabilities CAPABILITY_NAMED_IAM
スタックの作成に失敗した場合は、一旦スタックを削除して作り直して下さい。
aws cloudformation delete-stack --stack-name xxxx-build
スタック削除時の注意点
- ECRにイメージが残っていると失敗します。イメージは全て削除して下さい。
- CloudWatch Logsロググループに、ログストリームが残っていると失敗します。ログストリームは全て削除してください。
- IAMロールにポリシーがアタッチされていると失敗する時があります。その場合は、ロールのポリシーを全てデタッチしてからdeleteして下さい。
ちょっとだけ便利にしておく
- CodeBuildの作成したビルドプロジェクトの画面で、「ビルドの詳細」タブを選択
- 「送信元」カードが現れるので、「編集」をクリック
- 「プライマリソースのウェブフックイベント」カードが現れるので・・・
- 「イベントタイプ」を
プッシュ
のみ選択 - 「これらの条件でビルドを開始する」の「HEAD_REF」に
^refs/(tags/(v\d\.\d|dev)|heads/master)$
と入力しておく
- 「イベントタイプ」を
- 「ソースの更新」をクリック
こうすると、
- masterブランチにpushがあった時にビルド
v*.*
やdev
というタグが打たれた時に、そのブランチをビルド
という挙動にできます。
あと、何回も作成と削除を試していると、こんなのにも遭遇するので参考まで。
補足
IAMユーザはスタックとは別に管理
今回、パイプラインのスタックとは別にIAMユーザのスタックを作成しましたが、これは、IAMユーザに別のスタックに対する権限等をアタッチする場合を考えてのものです。例えば、前項で作成したIAMユーザに別リポジトリのアクセス権限を出したければ、論理名でこんな感じに追加してスタックを更新するだけで簡単に権限を追加できます。
- "ecr:DescribeRepositories" - "ecr:ListImages" - "ecr:DescribeImages" - "ecr:BatchGetImage" Resource: - !ImportValue xxxx-ECR - !ImportValue yyyy-ECR <-- こんな感じ
IAMユーザは、パイプラインのスタックとは独立して作成し、後から依存するリソースへの参照を入れてやる・・・という作業順序になります。
更に・・・
今回はプロジェクト名をxxxx
としてハードコーディングしましたが、コレをパラメータで抽象化し、汎化することができます。この時、Outputの論理名がCFn全体で重複してしまうとダメなので、Outputの論理名自体にパラメータを入れてやる必要があり、若干工夫が必要になります。長くなるので説明を省きますが、コードだけ書くと
出力側
Parameters: ProjectName: Type: String Default: xxxx ... Outputs: ECRArn: Value: !GetAtt ECR.Arn Export: Name: !Sub "${ProjectName}-ECR"
参照側
Parameters: ProjectName: Type: String Default: xxxx ... Resource: Fn::ImportValue: !Sub "${ProjectName}-ECR"
として、こんな感じに呼び出してやります。
aws cloudformation deploy --template ./main.yml \ --stack-name zzzz-build \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides \ ProjectName=zzzz
おわりに
今回ご紹介したのは、データエンジニアリング特有の技術というわけではありませんが、リブセンスではこういった技術も随時活用して、アナリストやディレクターが自分たちの作ったロジックを試しやすい環境、ビジネスにおけるデータ活用を簡単で安全に推進できる環境作りに力をいれています。
所々躓きポイントがあり、使い慣れるまで若干修行感のあるCFnですが、使い慣れてくると記述が単純なので楽です。もっとも、スマートな記述、美しいコード・・・とかいう世界とは無縁の泥臭い感じになるので、スタックを立てては消し、立てては消し・・・という職人的コーディング作業になります。こういうのが楽しめる感じになってくると、手の混んだ芸術的テンプレがムクムクと育って・・・。Systems Manager パラメータストアなんて機能もあるので、こんなので外部から設定を注入するとか、まだまだ色々できそうです。
「小〜中規模サービスを展開するには便利」と書きましたが、大規模インフラの記述もやってできない事はないと思います。ただし、上記の規模でもそれなりに煩雑なので、1つのスタックで記述するのが難しいと感じる程度までインフラが育ったら、そこがサブスタック分割のポイントと考えるのが良さそうです。単純・素朴を常に心がけてインフラを育てたいものです。
CFnで、ぜひお手軽IaCを楽しんで下さい。