コンテナのコンパイル – Dockerfiles、LLVM、BuildKit

今日は、 Earthly のAdam Gordon Bellによるブログを特集し、Dockerとコミュニティによって開発されたテクノロジーであるBuildKitがどのように機能し、シンプルなフロントエンドを作成する方法について 書いています 。 Earthlyは製品にBuildKitを使用しています。

1

概要

コンテナはどのように作られていますか? 通常、'RUN'、'FROM'、および 'COPY' などの一連のステートメントから、Dockerfile に入れられてビルドされます。 しかし、これらのコマンドはどのようにしてコンテナイメージに変換され、次に実行中のコンテナに変換されますか? これがどのように機能するかについての直感を構築するには、関連するフェーズを理解し、コンテナイメージを自分で作成します。 プログラムで画像を作成し、簡単な構文フロントエンドを開発し、それを使用して画像を作成します。

「ドッカービルド」について

コンテナイメージはいくつかの方法で作成できます。 ビルドパックを使うこともできますし、Bazel や sbt のようなビルドツールを使うこともできますが、イメージがビルドされる最も一般的な方法は、Dockerfile で 'docker build' を使うことです。 おなじみのベースイメージAlpine、Ubuntu、Debianはすべてこの方法で作成されています。     

ドッカーファイルの例を次に示します。

FROM alpine
COPY README.md README.md
RUN echo "standard docker build" > /built.txt"

このチュートリアルでは、この Dockerfile のバリエーションを使用します。 

次のように構築できます。

docker build . -t test

しかし、「ドッカービルド」と呼ぶと何が起こっているのでしょうか。 それを理解するには、少し背景が必要です。

バックグラウンド

 ドッカーイメージはレイヤーで構成されています。 これらのレイヤーは、不変のファイルシステムを形成します。 コンテナー イメージには、スタートアップ コマンド、公開するポート、マウントするボリュームなどの説明データも含まれています。 イメージを「docker実行」すると、コンテナランタイム内で起動します。

 私は類推によって画像とコンテナについて考えるのが好きです。 イメージが実行可能ファイルのようなものである場合、コンテナーはプロセスのようなものです。 1 つのイメージから複数のコンテナーを実行でき、実行中のイメージはイメージではなくコンテナーです。

1 2

例えを続けると、 BuildKitLLVM と同じようにコンパイラです。 ただし、コンパイラがソースコードとライブラリを取得して実行可能ファイルを生成するのに対し、BuildKit は Dockerfile とファイル パスを受け取り、コンテナー イメージを作成します。

3

Docker ビルドでは、BuildKit を使用して、DockerファイルをDocker イメージ、OCI イメージ、または別のイメージ形式に変換します。 このチュートリアルでは、主に BuildKit を直接使用します。

4

BuildKit の使用に関するこの入門書 では、コマンドラインから BuildKit、'buildkitd'、および 'buildctl' を使用する際の役立つ背景を提供します。ただし、今日の唯一の前提条件は、「brew install buildkit」または適切なOS の同等の 手順を実行することです。

コンパイラはどのように機能しますか?

従来のコンパイラは、高水準言語のコードを受け取り、それを低水準言語に下げます。 ほとんどの従来の事前コンパイラでは、最終的なターゲットはマシンコードです。 マシンコードは、CPUが理解できる低レベルのプログラミング言語です。

i️ おもしろい事実:マシンコードVS。 集会

マシンコードはバイナリで書かれています。 これは人間が理解するのを難しくします。 アセンブリコードは、人間が判読できるように設計されたマシンコードのプレーンテキスト表現です。 一般に、マシンが理解する命令(マシンコード)とアセンブリ内のOpCodeの間には1対1のマッピングがあります

LLVMのClangフロントエンドを使用して、古典的なCの "Hello, World"をx86アセンブリコードにコンパイルすると、次のようになります。

5

ドッカーファイルからのイメージの作成も同様に機能します。

6

BuildKit には、Dockerfile とビルド コンテキスト (上の図の現在の作業ディレクトリ) が渡されます。 簡単に言うと、dockerfileの各行は、結果の画像でレイヤーに変換されます。 イメージの構築とコンパイルの違いの 1 つは、このビルド コンテキストです。 コンパイラの入力はソースコードに限定されますが、「docker build」はホストファイルシステムへの参照を入力として受け取り、それを使用して「COPY」などのアクションを実行します。

落とし穴があります

「Hello, World」を1つのステップでコンパイルする以前の図では、重要な詳細が欠落していました。 コンピュータハードウェアは単一のものではありません。 すべてのコンパイラが高水準言語からx86マシンコードへのハンドコード化されたマッピングである場合、命令セットが異なるため、Apple M1プロセッサへの移行は非常に困難です。  

