Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

UnityでAPK拡張ファイルを利用するときのあれこれ

こんにちは。このブログでははじめまして。クライアントエンジニアの下村です。

この記事は Akatsuki Advent Calendar 2018 の19日目の記事です。 
前回は kidach1 さんの ニューラルネットワークでStyle Transferを行う論文の紹介 でした。

背景

この記事を開いた方にはご存知の事かと思いますが、Google Play Storeではアプリケーションパッケージのサイズに制限があり、100MBを超えるapkファイルをアップロードすることができません。 100MBを超える大きなアプリを提供したい場合は、APK拡張ファイルと呼ばれる別のパッケージにデータを分割する必要があります。

インターネット接続を前提とするソーシャルゲームでこの制限に引っかかることはそう多くありませんが、最近たまたまこれを利用する機会があったので備忘録を兼ねて知見を共有したいと思います。

UnityでAPK拡張ファイルを出力する

UnityでAPK拡張ファイルを出力する方法は非常に簡単です。 PlayerSettingsの Split Application Binary オプションにチェックを入れてビルドすると、apkファイルと一緒にobbファイルが生成されます。 Unityは分割されたファイルから自動的にアセットを読み分けるため、Unityの実装上で分割を意識する必要はほとんどありません。

分割したアプリを手動でインストールする

Build And Run を実行するとUnityは自動的にobbファイルをインストールしてくれます。 一方手動でアプリをインストールする場合は、 apkファイルに加えてobbファイルを特定の場所に配置する必要があります。

obbファイルの名前や置き場所は決まっていて、以下のようなパスに配置する必要があります。
外部ストレージのAndroidディレクトリは端末によってパスが異なる事がある点に注意してください。

/storage/emulated/0/Android/obb/<パッケージ名>/main.<ビルド番号>.<パッケージ名>.obb

ファイルの転送には adb push コマンドを使うのが便利です。

$ adb install App.apk
$ adb push App.main.obb /storage/emulated/0/Android/obb/jp.aktsk.testapp/main.1.jp.aktsk.testapp.obb

これで分割されたアプリをインストールすることができました。

APK拡張ファイルのバリデーション

obbファイルは通常apkファイルと一緒にインストールされますが、実態は外部ストレージに配置されるいちファイルに過ぎません。 アプリ起動時にファイルが存在しないという状況が起こりえることから、Googleはアプリ起動時にAPK拡張ファイルのチェックや再ダウンロードを行うよう喚起しています。

Googleからはobbファイルをバックグラウンドでダウンロードするライブラリが公開されていますが、今回は急ぎで対応が必要だったことからこの高機能なダウンローダーを実装して詳しく検証している時間がありませんでした。 幸い分割されたデータはそう大きくなく、最近ではダウンロード漏れもかなり稀な発生率であるらしいことから、起動時に簡単なファイルチェックのみを行うことにしました。

APK拡張ファイルをチェックするアクティビティを挟む

今回のアプリではUnityの起動直後にいくつものアセットを読み込んでいたため、UnityPlayerActivityが起動する前にobbファイルの存在チェックをする必要がありました。 そこでonStartでobbファイルの存在をチェックをするActivityを新しく作成します。

public class ExpansionFileCheckActivity extends Activity {
    @Override
    protected void onStart() {
        super.onStart();

        if (getMainObbFile().canRead()) {
            // UnityPlayerActivityを起動する
            return;
        }

        // エラーを表示してアプリを終了する
    }

    private File getMainObbFile() {
        String fileName = "main." + getVersionCode() + "." + getPackageName() + ".obb";
        return new File(getObbDir(), fileName);
    }

    private long getVersionCode() {
        try {
            PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
            return PackageInfoCompat.getLongVersionCode(packageInfo);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
            return 0;
        }
    }

    // (省略)
}

getObbDir() でobbファイルの配置されるディレクトリを取得できますが、ファイル名は自分で生成する必要があります。 Activityを追加したら、AndroidManifestを修正して最初に起動するActivityを置き換えます。

<activity android:name="jp.aktsk.testapp.ExpansionFileCheckActivity" android:label="@string/app_name" android:launchMode="singleTask" android:theme="@style/Theme.AppCompat">
  <intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
  </intent-filter>
</activity>
<activity android:name="com.unity3d.player.UnityPlayerActivity" android:label="@string/app_name" android:launchMode="singleTask">
  <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
</activity>

これで起動時に問題があれば適切なエラーを表示することができるようになりました。

必要なときだけアクセス権限を要求する

さらに一部の端末ではインストールされたobbファイルがユーザ所有にならず、アプリからアクセスできなくなることがあるようです。 Unityはこの問題を回避するため、 Split Application Binary オプションを有効にしてビルドすると READ_EXTERNAL_STORAGE 権限要求をAndroidManifestに自動で付与します。

しかしながら、ごく一部の端末のためにすべてのユーザーにこの強力な権限を要求するのはナンセンスです。ここはobbファイルが読み込めなかった場合にのみ権限を要求するようActivityを拡張しましょう。

public class ExpansionFileCheckActivity extends Activity {
    private static final int RC_PERMISSION_REQUEST = 1;

