💪

Goのプラクティスまとめ: dockerによるビルド

に公開

Goのプラクティスまとめ: dockerによるビルド

筆者がGoを使い始めた時に分からなくて困ったこととか最初から知りたかったようなことを色々まとめる一連の記事です。

以前書いた記事のrevisited版です。話の粒度を細かくしてあとから記事を差し込みやすくします。

他の記事へのリンク集

Docker / podmanなどによるビルド

プロジェクトを始めるまでで基本的なビルド方法については述べました。
しかし現代においてはアプリケーションは何かしらのコンテナとしてデプロイすることが普通であると思われるので、この記事では

  • そもそもDocker / コンテナ / イメージ / Dockerfile(Containerfile)とは何ぞやという簡単な説明
  • Docker / podman(podman-static)のインストール手順
  • Docker / podmanを用いたGoアプリケーションのビルド方法
    • cooporate proxy下対応
    • 最大限キャッシュを効かせる
    • 半自動的にGoの最新パッチバージョンを使用
    • おまけでmulti-arch buildに対応

について述べます。

Docker / podmanはいわゆるコンテナランタイムです。
コンテナは、アプリとその依存関係をパッケージ化し、それを隔離した環境で動作させる一連の仕組みだと思っておけばよいです。

コンテナは、一般的なアプリをPCにインストールしたときに起こる以下のような問題を回避することができます

  • アプリが保存するデータや設定ファイルの位置がほかのアプリと衝突する
  • アプリが必要なライブラリがいろいろあって全部入れないといけない
  • アプリ同士で必要なライブラリのバージョンが異なっており衝突する
  • 同じアプリを複数動かそうとすると一時ファイルの位置や、ネットワークのポートが衝突していまう

こうしたコンテナを動作させるのがコンテナランタイムです。

今回の記事は別にGoにばかり関係するというわけでもないですね。

対象読者/前提知識

  • 会社の同僚
  • 今までGoを使ってこなかった
  • dockerはいくらか触ったことがある
  • 高校レベルの英語読解能力

環境

win11のwsl2インスタンス内で動作させます。Docker Desktopをインストールした場合/PCに直接Linuxをインストールした場合でも基本的に同様になると思います。

$ wsl --version
WSL バージョン: 2.6.1.0
カーネル バージョン: 6.6.87.2-1
WSLg バージョン: 1.0.66
...

distroはUbuntu 24.04LTSです。説明は暗黙的にUbuntuを前提とします。

