悩みのタネの構成を最適化して、ビルドを高速化しましょう

CircleCI ユーザーの皆さんは日々、プロジェクトに最も役立つ機能を探し求め、処理時間とクレジット消費のバランスを取ろうと工夫を重ねており、CircleCI のカスタマー エンジニア チームはそうした皆さんが設定ファイルの構成を最適化できるようにご支援しています。しかし、いつでもエキスパートと一緒に構成を最適化する時間が取れるとは限りません。そこで、20 社以上のエンタープライズ規模のお客様の構成レビューを行ってきたカスタマー エンジニアリング チームが、簡単に最適化できるヒントや推奨事項をガイドとしてまとめました。

この記事では、適切な Executor の選択、ジョブの並列処理、キャッシュ、ワークスペースの使用、シークレットの管理、Orbs の使用という 6 つのポイントから、設定ファイルを最適化するためのベスト プラクティスをご紹介します。

適切な Executor の選択

CircleCI は今年、コンビニエンス イメージの刷新を図り、非常に高速な Docker イメージをリリースしました。そのいずれかを活用すれば、多くの CI パイプラインでメリットが得られるでしょう。yaml の docker キーを使用して Docker コンテナ内でイメージを実行すると、基本的な機能が最高スピードで提供されます。

これらのイメージは、Docker Hub cimg プロファイルで公開されています。アプリケーションに他のツールが必要な場合は、カスタム Docker イメージの実行を検討してみてください。以下に示すのは、Node が特定のバージョンに固定された、従来の大きなイメージを使用している例です。この test ジョブのように、Docker イメージを指定して、各ジョブの下で Executor を定義します。

test:
    docker:
      - image: circleci/node:9.9.

次世代の CircleCI Node イメージを使用すると、レイヤーの数を抑えられ、ビルドをスピードアップできます。次世代 Executor への変更は、イメージ名を変更するだけなので簡単です。

現在、ビルドやテストは Node 9.9.0 で実行されますが、最新バージョンの Node を使用することをお勧めします。そのためには以下のように、実行コンテナに使用するイメージを次世代イメージのいずれかに置き換えます。

docker:
  - image: cimg/node:latest

複数の環境を横断してテストを行う場合は、Node Orb を利用してマトリックス ジョブを設定する方法があります。マトリックス ジョブでは、複数のバージョンの Node を指定して、ベースとなる Node Docker レイヤー上で同じテストを行えます。

並列処理のベスト プラクティス

複数のコンテナで並行してジョブを実行するように構成すると、ビルドが高速化されます。たとえば、数百もの独立したテストが含まれ、時間のかかるテスト スイートを実行している場合、それらのテストを複数の Executor に分散して同時に実行することを検討しましょう。真に最適化された構成とは、並列処理を賢く活用しているものです。実行する並列 Executor の数をいくつにするか、タスクの分割によって節約される時間と複数のコンテナのスピンアップ時間の釣り合いがとれるかを慎重に検討する必要があります。また、それらの Executor でテストが正しく分割されているかについても確認してください。 以下の例で考えてみましょう。このテスト ジョブでは、npm run test コマンドを使ってテストを実行しています。

test:
 …
 parallelism: 10
 steps:
    ...
     - run: CI=true npm run test

並列処理を活用するという方向性は正しいのですが、記述が適切ではありません。この書き方だと、10 個のコンテナすべてに対して同じテストを実行することになってしまいます。複数のコンテナにテストを分割するには、CircleCI CLI の circleci tests split コマンドを使用する必要があります。テストがファイル名、クラス名、タイミング データのいずれかで分割され、各コンテナに自動的に割り当てられます。タイミング データで分割すると、テストが均等に実行されるようにコンテナ全体に分散されます。高速で処理の済んだコンテナが、時間のかかっているコンテナをアイドル状態で必要がなくなるため、並列化に最適です。

