読書メモ:GitHub CI/CD実践ガイド

読書メモ:GitHub CI/CD実践ガイド

読書メモ。

本書はCI/CDの設計や運用について、GitHubを使ってハンズオン形式で学ぶ書籍です。GitHub Actionsの基本構文からスタートし、テスト・静的解析・リリース・コンテナデプロイなどを実際に自動化していきます。あわせてDependabot・OpenID Connect・継続的なセキュリティ改善・GitHub Appsのような、実運用に欠かせないプラクティスも多数習得します。

実装しながら設計や運用の考え方を学ぶことで、品質の高いソフトウェアをすばやく届けるスキルが身につきます。GitHubを利用しているなら、ぜひ手元に置いておきたい一冊です。


第3章:ワークフロー構文の基礎 #

中間環境変数 #

コンテキストを直接、シェルコマンドへ埋め込むのはアンチパターンです。コンテキストによっては特殊文字が含まれ、シェルコマンドの実行に意図しない影響を与える恐れがあります。

name: Contexts
on: push
jobs:
  print:
    runs-on: ubuntu-latest
    steps:
      - run: echo "${{ github.actor }}"

この問題を回避するには、環境変数経由でコンテキストを渡します。これは中間環境変数と呼ばれるテクニックです。

name: Intermediate environment variables
on: push
jobs:
  print:
    runs-on: ubuntu-latest
    env:
      ACTOR: ${{ github.actor }}
    steps:
      - run: echo "${ACTOR}"

環境変数をクォートするには、参照時にダブルクォーテーションで囲みます。そうすればトークン分割やパス名展開が抑止されます。これは GitHub Actions ではなく、一般的なシェルの仕様です。この仕様を利用すると、安全にコンテキストを参照できます。

  1. コンテキストはシェルコマンドでハードコードせず、、環境変数を経由して渡す
  2. 環境変数はすべてダブルクォーテーションで囲む

Secrets のログマスク #

Secrets の自動ログマスクは便利ですが、あてにしすぎてはいけません。ログマスクのアルゴリズムは完全一致です。一文字変えるだけで回避できます。Secrets はそもそもログ出力しないことが安全です。

name: Secrets
on: push
jobs:
  print:
    runs-on: ubuntu-latest
    env:
      PASSWORD: ${{ secrets.PASSWORD }}
    steps:
      - run: echo "${PASSWORD}" # ログはマスクされる
      - run: echo "${PASSWORD:0:1} ${PASSWORD#?}" # ログはマスクされない
Run echo "${PASSWORD}"
> *** # マスクされる
Run echo "${PASSWORD:0:1} ${PASSWORD#?}"
> M ySecret # マスクされない

リテラル #

式で使用できるリテラルは、null、boolean、number、string の4種類です。string のみ ${{ }} を省略できます。

name: Literals
on: push
jobs:
  print:
    runs-on: ubuntu-latest
    steps:
      - run: printenv | grep 'LITERAL_'
        env:
          LITERAL_NULL: ${{ null }}
          LITERAL_TRUE: ${{ true }}
          LITERAL_NUMBER: ${{ 1234 }}
          LITERAL_STRING: ${{ 'Hello' }} # string 通常記法
          LITERAL_OMIT_STRING: World # string 省略記法

ワークフロー実行名 #

run-name を使えばワークフロー実行名を指定できます。name キーで指定するワークフロー名と異なり、コンテキストが利用できます。うまく命名するとログが探しやすくなります。

name: Run name
run-name: Run by ${{ github.actor }}
on: push
jobs:
  print:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Hello"

これで GitHub のワークフローページの一覧には Run by <ユーザー名> と表示されます。

第4章:継続的インテグレーションの実践 #

タイムアウト #

timeout-minutes キーを使うと、分単位でタイムアウトを指定できます。

name: Timeout
on: push
jobs:
  print:
    runs-on: ubuntu-latest
    timeout-minutes: 1
    steps:
      - run: sleep 120 # 2分(120秒)スリープする

タイムアウト設定は「常に」すべきです。あらゆるワークフローへ明示的にタイムアウトを設定しましょう。

なぜなら GitHub Actions のデフォルトタイムアウトは6時間という長い時間が設定されているためです。一般的なユースケースではこのデフォルト時間はいくらなんでも長すぎます。

シェル #

シェルの指定は省略可能です。Ubuntu の場合、省略時は Bash が使われます。

ここで重要な点があります。実は Bash の場合、shell キーの指定の有無で起動オプションが変わります。

  • 省略時:bash -e {0}
  • 記述時:bash --noprofile --norc -eo pipefail {0}

とくに重要なのは pipefail オプションです。Bash はデフォルトではパイプ処理中のエラーを無視します。しかしこの挙動は問題を生みがちで、エラーの発生場所から遠く離れた場所で予期せぬ不具合を起こしやすいのです。そのため Bash でスクリプトを実装する場合は pipefail オプションを有効にするのが定石です。

