【Golang】軽量なHTTPサーバのDockerコンテナを作成

Golangの入門書を読んで何か作成してみたいと思い、どうせならGolangが得意とする軽量なコンテナを作成しました。
# ゴール
下記をゴールとしました。
- 次の機能を有する静的コンテンツを公開するHTTPサーバとする
  - デフォルトで、ルートパスへのアクセスで hello ページを返す
  - バインドマウントでホストの静的Webコンテンツを返す
  - 任意の Content-Type を返すことが可能
- x64やarmなどのマルチプラットフォームに対応したコンテナイメージを作成してDocker Hubで公開
# 実装
まずは、Golangのソースです。
```golang
package main

import (
	"encoding/json"
	"fmt"
	"html"
	"io"
	"net/http"
	"os"
	"strings"
)

type Config struct {
	ContentTypes map[string]string
}

func getConfig() (config Config, err error) {
	jsonFile, err := os.Open("config.json")
	if err != nil {
		fmt.Println("config.json open err : ", err)
		return Config{}, err
	}
	defer jsonFile.Close()
	jsonData, err := io.ReadAll(jsonFile)
	if err != nil {
		fmt.Println("config.json read err : ", err)
		return Config{}, err
	}
	if err := json.Unmarshal(jsonData, &config); err != nil {
		fmt.Println("config.json parce : ", err)
		return Config{}, err
	}
	return config, nil
}

func startServer(config *Config) error {
	fileServer := http.FileServer(http.Dir("public"))
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		for path, contentType := range config.ContentTypes {
			if strings.HasPrefix(r.URL.Path, path) {
				w.Header().Set("Content-Type", contentType)
			}
		}
		fmt.Printf("URL.Path : %s\n", html.EscapeString(r.URL.Path))
		fileServer.ServeHTTP(w, r)
	})
	return http.ListenAndServe(":http", &handler)
}

func main() {
	config, err := getConfig()
	if err != nil {
		return
	}
	if err := startServer(&config); err != nil {
		fmt.Print(err)
	}
}
```
処理は大きく分けて設定のロードとサーバの起動があります。
静的コンテンツを返すだけの機能ならサーバはデフォルトの機能として拡張子から Content-Type を推定して返す機能を有するため設定ファイルのロードは不要なのですが、WebAPIのレスポンスを返すモックサーバ的な利用を想定して任意のパスへのアクセスは任意の Content-Type を返すということをしたかったため、設定ファイルへ設定することにしました。
設定ファイルは JSON で json.Unmarshal を使用してパースして構造体にマッピングしています。
サーバ側はリクエストハンドラ内で、リクエストのパスが設定したパスに前方一致すれば、Content-Typeを設定しています。
リクエスト時には標準出力にパスの情報だけ出力しています。これは docker logs で確認できるようにするためで、ログ出力抑止の設定が可能なようにも考えましたが、Docker の起動オプションで --log-driver none として抑止可能なためログ出力設定はやめました。
設定ファイルは下記のような感じです。
```JSON
{
	"ContentTypes": {
		"/api/": "application/json"
	}
}
```
次に Dockerfileです。
```Dockerfile:Dockerfile
FROM --platform=$BUILDPLATFORM golang:1.23.0-alpine3.20 AS build
ARG TARGETPLATFORM
WORKDIR /build
COPY ./src /build
RUN [ "${TARGETPLATFORM}" = "linux/amd64" ] && CGO_ENABLED=0 GOARCH=amd64 go build -ldflags="-s -w" -trimpath ||:
RUN [ "${TARGETPLATFORM}" = "linux/arm64" ] && CGO_ENABLED=0 GOARCH=arm64 go build -ldflags="-s -w" -trimpath ||:
RUN [ "${TARGETPLATFORM}" = "linux/i386" ] && CGO_ENABLED=0 GOARCH=386 go build -ldflags="-s -w" -trimpath ||:
RUN [ "${TARGETPLATFORM}" = "linux/arm/v5" ] && CGO_ENABLED=0 GOARCH=arm64 GOARM=5 go build -ldflags="-s -w" -trimpath ||:
RUN [ "${TARGETPLATFORM}" = "linux/arm/v6" ] && CGO_ENABLED=0 GOARCH=arm64 GOARM=6 go build -ldflags="-s -w" -trimpath ||:
RUN [ "${TARGETPLATFORM}" = "linux/arm/v7" ] && CGO_ENABLED=0 GOARCH=arm64 GOARM=7 go build -ldflags="-s -w" -trimpath ||:

FROM scratch
COPY --from=build /build/zeptohttpd /
COPY ./public /public
COPY ./config/config.json /
EXPOSE 80
CMD ["/zeptohttpd"]
```
build ステージでビルドして、スクラッチにビルドしたモジュールと hello ページの html ファイルと設定ファイルの３ファイルのみを入れたイメージを作成しています。作成したイメージの容量としては、docker images での表示では 5.24MB でした。ベースOSイメージが無いのでもう少し小さいと期待していましたがランタイム的なものも含むのでこれぐらいは仕方ないようにも思います。
マルチプラットフォームのビルドはそこそこ苦戦しました。ビルドは docker buildx を使用するのですが、ターゲットによって GOARCH などの変数を指定する手段として if のように条件判断をして実現はできたのですが、正直スマートではないと感じています。もっと良い手段があれば、コメントいただければ幸いです。
docker buildx のコマンドは下記で Docker Hub への push まで実施します。
```
$ docker buildx build . --platform \
　　linux/amd64,linux/arm64,linux/i386,linux/arm/v5,linux/arm/v6,linux/arm/v7 \
　　-t morststs/zeptohttpd:latest --push
```
上記のソースは、[GitHub](https://github.com/morststs/zeptohttpd)に公開しています。
# 使用方法
コンテナイメージは、[Docker Hub](https://hub.docker.com/r/morststs/zeptohttpd) で公開していますので、良ければ使ってみてください。ちなみに動作確認は、amd64しかしていません。
## シンプルに hello ページを表示
``` sh
$ docker pull morststs/zeptohttpd:latest
```
``` sh
$ docker run --rm -d -p 80:80 --name zeptohttpd morststs/zeptohttpd
```
``` sh
$ curl http://localhost:
<!DOCTYPE html>
<html lang="ja">

<head>
    <title>index page</title>
    <meta charset="utf-8">
</head>

<body>
    Hello World
</body>

</html>
```
## ホストの静的コンテンツを表示
ホストで public ディレクトリに静的コンテンツを準備する。
``` sh
$ docker run --rm -d -p 80:80 -v ./public:/public --name zeptohttpd morststs/zeptohttpd
```
## 設定した Content-Type を返す
```JSON:config.json
{
	"ContentTypes": {
		"/api/": "application/json"
	}
}
```
```JSON:public/api/getData
{"key":"value"}
```
``` sh
$ docker run --rm -d -p 80:80 -v ./public:/public -v ./config.json:/config.json --name zeptohttpd morststs/zeptohttpd
```
``` sh
$ curl -D - -X POST http://localhost:/api/getData
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 16
Content-Type: application/json
Last-Modified: Sat, 24 Aug 2024 09:13:05 GMT
Date: Sun, 25 Aug 2024 02:39:20 GMT

{"key":"value"}
```
``` sh
$ curl -D - -X GET 'http://localhost:/api/getData?val1=1&val2=2'
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 16
Content-Type: application/json
Last-Modified: Sat, 24 Aug 2024 09:13:05 GMT
Date: Sun, 25 Aug 2024 01:01:15 GMT

{"key":"value"}