🦴

golang 開発環境のベストプラクティスが分からん

2024/08/11に公開

はじめに

golang 開発環境を Docker で仮想化する際のベストプラクティスが分からん。ChatGPT に聞いても Claude に聞いても「マルチステージビルドでイメージを小さくすることができます」とか見当違いなことを答えてくる。golang で開発してる人はどうやって開発環境を仮想化してるんだ。

未だにベストプラクティスは分からないが、許容範囲の暫定的な解としてはこんな感じだろうか、と自分で思える方法を書いておく。

どうであればよいのか

要件としてはいくつか考えられる。

  1. golang のコンパイラなどは仮想環境に隔離すること
  2. 仮想環境は複数のサーバーで簡単に持ち回せること
  3. プロジェクトのソースコードは VSCode から編集できること

これを Docker でやってみようとすれば分かるが、1, 2, 3 を同時に満たすのが絶妙に面倒くさい

絶妙に面倒くさい

少しだけその面倒くささについて説明しておこう。まずプロジェクトのソースコードは Docker の外に永続化されていないといけないので、ボリュームをバインドマウントすることになる。

しかしホストが Linux の場合、バインドマウントしたファイルを自由にいじろうとすると、ホストとコンテナ内でユーザーIDとグループIDを揃えねばならない。さもなくばどこかのタイミングで chmodchown を走らせてファイルの権限を調整するという面倒な「一手間」が発生する。

一方でコンテナ内にホストの Linux アカウントと同様のユーザーIDとグループIDを持ったユーザーを作成できるタイミングは限られる。特に工夫しなければ Dockerfile の中でしか作成することはできない。頑張れば ENTRYPOINT の中で指定した Linux ユーザーを作成することもできるのだが、これはこれでけっこう頑張らねばならないし、コンテナの起動時にやたら気を遣うので正直面倒である。

そして最大の問題は、頑張って作ってしまうと壊すのがもったいなくなってしまうことである。新しい環境に対応したいときもサクッと手軽にとはいかなくなる。

とりあえず Docker でなんとかしてみる

今回考えた構成は以下である。今回はとにかくシンプルに行く。

  1. プロジェクトのソースコードはローカルでもサーバー上でもよい。
  2. プロジェクトのソースコードをサーバー上におく場合は VSCode の Remote Development で編集する。
  3. ソースコードが置いてあるマシンには Docker をインストールしておく。
  4. 開発環境のイメージをビルドするときにホストのアカウントと同じ ID を持つユーザーを自動で作成する。
  5. 使用するコマンドはすべて Makefile にまとめておく。

プロジェクトの構成は以下である。

ディレクトリ構成
golang/
├── Dockerfile
├── Makefile
└── work
    └── myapp
        └── server.go
Makefile
# sudo で実行するかどうか
SUDO ?= yes

# CMD_PREFIX を設定
ifeq ($(SUDO), yes)
    CMD_PREFIX := sudo
else
    CMD_PREFIX :=
endif

# ホストの Linux アカウント情報を取得する
DEV_USER := $(shell id -un)
DEV_UID := $(shell id -u)
DEV_GROUP := $(shell id -gn)
DEV_GID := $(shell id -g)

# 開発用イメージにつける名前
TAG ?= golang-dev-${DEV_USER}:latest

# golang サーバー起動時のポートバインディング
PORT ?= 1323:1323

# コンパイル対象
SOURCE ?= server.go

# アウトプット
TARGET ?= server

# 開発用イメージのビルド
.PHONY: image
image:
	${CMD_PREFIX} docker build \
		--build-arg DEV_USER=${DEV_USER} \
		--build-arg DEV_UID=${DEV_UID} \
		--build-arg DEV_GROUP=${DEV_GROUP} \
		--build-arg DEV_GID=${DEV_GID} \
		-t ${TAG} .

