dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

AWS CodeBuild+AWS SAM(Lambda)+Slackで最高なAndroid CI環境を作る

こんにちは。Androidエンジニアのうめもりです。

この記事はdely Advent Calendar 2018の1日目の記事です。

アドベントカレンダーについてはQiitaでもAdventarで公開していますので、ほかの記事については是非そちらから見てください。

Qiita: https://qiita.com/advent-calendar/2018/dely

Adventar: https://adventar.org/calendars/3535

DroidKaigi 2019楽しみですね。自分も3つほどセッションを投稿したのがめでたく1つ採択されまして、今からしっかり準備せねばと発表内容を作っている日々です。

「Android Vitals徹底活用」というタイトルで発表しますので、興味ある方いれば是非聞きに来てもらえるとうれしいです。

さて、今日は投稿したもので採用されなかったものうちの1つについて、せっかくですのでブログ記事としてまとめておこうと思います。

提出したセッションの概要についてはこんな感じでした。

AndroidでCIしてますか?

2018年現在では活用できるCIサービスが様々存在することもあり、AndroidのCI環境を構築することは特に複数人が開発にかかわるような環境では一般的なことになっていることと思います。その中でもAWS CodeBuildは1分単位の実行時間での課金体系、同時実行タスク数の制限が他サービスに比べてゆるい、など特徴的なCIサービスであることもあり、一度は導入を検討した方もいるのではないでしょうか。

しかし、AWS CodeBuildは特に他サービスとの連携周りの機能が貧弱なこともあり、様々な利点がありながらも導入されない、といったことが多いのではないかと思います。本発表ではAWS SAMを活用してAWS CodeBuild最大限に活用する方法について説明します。なお、本発表内のAmazon LambdaのコードはGolangを使用しています。

Androidのカンファレンスですが、実際はあんまりAndroidの話はしてないですねw

では内容に入っていきたいと思います。

delyのAndroid開発ではこんなことをCIbotにやってもらってます

2018年現在Android開発においてはCIを導入していないということはそうそうないことだと思うのですが、delyのAndroid開発でももちろんCIを導入しています。

Slack上のbot、そしてGitHubのWebHookからほとんどの機能を呼び出すように作っており、2018年12月1日現在ではこんなことができるようになっています。

  • GitHub上でPullRequestを作る、更新するたびにビルド、自動テスト、Lintを行う機能
  • Slack上からUIテスト(AWS DeviceFarm)を呼び出す機能
  • Slack上からDeployGateに自動ビルドされたAPKをアップロードしたうえでSlackにURLと最新のRelease Noteを通知する機能
  • Slack上からリリースブランチ、ホットフィックスブランチをオープンし、(メジャー/マイナー/パッチ)リリースごとにバージョン番号を自動でインクリメントする機能
  • 最新バージョンをリリースする際にGitHubのリリースページを作成し、Release Noteを自動で生成し登録する機能
  • Slack上からGoogle Playに自動ビルドされたAPKとProguardのマッピングファイルをアップロードし、アルファテストトラックに公開する機能
  • Slack上から特定のブランチのコードを使ってDockerのビルド用イメージを生成し、Amazon ECRにアップロードする機能

以上の機能がSlackやGitHubのWebHookごしに呼び出せるようになっています。これらの機能が自動で呼び出せる環境は、2018年現在においてもそこそこ頑張っているような気がしますが実際どうなんでしょう?(もしかして当たり前だったりしますか?)

このすべてのやり方をここに書いてしまうととても長くなってしまうので、実際に肝になる部分の一部だけ今回は記事として共有しますが、また別途細かく説明する記事を書こうかと思っています。

これらの機能をどのように実現しているか

では実際、これらの機能をどんなツールやサービスを使って実現しているか紹介します。

CIをやるにはとりあえずビルド用の環境が必要なのですが、それは以下のものを使って実現しています。

  • AWS CodeBuild
  • Amazon ECR

AWS CodeBuildは優秀なのにデフォルトの外部連携などが貧弱なせいで(Code Pipelineを使う方法やPush Hookはありますが、正直不足ですよね)あまり話題になっていない印象がありますが、delyのAndroid開発ではこちらをフル活用しています。CodeBuildを使う上で必要不可欠なのがAmazon ECR(Dockerのリポジトリサービス)なのですが、こちらにアップロードしたイメージをCodeBuildから使うと、ビルド用のDockerイメージのダウンロード速度が爆速になります。CircleCIを使っていた際もオリジナルのビルドイメージを使っていると5分~10分待たされたりすることがありましたが、こちらの環境に切り替えてからそういった待ち時間はほとんど発生しなくなりました。delyのAndroid開発ではなるべく依存関係を一緒くたに入れたイメージをAmazon ECRにアップロードすることで、いきなりビルドやテストから走るようにして、ビルド時間を削減しています。

  • AWS SAM(Lambda)

