Dockerを使用せずにイメージを作成し実行してみる - go-containerregistryによる実装

このページではコンテナイメージがどのように作成されているのかを、[go-containerregistryライブラリ](https://github.com/google/go-containerregistry)を使った実装例を通して解説します。Dockerfileを使わずに、プログラムからコンテナイメージを作成する過程を見ていきます。

## コードの全体像

createTarball、layerFromReaderはこちらに記載されていないです。あとの各部分の詳細解説でのみ記載しています。

```go
func main() {
	tarReader, err := createTarball("app")
	if err != nil {
		log.Fatal(err)
	}

	// tarballレイヤーを使用
	layer, err := layerFromReader(io.NopCloser(tarReader))
	if err != nil {
		log.Fatal(err)
	}

	// scratchベースの空イメージにレイヤーを追加
	img := empty.Image
	img, err = mutate.AppendLayers(img, layer)
	if err != nil {
		log.Fatal(err)
	}

	// Config設定（EntrypointやCmdなど）
	cfgFile, err := img.ConfigFile()
	if err != nil {
		log.Fatal(err)
	}

	cfgFile.Config.Entrypoint = []string{"/app"}

	// OSとArchitectureを設定（プラットフォーム警告を解消）
	cfgFile.OS = "linux"
	cfgFile.Architecture = "arm64" // ホストマシンに合わせる

	// 作成日時を設定
	cfgFile.Created = v1.Time{Time: time.Now()}

	// イメージのコンフィグを更新
	img, err = mutate.ConfigFile(img, cfgFile)
	if err != nil {
		log.Fatal(err)
	}

	// tarball形式で保存（Dockerでload可能）
	f, err := os.Create("custom-image.tar")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	tag, err := name.NewTag("custom/hello:latest")
	if err != nil {
		log.Fatal(err)
	}

	err = tarball.Write(tag, img, f)
	if err != nil {
		log.Fatal(err)
	}

	log.Println("Image created as custom-image.tar")
}
```

## 各部分の詳細解説

### 1. レイヤーの作成

コンテナイメージのレイヤーは、基本的にtarファイル（アーカイブ）です。以下のコードでは、指定されたファイル（この場合は実行可能ファイル「app」）をtarアーカイブに変換しています。

```go
func createTarball(path string) (io.Reader, error) {
	// メモリ上にバッファを作成して、そこにtarballを作成する
	buf := new(bytes.Buffer)
	tw := tar.NewWriter(buf)

	fileInfo, err := os.Stat(path)
	if err != nil {
		return nil, err
	}

	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	hdr := &tar.Header{
		Name:    "app",
		Size:    fileInfo.Size(),
		Mode:    0755,
		ModTime: fileInfo.ModTime(), // ファイルの変更日時
	}

	if err := tw.WriteHeader(hdr); err != nil {
		return nil, err
	}

	if _, err := io.Copy(tw, f); err != nil {
		return nil, err
	}

	tw.Close()
	return buf, nil
}
```

このコードでは：

- メモリ上にバッファを作成し、そこにtarファイルを書き込んでいます
- ファイル情報（サイズ、変更日時など）を取得して、tarヘッダーに設定しています
- ファイルの実行権限（0755）を設定しています
- Name: "app" としているので、コンテナ内では /app というパスでアクセスできます

### 2. レイヤーオブジェクトの作成

tarballからレイヤーオブジェクトを作成します。このレイヤーオブジェクトには、tarballの内容だけでなく、ダイジェスト（内容のハッシュ値）やDiffID（圧縮前のハッシュ値）も含まれます。

```go
func layerFromReader(reader io.Reader) (v1.Layer, error) {
	// すべてのデータをメモリにバッファリング
	// LayerFromOpenerは、何度もデータを読み込むため、メモリにバッファリングしておく
	b, err := io.ReadAll(reader)
	if err != nil {
		return nil, err
	}

	// バッファからレイヤーを作成（非圧縮のreaderを期待している）
	// 主にレイヤーのダイジェストとサイズとDiffIDを計算するために使用される
	// ダイジェストは、基本的にgzipで圧縮し計算している,主にpull時に使用される（マニフェスト）
	// DiffIDは、基本的にtarballのまま計算している,そのためコードの変更だけでなく、ファイル名の変更などでもDiffIDが変わる（コンフィグ）
	return tarball.LayerFromOpener(func() (io.ReadCloser, error) {
		return io.NopCloser(bytes.NewBuffer(b)), nil
	})
}
```

このコードでは：

- tarballをメモリに完全に読み込みます（複数回アクセスするため）
- LayerFromOpener関数を使用して、v1.Layerインターフェースを実装したオブジェクトを作成します
- ダイジェスト（gzip圧縮後のハッシュ）とDiffID（tarballそのもののハッシュ）が自動的に計算されます

### 3. イメージへのレイヤー追加

empty.Imageという空のイメージにレイヤーを追加します。これはscratchイメージ（完全に空のベースイメージ）に相当します。

```go
// scratchベースの空イメージにレイヤーを追加
img := empty.Image
img, err = mutate.AppendLayers(img, layer)
if err != nil {
	log.Fatal(err)
}
```

### 4. イメージ設定の追加

Dockerfileでいう、ENTRYPOINTなどの設定に相当する部分です。コンテナが起動した時の動作を定義します。

```go
// Config設定（EntrypointやCmdなど）
cfgFile, err := img.ConfigFile()
if err != nil {
	log.Fatal(err)
}

cfgFile.Config.Entrypoint = []string{"/app"}

// OSとArchitectureを設定（プラットフォーム警告を解消）
// これがないとdockerで実行時にwarningが出る
cfgFile.OS = "linux"
cfgFile.Architecture = "arm64" // ホストマシンに合わせる

// 作成日時を設定（CREATEDカラムに表示される）
cfgFile.Created = v1.Time{Time: time.Now()}

// イメージのコンフィグを更新
img, err = mutate.ConfigFile(img, cfgFile)
if err != nil {
	log.Fatal(err)
}
```

このコードでは：

- Entrypointを設定：コンテナ起動時に/appを実行します
- プラットフォーム情報（OS:LinuxとArchitecture:arm64）を設定します
- 作成日時を設定します（docker imagesコマンドのCREATEDカラムに表示される情報）
- mutate.ConfigFileを使って設定を適用します

### 5. イメージの保存

最後に、作成したイメージをtarballとして保存します。このファイルはdocker loadコマンドで読み込むことができます。

```go
// tarball形式で保存（Dockerでload可能）
f, err := os.Create("custom-image.tar")
if err != nil {
	log.Fatal(err)
}
defer f.Close()

tag, err := name.NewTag("custom/hello:latest")
if err != nil {
	log.Fatal(err)
}

err = tarball.Write(tag, img, f)
if err != nil {
	log.Fatal(err)
}

log.Println("Image created as custom-image.tar")
```

このコードでは：

- ファイル「custom-image.tar」を作成します
- イメージのタグを「custom/hello:latest」に設定します
- tarball.Write関数を使って、イメージをtarballとして書き出します

## コンテナイメージの使用方法

作成したイメージを使うには、以下のコマンドを実行します：

```bash
# イメージをDockerに読み込む
docker load < custom-image.tar

# コンテナを実行する
docker run --rm custom/hello:latest
```

## 以下に作成したイメージのmanifest.json, config.jsonを記載します

### [manifest.json](https://github.com/opencontainers/image-spec/blob/main/manifest.md)

イメージ全体の構成を定義するファイルです。これにより、複数のレイヤーを順番通りに組み合わせて、正しいイメージを構築できます。

```json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 276,
    // configファイルのhash値
    "digest": "sha256:cc0cb5f6c117ec948f7bc776ab6838e2fd08e64902d45db7abb2a2662173408f"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2067678,
      // tarballをgzipなどで圧縮したもののhash値
      "digest": "sha256:9dc5b7876564c5e2a14f8d4158ebbe0225ff3ebaab3a643d86729e188e4c9409"
    }
  ]
}

```

docker pullの時では、マニフェストファイルがダウンロードされ、各ダイジェストを下にイメージやコンフィグがダウンロードされます。

### [config.json](https://github.com/opencontainers/image-spec/blob/main/config.md)

1つのイメージに関する詳細な設定（メタデータ）を保持します。

```json
{
  "architecture": "arm64",
  "created": "2025-04-18T19:07:30.354193+09:00",
  "history": [
    {
      "created": "0001-01-01T00:00:00Z"
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
	    // tarballのhash値
      "sha256:fb8f29013478e4d93e35231d17b2bd8910979a5a0057edbe4c5558d624541cc6"
    ]
  },
  "config": {
    "Entrypoint": [
      "/app"
    ]
  }
}
```

この中のarchitecture, createdなどは先ほどのコード内で設定されたものです。これにより実行時に詳細な設定が利用できるようにまります。

## custom-image.tarを展開してみる

```bash
tar -xvzf custom-image.tar
```

展開すると以下のファイルができる
appは.tar.gzを展開したものなので、最初は無い
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2513666/98712155-ba5c-4d03-b7c9-b69922d52037.png)