コンパイラの作成者は、コンパイルをフェーズに分割することで、この課題を克服しました。 従来のフェーズは、フロントエンド、バックエンド、およびミドルです。 中間フェーズはオプティマイザと呼ばれることもあり、主に内部表現(IR)を扱います。

7

この段階的なアプローチは、新しいマシンアーキテクチャごとに新しいコンパイラを必要としないことを意味します。 代わりに、新しいバックエンドが必要です。 LLVMでどのように見えるかの例を次に示します。

9

中間表現

このマルチバックエンドアプローチにより、LLVMは、LLVM中間表現(IR)を標準プロトコルとして使用して、ARM、X86、およびその他の多くのマシンアーキテクチャをターゲットにすることができます。 LLVM IRは、バックエンドが入力として受け取ることができる必要がある人間が読めるプログラミング言語です。 新しいバックエンドを作成するには、LLVM IRからターゲットマシンコードへのトランスレータを作成する必要があります。 その変換は、各バックエンドの主要な仕事です。

このIRを取得すると、コンパイラのさまざまなフェーズがインターフェイスとして使用できるプロトコルが作成され、多くのバックエンドだけでなく、多くのフロントエンドも構築できます。 LLVMには、C ++、Julia、Objective-C、Rust、Swiftなど、多数の言語のフロントエンドがあります。  

10

あなたの言語からLLVM IRへの翻訳を書くことができれば、LLVMはそのIRをそれがサポートするすべてのバックエンドのマシンコードに変換することができます。 この変換関数は、コンパイラー・フロントエンドの主要なジョブです。

実際には、それだけではありません。 フロントエンドは入力ファイルをトークン化して解析する必要があり、適切なエラーを返す必要があります。 バックエンドには、多くの場合、実行するターゲット固有の最適化と適用するヒューリスティックがあります。 ただし、このチュートリアルでは重要な点は、標準表現を持つことは、多くのフロントエンドと多くのバックエンドを接続するブリッジになるということです。 この共有インターフェイスにより、言語とマシン アーキテクチャのすべての組み合わせに対してコンパイラを作成する必要がなくなります。 それはシンプルですが、非常に力を与えるトリックです!

BuildKit

イメージは、実行可能ファイルとは異なり、独自の分離されたファイルシステムを持っています。 それにもかかわらず、イメージを構築するタスクは、実行可能ファイルのコンパイルと非常によく似ています。 それらはさまざまな構文を持つことができます(dockerfile1.0、 dockerfile1.2), また、結果は複数のマシンアーキテクチャ(ARM64とx86_64)をターゲットにする必要があります。

"LLB は Dockerfile にとって、LLVM IR は C にとって何であるか" BuildKit Readme

この類似性は、BuildKit作成者で失われませんでした。 ビルドキットには独自の中間表現であるLLBがあります。 また、LLVM IRには関数呼び出しやガベージコレクション戦略などがありますが、LLBにはファイルシステムのマウントとステートメントの実行があります。

11

LLB はプロトコルバッファとして定義され、これは BuildKit フロントエンドが buildkitd に対して GRPC リクエストを行い、コンテナを直接ビルドできることを意味します。

12

プログラムによるイメージの作成

さて、十分な背景。 プログラムでイメージの LLB を生成してから、イメージを作成しましょう。  

i️ Go を使用する

この例では、既存の BuildKit ライブラリを活用できる Go を使用しますが、プロトコルバッファをサポートする任意の言語でこれを実現できます。

LLB 定義をインポートします。

import (
	"github.com/moby/buildkit/client/llb"
)

アルパインイメージのLLBを作成します。

func createLLBState() llb.State {   
 return llb.Image("docker.io/library/alpine").        
   File(llb.Copy(llb.Local("context"), "README.md", "README.md")).       
   Run(llb.Args([]string{"/bin/sh", "-c", "echo \"programmatically built\" > /built.txt"})).      
Root()  
}

'llbを使用して、 'FROM'と同等のことを実現しています。画像'。 次に、「ファイル」と「コピー」を使用して、ローカルファイルシステムからイメージにファイルをコピーします。 最後に、テキストをファイルにエコーするコマンドを「実行」します。 LLBにはさらに多くの操作がありますが、これら3つの構成要素を使用して多くの標準イメージを再作成できます。

最後に行う必要があるのは、これをプロトコルバッファに変換し、標準出力に出力することです。

func main() {

	dt, err := createLLBState().Marshal(context.TODO(), llb.LinuxAmd64)
	if err != nil {
		panic(err)
	}
	llb.WriteTo(dt, os.Stdout)
}

buildctl の 'dump-llb' オプションを使用してこれが生成するものを見てみましょう。

 go run ./writellb/writellb.go | 
 buildctl debug dump-llb | 
 jq .