    @Override
    protected void onStart() {
        super.onStart();

        checkApkExtension();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        if (requestCode != RC_PERMISSION_REQUEST) {
            super.onRequestPermissionsResult(requestCode, permissions, grantResults);
            return;
        }

        if (grantResults.length == 0 && !ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
            // アプリの設定画面を開く
            return;
        }

        checkApkExtension();
    }

    private void checkApkExtension()
    {
        if (getMainObbFile().canRead()) {
            // UnityPlayerActivityを起動する
            return;
        }

        if (PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[]{
                    Manifest.permission.READ_EXTERNAL_STORAGE
            }, RC_PERMISSION_REQUEST);
            return;
        }

        // エラーを表示してアプリを終了する
    }

    // (省略)
}

ファイルチェックに失敗した場合は READ_EXTERNAL_STORAGE 権限をチェックし、なければリクエストしてから再度チェックを行います。 このときユーザーが「今後表示しない」をチェックしていると shouldShowRequestPermissionRationalefalse を返すので、一言添えてアプリ設定画面に誘導するとよいでしょう。

最後にUnity側の権限リクエストを無効化する設定をAndroidManifestに追加すれば完了です。

<meta-data android:name="unityplayer.SkipPermissionsDialog" android:value="true" />

これでなんとかAPK拡張ファイルを利用したアプリをリリースする最低限の準備ができました。 あとはお好みでユーザーへの説明や操作指示などを肉付けしましょう。

その他の注意点

バイナリの配布方法

利用しているアプリケーション配信サービスによってはobbファイルを取り扱えないことがあります。ストアの内部テストだけでは大抵不足なので、ビルドの用途に応じてAPK拡張ファイルを利用するかを切り替えられるような仕組みを準備しておくべきでしょう。

StreamingAssetsへのアクセス

UnityでAPK拡張ファイルを利用するとStreamingAssetsはobbファイルの側に格納されます。AndroidにおいてStreamingAssets以下のファイルへのアクセスはどちらにあってもそう変わりません。
しかし、一部のサードパーティライブラリではobbファイル内のStreamingAssetsに正常にアクセスできないものがありました。

StreamingAssets以下にあるアセットが正しく読み込まれているか検証を忘れないようにしましょう。

ビルド番号

Google Play StoreではAPK拡張ファイルのビルド番号はアプリ本体のものと別に扱われるようですが、Unityは必ずアプリのビルド番号を用いてobbファイルを開こうとするようです。 Google Play Storeで新しいリリースを生成するとき、新しいobbファイルをアップロードせずに過去のファイルを選択すると、Unityからアクセスできなくなってしまいます。 

まとめ

UnityでAPK拡張ファイルを扱うのは簡単ですが、その仕組みには落とし穴がいくつもありました。 アプリサイズが逼迫したときはまずより簡単な手順(例えば、アーキテクチャ毎にapkをビルドするなど)を講じて、なお足りない場合に採用を検討すべきでしょう。

その上でどうしても必要になったときは、Unityによるサポートがあるからと軽視せず早めに実装と検証を行うことが大事ですね。

React Native製アプリのフォントサイズをいい感じに設定したい

そとあそび所属の天野(@mutachii) です。

この記事は Akatsuki Advent Calendar 2018 の17日目の記事です。
前回は s-capybaraさんの、Elixir でソースコードジェネレーションする - Qiitaでした。

はじめに

React Nativeでの開発で避けて通れないのは、多種多様なスクリーンで表示崩れを発生させないように実装することです。

幸い、Flexboxのサポートのおかげでレイアウトの実装で困ることは少ないですが、対応が面倒なのが fontSize の指定だと思います。
これらは当然 Flexbox では対応することができないし、widthやheight, paddingなどのようにパーセント指定することもできません。

React Native 開発始めたてのときにあるある(だと思っている)のは、普段は iPhoneX で動作確認している開発者が、
いざリリースするぞとなったタイミングで、iPhone8やiPhoneSEでは文字が大ぎることや、 逆に、iPadで確認すると文字が異様に小さいということに気づき対応に追われるということじゃないでしょうか...

今回は、そのfontSize 問題の解決に有用な

という2つのライブラリを紹介します。

react-native-size-matters

以下のように使うライブラリです。

import { scale, verticalScale, moderateScale } from 'react-native-size-matters';

const Component = props =>
  <View style={{
    width: scale(30), 
    height: verticalScale(50),
    padding: moderateScale(5)
  }}>
    <Text style={{
      fontSize: moderateScale(14)
    }}>すごい</Text>
  </View>

このscaleやverticalScaleがやっていることはすごく単純で、 基準となるスクリーンサイズ(350 x 650)に対する、現在のスクリーンサイズの倍率を算出して、その倍率をかけた数値を返してくれます。 それだけなので、fontSizeはもちろん、widthやheightにも利用できます。

moderateScaleだけはすこし特殊で、

Sometimes you don't want to scale everything in a linear manner, that's where moderate scale comes in.
The cool thing about it is that you can control the resize factor (default is 0.5).
If normal scale will increase your size by +2X, moderateScale will only increase it by +X, for example:
➡️ scale(10) = 20
➡️ moderateScale(10) = 15
➡️ moderateScale(10, 0.1) = 11