manifest.json
```json
[{"Config":"sha256:4aa6438f9d06d340b09fb6b0eb270e5e4b15187837003e02ce7384ece1aa2652","RepoTags":["docker.io/custom/hello:latest"],"Layers":["9dc5b7876564c5e2a14f8d4158ebbe0225ff3ebaab3a643d86729e188e4c9409.tar.gz"]}]
```
Configの値が展開後のファイル名と同じになっている、これはconfigファイルのshaの値
Layersの値が展開後の.tar.gzのファイル名と同じになっている

### どこでgzipで圧縮されているのか

`tarball.Write` 時に、圧縮したものを書き込んでいる。`l.Compressed()`が圧縮済みのデータをio.Readerから取得している。これは`tarball.LayerFromOpener`でlayerの構造体にcompressedopenerとして実装されている。

```go
// 上記のコード
err = tarball.Write(tag, img, f)

-------------

// go-containerregistory pkg v1 tarball write.go writeImagesToTar
// この処理がtarball.Writeの内部で呼ばれている
// Compressedが呼び出され、圧縮済みのreaderを取得している

r, err := l.Compressed()
if err != nil {
	return sendProgressWriterReturn(pw, err)
}
blobSize, err := l.Size()
if err != nil {
	return sendProgressWriterReturn(pw, err)
}

if err := writeTarEntry(tf, layerFiles[i], r, blobSize); err != nil {
	return sendProgressWriterReturn(pw, err)
}

------------------

// go-containerregistory pkg v1 tarball layer.go LayerFromOpener
// ここでCompressedが実装されている
// 圧縮済みのデータを取得するreaderがlayer構造体に実装されている
layer.uncompressedopener = opener
layer.compressedopener = func() (io.ReadCloser, error) {
	crc, err := opener()
	if err != nil {
		return nil, err
	}

	if layer.compression == compression.ZStd {
		return zstd.ReadCloserLevel(crc, layer.compressionLevel), nil
	}

	return ggzip.ReadCloserLevel(crc, layer.compressionLevel), nil
}
```

## まとめ

https://github.com/opencontainers/image-spec/blob/main/spec.md

初めてこれを見た時は何のこっちゃ分からん、という感じでした。が、色々触ってから見てみると結構見えるようになっていました。configのHistoryなどimage-spec > image configを眺めている中で知って、色々試したりもしました。

やっぱり、手を動かして色々作ってみることが大事だなーと思いました。

今度は、マルチアーキイメージを作成してプッシュするまでをやっていきたい。