ソフトウェア業界では、いくつかの重要な変化が起こっています。 AppleがすべてのマシンをカスタムARMベースのシリコンに移行し、AWSがGraviton2インスタンスで最高のコストあたりのパフォーマンス比を提供しているため、すべてのソフトウェアをx86プロセッサでのみ実行する必要があるとはもはや期待できません。 コンテナーを使用する場合、開発チームが異なるアーキテクチャを使用している場合や、開発先とは異なるアーキテクチャにデプロイする場合に、マルチプラットフォーム イメージを構築するために使用できる優れたツールがいくつかあります。 この投稿では、このようなビルドから最高のパフォーマンスを引き出す場合に使用できるいくつかのパターンを紹介します。
マルチプラットフォームのコンテナイメージを構築するには、次のコマンド docker buildx
を使用します。Buildx は、使い慣れた Docker ユーザー エクスペリエンスで多くの強力なビルド機能を有効にする Docker コンポーネントです。 buildxを介して実行されるすべてのビルドは、 Mobyビルドキット ビルダーエンジンで実行されます。 Buildx は、スタンドアロンで使用することも、たとえば Kubernetes クラスターでビルドを実行するために使用することもできます。 Docker CLI の 次のバージョン では、 docker build
コマンドもデフォルトで Buildx の使用を開始します。
既定では、Buildx で実行されるビルドは、コンピューターに一致するアーキテクチャのイメージをビルドします。 このようにして、作業しているのと同じマシンで実行されるイメージを取得します。 別のアーキテクチャ用にビルドするには、フラグを設定できます--platform
。--platform=linux/arm64
. 複数のプラットフォーム用にまとめてビルドするには、コンマ区切り記号を使用して複数の値を設定できます。
# building an image for two platforms
docker buildx build --platform=linux/amd64,linux/arm64 .
マルチプラットフォームイメージの構築は現在、Docker-containerおよびkubernetesドライバーでBuildKitを使用している場合にのみサポートされているため、マルチプラットフォームイメージを構築するには、 ビルダーインスタンスも作成 する必要があります。 1 つのターゲット プラットフォームの設定は、すべての buildx ドライバーで許可されています。
docker buildx create --use
# building an image for two platforms
docker buildx build --platform=linux/amd64,linux/arm64 .
Dockerfile からマルチプラットフォーム イメージをビルドする場合、実質的には、Dockerfile はプラットフォームごとに 1 回ビルドされます。 ビルドの最後に、これらのイメージはすべて 1 つのマルチプラットフォーム イメージにマージされます。
FROM alpine
RUN echo "Hello" > /hello
たとえば、2つのアーキテクチャ用に構築されたこのような単純なDockerfileの場合、BuildKitは2つの異なるバージョンのAlpineイメージ(1つはx86バイナリを含み、もう1つはarm64バイナリを含む)をプルし、それぞれでそれぞれのシェルバイナリを実行します。
さまざまな構築方法
一般に、マシンのCPUは、ネイティブアーキテクチャのバイナリのみを実行できます。 x86 CPU は ARM バイナリを実行できず、その逆も同様です。 では、上記の例をIntelマシンで実行している場合、ARMのシェルバイナリをどのように実行できますか? これは、直接行うのではなく、ソフトウェアエミュレータを介してバイナリを実行することによって行われます。
docker buildx ls
各ビルダーにインストールされているエミュレーターを示します。 システムにリストされていない場合は、イメージを使用して tonistiigi/binfmt
インストールできます。
この方法でエミュレータを使用するのは非常に簡単です。 Dockerfileを変更する必要はまったくなく、複数のプラットフォーム用に自動的にビルドできます。 しかし、それには欠点がないわけではありません。 このように実行されるバイナリは、アーキテクチャ間で命令を常に変換する必要があるため、ネイティブの速度では実行されません。 場合によっては、エミュレーションレイヤー でバグを引き起こす ケースを見つけることもあります。
このオーバーヘッドを回避する 1 つの方法は、最も長く実行されるコマンドがエミュレーターを介して実行されないように Dockerfile を変更することです。 代わりに、 クロスコンパイルステージを使用できます。
エミュレーションとクロスコンパイルの違いは、前者ではソフトウェアで別のアーキテクチャの完全なシステムをエミュレートするのに対し、クロスコンパイルでは、ターゲットアーキテクチャ用の新しいバイナリを生成する特別な構成オプションを備えたネイティブアーキテクチャ用に構築されたバイナリのみを使用することです。 名前が示すように、この手法はすべてのプロセスに使用できるわけではなく、ほとんどの場合、コンパイラを実行している場合にのみ使用できます。 幸いなことに、2つの手法を組み合わせることができます。 たとえば、Dockerfile では、エミュレーションを使用してパッケージ マネージャーからパッケージをインストールし、クロスコンパイルを使用してソース コードをビルドできます。
エミュレーションとクロスコンパイルのどちらを使用するかを決定する場合、考慮すべき最も重要なことは、プロセスが多くのCPU処理能力を使用しているかどうかです。 エミュレーションは通常、パッケージをインストールする場合や、ファイルを作成したり、1回限りのスクリプトを実行したりする必要がある場合に適した方法です。 しかし、クロスコンパイルを使用するとビルド(おそらく数十分)が速くなる場合は、Dockerfileを更新する価値があります。 ビルドの一部としてテストを実行したい場合、クロスコンパイルではそれを達成できません。 その場合に最高のパフォーマンスを得るには、アーキテクチャの異なる複数のマシンで リモートビルドクラスター を使用するという別のオプションがあります。
ドッカーファイルの準備
Dockerfileにクロスコンパイルを追加するために、 マルチステージビルドを使用します。 マルチステージビルドで使用する最も一般的なパターンは、ビルドアーティファクトを準備するビルドステージと、最終イメージとしてエクスポートするランタイムステージを定義することです。 ここでは同じメソッドを使用しますが、ビルドステージで常にネイティブアーキテクチャのバイナリを実行し、ランタイムステージにターゲットアーキテクチャのバイナリを含めるという追加の条件があります。
次のような FROM debian
コマンドでビルドステージを開始すると、ビルド中にフラグで --platform
設定された値に一致するDebianイメージをプルするようにビルダーに指示します。 代わりに私たちがしたいのは、このDebianイメージが常に現在のマシンにネイティブであることを確認することです。 x86システムを使用している場合は、代わりに次のような FROM --platform=linux/amd64 debian
コマンドを使用できます。 これで、ビルド中に設定されたプラットフォームに関係なく、この段階は常にamd64に基づいています。 新しいAppleMacのようなARMマシンに切り替えるとどうなるかを除いて? ここで、すべてのDockerファイルを変更する必要がありますか? 答えはノーであり、定数のプラットフォーム値をDockerfileに書き込む代わりに、FROM --platform=$BUILDPLATFORM debian
代わりに変数を使用する必要があります。
BUILDPLATFORM
は、使用できる 自動定義 (グローバル スコープ) ビルド引数 のセットの一部です。 それは常にプラットフォームまたは現在のシステムと一致し、ビルダーは私たちのために正しい値を入力します。
このような変数の完全なリストは次のとおりです。
BUILDPLATFORM — matches the current machine. (e.g. linux/amd64)
BUILDOS — os component of BUILDPLATFORM, e.g. linux
BUILDARCH — e.g. amd64, arm64, riscv64
BUILDVARIANT — used to set ARM variant, e.g. v7
TARGETPLATFORM — The value set with --platform flag on build
TARGETOS - OS component from --platform, e.g. linux
TARGETARCH - Architecture from --platform, e.g. arm64
TARGETVARIANT
ビルド段階では、ソースコードをプルしたり、使用したいコンパイラパッケージをインストールしたりできます。 これらのコマンドは、単一プラットフォームまたはエミュレーションベースの Dockerfile で既に使用しているコマンドと同じである必要があります。
ここで行う必要がある唯一の追加の変更は、コンパイラプロセスを呼び出すときに、実際のターゲットアーキテクチャのアーティファクトを返すように構成するパラメーターを渡す必要があることです。 ビルドステージには常にホストのネイティブアーキテクチャのバイナリが含まれているため、コンパイラは環境からターゲットのアーキテクチャを自動的に決定できなくなったことに注意してください。
ターゲットアーキテクチャを渡すために、前に示したのと同じ自動的に定義されたビルド引数を、今回はプレフィックス付き TARGET*
で使用できます。 ステージ内でこれらのビルド引数を使用しているため、 使用する前にローカルスコープ 内にあり、コマンドで ARG
宣言する必要があります。
FROM --platform=$BUILDPLATFORM alpine AS build
# RUN <install build dependecies/compiler>
# COPY <source> .
ARG TARGETPLATFORM
RUN compile --target=$TARGETPLATFORM -o /out/mybinary
あとは、ビルドの結果としてエクスポートするランタイムステージを作成するだけです。 この段階では、定義では FROM
使用 --platform
しません。書く FROM --platform=$TARGETPLATFORM
ことはできますが、とにかくそれがすべてのビルドステージのデフォルト値であるため、フラグの使用は冗長です。
FROM alpine
# RUN <install runtime dependencies installed via emulation>
COPY --from=build /out/mybinary /bin
確認のために、上記のDockerfileが2つのプラットフォーム用にビルドされた場合に何が起こるかを見てみましょう。 docker buildx build --platform=linux/amd64,linux/arm64 .
新しいApple M1マシンのようなARM64ベースのシステムで呼び出されます。
まず、ビルダーは ARM64 の Alpine イメージをプルダウンし、ビルドの依存関係をインストールして、ソースをコピーします。 これらの手順は、2 つの異なるプラットフォーム用にビルドしている場合でも、一度だけ実行されることに注意してください。BuildKit は、これらのプラットフォームの両方が同じコンパイラとソースコードに依存していることを理解するのに十分スマートであり、ステップを自動的に重複排除します。
これで、プロセスを実行しているコンテナーの 2 つの個別のインスタンスが呼び出され、 compiler
フラグに --target
異なる値が渡されます。
エクスポート段階では、BuildKit は ARM64 バージョンと x86 バージョンの両方の Alpine イメージをプルダウンするようになりました。 ランタイムパッケージが使用された場合は、エミュレーションレイヤーを使用してx86バージョンがインストールされます。 これらのステップはすべて、依存関係を共有していないため、ビルドステージと並行してすでに実行されています。 最後のステップとして、それぞれの compiler
プロセスによって作成されたバイナリがステージにコピーされます。
その後、両方のランタイム・ステージがOCIイメージに変換され、BuildKitはこれらの両方のイメージを含む OCIイメージ・インデックス 構造(マニフェスト・リストとも呼ばれる)を準備します。
囲碁の例
関数的な例として、Goプログラミング言語で書かれたサンプルプロジェクトを見てみましょう。
単純な Go アプリケーションを構築する一般的なマルチステージ Dockerfile は次のようになります。
FROM golang:1.17-alpine AS build
WORKDIR /src
COPY . .
RUN go build -o /out/myapp .
FROM alpine
COPY --from=build /out/myapp /bin
Goでクロスコンパイルを使用するのはとても簡単です。 あなたがする必要がある唯一のことは、環境変数を持つターゲットアーキテクチャを渡すことです。go build
、環境変数を理解します GOOS
GOARCH
。32ビットシステムのARMバージョンを指定するためのものもあります GOARM
。
GOOS
と GOARCH
の値は、BuildKit が Dockerfile 内で使用できるようにするために前に見た and TARGETARCH
値と同じ形式 TARGETOS
です。
以前に学んだすべての手順を適用すると、ビルドステージを修正してプラットフォームを構築し、変数を定義し ARG TARGET*
、クロスコンパイルパラメータをコンパイラに渡すと、次のようになります。
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS build WORKDIR /src COPY . . ARG TARGETOS TARGETARCH RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp . FROM alpine COPY --from=build /out/myapp /bin
ご覧のとおり、3つの小さな変更を加えるだけで済み、Dockerfileははるかに強力です。 これらの変更に欠点はなく、Dockerfileは引き続き移植可能であり、すべてのアーキテクチャで動作することに注意してください。 ちょうど今、非ネイティブアーキテクチャ用にビルドすると、ビルドがはるかに高速になります。
検討したい追加の最適化をいくつか見てみましょう。
Goアプリケーションが他のGoモジュールに依存している場合、通常、依存関係のソースをディレクトリ内に vendor
含めるか、プロジェクトにそのようなディレクトリが含まれていない場合、Goコンパイラはコマンドの実行中に go build
ファイルにリストされている go.mod
依存関係をプルします。
後者の場合、マルチプラットフォームビルドでプロセスが2回呼び出されたためgo build
、(独自のソースコードは1回しかコピーされませんが)、これらの依存関係も2回プルされることを意味します。 コマンドで ARG TARGETARCH
ビルドステージを分岐する前に、これらの依存関係をダウンロードするようにGoに指示することで、これを回避することをお勧めします。
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS build WORKDIR /src COPY go.mod go.sum . RUN go mod download COPY . . ARG TARGETOS TARGETARCH RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp .
FROM alpine COPY --from=build /out/myapp /bin
これで、2つの go build
プロセスが実行されると、事前にプルされた依存関係にすでにアクセスできます。 また、パッケージをダウンロードする前にとgo.sum
ファイルのみを go.mod
コピーしたため、通常のソースコードが変更されたときに、モジュールのダウンロードのキャッシュが無効になりません。
完全を期すために、Dockerfile内に キャッシュマウント も含めましょう。RUN --mount
オプションを使用すると、ソースコードへのアクセス、ビルドシークレット、一時ディレクトリ、キャッシュディレクトリに使用できるコマンドに新しいマウントポイントを公開できます。 キャッシュマウントは、次にビルダーを再度呼び出したときに再表示されるアプリケーション固有のキャッシュファイルを書き込むことができる永続ディレクトリを作成します。 これにより、ソース コードに変更を加えた後にインクリメンタル ビルドを実行するときに、パフォーマンスが大幅に向上します。
Goでは、キャッシュマウントに変換するディレクトリは次のとおりです /root/.cache/go-build
と /go/pkg
。 1つ目はGoビルドキャッシュのデフォルトの場所であり、2つ目はモジュールをダウンロードする場所です go mod
。 これは、ユーザーが root
であり、 GOPATH
であることを前提としています /go
。
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp .
マウント (デフォルトタイプ) を使用して type=bind
ソースコードにマウントすることもできます。 これにより、を使用してファイルを COPY
実際にコピーする際のオーバーヘッドを回避できます。 Dockerfile でのクロスコンパイルでは、ソース コードの変更によってターゲット固有の依存関係のキャッシュが無効になるため、定義 ARG TARGETPLATFORM
する前にソースをコピーしたくない場合は特に重要な場合があります。 マウントはデフォルトで読み取り専用でマウントされる type=bind
ことに注意してください。 実行中のコマンドでソース・コードにファイルを書き込む必要がある場合でも、マウントのオプションを使用 COPY
または設定rw
することができます。
これにより、完全に最適化された完全なクロスコンパイルGo Dockerfileが実現します。
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS build
WORKDIR /src
ARG TARGETOS TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp .
FROM alpine
COPY --from=build /out/myapp /bin
例として、最初に使用したマルチステージのDockerfileを使用してDocker CLIバイナリをビルドするのにかかる時間を測定し、次に最適化を適用したバイナリを構築しました。 ご覧のとおり、違いはかなり劇的です。
ネイティブアーキテクチャのみの初期ビルドでは、違いはごくわずかで、命令を実行する COPY
必要がないことからのわずかな変更だけです。 しかし、ARMとx86の両方のCPU用にイメージを構築すると、その違いは非常に大きくなります。 新しい Dockerfile では、アーキテクチャを 2 倍にしてもビルド時間が 70% しか増加しませんが (ビルドの一部が共有されているため)、2 番目のアーキテクチャを QEMU エミュレーションでビルドすると、ビルド時間はほぼ 7 倍長くなります。
追加したキャッシュマウントの追加の助けを借りて、Goソースコードの変更による増分再構築は、古いDockerfileと比較してばかげた100倍の速度向上の領域に達しています。