より高速なマルチプラットフォーム ビルド: Dockerfile クロスコンパイル ガイド

ソフトウェア業界では、いくつかの重要な変化が起こっています。 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 では、エミュレーションを使用してパッケージ マネージャーからパッケージをインストールし、クロスコンパイルを使用してソース コードをビルドできます。

エミュレーションドッカーの削除
エミュレーションとクロスコンパイルは、インテル/ AMDマシンで実行される「 — platform=linux / amd64、linux / arm64」でビルドします。青には x86 バイナリが含まれ、黄色には ARM バイナリが含まれます。

エミュレーションとクロスコンパイルのどちらを使用するかを決定する場合、考慮すべき最も重要なことは、プロセスが多くの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 プロセスによって作成されたバイナリがステージにコピーされます。

ドッカー1 50ierwpslz8axrax27tzxaを削除します
アップルM1マシンで実行されるサンプルドッカーファイルコマンド。青には x86 バイナリ、黄色には ARM が含まれます。

その後、両方のランタイム・ステージが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 、環境変数を理解します GOOSGOARCH 。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バイナリをビルドするのにかかる時間を測定し、次に最適化を適用したバイナリを構築しました。 ご覧のとおり、違いはかなり劇的です。

Delete blue docker 1 drkflmblqqwcic7 fwejmq
https://github.com/docker/cli テストドッカーファイルを使用したビルド時間(秒、低いほど良い)

ネイティブアーキテクチャのみの初期ビルドでは、違いはごくわずかで、命令を実行する COPY 必要がないことからのわずかな変更だけです。 しかし、ARMとx86の両方のCPU用にイメージを構築すると、その違いは非常に大きくなります。 新しい Dockerfile では、アーキテクチャを 2 倍にしてもビルド時間が 70% しか増加しませんが (ビルドの一部が共有されているため)、2 番目のアーキテクチャを QEMU エミュレーションでビルドすると、ビルド時間はほぼ 7 倍長くなります。

追加したキャッシュマウントの追加の助けを借りて、Goソースコードの変更による増分再構築は、古いDockerfileと比較してばかげた100倍の速度向上の領域に達しています。