というように、増加 / 減少 させるときの係数を指定できます。 スクリーンサイズが2倍になったからといって、すべての要素のサイズを2倍にしたいわけではない...というようなケースで使います。

微妙に問題になるのは、基準となるスクリーンのサイズが 350 x 680 という値なので、例えば デザインデータが iPhoneXで作成されている場合は、デザインどおりに実装できないことです。
とはいえ、上に書いたとおりとてもシンプルな実装なので、自分たちの基準となるスクリーンサイズを使って実装し直すという方法もありそうです。

react-native-responsive-fontsize

名前通りに responsive な fontSize の指定を可能にする RF という関数を提供してくれます。

import RF from "react-native-responsive-fontsize"

const styles = StyleSheet.create({
  welcome: {
    fontSize: RF(3.5),
    textAlign: "center",
    margin: 10
  },
  instructions: {
    textAlign: "center",
    color: "#333333",
    marginBottom: 5,
    fontSize: RF(2.5)
  }
})

こちらの実装もシンプルでデバイスのHeight を基準にフォントサイズを決めます。なので、RFの引数は fontSize ではなくて パーセンテージ であることに注意してください。

さいごに

fontSize問題にすぐ効く2つのライブラリを紹介しました。

基本的には薄い実装のライブラリ達なので、実装の方向性を確認して、自分たちのプロダクトに合わせて改変していくことや、
PRを出してライブラリ自体を改善しやすいのかなと思っています。

CodeBuildでサーバレスバッチ環境を運用する

この記事は Akatsuki Advent Calendar 2018 の15日目の記事です。 前回は sejimhpさんの、環境改善の必要性〜真っ白な Terminal から tmux へ移行〜 でした。

はじめに

システムを運用する上で、安価で運用負荷の少ないバッチ処理を設計したいニーズは多いと思います。そもそもバッチ処理自体を極力除外したいのですが、日々の運用ツールなども含めると、どうしても実装しなければならない場面が出てきます。

本記事では

  • 安く
  • サーバレスで
  • 並列性高く
  • スケジューラでもイベントドリブンでも

実行できる、AWS上でのバッチ環境の実装の話をしたいと思います。

よくあるAWSバッチ処理アーキテクチャパターン

AWSバッチ処理アーキテクチャパターンBest Practice for Online Game Development on AWS が詳しいです。これらのアーキテクチャに対して私見をまとめると

EC2パターン

  • Pros: シンプルで使いやすく、速やかに実装ができる。
  • Cons: EC2料金が常にかかる。環境変数などの運用管理も必要。

Lambdaパターン

  • Pros: サーバレス。AWSのリソースを扱うには便利。
  • Cons: ゲームアプリケーションのロジックなどは使い回せない。タイムアウトも短め。

ECSパターン

  • Pros: CodePipeline等のパイプラインに組み込める。
  • Cons: 結局EC2が必要で、かつバッチで使うには比較的複雑。

Fargateパターン

  • Pros: サーバレス。
  • Cons: コストが割高で、プロビジョニングに1〜2分かかる。

例を挙げればAWS Batchなどのサービスもあるのでキリがないのですが、結局似たようなProsConsになるかと思います。今回は、これらのアーキテクチャを使わず、CodeBuildでバッチ処理を実装をする話をしていきます。

CodeBuildとは?

AWSが提供するマネージドビルド環境で、buildspec.ymlに記載した通りにビルドをすることができます。  

よくあるCodeBuildの使い方

通常、docker build用の環境などに用います。 弊社でもdocker buildが主な用途で、CodeBuildでdocker build後、ECRへdocker pushし、ECS / Fargateへデプロイするために使用しています。

f:id:e__koma:20181214155303p:plain
CodeBuildの使用例

buildspec.ymlの例は以下のとおりです。 パラメータストアと連携して秘匿情報をセキュアに一元管理したり、ビルド済みのdocker imageを実行環境として使えるところも嬉しいです。

version: 0.2

env:
  parameter-store:
    AWS_ACCESS_KEY_ID: "project-id.aws.access.key.id"
    AWS_SECRET_ACCESS_KEY: "project-id.aws.secret.access.key"

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws --version
      - $(aws ecr get-login --no-include-email --region ap-northeast-1)
      - REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${IMAGE_NAME}
  build:
    commands:
      - echo Build started on $(date)
      - echo Building the Docker image...
      - |
        docker build -t ${REPOSITORY_URI}:latest -f ${DOCKERFILE_PATH} . \
        --build-arg YOUR_ENV=${YOUR_ENV}
  post_build:
    commands:
      - echo Build completed on $(date)
      - echo Pushing the Docker images...
      - docker push ${REPOSITORY_URI}:latest

CodeBuildを使用したバッチ環境

さて、本題です。 CodeBuildを使ってバッチ処理を実行する方法を説明します。

パターン1:バッチスクリプト実行

CodeBuildはビルド環境に様々な言語が用意されており、かつVPC内で実行することができるため、RubyPythonスクリプトを実行することができます。

f:id:e__koma:20181214170023p:plain
CodeBuildでスクリプト実行例
 