GitHub Actions でもこの定石に従い、すべてのワークフローにおいてシェル指定を明示的に bash と書くことを推奨します。

name: Shell
on: push
defaults:
  run:
    shell: bash # 明示することで、`bash --noprofile --norc -eo pipefail {0}` で実行される
jobs:
  print:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Hello"

第5章:運用しやすいワークフローの設計 #

デバッグログ #

GitHub Actions ではデバッグログが出力できます。出力方法は次のとおりです。

  • ワークフローの再実行時に「Enable debug logging」を有効化する
  • Secrets または Variables へ後述する値を設定する

デバッグログは2種類あります。

  • ステップデバッグログ
    • ステップのログをより詳細にします。このログを見れば、コンテキストやステップのステータスなどがトレースできます。
    • Secrets または Variables へ ACTIONS_STEP_DEBUG という名前で true を登録すれば有効化できます。
    • ステップデバッグログはステップページから参照できます。
  • ランナー診断ログ
    • ランナーの挙動が詳細に確認できます。
    • Secrets または Variables へ ACTIONS_RUNNER_DEBUG という名前で true を登録すれば有効化できます。
    • ランナー診断ログは、ブラウザから直接は見れません。ステップページの「歯車アイコン」 > 「Download log archive」からログアーカイブをダウンロードして参照します。

Variables と Secrets は3種類ある #

  1. Organization variables / secrets
  2. Repository variables / secrets
  3. Environment variables / secrets

これらは登録先が異なるだけで、参照方法はすべて一緒です。同名のものがある場合は、スコープが狭い方を優先します。つまり、 Environment の優先順位が最も高く、Organization が最も低いです。ただ分かりづらいため、そもそも同名にしないほうが良いでしょう。

キャッシュ #

ファイルのダウンロードに時間がかかる場合、キャッシュを活用すると高速化できます。

- uses: actions/cache@v4
  with:
    key: test-${{ runner.os }}-${{ github.sha }}
    path: ${{ github.workspace }}/dummy
    restore-keys: |
      test-${{ runner.os }}-

key キーにはキャッシュキーを指定します。キャッシュキーはキャッシュの生成と保存に使用する識別子です。

path にはキャッシュ対象のディレクトリパス、またはファイルパスを指定します。パスは複数指定でき、Glob もサポートしています。

restore-keys にはキャッシュミス時のリストアキーを複数指定します。

キャッシュの復元は次のようなフローをたどります。

  1. key キーで定義したキャッシュキーと、厳密に一致するキャッシュを探す
  2. リストアキーの定義順に、プレフィックスが一致するキャッシュを探す

restore-keys は省略可能です。省略時は key キーのみでキャッシュを探します。

キャッシュキーの設計は重要です。

プラットフォームごとに異なるキャッシュを利用しましょう。たとえば Linux と Windows では、同じキャッシュを使えない可能性があります。キャッシュは最低でも、OS ごとに分離しましょう。他にも CPU アーキテクチャもキャッシュキーの候補です。

key: example-${{ runner.os }}-${{ runner.arch }}

依存関係を更新したときだけキャッシュも更新しましょう。package-lock.json のようなロックファイルがある場合、ファイルハッシュをキャッシュキーに指定しましょう。hashFiles 関数が役立ちます。

key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  node-${{ runner.os }}-${{ runner.arch }}-

第6章:アクションによるモジュール化 #

Composite Action・JavaScript Action・Docker Container Action のいずれかでアクションは実装します。

アクションのメタデータ構文はワークフロー構文と似ています。ただし shell キーが省略できない、Variables / Secrets へアクセスできないなどの違いもあります。

GITHUB_TOKEN をアクションから参照 #

GITHUB_TOKEN は Secrets が保持する値です。そのためコンポジットアクションでは直接アクセスすることはできません。

env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # コンポジットアクションからは、secrets コンテキストは参照不可

しかし secrets.GITHUB_TOKEN プロパティに限り、裏技が存在します。github コンテキスト経由でアクセスすればよいのです。この意外な仕様は利用頻度が高いため、覚えておいて損はありません。

env:
  GITHUB_TOKEN: ${{ github.token }} # github コンテキストならコンポジットアクションからも参照可能

GITHUB_ACTION_PATH 環境変数とスクリプトの切り出し #

ステップの実装が長くなってきたら、別ファイルへ切り出すとメンテナンスしやすくなります。たとえばメタデータファイルと同じディレクトリへ、スクリプトファイルとして切り出します。

├ action.yml # メタデータファイル
└ script.sh  # 切り出したスクリプトファイル

このとき次のように実装したくなりますが、実行してみるとエラーになります。切り出したスクリプトが見つからないためです。

