GeekFactory

int128.hatenablog.com

Circle CIでCloud Functionをデプロイする

以前に Circle CIでCloud Functionをデプロイする - GeekFactory というエントリを書きましたが、gcloudコマンドでデプロイする方が簡単でした。gcloudコマンドでデプロイする手順を説明します。

GCPの設定

デプロイに使用するサービスアカウントを作成します。ロールは Project Editor を割り当てておきます。Cloud Functionsはまだベータ版なので専用のロールが用意されていないようです。

サービスアカウントを作成したら秘密鍵をダウンロードします。後からCircleCIで設定できるように、BASE64エンコードしておきます。

base64 service-account-xxxxxxxx.json

次に、デプロイで使用するストレージバケットを作成します。名前は何でも構いません。

CircleCIの設定

下記の内容で .circleci/config.yml を作成します。

version: 2
jobs:
  build:
    docker:
      - image: google/cloud-sdk
    working_directory: ~/repo
    steps:
      - checkout
      - run: echo "$GOOGLE_AUTH" | base64 -i --decode > "$HOME/gcp-key.json"
      - run: gcloud auth activate-service-account --key-file "$HOME/gcp-key.json"
      - run: gcloud --quiet config set project "$GOOGLE_PROJECT_ID"
      - run: gcloud beta functions deploy 関数名 --stage-bucket バケット名 --trigger-http

gcloud beta functions deploy コマンドの使い方は Deploying from Your Local Machine  |  Cloud Functions Documentation  |  Google Cloud Platform を参照してください。

下記の環境変数を設定します。

ビルドが成功すればデプロイ完了です。

Spring Security OAuthにおけるアクセストークン取得失敗時の例外

Spring Security OAuthでアクセストークンの取得に失敗した場合のリトライを設計するため、エラーケースと例外の対応を調べてみました。

