TECH MEMO / FIELD NOTECI/CD経験者のためのGitHub Actions再入門
はじめに
GitHub Actionsを使う必要が出てきたため、基礎から学び直すことにしました。GitLab CI/CDの利用経験はありましたが、その知識がGitHub Actionsでもどこまで通用するのか、一度整理しておく必要がありました。
実際に触ってみると、似ている部分は確かにあります。ただし、用語を置き換えるだけでは理解できない違いもありました。GitLab CI/CDのstageを前提に考えると、GitHub ActionsのJob設計はうまく整理できませんでした。変数の扱いも異なり、Contextをシェルの環境変数と同じ感覚で使うと、スクリプトインジェクションにつながる危険もあると分かりました。
そこで、学習用リポジトリを作り、Hello Worldからデプロイまでを段階的に試しました。各テーマでは、実装前に挙動を予想し、Workflowを実行してログを確認し、予想と異なった点を記録しています。
この記事では、GitHub Actionsの機能を網羅するのではなく、GitLab CI/CD経験者の私が実際につまずいたところを中心にまとめます。後半では、タイムアウトや排他制御、権限管理など、SREの立場でWorkflowを継続運用するためのポイントも整理します。
今回作ったWorkflowの全体像
題材には、/healthと/helloのエンドポイントだけを持つ小さなGoアプリを使いました。最終的なWorkflowは、次のような構成です。
lintとtestを並列で動かし、両方が成功したらbuildへ進みます。ビルドしたバイナリはArtifactとして保存し、別のRunnerで動くverifyとdeployがダウンロードします。
一度に完成形を作ったわけではありません。最初はechoだけのWorkflowを作り、Trigger、Context、Jobの依存関係、CacheとArtifact、権限、Environmentの順に要素を増やしました。アプリのビルドに入る前に最小構成を動かしたことで、GitHub Actionsの問題とGoアプリの問題を分けて考えられました。
GitLab CI/CDとの違いを先に整理する
GitLab CI/CDとGitHub Actionsには似た概念があります。しかし、名前だけを対応づけると、実行モデルの違いを見落とします。
| 観点 | GitLab CI/CD | GitHub Actions | 移行時に意識すること |
|---|---|---|---|
| 処理全体 | Pipeline | Workflow | .github/workflows/*.yml単位で定義する |
| 起動条件 | workflow: rules、rules | on、if | onはRunの作成、ifはJobやStepの実行可否を決める |
| 実行順序 | stages、needs | needs | StageではなくJob同士の依存関係を作る |
| 実行単位 | Job | Job | GitHub-hosted RunnerではJobごとに新しい実行環境が用意される |
| 変数 | CI/CD Variables | Context、env、vars、secrets | 評価される場所と機密性で使い分ける |
| 成果物 | artifacts | Artifact | 後続Jobで明示的にダウンロードする |
| 排他制御 | resource_group | concurrency | 同じデプロイ先を表すgroupを設定する |
この中で、私の認識が大きく変わったのはJobの実行順序とRunnerの分離でした。
実際に試して分かったGitHub Actionsの設計
Workflowは上から順に動く処理ではない
GitLab CI/CDではstagesにtest、build、deployなどを並べ、Jobをフェーズへ所属させる構成に慣れていました。GitHub Actionsには、これと同じStageの定義がありません。
needsを指定していないJobは並列で実行されます。順序が必要なところだけ、Job同士をneedsでつなぎます。GitHubのドキュメントでも、needsは先に正常終了してほしいJobを指定する仕組みとして説明されています。
jobs:
lint:
# lintを実行
test:
# testを実行
build:
needs: [lint, test]
# buildを実行
YAMLを上から読むより、Jobとneedsを頂点と辺にしたDAGとして見るほうが理解しやすくなりました。前段が失敗すると、それを必要とする後続Jobは標準ではスキップされます。失敗時にも通知や後片付けを動かしたい場合は、if: always()などの条件を別途考える必要があります。
Jobを直列にしてもRunnerは共有されない
最初は、needsでJobを直列につなげれば、同じ実行環境を引き継ぐと予想していました。しかし、GitHub-hosted Runnerでは、Jobごとに新しいRunner環境が用意されます。needsが制御するのは実行順序であり、ファイルシステムや環境変数の共有ではありません。
lint、test、buildにJobを分けると、それぞれでリポジトリのCheckoutと言語環境の準備が必要です。
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
ここで示しているActionのバージョンは、学習時に使用したものです。別のWorkflowへ取り入れるときは、各Actionの公式リポジトリで現在のリリースと移行時の注意点を確認します。
GitLab CI/CDではJob開始時にソースコードが用意されるため、最初はCheckout Actionを書き忘れました。また、ubuntu-latestにGoが入っていたので、setup-goがなくても偶然テストが動きました。Runnerイメージの更新後も同じバージョンで再現できるように、CIが使うGoはgo.modから明示的に取得することにしました。
Job間で小さな値を渡す場合はJob Outputs、ファイルを渡す場合はArtifactを使います。「後続Jobだから前のJobのファイルが残っている」という前提を持たないことが大切でした。
Contextと環境変数は評価される場所が違う
${{ github.event_name }}のようなContextと、シェルが参照する$NAMEは、どちらも変数に見えますが、評価する主体が違います。
- ContextやExpressionは、JobをRunnerへ送る前にGitHub Actionsが評価する
envで渡した環境変数は、Runner上でシェルが展開する
GitHubの変数に関するドキュメントを読むだけでは、この差を実感できませんでした。実際にログへ値を出し、Workflow、Job、Stepの各階層でenvを上書きして、ようやく整理できました。
この違いはセキュリティにも関係します。PRタイトルやブランチ名などの外部入力をrunへ直接埋め込むと、シェルスクリプトが作られる段階で攻撃者の文字列がコードとして入り込む可能性があります。
# 危険な例
- name: Print pull request title
run: echo "${{ github.event.pull_request.title }}"
学習用Workflowでは悪意のある文字列を用意し、直接展開した場合とenvを経由した場合を比較しました。ダブルクォートで囲んだだけでは防げず、直接展開した例では文字列中のコマンドが実行されました。
GitHubが推奨する対策は、外部入力を中間の環境変数へ渡し、シェルから参照する方法です。
- name: Print pull request title safely
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE"
Contextを使うときは、どこで評価され、その値を誰が変更できるのかまで確認する必要があります。
CacheとArtifactは「消えてもよいか」で使い分ける
CacheとArtifactは、どちらもWorkflowでファイルを保存する機能です。用途を判断するときは、「そのデータが消えても処理をやり直せるか」で考えると分かりやすくなりました。
- Cacheは、依存関係やビルドキャッシュなどの再生成できるデータを保存する
- Artifactは、ビルド結果やテストレポートなど、そのWorkflow Runが生成した成果物を保存する
GitHubのArtifactに関するドキュメントにも、Cacheがなければ再取得または再生成できる設計にすべきだとあります。Cache missは異常ではなく、保存済みデータを使わずに再取得や再生成へ進む正常系です。
今回のverifyはビルド済みバイナリを検証するため、Artifactがなければ続行できません。buildでアップロードし、別のJobで明示的にダウンロードしました。
- name: Upload build binary
uses: actions/upload-artifact@v4
with:
name: study-server-binary
path: bin/study-server
retention-days: 1
ダウンロードしたバイナリの実行権限が外れており、chmod +xが必要になる点でもつまずきました。upload-artifactではZIPへの格納時にファイルのパーミッションが保持されず、展開後のファイルは644になります。実行権限を含めて保存したい場合は、事前にtar形式へまとめる方法もあります。
デプロイにはEnvironmentとconcurrencyを使う
デプロイの演習では、workflow_dispatchの入力からstagingまたはproductionを選び、実際の外部環境へは接続せずに処理を疑似実行しました。
deploy:
if: github.event_name == 'workflow_dispatch'
environment:
name: ${{ inputs.environment }}
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
EnvironmentをJobへ指定すると、デプロイ先ごとの履歴、Secrets、保護ルールを扱えます。GitHubのデプロイに関するドキュメントでは、承認、実行ブランチの制限、Environment Secretsへのアクセス制御などが説明されています。
同じ環境への多重デプロイを防ぐにはconcurrencyを使います。Matrixのmax-parallelは一つのWorkflow Run内の並列数を制御するもので、別のRun同士の排他制御にはなりません。
デプロイでは進行中の処理を途中で止めたくないため、今回はcancel-in-progress: falseとしました。ただし、この指定だけで待機中のデプロイがすべて順番に実行されるわけではありません。既定では、同じgroupで待機できるのは1件だけで、新しい実行が来ると古い待機中の実行はキャンセルされます。すべてのデプロイを待機させる場合は、GitHubのconcurrencyの説明にあるqueue: maxも指定します。
一方、PRのLintやテストでは古いコミットの結果が不要になることが多いため、cancel-in-progress: trueにして最新のRunを優先する選択肢があります。
なお、workflow_dispatchで新しいWorkflowを手動実行できるのは、そのWorkflowファイルがデフォルトブランチに存在するときです。作業ブランチへ新規ファイルを置いただけでは実行できず、学習中に一度ここで止まりました。この制約はWorkflow構文の公式ドキュメントにも記載されています。
特に注意したいセキュリティ設計
GITHUB_TOKENを最小権限にする
Workflowには、リポジトリを操作するためのGITHUB_TOKENが自動で用意されます。既定の権限はリポジトリやOrganizationの設定にも影響されるため、設定画面の状態を前提にせず、Workflow側へ必要な権限を書くようにしました。
permissions:
contents: read
permissionsで一部の権限を指定すると、省略した権限はnoneになります。書き込みが必要なJobだけJob単位で権限を追加すれば、Workflow全体へ強い権限を渡さずに済みます。利用できる権限はGitHubのWorkflow構文で確認できます。
外部入力をコードとして解釈させない
PRタイトル、本文、ブランチ名は、リポジトリの外部から持ち込まれる可能性があります。これらをContextからrunへ直接埋め込まないことは、今回の実験で最も印象に残った点です。
ForkからのPRも同じです。通常のpull_requestでは、デフォルトでSecretsが渡らず、GITHUB_TOKENも読み取り専用になります。ただし、Privateリポジトリでは管理者が設定を変更できるため、リポジトリ側のポリシーも確認が必要です。
pull_request_targetは、許可されている場合、ベースリポジトリ側の信頼されたContextで動き、Secretsや権限を持つことがあります。GitHubのセキュリティガイドにあるとおり、その状態でFork側のコードをCheckoutして実行すると危険です。
サードパーティActionも実行コードとして扱う
Actionは短いYAMLで機能を追加できる一方、Runner上で動くコードでもあります。タグは移動できるため、厳格な環境では完全なコミットSHAへ固定する方法があります。GitHubのドキュメントでも、SHAは不変であり、タグやブランチより信頼性が高い参照方法として説明されています。
ただし、SHAへ固定すると更新を自動では受け取れません。固定して終わりではなく、Dependabotなどで更新を追跡する仕組みと組み合わせます。今回の学習用Workflowではメジャーバージョンタグを使いましたが、実用するWorkflowではActionの提供元、固定方法、更新方法をセットで決めたいところです。
SRE目線で考えるGitHub Actionsの運用
Workflowが一度成功すれば完成、とは考えません。CI/CDは開発者へ継続的にフィードバックを返し、デプロイ経路を支える仕組みです。遅い、頻繁に失敗する、原因を追えないという状態が続けば、変更を安全に届ける能力にも影響します。
タイムアウトで暴走を止める
今回のWorkflowでは、すべてのJobへtimeout-minutes: 5を設定しました。
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
Jobの既定タイムアウトは360分です。入力待ちやプロセスの停止漏れがあると、処理が進まないままRunnerの時間を使い続けます。通常時の所要時間を計測し、多少の揺れを許容できる値をJobごとに決めるほうが安全です。
古い実行を残すか、止めるかを決める
PRへ短時間に複数回pushすると、古いコミットのテストが動き続けることがあります。最新結果だけが必要なら、ブランチやPR単位のconcurrencyを設定し、cancel-in-progress: trueで古い実行を止められます。
ただし、デプロイを同じ方針で止めるとは限りません。処理の意味を見ずに一律設定するのではなく、「古い実行を中断しても安全か」を基準にします。
保存期間と再生成可能性を決める
Artifactにはretention-daysを指定できます。PRの動作確認にしか使わないバイナリを長期間残す必要はないため、今回は1日としました。
保存期間は、とにかく短ければよいわけではありません。障害調査や監査、リリース物の保存に必要な期間を先に決めます。そのうえで、再生成できるものはCache、特定のRunにひもづく成果物はArtifactへ分けます。
失敗したログへ早くたどり着く
ターミナルから調べる場合は、GitHub CLIの--log-failedを使うと、失敗したStepのログへ絞れます。
gh run list
gh run view <run-id> --log-failed
調査時間を短くするには、Stepへ具体的な名前を付けることも効きます。また、YAMLやExpressionの誤りをpush後に見つけると、修正のたびにRunを作ることになります。手元ではactionlintなどによる静的検査を行い、送信前に検出できる誤りを減らしたいところです。
CI/CD自体を観測する
CI/CDも状態を数字で見られると改善しやすくなります。今回は計測基盤までは作っていませんが、継続して運用するなら、次の値が判断材料になります。
- Workflowの成功率
- 実行時間と、その上位パーセンタイル
- Runnerが起動するまでの待ち時間
- 再実行すると成功するFlaky Testの割合
- Cacheのヒット率
- デプロイの頻度と失敗数
目標値は一律に決められません。PRのたびに動く軽量なCIと、夜間に動かすE2Eテストでは、求める応答時間が異なります。まずは「どのWorkflowからのフィードバックを、誰が、どれくらいの時間で必要としているか」を決め、それに合わせてSLIとSLOを置くのがよさそうです。
GitLab CI/CD経験者がつまずきやすいポイント
ここまでで触れなかったものも含め、TriggerとMatrixでは次の点につまずきました。
pull_request.branchesが見るのは、作業ブランチではなくPRの取り込み先です- PRでの
github.refは、作業ブランチ名ではなくrefs/pull/<番号>/mergeになる場合があります workflow_dispatchは、Workflowファイルがデフォルトブランチに存在しないと実行できません- Matrixは標準で
fail-fast: trueのため、一つの組み合わせが失敗すると他の実行がキャンセルされることがあります
すべてを暗記するより、Trigger、Job、Runner、シェルのどの段階で処理されるのかを考えるほうが、設定を読み解きやすくなりました。
設計を振り返って残った判断基準
学習を始めたときは、GitLab CI/CDの設定をGitHub Actionsの構文へ置き換えることに意識が向いていました。ひと通り試した後は、個々のキーを覚えるより、次の3つを考えるほうが設計しやすいと感じています。
1つ目は、失敗やデータの消失をどこまで許容できるかです。Cacheがなくても処理は再実行できるようにし、失うと後続Jobが動かない成果物はArtifactとして扱います。処理が終わらない場合に備えてタイムアウトを置き、Jobが失敗したときに後続を止めるのか、通知だけは動かすのかも決めます。
2つ目は、その値や操作を誰が制御できるかです。外部から変更できる文字列はコードとして解釈させず、GITHUB_TOKENにはJobが必要とする権限だけを渡します。デプロイ先ごとに承認やSecretsを分けたい場合は、Environmentごとに設定します。
3つ目は、古い実行にも意味が残っているかです。新しいコミットがpushされた後のPR検証はキャンセルできますが、途中まで進んだデプロイを同じ感覚で止めるのは危険です。Artifactの保存期間も、調査や監査で後から必要になる可能性を考えて決めます。
実際のクラウドへデプロイするなら、OIDCによる短命なアクセストークンも検討対象になります。今回は外部環境への接続までは試していないため、クラウド側の信頼条件とGitHub Actions側の権限については、次に検証したい内容として残しました。
おわりに
GitLab CI/CDの経験は、GitHub Actionsを学ぶうえで役に立ちました。Job、Runner、Artifactといった共通する概念があり、CI/CDで何を実現したいのかは変わりません。
一方、既存の知識へ無理に当てはめると、StageのないJob設計や、Contextと環境変数の違いを見落とします。私の場合は、完成したYAMLを読むより、挙動を予想して最小構成で実行し、ログと比較する方法が理解につながりました。
必要に迫られて始めた学習でしたが、GitLab CI/CDとの違いだけでなく、Workflowを継続して運用するうえで考えることも整理できました。今後は実際にGitHub Actionsを使う場面で今回の判断基準を活かし、まずは小さく動かしてログを確認しながら、運用で見えた課題に合わせてWorkflowを改善していきたいです。