# 開発用コンテナのシェルを起動
.PHONY: shell
shell:
	${CMD_PREFIX} docker container run -it --rm \
		-u ${DEV_UID} \
		-v ./work:/work \
		${TAG} bash

# ソースをビルド
# make build/myapp のような感じで使う
.PHONY: build/%
build/%:
	${CMD_PREFIX} docker container run --rm \
		-u ${DEV_UID} \
		-v ./work:/work \
		-w "/work/$(notdir $@)" \
		${TAG} go build -o ${TARGET} ${SOURCE}

# ビルドしたバイナリをフォアグラウンドで走らせる
# make run/myapp のような感じで使う
.PHONY: run/%
run/%:
	${CMD_PREFIX} docker container run --rm \
		-p ${PORT} \
		-u ${DEV_UID} \
		-v ./work:/work \
		-w "/work/$(notdir $@)" \
		${TAG} ./${TARGET}

# ビルドしたバイナリをバックグラウンドで走らせる
# make run/myapp のような感じで使う
# 出力は docker container logs コンテナ名 で確認できる
.PHONY: rund/%
rund/%:
	${CMD_PREFIX} docker container run -d --rm \
		-p ${PORT} \
		-u ${DEV_UID} \
		-v ./work:/work \
		-w "/work/$(notdir $@)" \
		${TAG} ./${TARGET}
Dockerfile
FROM golang:1.22-bookworm

# ホスト側のアカウントの情報を受け取る
ARG DEV_USER
ARG DEV_UID
ARG DEV_GROUP
ARG DEV_GID

# ユーザー作成のためいったん root になる
USER root