このJSON形式のLLBを取得します。

{
  "Op": {
    "Op": {
      "source": {
        "identifier": "local://context",
        "attrs": {
          "local.unique": "s43w96rwjsm9tf1zlxvn6nezg"
        }
      }
    },
    "constraints": {}
  },
  "Digest": "sha256:c3ca71edeaa161bafed7f3dbdeeab9a5ab34587f569fd71c0a89b4d1e40d77f6",
  "OpMetadata": {
    "caps": {
      "source.local": true,
      "source.local.unique": true
    }
  }
}
{
  "Op": {
    "Op": {
      "source": {
        "identifier": "docker-image://docker.io/library/alpine:latest"
      }
    },
    "platform": {
      "Architecture": "amd64",
      "OS": "linux"
    },
    "constraints": {}
  },
  "Digest": "sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7",
  "OpMetadata": {
    "caps": {
      "source.image": true
    }
  }
}
{
  "Op": {
    "inputs": [
      {
        "digest": "sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7",
        "index": 0
      },
      {
        "digest": "sha256:c3ca71edeaa161bafed7f3dbdeeab9a5ab34587f569fd71c0a89b4d1e40d77f6",
        "index": 0
      }
    ],
    "Op": {
      "file": {
        "actions": [
          {
            "input": 0,
            "secondaryInput": 1,
            "output": 0,
            "Action": {
              "copy": {
                "src": "/README.md",
                "dest": "/README.md",
                "mode": -1,
                "timestamp": -1
              }
            }
          }
        ]
      }
    },
    "platform": {
      "Architecture": "amd64",
      "OS": "linux"
    },
    "constraints": {}
  },
  "Digest": "sha256:ba425dda86f06cf10ee66d85beda9d500adcce2336b047e072c1f0d403334cf6",
  "OpMetadata": {
    "caps": {
      "file.base": true
    }
  }
}
{
  "Op": {
    "inputs": [
      {
        "digest": "sha256:ba425dda86f06cf10ee66d85beda9d500adcce2336b047e072c1f0d403334cf6",
        "index": 0
      }
    ],
    "Op": {
      "exec": {
        "meta": {
          "args": [
            "/bin/sh",
            "-c",
            "echo "programmatically built" > /built.txt"
          ],
          "cwd": "/"
        },
        "mounts": [
          {
            "input": 0,
            "dest": "/",
            "output": 0
          }
        ]
      }
    },
    "platform": {
      "Architecture": "amd64",
      "OS": "linux"
    },
    "constraints": {}
  },
  "Digest": "sha256:d2d18486652288fdb3516460bd6d1c2a90103d93d507a9b63ddd4a846a0fca2b",
  "OpMetadata": {
    "caps": {
      "exec.meta.base": true,
      "exec.mount.bind": true
    }
  }
}
{
  "Op": {
    "inputs": [
      {
        "digest": "sha256:d2d18486652288fdb3516460bd6d1c2a90103d93d507a9b63ddd4a846a0fca2b",
        "index": 0
      }
    ],
    "Op": null
  },
  "Digest": "sha256:fda9d405d3c557e2bd79413628a435da0000e75b9305e52789dd71001a91c704",
  "OpMetadata": {
    "caps": {
      "constraints": true,
      "platform": true
    }
  }
}

出力を見ると、コードがLLBにどのようにマップされるかがわかります。