CodeBuildの連携周りの貧弱さを補うために、AWS SAMを活用しています。Slackからの呼び出しや、GitHubのWebHookをこちらで構成したAPI Gatewayで受けて、実際のCodeBuildの呼び出し等を行っています。

全体の構成図

f:id:delyumemori:20181201152258p:plain

全体の構成はこんな感じになっています。

実際はGoogle Play Developer APIを使っていたりもしますが、今回はAWS CodeBuild周りの仕組みについて、そちらを採用した理由について説明します。

AWS CodeBuildを採用した理由

1分単位で使った分だけ課金

大抵のCIサービスだと、同時ビルド実行可能数を増やすごとに月額料金が増えるという形になっているところが多いと思いますが、AWS CodeBuildはシンプルにビルド時間が1分かかるごとに課金される課金体系になっています。

料金表はこちら https://aws.amazon.com/jp/codebuild/pricing/ ですが、一番大きいインスタンスサイズであるbuild.general1.large(15GBメモリ、8vCPU)でも、ビルド1分当たりの料金は0.020USDです。1回自動ビルドやテストを回すたびに数十円程度の料金体系なので、まず導入してみる、という時に非常に良いのではないでしょうか。(結局そのまま本採用しました)

ゆるい同時実行ビルド数の制限

https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/limits.html こちらにAWS CodeBuildの制限について書いてあるのですが、他のCIサービスに比べて圧倒的に同時実行ビルド数の制限が緩いです。導入直後は5個のビルドを同時実行できますが、通常は20個のビルドを同時実行できるようになっています。複数人でゴリゴリ開発しても、キューイングされたビルドを待つことはあまりないですね。

Dockerイメージの取得が爆速(Amazon ECRを使った際)

前述したとおりですが、CodeBuildとECRを連携するとDockerイメージの取得が爆速になります。これだけでもCodeBuildを採用する価値はあるのではないでしょうか。

CodeBuild上でのビルドの仕方は基本的な使い方をしているだけですが、Dockerイメージ構築の際に気を付けていることについて共有しておきます。(といってもDockerイメージを使ってビルドする際には当たり前の話ですが…)

ビルド用Dockerイメージ構築の際に気を付けていること

依存ライブラリもイメージに含めてしまう

もちろんCodeBulidにもキャッシュ機能があるので依存ライブラリはそちらに入れてしまう、というのもありなのですが、キャッシュの展開にもそれなりの時間がかかります。先にGradleのビルドを一回走らせてしまうことで、ビルドに必要な依存ライブラリは全てビルドイメージ上に展開された状態にしています。

Docker Multi-stage buildでイメージサイズを圧縮する

上記のことをやると、どうしても無駄はソースコード等がイメージに残りがちです。

https://docs.docker.com/develop/develop-images/multistage-build/

Docker 17.05から導入されたこちらのDocker multi-stage bulidの機能を使って、余計なファイルがイメージ上に残らないように工夫しています。

ビルド用dockerfile

以下が弊社で使っているビルド用のdockerfileです。

dangerやマーケティング用のツール、jsonパース用のjqなどを使うため、それらの依存関係も含んでいます。こういったものをイメージに含めておくこともできるのはDockerイメージを自前で管理していることの利点ですね。

FROM openjdk:8u141-jdk-slim AS intermediate

ENV ANDROID_SDK_FILENAME=sdk-tools-linux-3859397.zip
ENV JQ_PATH=/usr/local/jq
ENV ANDROID_HOME=/opt/android-sdk-linux
ENV ANDROID_SDK_URL="http://dl.google.com/android/repository/${ANDROID_SDK_FILENAME}" \
    ANDROID_API_LEVELS=android-16,android-26,android-27,android-28 \
    ANDROID_BUILD_TOOLS_VERSIONS=27.0.3,28.0.2
ENV APKTOOL_PATH=/usr/local/apktool
ENV ADJUST_DTT_PATH=/usr/local/adjust_dtt
ENV PATH=${PATH}:${ANDROID_HOME}/tools/bin:${JQ_PATH}:${APKTOOL_PATH}:${ADJUST_DTT_PATH}
ENV GRADLE_USER_HOME=/usr/local/gradle