実行後はCloudWatchEventsでイベントフックをして、slack通知をしてもいいですし、Lambdaで別の処理に移行するのも良いでしょう。CodePiplineやJenkinsのAWS CodeBuild Pluginを使用したPipelineに組み込めば、前後関係を豊富にカスタマイズできます。

この場合のbuild_spec.ymlは非常にシンプルに書けます。

version: 0.2

env:
  parameter-store:
    YOUR_ENV: "your.env"

phases:
  build:
    commands:
      - python ./test.py
artifacts:
  files: test.log

 

パターン2:ビルド済みアプリケーションの実行

  パターン1ではスクリプトを実行するだけでしたが、ビルド済みのアプリケーションを使った運用ツールを実行したい場合があります。例えばデータベースのmigration、 ゲームアプリケーションロジックを用いたアイテムドロップシミュレータなどです。

この要望を満たすためには、ビルド済みのアプリケーションImageを再度CodeBuildにpullしてこればOKです。

f:id:e__koma:20181214171045p:plain
CodeBuildでビルド済みアプリケーションの実行例

この場合、build_spec.ymlには少し工夫が必要になります。 ビルド済みのImageをpullしてきた後、docker runをする際にENTRY_POINTを空にして、実行したいコマンドを渡します。 こうすることでENTRY_POINTを無視して、ビルド済みのアプリケーションに対してコマンドを実行することができます。

version: 0.2

env:
  parameter-store:
    YOUR_ENV: "your.env"

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws --version
      - $(aws ecr get-login --no-include-email --region ap-northeast-1)
      - REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${IMAGE_NAME}
  build:
    commands:
      - echo started on $(date)
      - docker pull ${REPOSITORY_URI}:latest
      - echo ${BATCH_NAME} starting ...
      - |
        docker run \
        -e YOUR_ENV=${YOUR_ENV} \
        --entrypoint="" -i ${REPOSITORY_URI}:latest \
        sh -c "${BATCH_COMMAND}"
  post_build:
    commands:
      - echo ${BATCH_NAME} completed on $(date)

さて、ここまで説明してきたCodeBuildでのバッチ処理のメリットをまとめます。

機能性

  • ビルド済みのアプリケーションロジックを使い回せる
  • VPC内で実行できるためEC2 / RDSなどへのアクセスができる
  • 並列性が高い。
  • リソースの範囲内であれば長時間バッチも可能

運用性

  • サーバレス
  • 成功・失敗のイベントフックが豊富
  • Pipelineへの組み込みが容易なため前後関係の定義ができる

コスト

  • 従量課金のため、とにかく安い。

デメリットを挙げるとすれば以下のとおりです。

デメリット

  • プロビジョニングに30秒ほどかかる(ただしFargateのプロビジョニングよりかは速い)
  • 使える最大リソースは8vCPU、15GBメモリなので、これ以上のパワーが必要な処理ではリソースが足りない。

まとめ

CodeBuildを使ったバッチ処理を紹介してきました。 CodeBuildをバッチ環境として使い始めたプロジェクトでは、バッチサーバという概念がなくなりました。 興味がある人は、ぜひ使ってみてください!

GAE/Go & CircleCI でカナリアリリースをする

この記事はAkatsuki Advent Calendar 2018の12日目の記事です。 前回は hayamaruさんの、知覚メカニズムと網膜投影でした。

はじめに

継続的デリバリーという文脈で、素早く、安全にデプロイする手法について、近年多くの記事を見かけます。

例えば、カナリアのおかげで命拾い : CRE が現場で学んだことカナリアリリースについて詳しく触れられています。このアイデアは「たとえテスト環境で十分な検証をしたとしても、本番環境で問題が発生しないということを保証するのは難しいので、少しづつトラフィックを移動して問題があったらすぐにロールバックをしよう」というものです。

上記記事中に紹介されているKayentaといったツールを使ってカナリア分析を行い、自動的にカナリアリリースするというのも良いですが、人間による承認を元にデプロイするならもう少しコストの低い方法で良さそうです。

この記事では、CircleCIのワークフローを使って、承認を元にGoogle App Engineトラフィックを移動するという形で、カナリアリリースを実現します。

サンプルアプリケーション

この記事のサンプルコードはcsouls/gae_go111-deploy_sampleに置いています。 また、サンプルプロジェクト名をproject-nameとしているので、適宜読み替えてください。

(このコードは、Google Cloud Platformのリポジトリで公開されているGAEのサンプル、go11x/warmup からコピーして修正したものです。)

このアプリケーションは、初期処理が完了した後に、どれだけ時間が経過しているか?というのを表示するための実装と、ウォームアップリクエストを受け付けるための実装がされています。

GAEでのデプロイ

Google App Engineへのデプロイはとても簡単です。 Cloud SDK をインストールして、GCPコンソールから新しいプロジェクトを作り、以下のコマンドを実行したあとにデプロイするリージョンや確認に応答するだけで、アプリケーションのデプロイができてしまいます。

gcloud app deploy --project=project-name

結果

You are creating an app for project [project-name].
WARNING: Creating an App Engine application for a project is irreversible and the region
cannot be changed. More information about regions is at
<https://cloud.google.com/appengine/docs/locations>.