FileOpの一部としての「コピー」は次のとおりです。

    "Action": {
              "copy": {
                "src": "/README.md",
                "dest": "/README.md",
                "mode": -1,
                "timestamp": -1
              }

以下は、 'COPY'コマンドで使用するためのビルドコンテキストのマッピングです。

  "Op": {
      "source": {
        "identifier": "local://context",
        "attrs": {
          "local.unique": "s43w96rwjsm9tf1zlxvn6nezg"
        }
      }

同様に、出力には、コマンドとコマンド  `RUN` `FROM` に対応する LLB が含まれています。 

LLBの構築

イメージをビルドするには、まず 'buildkitd' を起動する必要があります。

docker run --rm --privileged -d --name buildkit moby/buildkit
export BUILDKIT_HOST=docker-container://buildkit

イメージをビルドするには、まず 'buildkitd' を起動する必要があります。

go run ./writellb/writellb.go | 
buildctl build 
--local context=. 
--output type=image,name=docker.io/agbell/test,push=true

出力フラグを使用すると、BuildKit で使用するバックエンドを指定できます。 OCIイメージを作成して docker.io にプッシュするように依頼します。 

i️ 実際の使用法

実際のツールでは、プログラムで 'buildkitd'が実行されていることを確認し、RPCリクエストを直接送信したり、わかりやすいエラーメッセージを提供したりすることができます。 チュートリアルの目的で、すべてをスキップします。

次のように実行できます。

> docker run -it --pull always agbell/test:latest /bin/sh

そして、プログラムによる「COPY」コマンドと「RUN」コマンドの結果を見ることができます。

/ # cat built.txt 
programmatically built
/ # ls README.md
README.md

さあ行こう! 完全なコード例は 、独自のプログラムによる Docker イメージ構築の出発点として最適です。

ビルドキットの真のフロントエンド

真のコンパイラフロントエンドは、ハードコードされたIRを出力するだけではありません。 適切なフロントエンドは、ファイルを受け取り、トークン化し、解析し、構文ツリーを生成し、その構文ツリーを内部表現に下げます。 モッカーファイルは 、そのようなフロントエンドの例です。

#syntax=r2d4/mocker
apiVersion: v1alpha1
images:- name: demo
  from: ubuntu:16.04
  package:
    install:
    - curl
    - git
    - gcc

また、Dockerビルドは「#syntax」コマンドをサポートしているため、「dockerビルド」を使用してモッカーファイルを直接ビルドすることもできます。 

docker build -f mockerfile.yaml

#syntax コマンドをサポートするために必要なのは、正しい形式の gRPC 要求を受け入れる Docker イメージにフロントエンドを配置し、そのイメージをどこかに発行することだけです。 その時点で、誰でもフロントエンドの「ドッカービルド」を「#syntax = あなたのイメージ名」を使用するだけで使用できます。

「ドッカービルド」用の独自のサンプルフロントエンドの構築

トークナイザーとパーサーを gRPC サービスとして構築することは、この記事の範囲を超えています。 しかし、既存のフロントエンドを抽出して変更することで、足を濡らすことができます。 標準の dockerfile フロントエンド は、moby プロジェクトから簡単に切り離すことができます。 関連する部分を スタンドアロンのリポジトリに引き出しました。 簡単な変更を加えてテストしてみましょう。

これまでは、ドッカーコマンド 'FROM'、 'RUN'、 'COPY' のみを使用してきました。 表面レベルでは、大文字のコマンドを使用すると、Dockerfile構文はプログラミング言語 INTERCALによく似ています。 これらのコマンドを同等のINTERCALに変更し、独自のIckfile形式を開発しましょう。

ドッカーファイルイックファイル
差出人から来る
走るお願いします
写しスタッシュ

dockerfile フロントエンドのモジュールは、入力ファイルの解析をいくつかの個別のステップに分割し、実行は次のように流れます。

13

このチュートリアルでは、フロントエンドに些細な変更を加えるだけです。 すべての段階をそのまま残し、既存のコマンドを好みに合わせてカスタマイズすることに焦点を当てます。 これを行うには、 'command.go'を変更するだけです。

package command

// Define constants for the command strings
const (
	Copy        = "stash"
	Run         = "please"
	From        = "come_from"
	...
)

そして、「STASH」コマンドと「PLEASE」コマンドの結果を確認できます。

/ # cat built.txt 
custom frontend built
/ # ls README.md
README.md

このイメージをドッカーハブにプッシュしました。 既存の Dockerfile に '#syntax=agbell/ick' を追加することで、誰でも 'ickfile' 形式を使用してイメージの構築を開始できます。 手動インストールは必要ありません!

i️ ビルドキットの有効化

BuildKit は Docker Desktop でデフォルトで有効になっています。 これは、現在のバージョンの Docker for Linux (「バージョン 20.10.5」) ではデフォルトで有効になっていません。 ビルドキットを使用するように「ドッカービルド」に指示するには、次の環境変数を設定します 'DOCKER_BUILDKIT=1'または エンジン構成の変更.

結論

コンパイラから借用した3段階の構造がイメージの構築に力を与え、LLBと呼ばれる中間表現がその構造の鍵となることを学びました。 知識を活用して、イメージを構築するための2つのフロントエンドを作成しました。  

フロントエンドに関するこの深いダイビングには、まだ探求すべきことがたくさんあります。 詳細を知りたい場合は、BuildKitワーカーを調べることをお勧めします。 労働者は実際の構築を行い、「docker buildx」と マルチアーキテクチャビルドの背後にある秘密です。 「docker build」は、リモートワーカーとキャッシュマウントもサポートしており、どちらもビルドの高速化につながる可能性があります。

Earthlyは 、繰り返し可能なビルド構文のためにBuildKitを内部的に使用しています。 これがなければ、コンテナ化されたMakefileのような構文は不可能です。 より健全なCIプロセスが必要な場合は、 それをチェックする必要があります

最新のコンパイラがどのように機能するかについては、さらに多くの調査が必要です。 現代のコンパイラは、多くの場合、多くのステージと複数の中間表現を持ち、非常に洗練された最適化を行うことができます。