RUN apt-get -y update && \
    apt-get -y --no-install-recommends install libc6-i386 lib32stdc++6 lib32gcc1 lib32ncurses5 lib32z1 \
      uni2ascii python-pip python-yaml \
      ruby \
      wget git curl ssh && \
    apt-get -y install ruby-dev make gcc && \
    gem install bundler && \
    gem install ruby-ll && \
    pip -q install awscli && \
    mkdir $JQ_PATH && cd $JQ_PATH && \
    wget -q -O- https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 > jq && chmod +x $JQ_PATH/jq && \
    mkdir $APKTOOL_PATH && cd $APKTOOL_PATH && \
    wget -q -O- https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool > apktool && chmod +x $APKTOOL_PATH/apktool && \
    wget -q -O- https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.3.4.jar > apktol.jar && chmod +x $APKTOOL_PATH/apktool.jar && \
    mkdir $ADJUST_DTT_PATH && cd $ADJUST_DTT_PATH && \
    wget -q -O- https://raw.githubusercontent.com/adjust/android_sdk/master/tools/adjust-dtt > adjust-dtt && chmod +x $ADJUST_DTT_PATH/adjust-dtt && \
    mkdir $ANDROID_HOME && cd $ANDROID_HOME && \
    wget -q ${ANDROID_SDK_URL} && \
    unzip ${ANDROID_SDK_FILENAME} && \
    rm ${ANDROID_SDK_FILENAME} && \
    mkdir ${GRADLE_USER_HOME} && \
    echo y | sdkmanager $(echo ${ANDROID_BUILD_TOOLS_VERSIONS} | sed 's/,/\n/g' | sed -E 's/(.+)/build-tools;\1/g' | tr '\n' ' ') "platform-tools" "extras;android;m2repository" "extras;google;m2repository" "extras;google;google_play_services" $(echo ${ANDROID_API_LEVELS} | sed 's/,/\n/g' | sed -E 's/(.+)/platforms;\1/g' | tr '\n' ' ')

WORKDIR /workspace

# Resolve Dependencies
FROM intermediate AS dependencies

COPY . /workspace

RUN ./gradlew --project-cache-dir=${GRADLE_USER_HOME} clean lintDevDebug assembleDevDebug

# Resolve Bundle
FROM intermediate

COPY Gemfile /workspace

RUN bundle install

# Copy Gradle Caches
COPY --from=dependencies /usr/local/gradle /usr/local/gradle

AWS CodeBuildをAPIからキックして動かす

AWS CodeBuildは、デフォルトではAWS CodePipelineからの呼び出し、またはGitHub等と連携してPush時に自動でビルドを行うことが可能です。なんとなくCIを回してテストコードを走らせる、あるいはデプロイパイプラインとして使うにはこれだけでも十分なのですが、例えば

  • PullRequestを作ったタイミングでビルドを回し、Danger等を使って自動でPullRequestのレビューを行う
  • デバッグ用のビルドと本番用のビルドを同じブランチから分岐してビルドさせる
  • 通常と違うパターンのビルドに対応させる

などは以上の手法を使っていると困難です。幸い、CodeBuildのAPIには外からビルドタスクを任意の設定で走らせるものがあり、こちらを使うことで細かいビルドタスクに対応することが可能です。

SDKから呼び出す

https://docs.aws.amazon.com/codebuild/latest/APIReference/API_StartBuild.html

実際にビルドタスクを呼び出すためのAPIはこちらです。弊社はLambda上のコードから呼び出していますが、GoのSDKでの呼び出し方はこんな感じになっています。

out, err := m.codeBuild.StartBuildWithContext(ctx, codebuild.StartBuildInput{
    ProjectName:   aws.String("kurashiru-android-ci"),
    SourceVersion: aws.String(branchName),
    EnvironmentVariablesOverride: []*codebuild.EnvironmentVariable{
        {
            Name:  aws.String("CI_SPLIT_APK"),
            Value: aws.String(strconv.FormatBool(splitApk)),
        },
        {
            Name:  aws.String("CI_BUILDTASK"),
            Value: aws.String(buildTask),
        },
        {
            Name:  aws.String("CI_BRANCH_NAME"),
            Value: aws.String(branchName),
        },
        ...
    },
})

