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

Yushin Matsuura

2025.5.29

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

コードの全体像

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

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ファイルに変換しています。

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(圧縮前のハッシュ値)も含まれます。

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イメージ(完全に空のベースイメージ)に相当します。

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

4. イメージ設定の追加

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

// 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コマンドで読み込むことができます。

// 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として書き出します

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

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

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

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

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

manifest.json

manifest.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

config.json

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

{
  "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を展開してみる

tar -xvzf custom-image.tar

展開すると以下のファイルができる

appは.tar.gzを展開したものなので、最初は無い

manifest.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として実装されている。

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

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

// go-containerregistory pkg v1 tarball write.go writeImagesToTar
// ここで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
// ここで圧縮済みのデータを取得する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を眺めている中で知って、色々試したりもしました。

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

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

ブログ一覧へ戻る

お気軽にお問い合わせください

SREの設計・技術支援から、
SRE運用内で使用する
ツールの導入など、
SRE全般についてご支援しています。

資料請求・お問い合わせ