Please choose the region where you want your App Engine application
located:

 [1] asia-east2    (supports standard and flexible)
 [2] asia-northeast1 (supports standard and flexible)
 [3] asia-south1   (supports standard and flexible)
 [4] australia-southeast1 (supports standard and flexible)
 [5] europe-west   (supports standard and flexible)
 [6] europe-west2  (supports standard and flexible)
 [7] europe-west3  (supports standard and flexible)
 [8] northamerica-northeast1 (supports standard and flexible)
 [9] southamerica-east1 (supports standard and flexible)
 [10] us-central    (supports standard and flexible)
 [11] us-east1      (supports standard and flexible)
 [12] us-east4      (supports standard and flexible)
 [13] us-west2      (supports standard and flexible)
 [14] cancel
Please enter your numeric choice:  2

Creating App Engine application in project [project-name] and region [asia-northeast1]....done.
Services to deploy:

descriptor:      [/Users/y.tanaka/src/sandbox/gae_go111_deploy_sample/app.yaml]
source:          [/Users/y.tanaka/src/sandbox/gae_go111_deploy_sample]
target project:  [project-name]
target service:  [default]
target version:  [20181212t155052]
target url:      [http://project-name.appspot.com]


Do you want to continue (Y/n)?  y
... deploy log ...

Deployed service [default] to [http://project-name.appspot.com]

デプロイの中身

gcloud app deploy を実行することで何が行われているかは、コマンドを実行したあとの標準出力に表示されています。

Services to deploy:

descriptor:      [/Users/y.tanaka/src/sandbox/gae_go111_deploy_sample/app.yaml]
source:          [/Users/y.tanaka/src/sandbox/gae_go111_deploy_sample]
target project:  [project-name]
target service:  [default]
target version:  [20181212t160030]
target url:      [http://project-name.appspot.com]

この例では、gae_go111_deploy_sample/app.yaml という App Engineの設定を元に、gae_go111_deploy_sampleをソースとして、project-nameというプロジェクトのdefaultというサービスの、バージョン20181212t160030を作成し、そのバージョンでサービスのトラフィックを100%処理する設定にした上で、http://project-name.appspot.com というURLで公開する、という内容となっています。

gcloud app deploy に、--no-promoteというフラグを指定すると、「新しいバージョンは作るが、トラフィックは移動しない」という動作になります。

デプロイのデフォルト動作

gcloud app browse --project=project-name を実行してブラウザでアクセスしてみると、実装の通りインスタンス起動からの経過時間が表示されます。表示されている時間は約50msということなので、このインスタンスに対する最初のアクセスであることが分かります。 f:id:csouls:20181212231737p:plain

再度アクセスすると、初回アクセスからの経過時間が表示され、同じインスタンスで要求を受けていることが分かります。 f:id:csouls:20181212231949p:plain

GAEのコンソール、Logging -> ログから、Stackdriver Logging に自動的に記録されたアクセスログが確認できます。レイテンシの内容を確認すると、初回アクセスの応答は50msではなく、775msかかっているようです。体験としてもこれくらい待たされています。

また、トレースがサンプリングされている場合は、レイテンシをクリックすることでトレースの詳細を確認することができます。

f:id:csouls:20181212232051p:plain

どうやら、サービスのデプロイにより新しいプロセスが起動され、loading requestが発生しているようです。 f:id:csouls:20181212232159p:plain

このサービスではapp.yamlに以下の記載をしているので、warmup requestsが有効になっていますが、単純に gcloud app deployをしても、warmupが実行されない様です。

inbound_services:
  - warmup

handlers:
  - url: /_ah/warmup
    script: auto
    login: admin

本番環境でデプロイの運用をする場合は、この動作についても何らかの対処が必要でしょう。

CircleCIの設定

デフォルトの動作を踏まえ、warmupを有効にする形で GAE & CircleCIでのカナリアリリースを設定していきます。

サービスアカウントの設定

この手順 に従い、circleci/gcp-cli@1.0.1を使う前提で、サービスアカウントの設定をします。気をつけることは以下です。

  1. GAE コンソールから、App Engine Admin API を有効にする必要があります。
  2. GAEでデプロイをさせるサービスアカウントには、「App Engine デプロイ担当者」「App Engine サービス管理者」「Cloud Build サービス アカウント」「ストレージのオブジェクト管理者」の権限が必要です。
  3. CircleCIのプロジェクト設定 -> BUILD SETTINGS -> Environment Variables -> Add Variable から、下表の環境変数を設定します。
環境変数 内容
GCLOUD_SERVICE_KEY base64 <service-account-json>.json の結果
GOOGLE_PROJECT_ID GCPプロジェクトのID
GOOGLE_COMPUTE_ZONE 動作させるZone。空白を設定すると、Zone指定なし。※後続の項で説明するcircleci/gcp-cliを使う場合、環境変数の定義は必ずしておかなければいけない。

gcp-cli/initializestepのパラメータで環境変数名を指定することができるので、一つのリポジトリに対して複数の環境があっても大丈夫です。

CircleCIの設定ファイル

リポジトリ直下の.circleci/config.yml に、以下の様に記述します。

version: 2.1
orbs:
  gcp-cli: circleci/gcp-cli@1.0.1
executors:
  golang:
    docker:
    - image: circleci/golang:1.11
  gcloud:
    docker:
    - image: google/cloud-sdk:latest

jobs:
  test:
    executor: golang
    steps:
      - checkout
      - restore_cache:
          keys:
          - go-mod-v1-{{ checksum "go.sum" }}
      - run:
          name: build
          command: go build
      - run:
          name: test
          command: go test

  deploy:
    executor: gcloud
    steps:
      - checkout
      - gcp-cli/initialize
      - run: gcloud app deploy --no-promote --version $CIRCLE_SHA1 --quiet

  set-traffic:
    parameters:
      before-traffic:
        type: string
        default: "0.99"
      after-traffic:
        type: string
        default: "0.01"
    executor: gcloud
    steps:
    - checkout
    - gcp-cli/initialize
    - run: |
        BEFORE_VERSION="$(gcloud app versions list --service=${GAE_SERVICE} --filter='traffic_split>0.5' --format='value(id)')"
        gcloud app services set-traffic --splits ${BEFORE_VERSION}=<< parameters.before-traffic >>,${CIRCLE_SHA1}=<< parameters.after-traffic >> --split-by=random --quiet

  promote:
    executor: gcloud
    steps:
    - checkout
    - gcp-cli/initialize
    - run: gcloud app services set-traffic --splits ${CIRCLE_SHA1}=1 --split-by=random --quiet --migrate

workflows:
  version: 2
  test_and_deploy:
    jobs:
      - test
      - deploy:
          requires:
          - test
          filters:
            branches:
              only:
                - master
      - hold-canary:
          type: approval
          requires:
          - deploy
      - set-traffic:
          name: canary
          requires:
          - hold-canary
          before-traffic: "0.01"
          after-traffic: "0.95"
      - hold-promote:
          type: approval
          requires:
          - canary
      - promote:
          requires:
          - hold-promote

以下、ポイントを解説します。

orbs

orb とは、CircleCI 2.1から追加された、設定をパッケージとして共有可能にするものです。 今回はGCP CLIを使うので、その初期設定でcircleci/gcp-cli@1.0.1を利用しています。

orbs:
  gcp-cli: circleci/gcp-cli@1.0.1

...snip...

jobs:
    steps:
      - gcp-cli/initialize

deploy ジョブ

新しいバージョンを作るジョブです。 $CIRCLE_SHA1をバージョン名として指定しています。これで、CircleCIから操作するバージョンを特定するのが簡単になります。

  deploy:
    executor: gcloud
    steps:
      - checkout
      - gcp-cli/initialize
      - run: gcloud app deploy --no-promote --version $CIRCLE_SHA1 --quiet

set-traffic ジョブ

トラフィックの分割をするジョブです。 トラフィックの50%以上を割り当てられているバージョンを前のバージョンだと判断しています。

  set-traffic:
    parameters:
      before-traffic:
        type: string
        default: "0.99"
      after-traffic:
        type: string
        default: "0.01"
    executor: gcloud
    steps:
    - checkout
    - gcp-cli/initialize
    - run: |
        BEFORE_VERSION="$(gcloud app versions list --service=${GAE_SERVICE} --filter='traffic_split>0.5' --format='value(id)')"
        gcloud app services set-traffic --splits ${BEFORE_VERSION}=<< parameters.before-traffic >>,${CIRCLE_SHA1}=<< parameters.after-traffic >> --split-by=random --quiet

promote ジョブ

トラフィックを新しいバージョンに100%移行するジョブです。 --migrate オプションを付けることで、トラフィックを徐々に移行してくれます。 また、Warmup処理を実行してくれます。

promote:
  executor: gcloud
  steps:
  - checkout
  - gcp-cli/initialize
  - run: gcloud app services set-traffic --splits ${CIRCLE_SHA1}=1 --split-by=random --quiet --migrate

ワークフロー

test -> deploy -> hold-canary -> set-traffic(canary) -> hold-promote -> promoteの順に並ぶように、requiresで設定しています。

また、deployはmasterブランチでのみ実行されるので、それ以外のブランチではtestだけが実行されます。

hold-canary, hold-promote では type: approval で設定しているため、ここで人間の承認が入るという設定になっています。

workflows:
  version: 2
  test_and_deploy:
    jobs:
      - test
      - deploy:
          requires:
          - test
          filters:
            branches:
              only:
                - master
      - hold-canary:
          type: approval
          requires:
          - deploy
      - set-traffic:
          name: canary
          requires:
          - hold-canary
          before-traffic: "0.01"
          after-traffic: "0.95"
      - hold-promote:
          type: approval
          requires:
          - canary
      - promote:
          requires:
          - hold-promote

結果

masterブランチにpushしたあとに、deployジョブ(新バージョンの作成)が完了した状態です。 type: approvalのワークフロー定義により、hold-canaryで止まっていることが分かります。 f:id:csouls:20181212233504p:plain

hold-canaryを承認し、canaryジョブまで実行された結果です。 ワークフローの承認者がアイコンで表示されていること、次のhold-promote で止まっていることが分かります。このジョブの前に、承認待ちのワークフローへのリンクをSlackへ通知するのも良いでしょう。 f:id:csouls:20181212233819p:plain

トラフィックの割当も新バージョンに1%, 旧バージョンに99%となっています。 この状態でアプリケーションエラーが発生していないか、新バージョンのレイテンシやその他メトリクスに悪化したものがないか、を確認することができます。 f:id:csouls:20181212233950p:plain

承認する際は、このようなダイアログが表示されるので、間違えてクリックしてしまっても大丈夫です。 f:id:csouls:20181212233236p:plain

--migrate オプションを付けたことで、トラフィックが徐々に移行されていることが分かります。 f:id:csouls:20181212233247p:plain

Stackdriver loggingからログをみても、warmup リクエストが処理されており、2回目の応答は4ms程度で返せていることが分かります。 f:id:csouls:20181212233311p:plain

開発者の数が50名を超えるような大規模なプロジェクトになると、冒頭で紹介したKayentaのようなツールが必要になると思いますが、小規模であればこのCircleCIのワークフローを使った人間による承認でも十分なことが多いと思います。

設定にコストをかけずに、GAEのカナリアリリースを実現する手段として、CircleCIと組み合わせるアイデアを覚えておくと役に立つことがあるかもしれません。

Git のベースブランチを記録する

この記事は Akatsuki Advent Calendar 2018 の 8 日目の記事です。

このブログでは初めましてになります。id:thinca です。今日は Vim の話…ではなく、お仕事用にごにょごにょしている Git の設定の話をしようかと思います。

背景

私のいるチームでは複数バージョン平行開発が行われており、それもあいまって Git リポジトリには各バージョンや stagingmaster など様々なブランチが存在しています。

そしてそれぞれのブランチから作業用のブランチを作るわけですが、Git では「あるブランチがどのブランチから派生したのか」ということは記録されていません*1。Git の構造を考えると記録されていないのは当然なのですが、これを記録するようにすると、様々な場面で活用できます。そこで私が普段している工夫を紹介したいと思います。

Git の alias 機能

この工夫のために、Git に大量の alias を定義しています。今回の話はほぼ、この alias 集の紹介になります。

各 alias は別の alias に依存しているものもあるので、順を追って紹介していきます。

以後出てくる xxx = yyy の形式の行は、Git の config ファイル内での記述になります。

現在のブランチを取得する

current-branch = symbolic-ref --short HEAD

ありそうでない、現在のブランチを表示するコマンドです。様々なシーンで使うので、最初に紹介しておきます。

ブランチ元のブランチを記録/表示する

base-branch = !git config "branch.$(git current-branch).base"

本稿で伝えたい工夫のコアの部分になります。git config を使って、各ブランチの base と言う名前の項目にブランチ元のブランチ(=base branch)を記録できるようにします。 以下のような感じで使います。

$ git current-branch
new-feature

# ベースブランチを master に設定する
$ git base-branch master

# ベースブランチを表示する
$ git base-branch
master

$

とは言っても、実際にこのコマンドを直接使うことはあまりありません。他の alias から利用する場合が多いです。

ちなみに、こうして記録した内容は .git/config ファイル内に記録されます。ブランチを消したら設定も一緒に消えるので、ゴミが残ったりはしません。

[branch "new-feature"]
    base = master

作業用ブランチを作成する

現在のブランチから作業用ブランチを作成する際、私はよく git cob という alias で行っていました。これは最初は git checkout -b の略だったのですが、base-branch を導入した今では以下のようになっています。

cob = "!f() { \
  b=$(git current-branch) && \
  git checkout -b \"${1}\" && \
  git base-branch \"${b}\"; \
}; f"

alias で引数を受け取りつつアレコレしたい場合のイディオムの 1 つとして、関数を作ってこれを実行しています。他にも方法はありますが、私はこの方法をよく使います。

これで、git cob new-feature を実行すると、new-feature ブランチのベースブランチとして元いたブランチが記録されるようになりました。

分岐地点のコミットを得る

せっかく記録をしても活用しないと意味がありません。そのためにも、追加の補助機能を用意します。

base-commit = !git merge-base origin/$(git base-branch) HEAD
     -o--o  <- new-feature
    /
o--o--o--o  <- master
   ^
   ここを得る

こんな感じで、現在のブランチを切った根本に当たるコミットの sha1 値を得るコマンドになります。これがあると、以下のようなことができます。

現在のブランチ内だけのログを表示

普通にログを表示すると、辿れるだけ際限なく表示されます。今のブランチでは何をやったのかに注目したい場合、その部分だけ表示できると便利です。

fl = !git log $(git base-commit)..HEAD --reverse

ログの表示の仕方は好みがあると思うので、お好みの感じにするとよさそうです。alias の名前も短くて覚えやすいのを付ければよいかと思います。

現在のブランチで変更したファイルを表示

先ほどと同じようなやつですね。これは diff でできます。

bf = !git diff --name-only "$(git base-commit)..HEAD"

変更したファイルだけ、まとめて lint にかけたりする際に便利です。

$ rubocop $(git bf)

特定のバージョンブランチから派生した作業用ブランチ一覧を表示する

あちこちのバージョンで気まぐれに始めては途中で止まっているブランチがあるため、通常の git branch によるブランチ一覧だとどのブランチがどのバージョン上で作業していたものなのかがわかりづらくなってしまいます。そこで、今いる(あるいは指定した)ブランチから作られたブランチの一覧を表示できるようにします。

features = "!f() { \
  git branch | \
    cut -c3- | \
    while read b; \
      do echo \"$(git config \"branch.${b}.base\") -> ${b}\"; \
    done | \
    grep -v '^ -> ' | \
    grep \"^${1:-$(git current-branch)} ->\" | \
    sort; \
}; f"

こういう感じで結果が見れます。

$ git features master
master -> feature-a
master -> feature-b

$

rebase する

当然 rebase も簡単にできます。

rbu = !git rebase "origin/$(git base-branch)"
     -o--o  <- new-feature
    /
o--o--o--o  <- master

 |
 v

           o--o  <- new-feature
          /
o--o--o--o  <- master

fetch によって origin が伸びている場合が多いので、origin のブランチの先頭に対して rebase するようにしています。 実は git base-branch でもこっそり origin を見ていました。これは今回のように rebase することがあるためです。

base を移動する

version-a ブランチからブランチを作ってやってる作業を version-b に入れることになった!となったら、rebase を利用すればサクッと移動できます。

move-base = "!f() { \
  [[ -n $1 ]] && \
  git rebase --onto $1 $(git base-branch) && \
  git base-branch $1; \
}; f"

git move-base version-bversion-b に移動できます。

現在の差分をブランチ中の特定のコミットに混ぜ込む

ブランチで作業中に、途中で typo を入れ込んだことに気付いた時、新たに Fix typo なんてコミットを積む人はいないと思います。後から見て履歴がわかりやすくなるように、最初から typo を入れないように履歴を修正するでしょう。となったら rebase による履歴改変の出番となるわけですが、普通にやると意外に面倒です。これもサクッとできるようにしています。

まずは混ぜ込む先のコミットを簡単に選べるようにしたい、ということで以下は fzf を使ってコミットを選択するコマンド。コミットの範囲を引数に渡します。previewgit show を使ってコミットの中身を見つつ選択できるようにしてあります。

select-commit = "!f() { \
  git log --oneline ${1} | \
    fzf --preview='git show --color {1}' | \
    cut -b 1-7; \
}; f"

続いて現在の差分を、この git select-commit を使って選択したコミットに対する fixup コミット*2としてコミットするコマンド(わかりづらい)が以下。

fixcommit = "!f() { \
  git commit --fixup=\"$(\
    git select-commit \"${1:-$(git base-commit)}..HEAD\"\
  )\"; \
}; f"

git base-commit を使って適切な範囲のみを候補として表示するようにしています。

さて、fixup コミットを作ったので、これを適用します。適用するためのコマンドが以下。

fixup = !git -c core.editor=true rebase -i --autosquash $(git base-commit)

base を記録しているおかげで、rebase を機械的に実行できます。--autosquash を付ければ最初から fixup 済みの状態でエディタが開くので、編集する必要もありません。編集しなくてもよいので、エディタとして true コマンドを指定して、すぐに閉じています。これで fixup を適用できます。

ここまで来たらまとめてやってしまいたいので、まとめてやるコマンドを用意しています。

fix = "!f() { \
  git fixcommit ${1} && \
  git fixup; \
}; f"

つまり、typo 直したい!となったら、

  1. 直して git add
  2. git fix
  3. fzf が起動するので対象コミットを選択
  4. 混ぜ込み完了

となります。簡単ですね。

GitHub の PR 作成ページを開く

近ごろの GitHub はとても便利なので、新規ブランチを push すると「このページにアクセスすれば PR 作れるよ!」と教えてくれます。*3

remote: Create a pull request for 'new-feature' on GitHub by visiting:
remote:      https://github.com/aktsk/awesome-product/pull/new/new-feature

しかし、いざこのページにアクセスすると base はデフォルトブランチとしてページが開きます。デフォルトブランチと開発ブランチでは差が大きくなっていることは珍しくなく、差分の生成のために GitHub を開くのが重くなります。こんなページは開きたくない。

そこで、直接!目的の base に対して PR が作れるページをブラウザで開くコマンドです。ブラウザを開くために open を使っているので、MacOS 専用です。

gh-repo = "!git remote get-url origin | sed 's/.*:\\(.*\\)\\.git/\\1/'"
mkpr = "!open https://github.com/$(git gh-repo)/compare/$(git base-branch)...$(git current-branch)?expand=1"

まとめ

今回は、Git にベースブランチを記録することで様々な作業を効率化する例を紹介しました。Git の alias の設定は config ファイル 1 つで管理できるため、環境間での取り回しがやりやすいです。その分複雑なことをしようとするとメンテナンスが難しくなってしまいますが、その点は適切に改行を入れたり、コマンドを分割することで緩和してみました。

小さな工夫で作業を効率化できることは多いので、不便に感じたら積極的に効率化して行きましょう。

*1:単純なリポジトリであればある程度推察できる場合がありますが、複雑になると失敗するので確実性がありません。

*2:fixup コミットについてはこちらの記事等が参考になります。

*3:この URL は例なので実在しません。