このページではコンテナイメージがどのように作成されているのかを、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を展開したものなので、最初は無い
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を眺めている中で知って、色々試したりもしました。
やっぱり、手を動かして色々作ってみることが大事だなーと思いました。
今度は、マルチアーキイメージを作成してプッシュするまでをやっていきたい。