最後に、テスト スイートの並列化が適正なレベルになっているかどうかを考えてみましょう。環境のスピンアップに約 30 秒かかっても、各コンテナでのテストが 30 秒で済むケースなら、並列処理を減らしてジョブ実行全体でセットアップの時間を短縮するというのも価値のある判断です。テストの実行時間とスピンアップ時間に黄金比が存在するわけではありませんが、最適なビルドのためには考慮すべきポイントでしょう。ファイル名とタイミングでテストを分割し、指定したコンテナでより多くのテストを実行するように最適化する場合、以下のような構成になります。

test:
 …
 parallelism: 5
 steps:
    ...
     - run: |
            TESTFILES=$(circleci tests glob "test/**/*.test.js" | circleci tests split --split-by=timings)
            CI=true npm run test $TESTFILES

キャッシュのベスト プラクティス

キャッシュでビルドを高速化しましょう。この機能により、時間のかかるフェッチ操作のデータを再利用できます。以下の例では、キャッシュを使用して、以前に実行したジョブから npm の依存関係を復元しています。npm の依存関係がキャッシュされているため、npm install ステップでは package.json ファイルに記述されている新しい依存関係のみがダウンロードされます。この依存関係のキャッシュは、npm、Yarn、Bundler、pip などのパッケージ依存関係マネージャーでよく使われていますが、restore_cachesave_cache という 2 つの特別なステップが必要です。それでは、これらのキャッシュ ステップを test ジョブでどのように使用するのか見てみましょう。

test:
  ...
  steps:
    …
    - restore_cache:
        keys:
          - v1-deps-{{ checksum "package-lock.json" }}
    - run: npm install
    - save_cache:
        key: v1-deps-{{ checksum "package-lock.json" }}
        paths:
          - node_modules

restore_cachesave_cache の両方のステップでキーを使用していることにお気付きでしょうか。このキーは、キャッシュを見つけるための一意の識別子です。save_cache ステップでは、このキーに基づいてキャッシュするディレクトリを指定します。上の例では、node_modules ディレクトリを保存しているため、そのディレクトリの Node の依存関係を以降のジョブで使用できます。 restore_cache ステップでは、同じキーを使って、ジョブに復元するキャッシュを検索します。上の例では、キャッシュを識別するためのバージョンと、依存関係マニフェスト ファイルのハッシュ値 (checksum “package-lock.json”) を組み合わせた文字列をキーとしています。

これは、キャッシュの復元と保存の標準的なパターンですが、フォールバック キーを使用すると最適化できます。フォールバック キーとして、候補となる一連のキャッシュを指定することで、一致するキャッシュが見つかる可能性が高まります。たとえば、アプリケーションの package.json に 1 つのパッケージが追加された場合、チェックサムによって生成される文字列が変わり、キャッシュ全体が見失われてしまいます。しかし、フォールバック キーを追加して一致する範囲を広げると、別の使用可能なキャッシュをヒットさせることができます。フォールバック キーを追加してキャッシュを復元する例を以下にお見せします。

test:
  ...
  steps:
    …
    - restore_cache:
        keys:
          - v1-deps-{{ checksum "package-lock.json" }}
          - v1-deps-
    

キーのリストに別の要素を追加していることに注目してください。もう一度、package.json に 1 つのパッケージが追加されたシナリオに沿って、詳しく見てみましょう。この場合、最初のキーではキャッシュが見つかりません。しかし、2 番目のキーでは、以前に保存されていた、古い package.json ファイルでのキャッシュを復元できます。依存関係のインストール ステップである npm install では、変更されたパッケージがフェッチされるだけです。すべてのパッケージを不必要にフェッチして余計なコストをかけずに済みます。CircleCI のドキュメントにアクセスして、フォールバック キーと部分キャッシュの復元について詳しくご確認ください。

ワークスペースへの選択的な永続化