基本的な設定はコンソールで行っておくとして、肝はSourceVersionとEnvironmentVariablesOverrideの部分です。SlackやGitHubからビルドタスクを呼び出す際は、SourceVersionにブランチ名などを指定し、EnvironmentVariablesOverrideにビルドごとに異なる環境変数を設定し、実際にCodeBulid上で走るシェルスクリプトでその環境変数を利用して処理内容を分岐しています。

AWS SAMを使ってSlackとGitHubからCodeBuildを動かす

上記のAPIを使い、SlackやGitHubからCodeBuildのビルドタスクを走らせています。基本的には、

$ sam init

からCloud Formationのテンプレートを生成し、そこからLambdaの構成を作っていきます。

生成されたyamlを使って実際にAWS上に環境を構築するコマンドはこんな感じです。

$ aws cloudformation package \
    --template-file template.yml \
    --s3-bucket <ビルドされたlambda用バイナリのアップロード先のS3バケット> \
    --s3-prefix <ビルドされたlambda用バイナリのアップロード先のS3のパスのprefix> \
    --output-template-file .template.yml
$ aws cloudformation deploy \
    --template-file .template.yml \
    --stack-name <デプロイ先にstack名> \
    --capabilities CAPABILITY_IAM

実際はこれをシェルスクリプトに記述してそれを実行するようにしています。

CloudFormationのテンプレートファイルの例

弊社のCI botを構成しているCloud Formationのテンプレートを共有しておきます。

Google Play Developer APIのための記述も入っているので少し長いのですが、AWS SAMではLambdaのパーミッションの定義やAPI Gatewayの定義も一緒に書くことができるのが特徴です。

CI botの構成をすべてここで表現できるので、メンテナンスもしやすくていいですね。

AWSTemplateFormatVersion: "2010-09-09"
Transform: 'AWS::Serverless-2016-10-31'
Parameters:
  SlackToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /rebecca/slack_token
  GitHubToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /rebecca/github_token
  GoogleApiCredentials:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /rebecca/google_api_credentials
Resources:
  LambdaIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        -
          PolicyName: "rebecca_lambda"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action: "s3:ListBucket"
                Resource: "arn:aws:s3:::anonymous-ci-artifacts"
              -
                Effect: "Allow"
                Action: "s3:GetObject"
                Resource: "arn:aws:s3:::anonymous-ci-artifacts/*"
              -
                Effect: "Allow"
                Action:
                  - "codebuild:StartBuild"
                  - "codebuild:StopBuild"
                  - "codebuild:ListBuildsForProject"
                  - "codebuild:BatchGetBuilds"
                Resource: "arn:aws:codebuild:ap-northeast-1:xxxxxxxxxxxxx:project/*"
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:DescribeLogGroups"
                  - "logs:DescribeLogStreams"
                  - "logs:PutLogEvents"
                  - "logs:GetLogEvents"
                  - "logs:FilterLogEvents"
                Resource: "*"
  SlackApp:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: lambda-rebecca-slack
      Runtime: go1.x
      CodeUri: build/lambda-rebecca-slack.zip
      Timeout: 300
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          REBECCA_SLACK_TOKEN: !Ref SlackToken
          REBECCA_GITHUB_TOKEN: !Ref GitHubToken
          REBECCA_GOOGLE_API_CREDENTIALS: !Ref GoogleApiCredentials
      Events:
        Post:
          Type: Api
          Properties:
            Path: /slack
            Method: post
  GitHubApp:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: lambda-rebecca-github
      Runtime: go1.x
      CodeUri: build/lambda-rebecca-github.zip
      Timeout: 300
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          REBECCA_SLACK_TOKEN: !Ref SlackToken
          REBECCA_GITHUB_TOKEN: !Ref GitHubToken
          REBECCA_GOOGLE_API_CREDENTIALS: !Ref GoogleApiCredentials
      Events:
        Post:
          Type: Api
          Properties:
            Path: /github
            Method: post

さて、それでは説明に入っていきます。

API GatewayのエンドポイントをCloud Formationから作成し、Lambdaと紐づける

  SlackApp:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: lambda-rebecca-slack
      Runtime: go1.x
      CodeUri: build/lambda-rebecca-slack.zip
      Timeout: 300
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          REBECCA_SLACK_TOKEN: !Ref SlackToken
          REBECCA_GITHUB_TOKEN: !Ref GitHubToken
          REBECCA_GOOGLE_API_CREDENTIALS: !Ref GoogleApiCredentials
      Events:
        Post:
          Type: Api
          Properties:
            Path: /slack
            Method: post