# 必要なグループとユーザーを作成する
RUN groupadd --gid ${DEV_GID} ${DEV_GROUP} \
    && useradd --no-log-init -m --home "/home/${DEV_USER}" \
        --shell /bin/bash --uid "${DEV_UID}" --gid "${DEV_GID}" "${DEV_USER}"

ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update --yes \
    && apt-get upgrade --yes \
    && apt-get install --yes --no-install-recommends \
        vim iputils-ping net-tools \
    && apt-get clean && rm -rf /var/lib/apt/lists/*
        
# ワーキングディレクトリを /work にしておく
WORKDIR /work
myapp/server.go
// from https://echo.labstack.com/docs/quick-start

package main

import (
	"net/http"
	
	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	})
	e.Logger.Fatal(e.Start(":1323"))
}

使い方

使い方は make コマンドを経由すればとても簡単である。

0. sudo の切り替え

Makefile の中身について少し補足しておく。

Docker を使用する場合はユーザーに sudo 権限を渡すか、ユーザーを docker グループに追加するかのどちらかである。前者であれば docker コマンドを実行するとき手前に sudo をつける必要があり、後者であればつける必要がない。

Makefile の中にあるコマンドは上記のように状況に応じて sudo を書くか書かないか編集するのが面倒なので、SUDO という make 変数で sudo 実行を制御できるようにしてある。Makefile の冒頭にある SUDO ?= yes の部分をそのままにしておけば sudo 実行され、SUDO ?= no のように書き変えれば sudo なしで実行される。

以下のようにして単発での切り替えも可能である。

# sudo ありで実行する
SUDO=yes make image

# sudo なしで実行する
SUDO=no make image

1. 開発用コンテナイメージのビルド

まずは開発用コンテナのイメージをビルドする。このコマンドで作成されるイメージの名前には golang-dev-{username} のようにホスト側のユーザー名が刻まれるので、複数のアカウントで使用したときも作成されるイメージが被ることはない。

逆に言えばアカウントの分だけイメージが作成されてしまうので、大人数で開発する場合にはこの運用は向かない。起動時にユーザーを切り替える方法などを検討する。

# イメージをビルドする
cd golang
make image

# ビルド結果を確認する
docker image ls
output
REPOSITORY          TAG         IMAGE ID       CREATED         SIZE
golang-dev-josh     latest      824d9b0fee39   3 days ago      822MB

2. プロジェクトの初期化

開発用コンテナでシェルを起動してプロジェクトを初期化する。今回は echo を使ったサーバーであり、以下のクイックスタートガイドにあるソースコードをそのまま使用しているので、初期化手順もこのクイックスタートに倣う。

https://echo.labstack.com/docs/quick-start

# 開発用コンテナのシェルを起動する
cd golang
make shell

# 以下は開発用コンテナの中で行う
# 初期化の手順はプロジェクトごとに好きなようにやる
cd myapp
go mod init myapp
go get github.com/labstack/echo/v4

この手順が完了するとホスト側からも以下のように go.modgo.sum が見えるようになるはずである。

ディレクトリ構成
golang
├── Dockerfile
├── Makefile
└── work
    └── myapp
        ├── go.mod
        ├── go.sum
        └── server.go

3. ソースコードのビルド

上記の手順が完了していれば以下のようにソースコードをビルドすることができる。

cd golang
make build/myapp

build/{subdir}subdir には golang/work 以下のビルド対象のサブディレクトリを指定できるように Makefile を書いてある。これにより複数のプロジェクトをこの開発用コンテナで管理することができる。

上記を実行すると server というバイナリが生成される。

ディレクトリ構成
golang
├── Dockerfile
├── Makefile
└── work
    └── myapp
        ├── go.mod
        ├── go.sum
        ├── server      ← これ
        └── server.go

コンパイルしたいソースコードと、ターゲットのバイナリの名前は変数にしてあるので以下のように指定することもできる。

SOURCE=main.go TARGET=a.out make build/yourapp

この辺りは Makefile を好きなように書き換えて自分の環境に合わせるとよい。書き方が分からなければ ChatGPT なり Claude なりに質問すればよい。

4. ビルドしたバイナリを実行する

ビルドしたバイナリにはフォアグラウンド実行の run コマンドとバックグラウンド実行の rund コマンドを用意した。これもサブディレクトリを指定できる。

# フォアグラウンド実行
cd golang
make run/myapp

# バックグラウンド実行
cd golang
make rund/myapp

# ポートバインディングは PORT で指定できる
# ターゲットとなるバイナリは TARGET で指定できる
PORT=3000:1323 TARGET=a.out make run/yourapp
うまく起動しているかの確認
josh@mynotepc:~/Projects/golang$ make rund/myapp
sudo docker container run -d --rm \
        -p 1323:1323 \
        -u 1000 \
        -v ./work:/work \
        -w "/work/myapp" \
        golang-dev-josh:latest ./server
336a11b31b7645db7cde5c86821e8a0219b6688fc3dea2b1b24bf85bf5d0259e
josh@mynotepc:~/Projects/golang$ sudo docker container ls
CONTAINER ID   IMAGE                    COMMAND      CREATED          STATUS         PORTS                                       NAMES
336a11b31b76   golang-dev-josh:latest   "./server"   10 seconds ago   Up 9 seconds   0.0.0.0:1323->1323/tcp, :::1323->1323/tcp   inspiring_chatterjee
josh@mynotepc:~/Projects/golang$ sudo docker container logs inspiring_chatterjee

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.12.0
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323
josh@mynotepc:~/Projects/golang$ curl http://localhost:1323
Hello, World!

5. 他のプロジェクトも追加する

Makefile は複数プロジェクトに対応できるように書いたので、以下のようにサブディレクトリを切れば複数のプロジェクトも管理できる。あなたのプロジェクトに合わなければ好きなように Makefile を改造してほしい。

ディレクトリ構成
golang/
├── Dockerfile
├── Makefile
└── work
    ├── myapp
    │   └── server.go
    └── yourapp
        └── server.go

おしまい

golang の開発環境を Docker で構築できないかいろいろ試してみたのだが、いまのところ今回紹介する方法が構築の手間と汎用性のバランスが取れていてよいと思っている。

もっといい方法があればぜひ教えてください。

Discussion