ダウンストリーム ジョブでは、先行するジョブで生成したデータへのアクセスが必要になる場合があります。ワークスペースを使用すると、ワークフローの全期間にわたってファイルを保存できます。以下の構成を例に説明しましょう。build ジョブで Node アプリケーションをビルドし、ワークフローの次のジョブでそのアプリケーションをデプロイしています。この構成では、build ジョブで作業ディレクトリ全体をワークスペースに永続化し、次に deploy ジョブでそのディレクトリをアタッチして、ビルドしたアプリケーションにアクセスできるようにしています。

  build:
    ...
    steps:
      ...
      - run: npm run build
      - persist_to_workspace:
          root: .
          paths:
            - '*'
  deploy:
    ...
    steps:
       ....
        - attach_workspace:
            at: .

この構成は、build ジョブで作成したアプリケーション ディレクトリに deploy ジョブでアクセスできるようにするという点では有効ですが、理想的とは言えません。ワークスペースでは基本的に、tarball が作成され、blob ストアに格納されているだけであり、ワークスペースをアタッチするには、それらの tarball をダウンロードして解凍する必要があります。そして、そのプロセスには時間がかかってしまいがちです。後続のジョブで必要なファイルを選択的に永続化する方が高速になります。そこで、npm run build ステップで、圧縮できるよう build ディレクトリを生成してから、ワークスペースにデプロイメント用に保存することにしてみましょう。最適化後の構成は、次のようになります。

  build:
    ...
    steps:
      ...
      - run: npm run build
      - run: mkdir tmp && zip -r tmp/build.zip build
      - persist_to_workspace:
          root: .
          paths:
            - 'tmp'

  deploy:
    ...
    steps:
       ....
        - attach_workspace:
            at: .

これで、ビルド アーティファクトを含む tmp ディレクトリがプロジェクトの作業ディレクトリにマウントされるようになります。最適化前の構成では、作業ディレクトリ全体をアップロード、ダウンロードしていましたが、最適化後の構成では、ビルドしたアプリケーションを圧縮し、選択的な保存を行っているため、ワークスペースのアーカイブ、アップロード、ダウンロードに要する時間が節約されます。圧縮したファイルは、ワークスペースの一時ディレクトリに保存されます。ダウンストリーム ジョブでは、ワークスペースをアタッチすることで、保存しておいた zip ファイルにアクセスできるようになります。詳細については、CircleCI のワークスペースについて解説した記事をご覧ください。

シークレット管理のベスト プラクティス

シークレットをバージョン管理システムにチェックインするのは望ましくなく、シークレットは設定ファイルにプレーン テキストで書き込んではいけません。CircleCI ではコンテキスト機能が提供されており、組織内のプロジェクト間で環境変数を安全に保護しながら共有できます。コンテキストは、基本的にはシークレットの保存場所であり、環境変数を名前/値のペアとして設定でき、実行時に挿入されます。よく理解していただけるよう、まずは安全でない構成で説明しましょう。この構成の deploy ジョブでは、プレーン テキストで記述した AWS シークレットが指定されています。

deploy:
 …
 steps:
    ...
     - run: 
            name: Configure AWS Access Key ID
            command: |
              aws configure set aws_access_key_id K4GMW195WJKGCWVLGPZG --profile default
        - run: 
            name: Configure AWS Secret Access Key
            command: |
              aws configure set aws_secret_access_key ka1rt3Rff8beXPTEmvVF4j4DZX3gbi6Y521W1oAt --profile default

: 上記で使用しているのは、デモのためだけのダミーの認証情報です。

このテキストは、CircleCI 上のプロジェクトにアクセスできるすべての開発者に見えてしまいます。このようなシークレットは、コンテキスト内の環境変数として保存してください。シークレット キーとアクセス ID を、名前/値のペアで aws_secrets というコンテキストに追加し、環境変数としてアクセスできるようにします。コンテキストは、ワークフロー内のジョブで使用できます。先ほどの構成を安全なものに修正すると、以下のようになります。

deploy:
 …
 steps:
    ...
     - run: 
            name: Configure AWS Access Key ID
            command: |
              aws configure set aws_access_key_id ${AWS_ACCESS_KEY_ID} --profile default
        - run: 
            name: Configure AWS Secret Access Key
            command: |
              aws configure set aws_secret_access_key ${AWS_SECRET_ACCESS_KEY} --profile default

