1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-23

このページではコンテナイメージがどのように作成されているのかを、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

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

{
  "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

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を展開したものなので、最初は無い
image.png

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
// この処理が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
}

まとめ

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

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?