アクセストークンの取得をリトライするには以下の2つの方法があります。

  1. アクセストークン取得(AccessTokenProvider#obtainAccessToken)をリトライする。
  2. ClientHttpRequestInterceptor でHTTPリクエストをリトライする。

前者の場合、失敗の原因にかかわらず OAuth2AccessDeniedException が発生するので、 OAuth2AccessDeniedException に対してリトライを行えばOKです。後者の場合は下記を参考にしてください。(7/28追記)

以下のテストケースに記載があります。

テストケースからは下記の仕様が読み取れます。

エラーケース 例外
ステータスコード400で "{"error":"access_denied"} が返された場合 UserDeniedAuthorizationException
ステータスコード403で "{"error":"access_denied"} が返された場合 OAuth2AccessDeniedException
400番台のステータスコードが返された場合 HttpClientErrorException
500番台のステータスコードが返された場合 HttpServerErrorException

これだけでは足りないので実際に検証した結果が下記です。

エラーケース 例外
接続に失敗した場合 ConnectException
Content-Typeとリクエストボディが合致しない場合 OAuth2AccessDeniedException 未調査

上記を踏まえると、リトライの対象例外は ConnectExceptionHttpServerErrorException にするとよさそうです。リトライの実装方法は下記で書いているのでご参考まで。

int128.hatenablog.com

Jenkinsでテストケースを並列実行して所要時間を短縮する

プロダクトが成長するにつれてCIの所要時間が長くなる悩みを抱えている方は多いと思います。本稿では、テストケースの並列実行でスローテストを乗り越える方法を検討します。

CIの所要時間を短縮する戦略

CIの所要時間が長くなる主な原因はテストです。だがしかし、テスト以外にコンパイルスキーママイグレーションなども時間がかかります。まずは、ビルドレポートで何のタスクに時間がかかっているか調べましょう。Gradleの場合は --profile オプションで各タスクの所要時間を確認できます。

ここではテストの所要時間にフォーカスします。

テストの所要時間を短縮するにはいくつかの方法がありますが、筆者のおすすめは以下を順番に行うことです。

  1. テストレポートでテストケースの所要時間を確認し、長時間のものから改善していく。
  2. テストケースを並列実行する。
  3. 高価なインスタンスタイプに変える。(札束で殴る)

改善に取りかかる前に、異常に時間のかかっているテストケースがないか確認することをおすすめします。テストデータを作成するためにauto commitで数千件のINSERTを回していたとか、大量の一時ファイルに書き込んでいたけど実は不要だったとか、改善できるものが見つかるかもしれません。

それでも打ち手がなくなった場合はテストケースの並列実行を検討しましょう。すでにJenkins Agentを複数ノードで運用しているのであれば、設定を変えるだけなので簡単です。これからJenkins Agentノードを増やす場合はインスタンスタイプも見直してみてもよいでしょう。金で解決できるなら早いもんです。

Jenkins Agentを複数ノードで運用する

テストを並列実行するには、まずJenkins Masterに複数のJenkins Agentを登録する必要があります。この時、テストの実行時にどのJenkins Agentか識別できるように、環境変数 JENKINS_AGENT_ID を設定しておきます。

実行環境 環境変数 JENKINS_AGENT_ID の設定値
Jenkins Agent #1 1
Jenkins Agent #2 2
Jenkins Agent #3 3

Jenkins Agentサーバは運用管理が面倒なので、プロビジョニングを自動化してImmutableにしておくとよいでしょう。不要になったら削除、必要になったら起動ができると理想です。

リソースの競合を避ける

データベースに依存するテストケースを同時に実行すると競合してしまうため、Jenkins Agentごとに独立したデータベースを割り当てる必要があります。CIの所要時間を考えると、1つのデータベースサーバにあらかじめ複数のデータベースを作成しておくとよいでしょう。例えば、下記のように割り当てます。

実行環境 データベース接続先 データベース名
ローカルでテスト実行 localhost example0
Jenkins Agent #1 でテスト実行 RDS example1
Jenkins Agent #2 でテスト実行 RDS example2
Jenkins Agent #3 でテスト実行 RDS example3

MySQLなら下記で新しいデータベースを作成できます。

CREATE DATABASE example0;
GRANT ALL ON example0.* to 'foo'@'%' identified by 'bar';

Spring Bootであれば application-test.yml環境変数を使ってデータソースを設定できます。

spring:
  datasource:
    url: "jdbc:mysql://${test.db.host:localhost}/${test.db.schema:example0}?useSSL=false"
    username: "foo"
    password: "bar"
    driver-class-name: com.mysql.jdbc.Driver

上記のように書くと、ローカルでは mysql://localhost/example0 、Jenkins Agentでは環境変数 TEST_DB_HOSTTEST_DB_SCHEMA の値を使うことができます。

最後に、Jenkinsfileでテストの実行時に環境変数を渡すように設定します。

def rdsEnv() {
  assert env.JENKINS_AGENT_ID
  [
    "TEST_DB_HOST=xxxxxxxx.xxxxxxxx.ap-northeast-1.rds.amazonaws.com",
    "TEST_DB_SCHEMA=example${env.JENKINS_AGENT_ID}",
  ]
}

node {
  checkout scm
  stage('test') {
    withEnv(rdsEnv()) {
      sh './gradlew check'
    }
  }
}

データベースだけでなく、Redisなどのキャッシュや外部APIも同じです。

Jenkins Agentサーバにデータベースを立てる方法もありますが、運用管理が面倒になることや、同じサーバのCPU時間やI/O帯域を消費してしまうことから、マネージドサービスを利用する方がよいでしょう。

テストケースを分散させる

テスト設計やモジュール構成に依存しますが、一般には以下の方法があると思います。

  1. サブプロジェクトごとにテストケースを並列実行する。
  2. テストケースをパッケージ名やアノテーションでグループ化して、並列実行する。

ここでは1の方法を説明します。

Jenkinsfileでは parallel ブロックで並列実行を定義できます。例えば、下記のように書くとGradleの api:check タスクと batch:check タスクを並列実行できます。

parallel(
  api: {
    node {
      checkout scm
      stage('test') {
        withEnv(rdsEnv()) {
          sh './gradlew api:check'
        }
      }
    }
  },
  batch: {
    node {
      checkout scm
      stage('test') {
        withEnv(rdsEnv()) {
          sh './gradlew batch:check'
        }
      }
    }
  },
}

ごちゃごちゃするので省いていますが、実際にはテスト実行後にテストレポートを回収するタスクが必要です。下記を finally で囲んで入れておくとよいでしょう。

junit allowEmptyResults: true, testResults: 'api/build/test-results/test/*.xml'
publishHTML([allowMissing: true, alwaysLinkToLastBuild: true, keepAll: false, reportDir: 'api/build/reports/jacoco/test/html', reportFiles: 'index.html', reportName: 'Coverage (api)'])
publishHTML([allowMissing: true, alwaysLinkToLastBuild: true, keepAll: false, reportDir: 'api/build/reports/tests/test', reportFiles: 'index.html', reportName: 'Test (api)'])

まとめ

本稿では、Jenkinsでテストケースを並列実行し、CIの所要時間を短縮する方法を説明しました。

  • リソースの競合を避ける(データソース、キャッシュ、APIなど)
  • テストケースを分散させる(サブプロジェクト、パッケージ名など)