CI/CDパイプライン:マルチアーキテクチャDockerビルドとリリースプロセス
前提知識
- ›第1回・第3回:アーキテクチャとクライアントの違い
- ›GitHub Actionsのワークフロー構文
- ›Dockerのマルチアーキテクチャイメージの概念(manifest、digest)
CI/CDパイプライン:マルチアーキテクチャDockerビルドとリリースプロセス
第4回では、dependency updaterがバージョンバンプのPRを作成する仕組みを見ました。それらのPRはバリデーションビルドをトリガーし、マージされると、マルチアーキテクチャイメージをGitHub Container Registryにプッシュするプロダクションビルドが実行されます。このパイプラインは合計6つのDockerイメージビルド(3クライアント × 2アーキテクチャ)をファンアウトで並列実行し、3つのmanifestマージジョブによってファンインします。
この最終回では、GitHub Actionsワークフローを詳しく解説します。ファンアウト/ファンインのビルドパターン、プラットフォーム固有のfeatureフラグ、マルチアーキテクチャのmanifestマージ、そしてこのシリーズを通じて見てきたサプライチェーン保護を補完する多層セキュリティ強化について掘り下げます。
パイプラインの全体像:3クライアント × 2アーキテクチャ
CIの戦略は、異なる目的を持つ2つのワークフローファイルにまたがっています:
| ワークフロー | トリガー | 目的 |
|---|---|---|
docker.yml |
Push to main, version tags (v*) |
Build, push, and merge multi-arch images |
pr.yml |
Pull requests | Build-only validation (no push) |
docker.ymlのプロダクションパイプラインは合計9つのジョブを生成します:
flowchart TD
subgraph "Fan Out: 6 parallel build jobs"
G1["geth<br/>linux/amd64"]
G2["geth<br/>linux/arm64"]
R1["reth<br/>linux/amd64<br/>+asm-keccak"]
R2["reth<br/>linux/arm64"]
N1["nethermind<br/>linux/amd64"]
N2["nethermind<br/>linux/arm64"]
end
subgraph "Fan In: 3 merge jobs"
MG["merge-geth"]
MR["merge-reth"]
MN["merge-nethermind"]
end
G1 -->|digest artifact| MG
G2 -->|digest artifact| MG
R1 -->|digest artifact| MR
R2 -->|digest artifact| MR
N1 -->|digest artifact| MN
N2 -->|digest artifact| MN
MG --> IMG1["ghcr.io/base/node-geth<br/>+ ghcr.io/base/node"]
MR --> IMG2["ghcr.io/base/node-reth"]
MN --> IMG3["ghcr.io/base/node-nethermind"]
matrix戦略により、アーキテクチャをまたいだ真の並列ビルドが実現されます。各アーキテクチャは専用のrunnerタイプで動作します。amd64にはubuntu-24.04、arm64にはubuntu-24.04-armです。これはQEMUによるクロスコンパイルではなく、ネイティブコンパイルです。Rethのmaxperfプロファイルのようなパフォーマンスを重視するビルドでは、この点が特に重要になります。
ビルドジョブのパターン
Gethのビルドを例に、単一ビルドジョブの構造を追ってみましょう。ジョブ定義はdocker.ymlの24行目から始まります:
sequenceDiagram
participant GH as GitHub Runner
participant GHCR as Container Registry
participant ART as Artifact Storage
GH->>GH: 1. Harden runner
GH->>GH: 2. Checkout code
GH->>GHCR: 3. Docker login
GH->>GH: 4. Extract metadata (tags, labels)
GH->>GH: 5. Setup buildx
GH->>GHCR: 6. Build & push by digest
GH->>ART: 7. Upload digest artifact
注目すべきはステップ6です。ビルドはタグ付きイメージをプッシュするのではなく、push-by-digest=trueを使ってコンテンツdigest(SHA256ハッシュ)のみで識別されるタグなしイメージをプッシュします:
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
digestはエクスポートされ、GitHub Actionsのアーティファクトとしてアップロードされます(71〜88行目):
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: digests-geth-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
digestはファイル名がdigestハッシュである空ファイルとして保存されます。ファイルの内容は不要で、ファイル名だけが重要というスマートな工夫で、アーティファクトのアップロード/ダウンロードを経ても問題なく機能します。
プラットフォーム固有のビルドフィーチャー
最も興味深いmatrixのバリエーションは、Rethのビルドにあります(89〜99行目):
reth:
strategy:
matrix:
settings:
- arch: linux/amd64
runs-on: ubuntu-24.04
features: jemalloc,asm-keccak,optimism
- arch: linux/arm64
runs-on: ubuntu-24.04-arm
features: jemalloc,optimism
asm-keccakフィーチャーはamd64のみで有効化されています。これはEthereumが多用する暗号学的プリミティブであるKeccak-256ハッシュ関数に対して、手書きのx86-64アセンブリ命令を活用する機能です。ARMプロセッサには必要なアセンブリ命令がないため、arm64ビルドからは除外されています。
フィーチャーはビルド引数として渡されます(134〜135行目):
build-args: |
FEATURES=${{ matrix.settings.features }}
GethとNethermindはアーキテクチャ間でmatrix設定が同一で、プラットフォーム固有のフィーチャーはありません。両者の違いはCIレベルではなく、Dockerfileレベル(ビルドツールチェーン)にあります。
ヒント:
jemallocアロケーター(両アーキテクチャに含まれる)はシステムのmallocを置き換え、ブロックチェーンの状態管理に典型的な高いフラグメンテーションパターン下でのRethのメモリ割り当てパフォーマンスを大幅に改善します。
pr.ymlのPRバリデーションワークフローはpush: falseを設定する点を除いて、プロダクションmatrixと完全に同一の構成です:
- name: Build the Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
with:
push: false
platforms: ${{ matrix.settings.arch }}
これにより、開発用イメージでregistryを汚染することなく、すべてのPRが同じビルドmatrixでバリデーションされることが保証されます。
Manifestマージ:アーキテクチャdigestを統合する
マルチアーキテクチャの仕組みが実際に動くのはマージジョブです。代表例としてmerge-gethを見てみましょう。
flowchart TD
A["Download amd64 digest artifact"] --> C
B["Download arm64 digest artifact"] --> C
C["Both digests in /tmp/digests/"] --> D["docker buildx imagetools create"]
D --> E["Multi-arch manifest"]
E --> F["ghcr.io/base/node-geth:main"]
E --> G["ghcr.io/base/node-geth:sha-9480ec..."]
E --> H["ghcr.io/base/node:main (deprecated)"]
マージジョブはパターンマッチングを使って、両アーキテクチャのビルドからdigestアーティファクトをダウンロードします:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: ${{ runner.temp }}/digests
pattern: digests-geth-*
merge-multiple: true
次に、metadataアクションが適切なタグ(ブランチ名、Gitタグが存在する場合はそのタグ、ロングSHA)を生成します:
tags: |
type=ref,event=branch # "main" for pushes to main
type=ref,event=tag # "v1.2.3" for version tags
type=sha,format=long # "sha-9480ec16531c2f222ea18eba6efed235e7210381"
最後に、docker buildx imagetools createがアーキテクチャ別のイメージを単一のマルチアーキテクチャmanifestにまとめます(264〜268行目):
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.NAMESPACE }}/${{ env.GETH_DEPRECATED_IMAGE_NAME }}@sha256:%s ' *)
printf ... *のglobはディレクトリ内のすべてのdigestファイル名に展開されます。先ほど作成した空ファイルです。各ファイル名がdigestハッシュそのものであり、@sha256:プレフィックスを付けることで有効なイメージ参照になります。
GethのマージジョブはRethやNethermindと異なり、node-geth(正式名称)とnode(後方互換性のための非推奨エイリアス)の2つのイメージ名にプッシュします。RethとNethermindはそれぞれ自身の名前にのみプッシュします。
CIにおけるセキュリティ強化
CIパイプラインは、Dockerfileのコミットハッシュ検証を補完する複数層のサプライチェーンセキュリティを実装しています。
ActionのSHAピン留め
すべてのGitHub Actionの参照は、バージョンタグではなく完全なコミットSHAを使用しています:
# Instead of: uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
GitHub Actionsのバージョンタグは(アップストリームリポジトリのGitタグと同様に)移動できます。コミットSHAを使うことで、将来のタグ変更に関わらず、レビューされた正確なコードがワークフローで実行されることが保証されます。SHA後のコメントは、人間が読みやすいようにバージョンを示しています。
Harden Runner
すべてのジョブはstep-security/harden-runnerから始まります:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863
with:
egress-policy: audit
これはrunnerからのすべてのアウトバウンドネットワーク接続を監視し、ビルドが接続するすべての外部サービスの監査証跡を提供します。auditモード(blockではない)に設定されていますが、予期しないアウトバウンド接続を通じたサプライチェーン攻撃への可視性を確保します。
最小限の権限
ワークフローは必要な権限のみをリクエストします(19〜21行目):
permissions:
contents: read
packages: write
PRワークフローはさらに制限が厳しく、イメージをプッシュしないためcontents: readのみです。
| セキュリティレイヤー | 適用箇所 | 保護対象 |
|---|---|---|
| Pinned action SHAs | All workflows | Action tag-moving attacks |
| harden-runner | All jobs | Unexpected outbound connections |
| Minimal permissions | Workflow-level | Over-privileged token access |
| Commit hash verification | Dockerfiles | Upstream tag-moving attacks |
| versions.json pinning | Repository | Uncontrolled version drift |
| Anti-downgrade validation | Dependency updater | Malicious version rollbacks |
PRバリデーションとIssue管理
メインのビルドパイプライン以外にも、リポジトリを健全に保つための2つのサポートワークフローがあります。
pr.ymlワークフローは、すべてのプルリクエストに対してフルビルドmatrixを実行します。6つの並列ジョブ(3クライアント × 2アーキテクチャ)がすべてパスしなければマージできません。これにより、Dockerfileの問題、バージョンの不一致、アップストリームのビルド破壊を早期に検出できます。
flowchart LR
PR["Pull Request"] --> V["6 parallel builds<br/>(3 clients × 2 archs)"]
V -->|All pass| M["Merge allowed"]
V -->|Any fail| B["Merge blocked"]
stale.ymlワークフローは毎日UTC 0時30分に実行され、非アクティブなissueとPRを自動管理します:
days-before-stale: 14
days-before-close: 5
14日間アクティビティのないissueとPRには「stale」ラベルと警告メッセージが付きます。さらに5日間非アクティブが続くと自動的にクローズされます。これにより、issue trackerに放置されたチケットが溜まり続けるのを防ぎます。
CI/CDパイプラインの全体像
4つのワークフローがdependency updateのライフサイクルの中でどのように連携するかを可視化してみましょう。これはビルドパイプラインの最も一般的なトリガーです:
flowchart TD
A["Daily cron (1 PM UTC)"] -->|"update-dependencies.yml"| B["Dependency updater runs"]
B -->|"New versions found"| C["Auto-create PR"]
C -->|"pr.yml triggers"| D["6 validation builds<br/>(no push)"]
D -->|"All pass"| E["Reviewer approves & merges"]
E -->|"docker.yml triggers"| F["6 production builds<br/>(push digests)"]
F --> G["3 manifest merge jobs"]
G --> H["ghcr.io/base/node-geth<br/>ghcr.io/base/node-reth<br/>ghcr.io/base/node-nethermind"]
B -->|"No updates"| I["Workflow exits"]
style A fill:#e6f3ff
style H fill:#e6ffe6
毎日のcronトリガーから公開されるマルチアーキテクチャイメージまで、パイプラインはヒューマンレビューを唯一の手動ゲートとして完全自動化されています。dependency updaterが新バージョンを見つけ、差分リンク付きのPRを作成し、CIがビルドをバリデーションし、レビュアーが承認してマージすると、プロダクションイメージのビルドがトリガーされます。
シリーズまとめ
この5本の記事を通じて、base/nodeリポジトリのライフサイクル全体を追ってきました:
- アーキテクチャ:約30ファイルのデプロイメントオーケストレーターで、2サービスのDocker Composeトポロジー、3層の設定、バージョンピン留めビルドを採用
- ブートシーケンス:ポーリングループによるサービス順序付け、consensus dispatcherのルーティング、Rethの注目すべき多フェーズ初期化
- クライアント比較:基本的に異なるビルドパイプラインとフィーチャーセットを持つ3つのクライアントが共通インターフェースで統一される構造——重要なbase-consensus非対称性を含む
- 依存関係管理:4つのバージョンフォーマットを処理し、アンチダウングレード保護を実施し、差分リンク付きのPRを自動生成するGo CLI
- CI/CD:ファンアウト/ファンインのmanifestマージと多層サプライチェーンセキュリティを備えたマルチアーキテクチャビルド
ここから浮かび上がる設計哲学は、実装よりもコンポジションというものです。このリポジトリはブロックチェーンのロジックを自前で実装するのではなく、バージョンピン留め、検証済みビルド、自動更新によってアップストリームの実装をオーケストレーションします。すべてのシェルスクリプト、Dockerfile、CIワークフローがこのオーケストレーションという目標に沿って設計されています。その結果、docker compose up一つで、3つのexecution clientのいずれかを使い、検証済みのソースコードからビルドされ、あらゆるアーキテクチャで動作するプロダクション対応のBase L2ノードを起動できます。
ヒント: 理解を確かなものにする最善の方法は、実際に動かしてみることです。リポジトリをクローンし、
CLIENT=rethを設定し、.env.mainnetにL1エンドポイントを設定して、docker compose upを実行してみましょう。execution clientが起動し、consensus entrypointがreadiness確認のポーリングを行い、Engine API接続が確立されるのを観察してください。そしてログをentrypointスクリプトと見比べながら読むと、コードの全体像が鮮明につかめるはずです。