steps:
  - run: ./script.sh # エラーになる
    shell: bash

切り出したスクリプトを実行するには、GITHUB_ACTION_PATH 環境変数を経由しましょう。先ほどのコードを、次のように書き換えます。

steps:
  - run: "${GITHUB_ACTION_PATH}/script.sh" # これで実行できる
    shell: bash

第14章:GitHub Actions の高度な使い方 #

動的なワークフロー定義 #

fromJSON 関数は引数の文字列を JSON オブジェクトまたは JSON データ型へ変換する関数です。関数の振る舞いは名前どおりで、意外性はありません。

しかし fromJSON を使えば動的にワークフロー定義を生成できます。これ以外の方法で実現できないユースケースが存在するため、覚えておく価値はあります。

name: Dynamic matrix
on: push
jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      matrix-json: ${{ steps.dynamic.outputs.json }}
    steps:
      - id: dynamic
        run: | # マトリックス定義を JSON 文字列で記述
          json='{"runner": ["ubuntu-latest", "macos-latest"]}'
          echo "json=${json}" >> "${GITHUB_OUTPUT}"
  print:
    needs: [prepare]
    strategy: # JSON 文字列から fromJSON 関数で動的にマトリックスを生成
      matrix: ${{ fromJSON(needs.prepare.outputs.matrix-json) }}
    runs-on: ${{ matrix.runner }}
    steps:
      - run: echo "Hello"

この例では結局ハードコードしていますが、この仕組みを使えば事前にマトリックスを決定できないユースケースで動的に生成できます。

第15章:GitHub Actions のセキュリティ #

スクリプトインジェクション #

外部から入ってくるデータは信頼できません。たとえば次のように、プルリクエストのタイトルを標準出力するとしましょう。

- run: echo "${{ github.event.pull_request.title }}"

このとき「inject"; printenv; #」というタイトルでプルリクエストを作成し、ワークフローを実行します。するとワークフロー実行ログに環境変数が出力されてしまいます。

プルリクエストのタイトルを悪用して、実装者が意図していないコマンドの実行に成功したわけです。

GitHub Actions では信頼できないデータが、github コンテキストに多数含まれています。具体例をいくつか挙げます。

  • github.event.pull_request.title: プルリクエストのタイトル
  • github.event.pull_request.body: プルリクエストの本文
  • github.head_ref: ブランチ名
  • github.event.issue.title: イシューのタイトル
  • github.event.issue.body: イシューの本文

スクリプトインジェクションで効果的な対策は、先に説明した中間環境変数です。

- run: echo "${PR_TITLE}"
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}

中間環境変数の使用を習慣化しましょう。

ワークフローレベルのパーミッション無効化 #

パーミッションは未定義の場合、デフォルトのパーミッションに従います。一方で明示的にパーミッションを定義すると、その定義が優先されます。そこで次の構文を使い、ワークフローレベルのパーミッションをすべて無効化しましょう。

するとデフォルトパーミッションも無効化され、コード参照すら明示的な許可が必要になります。

permissions: {}

そして各ジョブへ都度、必要なパーミッションを定義します。記述量は増えますが、不要なパーミッションは自然と排除されます。面倒に見えますが、習慣にしましょう。

第18章:継続的デリバリーの実践 #

ソフトウェア開発組織のパフォーマンスを研究しているチームとして、Google の DORA(DevOps Research and Assessment)が有名です。

組織にとって収益や生産性、顧客満足度の向上は重要な関心事です。DORA ではこれらの指標を組織パフォーマンスと呼んでいます。

DORA のもっとも重要な研究成果は、「ソフトウェアデリバリーパフォーマンスは、組織パフォーマンスと高い相関がある」と学術的な方法で示したことです。

業界や組織規模は関係ありません。ソフトウェアによる価値提供の能力が、あらゆる組織の業績や競争力へつながると統計的に導き出しました。

ソフトウェアデリバリーパフォーマンスとは、高速かつ安全にソフトウェアを届ける能力のことです。4つの指標で計測します。

  • 変更リードタイム:コミットからデプロイまでの時間
  • デプロイ頻度:本番環境へデプロイする頻度
  • 変更失敗率:デプロイ起因の障害から、ユーザー体験の低下を招く割合
  • デプロイ失敗時の回復時間:デプロイ起因の障害から、回復するまでの時間

「変更リードタイム」と「デプロイ頻度」はスループットを示します。ソフトウェアを生み出すスピードの評価指標です。

「変更失敗率」と「デプロイ失敗時の回復時間」は安定性を示します。こちらは生み出したソフトウェアの品質を評価する指標です。

DORA の研究で興味深いのは「スピードが速い組織ほど品質も高く、逆にスピードが遅い組織は品質も低い」という主張です。スピードと品質は両立できないという定説とは逆です。