$ cat /etc/*-release
...
DISTRIB_DESCRIPTION="Ubuntu 24.04.3 LTS"
...

ランタイムにはpodmanを用います

$ podman --version
podman version 5.7.0

タイトルが「dockerで」なのにいきなりそこに反するような形になってすみません。
この辺の記事群のタイトルとslugを決めたのがおおよそ1年前なので、時間が空いていろいろ事情が変わってしまいました。
ただpodmanにはおおむねdockerとの互換性があります。この記事範疇の内容ではどちらを使っても変わらないので単にpodmanの部分をdockerと読み替えてもらっても問題ないはずです。

そもそもDocker / コンテナ / イメージ / Dockerfileってなに?

コンテナにまつわる用語としてDocker / コンテナ / イメージ / Dockerfileなどがよく聞かれると思います。
これらが一体何なのかという話をざっくりこことでしておきます。
全体の構図が見えることのみを目指すので、厳密でなかったり詳細でなかったりしますが、記事の趣旨からして詳細に立ち入ることはしません。

概説

concept-of-container

コンテナは「そのアプリだけ入ったLinuxシステムみたいなやつ」を隔離した環境で動かすアプリの動作方式などのことを言います。

コンテナ内ではファイルシステムを好きにいじっていいですし、そのアプリ専用のライブラリとかが全部あるべき場所にある状態にできます。sambaに対する/etc/smb.confみたいに、決まりきったパスの設定ファイルを読み込むアプリがある場合、設定ファイルのパスを変更できなかったらそのアプリは同時に1つしか動かすことができませんが、コンテナではお互い隔離されるため、同じパス(/etc/smb.conf)でもコンテナ同士では異なったファイルを読み込ませることができます。
また、ネットワークのポートについても同様で、コンテナ内のポートは、ホスト側に公開されるときに別のポートに割り当てることができます。

objects-relation

コンテナはイメージというコンテナのテンプレートのようなものから作成されます。イメージはDockerfile(Containerfile)という、簡単なsyntaxで構成されたテキストファイルをもとに作成することができます。

Dockerfile(Containerfile)で、apt-getでライブラリやアプリを導入したり、go buildなどでアプリケーションをビルドしたりして、「アプリ」と「アプリが動作する環境」を作るためのビルドスクリプトを記述します。
ちなみにDockerfileContainerfileは仕様上全く同じものです。コンテナエコシステムが広がるにしたがってDocker以外のものが増えて来たので固有名詞であるdocker-というのを消そうという動きがあります。

podman image build/docker image buildなどのコマンドでDockerfile(Containerfile)をビルドしてイメージを作成します。
イメージはそのような方法で作られた、「アプリを含んだファイルシステム」と、環境変数・エントリーポイント(=コンテナ実行時に実行されるコマンド)などを含んだ設定ファイルからなります。

コンテナは、イメージをもとに作成される、「実行可能なインスタンス」です。
コンテナの隔離環境は、中で動いているアプリから見ると普通のLinuxみたいに見えて、ファイルシステムに書き込みを行ったりできます。一方で、イメージはリードオンリーで不変ですので、イメージからコンテナを作るには書き込みができる領域を確保する必要があります。また、イメージでは決められない「どのホストのポートをコンテナのどのポートに公開するか」とか、「どのパスにどのボリュームをマウントするか」などのコンテナ固有の情報が含まれます。

利点

コンテナシステムの利点は以下などがあると考えられます。

  • スケール性:
    • アプリ単位の隔離環境を用意するため、同じアプリを容易に複数動作させられる。
    • 依存関係をすべて含むため、複数のホストマシンで分散して動作させられる。
    • アプリ単位、つまり1プロセス-1コンテナの粒度で分割するのがふつうであるため、必要な部分だけを稼働数を増減することが容易
  • 配布の容易性:
    • イメージをファイルとして保存するためのフォーマットが規定されたことで、容易に共有が可能
  • 管理の容易性:
    • 隔離されていることでホスト環境を汚さない(=アンインストール時に消し忘れそうなものがない)

コンテナを調べていると、よく「KVM/Hypervisor仮想化と違ってゲストOSがないので軽量」という言い回しがされるように思います

参考:

実際dockerはlinux kernel機能のnamespaceを使用して隔離環境を作成するためdockerに関しては普通はこの言説のとおりだと思います。
ただし別段その方式に限らなければならないわけではなく、実際にdockerから利用可能なコンポーネントであるkata-container(QEMU/KVMFirecrackerなど)やwindows containerのrunhcs(Hyper-V)などはVMを使ってコンテナの隔離環境を作成します。
OCI Runtime Spec上でも隔離環境の作成方法に指定はありません。

この事実からゲストOSがないことがコンテナの本質ではなく、前述のアプリ配布エコシステムの成立と、1プロセス-1コンテナの粒度で隔離することが目指したいものだと言えます。

...という説明から前述の「そのアプリだけ入ったLinuxシステムみたいなやつ」という説明も正確でないことがわかります(windowsやfreebsdのcontainerがありますから)。ですが、筆者の観測する限り大抵コンテナと言ったらLinuxです。

Docker / podman ?

コンテナランタイムです。

  • イメージをpullしたり、buildしたり、
  • コンテナを作成/実行したり
  • イメージ/コンテナの作成/停止/実行をしたり

するものです。

Dockerはこの分野の草分け的存在です。Docker, Inc.開発。開発者ツールとしてはかなりポピュラーだと思います。
podmanDockerより後発のランタイム。Red Hat開発。rootless by default, daemonlessなどいろいろ進んだ機能が多い。手元で動かすならdockerより扱いが楽なこともしばしば。

全く違うところが作っているので中身の作りは違いますが、コマンドとしては互換性があります。

version 1.0.0のリリース時期はdockerのほうが速い:

rootless?

なんかコンテナ周りの話を読んでるとrootlessって言う語がよく言われていませんか?

これはその言葉のとおり、root以外のユーザーでコンテナデーモンを動かすということをさします。

コンテナを成立させるために使われるlinux kernelの機能などは、普通にするとroot権限が必要です。
「そのアプリだけが入ったLinuxシステムみたいなやつ」を成立させるのは「アプリが入っているファイルシステム」、独立したネットワーク/pid/ユーザー/グループ空間が必要です。ファイルシステムは複数のimage layerを重ね合わせることで成り立っています(いわゆるuniom mount filesystem)。普通mountコマンドを用いるとsudoが必要ですから、そこから普通はroot権限が必要であるとわかると思います。

Dockerdockerdというデーモンプロセスがイメージやコンテナの状態を管理し、dockerコマンドがそこにリクエストをするというサーバー・クライアント型ソフトウェアです。

  • dockerdを自由に操作できる攻撃が成立すると、攻撃できる範囲が非常に広くなってしまいます。
  • dockerdrootで動作しているとコンテナをrootで動作させることも可能です。実際特に設定しなければコンテナはuid=0, つまりrootで動作します。コンテナ内のrootはコンテナ外=ホストでのrootであるので、もし仮にコンテナに攻撃が成立すると、マウントしているvolume内のroot権限を必要とするファイルが読み書きされてしまいます。

rootlessである場合、dockerdがユーザーの権限で動作するため、コンテナのrootはコンテナ外ではそのユーザーとなります。
そのため攻撃成立後にできることが狭くなり、安全性が増します。

Reference

これ以上の詳細な情報は公式的なドキュメントへお進みください。

(podmanはcliはDocker互換なのでreferenceもDockerのものを見たらいい。違いが出るところまで踏み込みません)

Docker / podman(-static)のインストール方法

Dockerpodmanのインストール方法について述べます。podmanのほうはpodman-staticを用いるため、やや変則的な方法になります。
podman-staticのビルドにはDockerが必要なことに注意してください。

Docker

以下の2つが代表的なインストール方法かと思います

ただしDocker Desktop従業員250人以上もしくは年間売り上げ$10 million(≒15.6億円)で有料ライセンスが必要となることに注意です。
Rancher DesktopはほぼDocker Desktopと同じようなことをするOSSでこちらはApache-2.0 licenseです。起動が遅かったりするのでwindows側からdockerコマンドをたたきたいとかでない限りはおすすめしません。

Install Docker Engineについてのみ説明します。

#!/bin/bash

set -Cue

# Add Docker's official GPG key:
sudo -E apt-get update
sudo -E apt-get install -y ca-certificates curl

sudo -E install -m 0755 -d /etc/apt/keyrings
sudo -E curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo -E chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo -E apt-get update
sudo -E apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

rootlessで実行したい場合は追加で下記も実施します。開発環境のdockerとしてはrootlessにしておくほうがお勧めです。

https://docs.docker.com/engine/security/rootless/

筆者の環境ではrootless化するとdocker-credential-passPATHに見つからないというエラーでビルドが行えなかったので下記から落として適当な位置に置きました。

https://github.com/docker/docker-credential-helpers

$ cd $(mktemp -d)
$ curl -L https://github.com/docker/docker-credential-helpers/releases/download/v0.9.4/docker-credential-pass-v0.9.4.linux-amd64 -o docker-credential-pass
$ chmod +x docker-credential-pass
$ mv docker-credential-pass ~/.local/bin

podman

podman-staticを利用してビルドします。

podman-staticstatic(=動的にロードされるライブラリがない=ホスト環境に対する依存性が低い)にpodmanをビルドするためのスクリプト集です。ちなみにdockerコマンドが必要です。

repositoryをcloneして下記を実行し、./build/asset/podman-linux-amd64以下のファイルを適当なところにコピーしたら完了です。

実行する前に、スクリプトの内容はよく読んでおきましょう: https://github.com/mgoltzsche/podman-static/blob/master/Dockerfile

  sudo make
  sudo make singlearch-tar

(rootless dockerならsudoを外す)

./build/asset/podman-linux-amd64以下を/以下に構造を保ったままコピーしていけば標準的なパスにpodmanが入るような状態になるようです。

筆者は~/.local/share/podman以下にビルド成果物をまとめておきたかったためさらに追加のビルドスクリプトを組んでいます。

以下をcloneして下記のスクリプトを実行します。denoが必要です。

https://github.com/ngicks/dotfiles

build/podman-static/build.sh
build/podman-static/install.sh

(もちろん実行する前に中身はよく読んでください)

install.sh完了後、下記を.bashrcなどから呼び出すとpodmanコマンドにPATHが通ります。

. ~/.config/containers/path.sh

GoをビルドするDockerfile example

以下にGoをstatic binaryにビルドするDockerfileの例を示します。

  • 通常版・企業プロキシ下版の2バージョンを説明します。
  • 双方でprivate repository管理のgo moduleがあってもビルドできるようにします。
  • ほぼすべてがキャッシュに乗るので初回以降はほとんど時間がかかりません。
  • apt以外のパッケージマネージャには対応できていません。筆者がそれら(apkpacmanなど)を使うことがあったら調べて追記します。
  • 現在のプロジェクトのgo.modを解析して得られたGo versionの最新のパッチバージョンでビルドできるような半自動的な仕組みを考えます。
    • つまり、go.modの記載がgo1.24.1の場合、go1.24.10でビルドする、みたいな感じです。

コードはここに置いてあります: https://github.com/ngicks/go-example-basics-revisited/tree/main/building-with-docker

通常版・企業プロキシ下版両方を示してから各パートとポイントについて説明し、最新のパッチバージョンを取得する方法を含んだビルドスクリプトを示します。
最後におまけとしてmulti-archビルド(amd64(デスクトップPCなど)でarm64(Raspberry Piなど)むけのimageをビルドすること)をする方法を示します。

Dockerfile(Containerfile)の例

通常版

# syntax=docker/dockerfile:1

ARG TAG_GOVER="1.25.0"
ARG TAG_DISTRO="bookworm"

FROM docker.io/library/golang:${TAG_GOVER}-${TAG_DISTRO} AS builder

ARG CGO_ENABLED="0"
ARG GOCACHE="/root/.cache/go-build"
ARG GOENV="/root/.config/go/env"
ARG GOPATH="/go"
ARG GOPRIVATE=""

ARG SSH_HOSTS="github.com,"
ARG MAIN_PKG_PATH="."

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
<<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    apt-get update
    apt-get install -yqq --no-install-recommends git-lfs openssh-client
EOF

RUN <<EOF
    mkdir -p -m 0700 ~/.ssh
    for item in $(echo $SSH_HOSTS | tr ',' '\n' ); do
      if [ ! -z ${item} ]; then
        git config --global url."ssh://git@${item}".insteadOf https://${item}
        ssh-keyscan ${item} >> ~/.ssh/known_hosts
      fi
    done
EOF

WORKDIR /app/src

RUN --mount=type=ssh \
    --mount=type=secret,id=goenv,target=/root/.config/go/env \
    --mount=type=cache,target=/go \
    --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=bind,target=/app/src \
<<EOF
    go mod download
    # go generate ./...
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

WORKDIR /app

FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea

COPY --from=builder /app/bin /app/bin

ENTRYPOINT [ "/app/bin" ]

企業プロキシ下版

# syntax=docker/dockerfile:1

ARG TAG_GOVER="1.25.0"
ARG TAG_DISTRO="bookworm"

FROM docker.io/library/golang:${TAG_GOVER}-${TAG_DISTRO} AS builder

ARG CGO_ENABLED="0"
ARG GOCACHE="/root/.cache/go-build"
ARG GOENV="/root/.config/go/env"
ARG GOPATH="/go"
ARG GOPRIVATE=""

ARG MAIN_PKG_PATH="."

# for curl, etc.
ARG SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt"
ARG NODE_EXTRA_CA_CERTS=${SSL_CERT_FILE}
ARG DENO_CERT=${SSL_CERT_FILE}

ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG NO_PROXY
ARG http_proxy
ARG https_proxy
ARG no_proxy

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt \
<<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    apt-get update
    apt-get install -yqq --no-install-recommends git-lfs
EOF

WORKDIR /app/src

RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    --mount=type=secret,id=goenv,target=/root/.config/go/env \
    --mount=type=cache,target=/go \
    --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=bind,target=/app/src \
    --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt \
<<EOF
    go mod download
    # go generate ./...
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

WORKDIR /app

FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea

COPY --from=builder /app/bin /app/bin

ENTRYPOINT [ "/app/bin" ]
dockerなら動くもうちょいセキュア版

HTTP_PROXYなどをsecret mountするようにしたほうがセキュアだと思いますが、podmanだと動かなかった・・・

# syntax=docker/dockerfile:1

ARG TAG_GOVER="1.25.0"
ARG TAG_DISTRO="bookworm"

FROM docker.io/library/golang:${TAG_GOVER}-${TAG_DISTRO} AS builder

ARG CGO_ENABLED="0"
ARG GOCACHE="/root/.cache/go-build"
ARG GOENV="/root/.config/go/env"
ARG GOPATH="/go"
ARG GOPRIVATE=""

ARG MAIN_PKG_PATH="."

# for curl, etc.
ARG SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt"
ARG NODE_EXTRA_CA_CERTS=${SSL_CERT_FILE}
ARG DENO_CERT=${SSL_CERT_FILE}

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt \
    --mount=type=secret,id=HTTP_PROXY,env=HTTP_PROXY \
    --mount=type=secret,id=HTTPS_PROXY,env=HTTPS_PROXY \
    --mount=type=secret,id=NO_PROXY,env=NO_PROXY \
    --mount=type=secret,id=http_proxy,env=http_proxy\
    --mount=type=secret,id=https_proxy,env=https_proxy \
    --mount=type=secret,id=no_proxy,env=no_proxy \
<<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    apt-get update
    apt-get install -yqq --no-install-recommends git-lfs
EOF

WORKDIR /app/src

RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    --mount=type=secret,id=goenv,target=/root/.config/go/env \
    --mount=type=cache,target=/go \
    --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=bind,target=/app/src \
    --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt \
    --mount=type=secret,id=HTTP_PROXY,env=HTTP_PROXY \
    --mount=type=secret,id=HTTPS_PROXY,env=HTTPS_PROXY \
    --mount=type=secret,id=NO_PROXY,env=NO_PROXY \
    --mount=type=secret,id=http_proxy,env=http_proxy\
    --mount=type=secret,id=https_proxy,env=https_proxy \
    --mount=type=secret,id=no_proxy,env=no_proxy \
<<EOF
    go mod download
    # go generate ./...
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

WORKDIR /app

FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea

COPY --from=builder /app/bin /app/bin

ENTRYPOINT [ "/app/bin" ]

Go固有のポイント

  • git-lfsを入れよう: 以前の記事のこの部分でも書きましたが、git-lfsはとりあえずすべての環境に入れておいたほうがいいです。
    • private gitからGo moduleを落としてくるとき、git-lfsの有無でgit fetchした結果が変わるためmodule sumが食い違ってしまうためです。
  • CGO使わない場合はCGO_ENABLED=0にしてstatic binaryに: 動的ロードされるライブラリをなくすと環境の自由度が上がります。
    • glibcなどのバージョン差を気にしなくてよくなる
    • コンテナ内で使うならば特にバージョンずれるとかない気がするので常に1でも問題ない気はします。
    • CGO必要になる場合、例えばgithub.com/mattn/go-sqlite3を使う場合は明示的に1にします。
  • 最終ステージはdistrolessにしてもいい
    • distrolessはコンテナに必要最低限なファイルだけが含まれたベースイメージです。
    • 余計なものがないということは、軽いし、攻撃に使える界面も少ないということにあります。
    • shellすらないものもあります。
    • サポートされているjava/nodejsなど以外にも、Goのようなシングルバイナリを吐く形式のコンパイルができる場合は相性がいいです。

Goに関係するパラメータ群とその意味

名前 変更必要? 説明
ARG TAG_GOVER ビルドに使うGoのバージョン。後述するスクリプトで自動的に決定する
ARG TAG_DISTRO yes ビルドに使うGoイメージのディストロ。debianのバージョンが進むたび名前が変わる
ARG CGO_ENABLED yes CGOが含まれないとき0に設定するとstaticなバイナリになる
ARG GOCACHE ビルドキャッシュの位置
ARG GOENV go envで読み書きできるファイルの位置
ARG GOPATH モジュールキャッシュなどの位置
ARG GOPRIVATE yes private registryのpath prefix. host以降(e.g. github.com/ngicks/go-playground).
ARG MAIN_PKG_PATH yes build context内のビルド対象へのパス
secret mount goenv ホスト側のgo envをマウントするためのid.

Dockerfile内で参照されていない変数は環境変数として各コマンドに渡されます。

GOPRIVATEはホスト側でgo envに書き込んで、ファイルで渡してもいいですが、永続化する必要のない実験用のレジストリとか入れるためにARGにしてあります。普通はいらない気がしますので消してしまったほうがいいかも。

ポイント1: # syntax=docker/dockerfile:1を先頭につけとく

https://docs.docker.com/build/buildkit/frontend/

追加の文法(<<EOFのヒアドキュメントなど)を使えるようにするためのインストラクションです。

docker build --build-arg BUILDKIT_SYNTAX=${syntax}によって設定できるとも書いてあります。
環境によってこの変数が自動的に設定されていたりされていなかったりすることがあってややこしかったのでとりあえず# syntax=docker/dockerfile:1をファイル先頭に書いておくことを推奨します。

  • syntax=docker/dockerfile:1なら1.x.yの範囲
  • syntax=docker/dockerfile:1.20なら1.20.xの範囲

のような感じで、バージョン範囲の指定も行えるとあります。
ただ、筆者の体験する限りバージョンが上がることで変更された挙動によってうまく動かなくなったことはなかったためとりあえず:1の指定の仕方を推奨しておきます。トラブルが起きたらより狭い固定をしましょう。

podmanというかbuildahはこの設定読んでない気がします。

ポイント2: docker.io/libraryを省略しない

例では以下のように書いています。

FROM docker.io/library/golang:${TAG_GOVER}-${TAG_DISTRO} AS builder

しかし実際にはdocker.io/libraryの部分を省略した例のほうがよく見るかもしれません。

例えば以下のコマンドを実行してみてください

$ docker image pull golang:1.25.4-bookworm

別に成功しますよね

このイメージはdocker hubでホストされています。以下のページです。

https://hub.docker.com/_/golang

Dockerのdocument曰く、imageの名前は以下の仕様に従います。

https://docs.docker.com/reference/cli/docker/image/tag/#description

[HOST[:PORT]/]NAMESPACE/REPOSITORY[:TAG]

記述のとおり、HOST,NAMESPACE部分は省略可能でデフォルトはそれぞれdocker.iolibraryとなります。

これの何が困るかというと、Docker以外のランタイムが大量に出てきた結果、この補完の処理がうまく動かなったり(github.com/containerd/nerdctl#4468)、そもそも補完しないツールが出てきていることです(podmanはどのレジストリに補完するかの選択が出る)。

docker.io/libraryは省略しないようにしましょう。aならdocker.io/library/aa/bならdocker.io/a/b、もとからa/b/cならそのままで。

  • node -> docker.io/library/node
  • astral/uv -> docker.io/astral/uv
  • gcr.io/distroless/static-debian12 -> (そのまま)

ポイント3: multi-stage buildを使う

https://docs.docker.com/build/building/multi-stage/

multi-stage buildはDockerfileFROMが複数あることをさします。

FROMから始まる一連のビルドステップをステージと呼ぶようです。(多分build-contextでcontextという語が使われちゃってるからstageと呼んでいるのだと思う。根拠なし。)
FROMの後に、さらにFROMが書かれるとそれらはステージとして分かれることになります。

  • 各ステージは無関係: ステージはアーティファクトや中間イメージを共有しません。
  • COPY --from=<stage-name>で成果物のコピーが可能
  • 最終的なビルドステージのみがイメージに保存されます。

ビルド環境と実行環境を別のステージとして用意し、最終的なイメージには実行環境を残すのが典型的な使い方になると思います。

つまりメリットとして以下があります。

  • イメージサイズの減少: ビルドに必要なツールなどを含まない形でビルドが行えます。
  • attack surfaceの減少: 余計なツールがなくなる。
  • ビルド高速化(並列ビルド): 各ステージは無関係なのでそれぞれを同時にビルドすることができます

この記事で見せた例でもmulti-stage buildを行っています。

FROM docker.io/library/golang:${TAG_GOVER}-${TAG_DISTRO} AS builder

# ...

RUN <<EOF
    # ...
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

# ...

FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea

COPY --from=builder /app/bin /app/bin

ENTRYPOINT [ "/app/bin" ]

ASでステージに名前を付け、go buildでバイナリを出力し、COPY --from=builderで最終ステージにコピーしています。最後のステージにはgcr.io/distroless/static-debian12の内容と、ビルドした/app/binだけが残り、goコマンドなどは入りません。

(補足)そもそも削除はイメージの容量を減らさない

そもそもファイル削除ではイメージサイズが減りません。
OCI Image Specでも書かれていますが、削除はwhiteoutという仕様にしたがってマーカーファイルを置くことで表現されます。実際にはデータは消えません。
ファイルの削除によって本当にイメージ上からファイルが消えるのは、そのファイルがそのレイヤー上で作成されていたときのみです。

実際に挙動を観測してみましょう。
例えばbuild-essentialを導入してgccなどを含めたビルドツールを導入してみます。

build-essential.Containerfile
FROM docker.io/library/ubuntu:noble-20251013

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
<<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    apt-get update
    apt-get install -yqq --no-install-recommends build-essential
EOF

こうするとimage (virtual) sizeが300MiB以下程度跳ね上がっていることがわかります。

$ podman image ls
REPOSITORY                          TAG                IMAGE ID      CREATED         SIZE
localhost/build-essential           0.0.0              6a2afdf0228d  17 seconds ago  378 MB
docker.io/library/ubuntu            noble-20251013     c3a134f2ace4  6 weeks ago     80.6 MB

これに対してさらに、導入したパッケージを消すようなコードを加えてみます。

build-essential-rm.Containerfile
FROM docker.io/library/ubuntu:noble-20251013

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
<<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    apt-get update
    apt-get install -yqq --no-install-recommends build-essential
EOF

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
<<EOF
    rm /usr -rf
EOF

ファイルは消えているようですが

$ podman container run -it --rm localhost/build-essential-rm:0.0.0
Error: crun: executable file `/bin/bash` not found: No such file or directory: OCI runtime attempted to invoke a command that was not found

容量は変わりません

REPOSITORY                          TAG                IMAGE ID      CREATED         SIZE
localhost/build-essential-rm        0.0.0              f1d0c07bcbb4  2 minutes ago   378 MB

ポイント4: RUN --mount=type=cacheでキャッシュする

https://docs.docker.com/reference/dockerfile/#run---mounttypecache

ビルド間でキャッシュを永続化する機能です。

実際に以下のように利用しています。

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
<<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    apt-get update
    apt-get install -yqq --no-install-recommends git-lfs openssh-client
EOF
RUN \
    --mount=type=cache,target=/go \
    --mount=type=cache,target=/root/.cache/go-build \
<<EOF
    go mod download
    # go generate ./...
    go build -o ../bin ${MAIN_PKG_PATH}
EOF
  • 同時アクセス不可なものはsharing=lockedをつける。
    • aptは同時に1つしか実行できない。
  • 同時アクセス可のものは何もつけない。
    • Goのキャッシュはconcurrenct-safe
      • GOPATH(/go): 26794#issuecomment-442953703
      • GOCACHE(~/.cache/go-build): go help cache参照: The cache is safe for concurrent invocations of the go command.
  • どっちかわかんない場合はprivatelockedにしておくと(効率は落ちるが)安全。

これに関連して、ベースイメージがキャッシュを消す設定をしている場合、これを覆す必要があります。
例では以下のように、aptのキャッシュが残るように設定を行っています。

RUN <<EOF
    rm -f /etc/apt/apt.conf.d/docker-clean
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
EOF

実際にコンテナに入って/etc/apt/apt.conf.d/docker-cleanの中身を見たらわかりますが、ダウンロードしたキャッシュなどを消す設定が入っています。これはdockerユーザー向けの気遣いです。キャッシュをかけないケースではこの設定のほうがイメージが軽くなるので良いです。

ポイント5: RUN --mount=type=bindでソースコードをマウントする

https://docs.docker.com/reference/dockerfile/#run---mounttypebind

unix系のシステムでディレクトリを別のディレクトリにつなげることをbind mountと言います。mount(8)とか読んでおいてください。

ビルドコンテクストとかほかのステージとかをマウントすることができる機能です。

WORKDIR /app/src
RUN \
    --mount=type=bind,target=/app/src \
<<EOF
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

こうすることでソースコードのコピーを行うことなくビルドを行うことができます。

(補足)--mount=type=bindを使わないほうがいい時

そもそも--mount=type=bindはソースコードがすでにローカルにあるのが前提ですので、Dockerfileだけ送る形式(CIなど?)でビルドする際には使えない可能性が高いです。

それを除くとプログラミング言語やビルドの仕方によっては--mount=type=bindを使わないほうがよさそうな時があります。

代わりの下記のようにCOPYでbuild-contextをbuild backendに送ります。

WORKDIR /app/src
COPY . .
RUN <<EOF
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

COPY . .の行でビルドコンテクストがすべてbuild backendに送信されてしまうはずなので、不要なファイルは.dockerignoreなどで無視できるようにしたほうが良いです。

https://docs.docker.com/build/concepts/context/#dockerignore-files

どういう時に使うべきじゃないかというと

ビルドシステムがbuild-contextに固有なものを書き出してしまうとき

です。
以下が具体例です。

  • Node.js
    • node-gypが使われてるとき
      • npmモジュールがnative-moduleをビルドしてしまう時、プラットフォーム差が発生
    • devDependenciesが存在する
      • ./node_modulesの中身がビルドに影響されて入れ替わるため、ホスト側に影響が波及してしまう
    • 例外: esbuildなどでバンドルするため出力がディレクトリ外に指定できるとき。
  • python
    • venv(uv/poetryなど)がディレクトリ内に作成される場合
      • デフォルト設定でpyroject.tomlと同じ階層の.venvディレクトリに書き込みます。
      • venvはsymlinkでpython実行ファイルにリンクされるためコンテナ内外でリンク先の違いにより齟齬が発生
    • インストール時にnative moduleをビルドするとき
      • 結構何でもビルドしてる感じします。
  • Rust
    • なにも設定を加えないとディレクトリ内のtargetディレクトリにビルド成果物を出力します。
    • 例外: build.target-dirの指定でディレクトリ外に出力されるとき

どっちかというとGoがコンテナ上でビルドするときに都合が良すぎるだけで、基本はコピーしたほうが安全かもしんないです。
もちろんGoでもビルドスクリプト中で環境に固有なものを吐き出してしまう場合はbindを使わないほうがいいです。めったにないとは思うんですが。

ポイント6: private gitを使うための設定(RUN --mount=type=ssh)

以前の記事のこの部分で説明しましたが、Goでprivate VCS(Version Control System)でホストされるモジュールを取得する際、特別な設定がされていなければVCSに対応するコマンド、つまりgitコマンドが使用されます。

そこでprivateなGo moduleが含まれる場合、以下が必要となります。

  • Dockerfile側:
    • ssh clientの追加
    • ssh-keyscanによるknown_hostsのリスト
    • insteadOf設定によってgitコマンドがsshを使うように変更
    • RUN --mount=type=sshによってssh-agentのソケットのマウントを要求
  • ホスト側:
    • ssh-agentの準備
    • github.com/gitlab.comなどのホスティングサービスにssh keyの登録

https://docs.docker.com/reference/dockerfile/#run---mounttypessh

Dockerfile側

Dockerfileは以下のように各種設定が必要です。

# openssh-clientが必要なので入れておく
RUN \
<<EOF
    apt-get update
    apt-get install -yqq --no-install-recommends openssh-client
EOF

ARG SSH_HOSTS="github.com,gitlab.com"

# `~/.ssh/known_hosts`を埋めておくことで、ssh login時にプロンプトが出ないようにする
# ホストの`~/.ssh/known_hosts`をマウントしたほうがいいかも
# ssh-keyscanはHTTP_PROXYを無視するのでproxy下環境では使えない。
RUN <<EOF
    mkdir -p -m 0700 ~/.ssh
    for item in $(echo $SSH_HOSTS | tr ',' '\n' ); do
      if [ ! -z ${item} ]; then
        git config --global url."ssh://git@${item}".insteadOf https://${item}
        ssh-keyscan ${item} >> ~/.ssh/known_hosts
      fi
    done
EOF

RUN --mount=type=ssh \
<<EOF
    go mod download
    # go generate ./...
    go build -o ../bin ${MAIN_PKG_PATH}
EOF

ホスト側

ホスト側ではssh keyの作成、ssh-agentの準備、ホスティングサービスへのpubkeyの登録などが必要です。下記を参考に行ってください。

https://docs.github.com/en/authentication/connecting-to-github-with-ssh

https://docs.gitlab.com/user/ssh/

筆者はgpg-agentssh-agentの役割を担ってもらうことにしています。
下記のスクリプトを.bashrcなどから読み込ませると

if [ -t 0 ]; then
        # Set GPG_TTY so gpg-agent knows where to prompt.  See gpg-agent(1)
        export GPG_TTY="$(tty)"
fi

# https://wiki.archlinux.org/title/GnuPG#SSH_agent

# Start gpg-agent if not already running
if ! pgrep -x -u "${USER}" gpg-agent &> /dev/null; then
  gpg-connect-agent /bye &> /dev/null
fi

# Additionally add:
# Set SSH to use gpg-agent (see 'man gpg-agent', section EXAMPLES)
unset SSH_AGENT_PID
if [ "${gnupg_SSH_AUTH_SOCK_by:-0}" -ne $$ ]; then
  # export SSH_AUTH_SOCK="/run/user/$UID/gnupg/S.gpg-agent.ssh"
  export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
fi

# Refresh gpg-agent tty in case user switches into an X session
gpg-connect-agent updatestartuptty /bye > /dev/null

dbus-update-activation-environment --systemd SSH_AUTH_SOCK

(podman)ビルド直前にgpg-agentをunlockしておく

秘密鍵にpassphraseがついている場合、pinentryというプログラムでプロンプトを出してそのパスワードを入力させる仕組みになっています。
ビルド中にこれへのアクセスが必要になったときプロンプトが出現するとことになりますがpodmanが利用するbuild backendコンポーネントのbuildahの都合でpassphraseの入力がほぼ不可能になっています。

下記より、buildahにはハードコードされた2秒というタイムアウトがあります。

https://github.com/containers/buildah/blob/v1.42.1/pkg/sshagent/sshagent.go#L126-L131

下記のような感じで、ssh-add -Tをビルド直前に読んで鍵のアンロックを行っておく必要があります。

ssh-add -T ~/.ssh/id_ecdsa.pub

podman buildx build \
    --ssh default=${SSH_AUTH_SOCK} \
...

Dockerの実装はだいぶ違ったのでこの問題は起きないかも。特に検証していないのでわかりませんが。

そもそもCIなどと相性が悪そうなので利用する機会は少ないかもしれないですね。

ポイント7: distrolessを使うならsha256sumでイメージを指定しよう

再現性を優先する場合はdistrolessの指定はsha256sumでしたほうが良いです。

最終ステージをこうしています。

FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea

これはdistrolessが基本的にタグをつけないスタイルだからです。

このsha256sumは以下の手順でえらえます。

$ podman image pull gcr.io/distroless/static-debian12:latest
$ podman image inspect gcr.io/distroless/static-debian12:latest --format '{{.Digest}}'
sha256:ed92139a33080a51ac2e0607c781a67fb3facf2e6b3b04a2238703d8bcf39c40

:latestの中身はビルドされるたびに変わるので時々pullしなおします。

ポイント8: (Hack)secret mountをファイルをマウントできる方式としてつかう

RUN --mount=type=bindだとファイルもしくはディレクトリをマウントできますがsecret mountはファイルしかマウントできないのでファイルだけをマウントする際にsecret mountを悪用しています。

go.mod記載のgo versionから最新のpatch versionを取得

ということができるツールを作りました。

こういうgo.modがあるディレクトリで

module github.com/ngicks/go-example-basics-revisited/building-with-docker

go 1.25.0

require (
	github.com/ngicks/go-iterator-helper v0.0.23
	github.com/ngicks/go-playground v0.0.1
)

で実行すると

$ go run github.com/ngicks/go-common/tools/golatestpatchver@latest
1.25.4

これをビルドスクリプトから読み込ませれば常に最新のパッチバージョンでビルドできます!

一応どのように作ったかの説明:

  • Goのすべてのバージョンの一覧は以下の二通りの方法で得ることができます
    • curl https://proxy.golang.org/golang.org/toolchain/@v/list
    • git ls-remote --tags https://go.googlesource.com/go 'go*'
  • 多分リリースされてから反映されるであろうからgoproxyに問い合わせるほうがリリース直後のタイミング問題が起きにくいから、そちらのほうがいい気がしますが、すべてのツールチェイン向けのデータが出てきてちょっと冗長なのでgitのほうを採用することにしました。
  • 出力結果を解析し、ソートします。
    • バージョンタグはsemverにしたがっておらず、go1.9beta1のような感じで数字部分がx.y.zの3桁じゃなかったり、beta1, rc1のようなsuffixが付きます。
    • そこで, beta1 -> -beta.1に変換するなどしてsemverにしてから解析しています。
  • x.y.zの部分のうち、yが一致しているもので最新をprintします。

となります。ちょっとめんどくさいですね。

ビルドして実行

以下のようなスクリプトでビルドできます。

#! /bin/sh

set -Cue

if [ -z ${1:-""} ]; then
  echo "set repo:tag as first cli argument"
  exit 1
fi

TAG_GOVER=1.25.0
if [ -f ./ver ]; then
  TAG_GOVER=$(cat ./ver)
fi

arch=${TARGET_ARCH:-""}

if [ -z ${arch} ]; then
  case $(uname -m) in
    "x86_64")
      arch="amd64";;
    "x86_64-AT386")
      arch="amd64";;
    "aarch64_be")
      arch="arm64be";;
    "aarch64")
      arch="arm64";;
    "armv8b")
      arch="arm64";;
    "armv8l")
      arch="arm64";;
  esac
fi

if [ -z $arch ]; then
  echo "arch unknown: $(uname -m)"
  exit 1
fi

echo $arch

# let gpg key unlocked for ssh login.
# As you can see in https://github.com/containers/buildah/blob/v1.42.1/pkg/sshagent/sshagent.go#L126-L131
# buildah sets 2 sec timeout for ssh-agent so you have low chance to successfully enter passphrase.
ssh-add -T ~/.ssh/id_ecdsa.pub

podman buildx build \
    --platform linux/${arch} \
    --build-arg TAG_GOVER=${TAG_GOVER} \
    --build-arg MAIN_PKG_PATH=${MAIN_PKG_PATH:-./} \
    --build-arg GOPRIVATE=${GOPRIVATE:-""} \
    --secret id=goenv,src=$(go env GOENV) \
    --ssh default=${SSH_AUTH_SOCK:-""} \
    -t ${1}-${arch} \
    -f Containerfile \
    .

最新のパッチバージョンはgo run github.com/ngicks/go-common/tools/golatestpatchver@latest > verでファイルに保存してそこを経由させています。
こうしないと最新のGoがリリースされてからdockerhubにイメージが上がるまでの隙間時間に対応しにくいですからね。
のちのmulti-arch buildに備えてarchでtagをsuffixすることにしています。

jokeなのでjokeという名前でビルドしてみます

$ ./build.sh joke:0.0.3

できます

$ podman image ls
REPOSITORY                          TAG                IMAGE ID      CREATED            SIZE
localhost/joke                      0.0.3-amd64        b2c977b38cfb  3 days ago         5.45 MB

実行してみます。

$ podman container run -t --rm localhost/joke:0.0.3-amd64

yay
🐤< コンニチハ! ₍₍⁽⁽ 🐓₎₎⁾⁾ ₍₍⁽⁽🐔₎₎⁾⁾ ₍₍⁽⁽🐣₎₎⁾⁾ ₍₍⁽⁽🐧 ₎₎⁾⁾

鳥が踊ります。

企業プロキシ下版の考慮点

  • HTTP_PROXY, HTTPS_PROXY, NO_PROXYとそれらの小文字版を環境変数として導入。
    • Dockerの場合: BasicAuth必要なproxyの場合秘密情報を含むので、secret mountで環境変数としてマウントします。
    • podmanの場合: RUN --mount=type=secret,type=envが動作しなかったのでbuild-argとして導入します。
    • (別のフォワードプロキシを立ててそこのURLを指定する、そのプロキシでBasicAuthの情報を付け足す、という方法のほうがいいんではないかと思いますが)
  • 企業プロキシはsshを通さないことが多いみたいなのでssh関連のものは全部削除
  • SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, DENO_CERTを宣言することでcurlなど、Node.jsdenoがそれぞれが企業プロキシのオレオレ証明書を含んだca bundleを使うように指定します。
    • imageにca-certificatesパッケージを導入する場合はパスかぶりを避けるため/etc/ssl/certs/ca-certificates.crt以外の位置(/ca-certificates.crtなど)を指定してマウント位置も買えたらいいです。
    • ソース見る限りGoSSL_CERT_FILEを読みに行きます。
  • .netrcファイルを作成し、secret mountでマウントする
    • 平文で機密情報を書かないといけないフォーマットなので書き込むcredentialはできる限り短命なほうが良いです。
    • フォーマットはIBM: .netrc ファイルの作成などをご覧ください
 ARG GOPATH="/go"
 ARG GOPRIVATE=""

-ARG GIT_SSH_HOSTS="github.com,"
 ARG MAIN_PKG_PATH="."

+# for curl, etc.
+ARG SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt"
+ARG NODE_EXTRA_CA_CERTS=${SSL_CERT_FILE}
+ARG DENO_CERT=${SSL_CERT_FILE}
+
+ARG HTTP_PROXY
+ARG HTTPS_PROXY
+ARG NO_PROXY
+ARG http_proxy
+ARG https_proxy
+ARG no_proxy
+
 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
     --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt \
 <<EOF
     rm -f /etc/apt/apt.conf.d/docker-clean
     echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
     apt-get update
-    apt-get install -yqq --no-install-recommends git-lfs openssh-client
-EOF
-
-RUN <<EOF
-    mkdir -p -m 0700 ~/.ssh
-    for item in $(echo $GIT_SSH_HOSTS | tr ',' '\n' ); do
-      if [ ! -z ${item} ]; then
-        git config --global url."ssh://git@${item}".insteadOf https://${item}
-        ssh-keyscan ${item} >> ~/.ssh/known_hosts
-      fi
-    done
+    apt-get install -yqq --no-install-recommends git-lfs
 EOF

 WORKDIR /app/src

-RUN --mount=type=ssh \
+RUN --mount=type=secret,id=netrc,target=/root/.netrc \
     --mount=type=secret,id=goenv,target=/root/.config/go/env \
     --mount=type=cache,target=/go \
     --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=bind,target=/app/src \
+    --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt \
 <<EOF
     go mod download
     # go generate ./...
@@ -48,10 +51,7 @@ EOF

 WORKDIR /app

-# arm64
-FROM gcr.io/distroless/static-debian12@sha256:ed92139a33080a51ac2e0607c781a67fb3facf2e6b3b04a2238703d8bcf39c40
-# amd64
-# FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea
+FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea

 COPY --from=builder /app/bin /app/bin

ビルドは以下のスクリプトで行います。

#! /bin/sh

set -Cue

if [ -z ${1:-""} ]; then
  echo "set repo:tag as first cli argument"
  exit 1
fi

TAG_GOVER=1.25.0
if [ -f ./ver ]; then
  TAG_GOVER=$(cat ./ver)
fi

arch=${TARGET_ARCH:-""}

if [ -z ${arch} ]; then
  case $(uname -m) in
    "x86_64")
      arch="amd64";;
    "x86_64-AT386")
      arch="amd64";;
    "aarch64_be")
      arch="arm64be";;
    "aarch64")
      arch="arm64";;
    "armv8b")
      arch="arm64";;
    "armv8l")
      arch="arm64";;
  esac
fi

if [ -z $arch ]; then
  echo "arch unknown: $(uname -m)"
  exit 1
fi

echo $arch

# this is really needed.
export HTTP_PROXY=${HTTP_PROXY}
export HTTPS_PROXY=${HTTPS_PROXY:-$HTTP_PROXY}
# maybe being empty is ok.
export NO_PROXY=${NO_PROXY:-""}
export http_proxy=${http_proxy:-$HTTP_PROXY}
export https_proxy=${https_proxy:-$HTTPS_PROXY}
export no_proxy=${no_proxy:-$NO_PROXY}

podman buildx build \
    --platform linux/${arch} \
    --build-arg TAG_GOVER=${TAG_GOVER} \
    --build-arg MAIN_PKG_PATH=${MAIN_PKG_PATH:-./} \
    --build-arg GOPRIVATE=${GOPRIVATE:-""} \
    --secret id=goenv,src=$(go env GOENV) \
    --secret id=netrc,src=${NETRC:-$HOME/.netrc} \
    --secret id=certs,src=${SSL_CERT_FILE:-/etc/ssl/certs/ca-certificates.crt} \
    --build-arg HTTP_PROXY=${HTTP_PROXY} \
    --build-arg HTTPS_PROXY=${HTTPS_PROXY} \
    --build-arg NO_PROXY=${NO_PROXY} \
    --build-arg http_proxy=${http_proxy} \
    --build-arg https_proxy=${https_proxy} \
    --build-arg no_proxy=${no_proxy} \
    -t ${1}-${arch} \
    -f behind-proxy.Containerfile \
    .

その他のプラクティス集

言いたいことは終わったけどもうちょい細かい|詳しい話とか

ENTRYPOINT/CMD使い分け

どちらもコンテナのデフォルトコマンドを決めるもの。ただし両方あるとENTRYPOINT CMDの順で組み合わされる。

# https://docs.docker.com/reference/dockerfile#entrypoint
ENTRYPOINT ["top", "-b"]
CMD ["-c"]
  • ENTRYPOINT(docker|podman) container (create|run)--entrypointで上書き可能
  • CMD(docker|podman) container (create|run) foo bar ...foo bar ...の部分で上書き可能

ENTRYPOINTのほうが上書きしにくい。docker/podman cliからだとENTRYPOINTはarrayで上書きできないかも・・・(composeからはできる)

広く公開して設定をあれこれいじってほしい場合は上のようにdefault-ishなコマンドのベース部分をENTRYPOINTにして残りのオプションをCMDに置くと気が利いていて意図が伝わりやすいと思います。
狭くしか公開しないor狭い使い道しかない場合はENTRYPOINTだけ指定しておけばok。そもそもアプリとしてcli argの指定がない部分はデフォルト値を使う挙動にしておいたほうがいいでしょう。というのもCMDでデフォルト値をつけて回ると、create|runの引数で全部上書きされるので部分的な変更ができないからです。

イメージ内でUSER変えるべきか

Dockerfile内でUSERを使ってユーザーを切り替えるべきかについて

https://docs.docker.com/reference/dockerfile#user

前述通りrootfulなコンテナのuid=0(root)はコンテナ外でもuid=0です。
事故を防ぐためにはDockerfile内でUSERインストラクションを使ってユーザーを切り替えておくと高い権限でコンテナアプリが実行される事故が防げます。

ただ筆者は「(docker|podman)container create --userでcontainer creation時にユーザーを変える運用をするからどっちでもいい」派です。

理由は簡単で

  • ホスト環境のuid/gidとの都合をつけるのが面倒
    • ホストから/etc/passwd, /etc/groupをマウントしたときuid/gid関係がずれる可能性
    • ランタイム側がrootlessだった場合はfake root(=コンテナ内におけるuid=0, ホストにおけるdocker/podmanコマンド実行者)で動作し続けるほうが望ましい
  • ホストのファイルに触らない(=bind mountしない)ならそもそもuid/gidはなんでもいい

逆に言うとDockerfile--userでユーザーが切り替わっていても動作できるような考慮が必要です。
うっかりrootmkdirしたりするとファイルが作れなくて困ります。

ちなみに最もやりづらいパターンがENTRYPOINTで指定されたプログラムの中でrootから別ユーザーに切り替えるパターンです。
docker.elastic.co/elasticsearch/elasticsearch-ossがこれをするのでずいぶん困りました・・・
そういうのやらないでほしいなっていうのが筆者の願いです。

VOLUMEはイメージ内に存在するディレクトリを指定してはいけない

VOLUME instrcuctionで指定したディレクトリをvolumeとしてホストのディスクをマウントすべきかを指定できます。

https://docs.docker.com/reference/dockerfile#volume

(docker|podman) container create --mount type=volume,src=${foo},dst=${bar}でこのパスに外部ストレージをマウントしてねという意思表明として使うものなんだと思います。
(少なくとも)dockerでは指定がなければanonymous volumeを作ってマウントします。

COPY . /data
VOLUME ["/data"]

このようにイメージ内に既に存在するディレクトリをVOLUMEで指定すると、volumeの中身の空の場合にイメージの内容がコピーされます。

問題は以下です:

  • コピー途中で電断が起きたら(当たり前だが)コピーしなおす挙動はない
  • イメージのバージョンが上がるなどしてイメージ中のコンテンツが変わってもコピーしなおしは起きない

この挙動ないほうが望ましい気がします。
anonymous volumeの管理をしないためにもVOLUME instruction自体使わないようにし、--mount type=volumeを指定するときもdstはイメージ内に存在しないパスにしたほうが良いでしょう。

この挙動はdocker v20.10.xあたりの時点でソースコードを読んで確認しています。多分変わってないと思います。

--read-onlyで動作させよう

--read-only(podmanでは--read-only-tmpfs=falseも)でコンテナを動作させましょう。

コンテナのroot filesystemはデフォルトでは読み書き可能ですが書いてしまうとコンテナにステートが保存されてしまい、コンテナの再生成でそのステートがリセットされてしまいます。
docker composeは環境変数などの設定が変わるとイメージのバージョンが同じでもコンテナの再生成が起こるため、--read-onlyをつけて万一にも意図しない場所への書き込みが起きないことを保証しておくのが(本番環境では)おすすめです。

(おまけ)multi-arch build

手元のPCはamd64だけど実行したい環境はarm64だ、とかそういうケースが最近は増えてきているんじゃないかと思います。
というのもmacのラップトップはarm64だったりしますし、Raspberry Piもarm64です。

そもそも対象読者はCPU Architectureというのがわからないでしょうか?
CPU Architectureとはプログラムから見ると命令セットの仕様のことです。
昨今の高レベルなプログラミング言語を書いていると違いは意識されにくいかもしれませんが、アセンブリを直接書くと大幅に違います。
Goもランタイムのところにアーキテクチャ依存のアセンブリがおいてあるので違いを見比べてみるといいと思います。

ていう説明だとよくわかんないですよね。
ここで分かってほしいのはプログラムは特定のos/arch(Architecture)の組み合わせ向けにビルドされることが一般的で、この組み合わせをプラットフォームなどと呼ぶことと、あるプラットフォーム向けにビルドされたプログラムはほかのプラットフォームだと基本的に動かないということです。

arm64(aarch64)はamd64(x86_64)より安価なので利用される場面が多いようです。

ビルドシステムが動作しているマシンとは異なるOS/アーキテクチャ向けにプログラムをビルドすることをcross-compilationなどと呼びます(Wikipedia: Cross-Compiler)。
Goは容易に別プラットフォーム向けのバイナリをビルドできますが、コンテナはGoだけでは済まないことがあるため、qemuなどのVMを使ってcross-compilationを行います。

buildahのドキュメント曰くmulti-arch buildにはqemu-user-staticが必要です([1], [2])。dockerでも同様です(がサードパーティツールでqemu-userを導入させる形式なようです)([3])。

ソースを見る限りbinfmt_miscも必要ですが、そもそもwsl2のインスタンスなら元から有効になっているようです。

$ sudo apt install qemu-user-static
# wsl2なら既に有効になっているはず
$ sudo systemctl enable --now systemd-binfmt
$ modprobe binfmt_misc
# 何も表示されなければok

さて、Containerfileについてなのですが、

FROM docker.io/library/golang:${TAG_GOVER}-${TAG_DISTRO}

の部分は、dockerの仕様に基づきmultiarchに対応したイメージである場合は自動的にビルド対象のarchのイメージがpullされるのでこのままでよいです。

問題はdistrolessの部分で、こちらはhash sumで指定しているので自動的なフォールバックがかからないんですね。

-FROM gcr.io/distroless/static-debian12@sha256:6ceafbc2a9c566d66448fb1d5381dede2b29200d1916e03f5238a1c437e7d9ea
+# arm64
+FROM gcr.io/distroless/static-debian12@sha256:ed92139a33080a51ac2e0607c781a67fb3facf2e6b3b04a2238703d8bcf39c40

(この部分はほかの人はどうしているんだろう?まだ何も調べていない)

ではこの状態でビルドしてみます。
build.shではarchで分岐できるように調節してあるので、環境変数を設定してスクリプトを呼び出すだけです。
Red Hat Documentation: コンテナーの構築、実行、および管理: 4.8.マルチアーキテクチャーイメージのビルドを見る限り、podman build--platform linux/amd64,linux/arm64を指定すればまとめてビルドしてくれるみたいですが、安定してイメージにarchに基づいたタグをつける方法がぱっと見つからなかったので細かく分けています。privateに使えるcontainer registryを動作させているならそちらの方法でよいかなと思います。

TARGET_ARCH=arm64 ./build.sh joke:0.0.3

特に問題なくビルドできました。

本当に動くのかどうかをRaspberry Piで試しましょう。
筆者はk3sでクラスターを組んでいるので、そこに投入します。レジストリ経由ではないイメージの受け渡しの方法がすぐに見つからなかった(実際ないかも)のでローカルにイメージを保存してローカル経由でk3s組み込みのcontainerdにロードさせます。

$ podman image save localhost/joke:0.0.3-arm64 | gzip > joke:0.0.3-arm64.tar.gz
$ scp ./joke:0.0.3-arm64.tar.gz ${remote-machine}:/tmp
$ ssh ${remote-machine}
$$ sudo k3s ctr images import /tmp/joke\:0.0.3-arm64.tar.gz

動かしてみます

$ kubectl run joke --image=localhost/joke:0.0.3-arm64 --image-pull-policy=Never
pod/joke created
$ kubectl logs pod/joke
yay
🐤< コンニチハ! ₍₍⁽⁽ 🐧₎₎⁾⁾ ₍₍⁽⁽🐓₎₎⁾⁾ ₍₍⁽⁽🐔₎₎⁾⁾ ₍₍⁽⁽🐣 ₎₎⁾⁾
 %

動いてますね。

おわりに

自分が書いたDockerfileを参照しなおすためにいろんなところを何度も開き直している自分を見つけたのでまとめておきました。

private gitを使うさいのビルド方法は結構難儀しましたが、あまり書かれてるところを見たことがない気がしたのでやってよかったと思います。

資料や挙動は確認できるものはしていますが、間違っている場合にはコメントで教えていただけると幸いです。

GitHubで編集を提案

Discussion