workflows:
  test-build-deploy:
     ...
      - deploy:
          context: aws_secrets
          requires:
            - build

注目したいのは、シークレットがプレーン テキストから環境変数に変わり、コンテキストがワークフローのジョブで使用されていることです。セキュリティを強化するために、CircleCI ではシークレット マスキング機能を採用しており、ユーザーによって誤ってシークレットの値が出力されることがないようになっています。

Orbs と再利用可能な構成要素

ここまでの最適化で、ビルドに適した Executor を選択し、テストを適切に分割し、処理の重複を避けるためにワークスペースに永続化してきました。これと同じことを他のすべてのプロジェクトにも行わなければなりません。たいへんな作業ですね。複数のビルド間で共通する構成要素を再利用できればよいのにと思いませんか。実は、そのための機能が存在するのです。

Circle CI は Orb と呼ばれる機能を提供しています。これを使用すると、一元的に構成要素を定義して、複数のプロジェクトですばやく簡単に再利用することができます。それだけでなく、Orb にはパラメーターを渡せるため、パラメーターに応じて複数の異なる動作をする 1 つの Orb を作成して、さまざまなプロジェクトで機能させることも可能です。

バージョン 2.1 の設定ファイルでは、構成の再利用可能な要素を定義することで、単純なジョブ ステップでも Executor 全体でも、同じパイプライン内の複数のジョブで再利用できるようになります。また、再利用可能な要素にパラメーターを渡すこともできます。これは、設定ファイルの複数の要素を、パイプラインの複数の部分で再利用する必要がある場合に便利です。

では、実際には Orb はどのように使用するのでしょうか。以下は、AWS S3 バケットへのデプロイメントの例です。構成全体が AWS S3 デプロイメント Orb を使用せずに記述されています。

- deploy:
    name: S3 Sync
    command: |+
      aws s3 sync \
        build s3://my-s3-bucket-name/my-application --delete \
        --acl public-read \
      --cache-control "max-age=86400"

これでジョブは機能します。ただし、S3 デプロイメント Orb を使用すると、以下のようになります。

- aws-s3/sync:
     from: bucket
     to: ‘s3://my-s3-bucket-name/my-application’
     arguments: |
       --acl public-read \
       --cache-control "max-age=86400"

S3 へのデプロイメントのために個別のデプロイ ステージを宣言する必要はありません。設定ファイル内で Orb から S3 の同期ステップを呼び出すだけです。また、記述されている情報はほとんど変わっていませんが、構成ファイルのスクリプトとしてではなく、Orb に渡すパラメーターとして表されていることに注目してください。よりコンパクトになっただけでなく、引数を追加、削除、変更することで、必要に応じて S3 へのデプロイメントを簡単に変更できるようになっています。さらに、ひとめで把握しやすく、Orb を更新するだけで複数のプロジェクトをスケーリングできるようになります。ここで紹介したヒントの中でも特に大切なポイントとして、DRY (Don’t Repeat Yourself: 繰り返しを避けること) の原則は、単なる美学的なものではありません。Orb は、プロジェクトをまたいだ複製を可能にする価値ある機能です。Orb を使った最適化をぜひ実践してみてください。

構成レビュー サービスをご利用ください

プレミアム サポートのゴールド プランとプラチナ プランでは、プレミアム サービスとして構成レビューを承っています。構成レビュー サービスでは、CircleCI DevOps カスタマー エンジニアが、お客様がビルド サイクルを短縮できるよう、新規構成の作成や既存構成のレビューをお手伝いします。お客様のニーズをうかがいながら、CircleCI の機能を最大限に活用するための推奨事項をご提案いたします。

その他のプレミアム サービスやサポート プランの詳細については、cs@circleci.com までお問い合わせください。


この記事の作成には、カスタマー エンジニアリング チームのメンバーである Anna Calinawan、Johanna Griffin、Grant MacGillivray が協力してくれました。