LIVESENSE Data Analytics Blog

リブセンスのデータ分析、機械学習、分析基盤に関する取り組みをご紹介するブログです。

CloudFormationでコンテナビルド用パイプラインをつくる

テクノロジカルマーケティング部の橋本です。 肩書的には分析基盤開発・保守を担当するエンジニアですが、近頃は基盤開発に限らず、データアナリストが推進するデータ活用施策をエンジニアの立場でサポートしており、施策実行のために必要となる周辺システムの開発も行っています。仕事とあらば、できる事はなんでもやります。

そんな中で、最近バッチ処理構築のお手伝いをすることが多かったのですが、そこで利用した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テンプレートとして記述していきます。

f:id:livesense-analytics:20190627171857j:plain
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.ymlbuild-logs.ymlで作成した論理名の変数を!ImportValueで参照しています。こうすることで、予め作成されたリソースの属性値を参照し、スタックの定義に用いることができます。この論理名は、テンプレートが実行される前にCFnに定義されている必要があり、順番を違える(CFnに論理名が未定義のままテンプレートを実行してしまう)と参照エラーになってスタックの作成が止まってしまいます。main.ymlDependsOn属性を設定したのはこのためです。

最後に、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ユーザを追加することもできます。

作業手順

  1. Githubリポジトリを作り、buildspec.ymlをmasterブランチのルートディレクトリに入れておく
  2. CodeBuildにOAuthを通しておく(次の項に記載)
  3. CFnテンプレートを適用する
  4. CodeBuildのbranch filterを設定しておく(「ちょっとだけ便利にしておく」に記載)
  5. デプロイユーザのIAMユーザを作り、クレデンシャルを発行しておく
  6. デプロイユーザのクレデンシャルを使って、Cronをサーバに設定する

使用方法

使用前に

CFnの操作はAdministrator権限のIAMユーザで行う

CFnがテンプレートにより実行するAWSへの操作については、CFnを実行したユーザの権限が引き継がれます。適切な権限がないとリソースの作成、変更に失敗するので、基本的にはAdministrator権限のユーザでしましょう。もしAdministrator権限が強力過ぎるとか、得られないような場合には、リソース管理に必要なPolicyを適切にアタッチしたユーザでCFnの操作をしなければなりません。

CodeBuildにデプロイユーザのGithubアカウントでOAuthを通しておく

CodeBuildがGithubにアクセスする際のアカウントを設定します。残念ながらこれはWebコンソールからしか実行できないようです。1回だけ設定しておけば、接続を解除しない限り再設定は不要です。

先ず、Administrator権限のIAMユーザで、CodeBuildを開いて、「ビルドプロジェクトを作成する」をクリック。

f:id:livesense-analytics:20190624171052p:plain
「ビルドプロジェクトを作成する」を選ぶ

「送信元」カードの「ソースプロバイダ」を、「Github」にして、「リポジトリ」の項目で「OAuthを使用して接続する」を選択し、「GitHubに接続」ボタンを押す。

f:id:livesense-analytics:20190624171117p:plain
GitHubに接続

するとGitHubのドメインにリダイレクトされるので、デプロイユーザアカウントで、OAuth認証を行う。

f:id:livesense-analytics:20190624171503p:plain
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して下さい。

ちょっとだけ便利にしておく

f:id:livesense-analytics:20190624171703p:plain
プライマリソースのウェブフックイベント

  1. CodeBuildの作成したビルドプロジェクトの画面で、「ビルドの詳細」タブを選択
  2. 「送信元」カードが現れるので、「編集」をクリック
  3. 「プライマリソースのウェブフックイベント」カードが現れるので・・・
    • 「イベントタイプ」をプッシュのみ選択
    • 「これらの条件でビルドを開始する」の「HEAD_REF」に^refs/(tags/(v\d\.\d|dev)|heads/master)$と入力しておく
  4. 「ソースの更新」をクリック

こうすると、

  • 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を楽しんで下さい。