こちらがAWS SAMの中で、SlackのWebHook用のLambdaの記述です。Properties.Events以下がAPI Gateway用の定義になっています。

      Events:
        Post:
          Type: Api
          Properties:
            Path: /slack
            Method: post

重要なのはType: Apiの指定、そしてPathとMethodをPropertiesで指定することです。これによりこのCloudFormationで構成されたAPI Gatewayの/slackのURLをSlackのWebHookのURLとして指定することで、実際にSlackのWeb Hookを受け取ることができるようになっています。

Goでこちらのリクエストを受け付けるには、

func handleRequest(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
  ...
}

func main() {
    lambda.Start(handleRequest)
}

こんな感じのコードで受け付けられます。シグネチャがシンプルでいいですね。

AWS Systems ManagerのParameter Storeを使って環境変数の設定項目をリポジトリに入れなくて済むようにする

CloudFormationのテンプレートファイルにAPIのトークンを入れてしまうのは、このテンプレートファイル自体をGitリポジトリ等で管理することを考えるとあまりよくはありません。弊社はAWS Systems ManaerのParameter Storeの機能を使うことで、デプロイ時にAPIのトークンを設定してデプロイするようにしています。

詳しくはこちらに書いてありますので、こちらをご参照ください。

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-paramstore.html

実際にAWS SAMの中でこちらで定義したパラメータを適用するには、

Parameters:
  SlackToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /rebecca/slack_token
  GitHubToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /rebecca/github_token
  GoogleApiCredentials:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /rebecca/google_api_credentials

こちらでパラメータ名を定義しておき、

      Environment:
        Variables:
          REBECCA_SLACK_TOKEN: !Ref SlackToken
          REBECCA_GITHUB_TOKEN: !Ref GitHubToken
          REBECCA_GOOGLE_API_CREDENTIALS: !Ref GoogleApiCredentials

このように環境変数を設定する側で、あらかじめ定義していたパラメータ名を参照するだけで出来るようになっています。

Slack用、GitHub用に走らせるLambda用のバイナリを切り分けてデプロイする方法

CI botとなるとやはりSlack、GitHubなど、様々なところからのトリガーでビルドを走らせたくなりますが、それをすべて同一のLambdaのエンドポイントで処理するのは保守性の面から考えてもあまりよくありません。弊社はそのそれぞれを別のバイナリとして切り分けてビルドしています。

といっても、単に

+ /
+ /github
    - main.go
+ /slack
    - main.go

とそれぞれmainパッケージを作り、別々にデプロイパッケージを作っているだけです。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-go-how-to-create-deployment-package.html

こちらにGoを使った場合のデプロイパッケージの作り方が書いてあります。

最後に注意点

大分長くなってしまったので、今回は最後にCI botを実装するうえでハマったポイントをご紹介しておきます。

SlackのWebHook連携上の注意点

SlackのEvents APIのWebHookを実装する上で一つ問題になるのが、 3秒でレスポンスを返さないとエラーになり、リトライのリクエストが飛んでくる という点です。どうしてもCI周りのタスクは即座にレスポンスを返せないタスクもあり、レスポンスだけ返して処理をするという方法も、例えば別のLambdaを呼ぶなどの方法で実現できますがどうしても煩雑になりがちなので、リトライリクエストを無視するという力技で対応しています。

  if req.Headers["X-Slack-Retry-Reason"] != "" {
    // リトライ時は無視
    return events.APIGatewayProxyResponse{
      StatusCode: 200,
    }, nil
  }

X-Slack-Retry-Reasonがついてたら成功レスポンスを即座に返してしまうという雑な方法ですねw

Events APIの仕様については https://api.slack.com/events-api ここに詳しいので、実際に実装する場合はここをよく読んだうえで実装しましょう。

締め

さて、AWS SAMとAWS CodeBuildでCI環境を作るうえでのポイントについていくつか書きました。この辺を押さえておけば、あとはがんばってGitHubやGoogle PlayなどのAPIをゴリゴリ呼んで実装するだけです。皆さんもAWS SAMとAWS CodeBuildで最高なCI環境を作りましょう。

記事の内容に質問などある方は、 Twitterで @kr9ly に気軽に聞いてください。

さて、dely Advent Calendarの明日のタイトルは、 「明日からプロダクトマネージャー」と言われたら です。楽しみですね。

2日目の記事です

tech.dely.jp