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

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

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と組み合わせるアイデアを覚えておくと役に立つことがあるかもしれません。