GoでDocker マイクロサービスを構築する—multi-stage build & Compose

GoでDocker マイクロサービスを構築する—multi-stage build & Compose | mohablog
目次

Goマイクロサービスをコンテナで動かす理由

main.go をビルドして単一バイナリで動かす。その単一バイナリをDocker イメージにパッケージ化し、複数の独立したコンテナで複数のマイクロサービスを管理する。これがゴールです。

静的リンクされるため、Go バイナリはランタイム環境なしで動く。Docker イメージは小さく、本番環境と開発環境の差異がなくなる

Dockerで管理する利点

  • 依存関係の統一: Dockerfile に明記した環境が本番と同じ
  • スケール管理: ポート・ボリューム・ネットワークを宣言的に定義
  • 開発・テスト効率化: Docker Compose で立ち上げと破棄を自動化

本記事の到達点

APIサーバー1つ + ワーカーサービス1つの2コンテナシステムを Docker Compose で起動。サービス間の HTTP 通信を実装します。

Go のマルチステージビルドで軽量イメージを作る

Docker の「What will you learn」セクション にもある マルチステージビルドは、Goバイナリの サイズ削減に必須です。

ビルドステージと実行ステージを分ける。ビルドステージは Go のコンパイラと全ソースコードを含めますが、実行ステージはバイナリだけをコピー。イメージサイズが 800MB → 20MB 程度に圧縮できます。

基本的な Dockerfile(multi-stage)

# ビルドステージ
FROM golang:1.23 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

# 実行ステージ
FROM alpine:3.20

WORKDIR /root/
COPY --from=builder /app/main .

CMD ["./main"]

重要なポイント:

  • CGO_ENABLED=0: C言語ライブラリへの依存をなくす(alpine で動作保証)
  • -installsuffix cgo: C 関連のシンボルを除去
  • --from=builder: 前のステージからバイナリだけを抽出

このビルドを実行すると、イメージは20-30MBに納まります。

ビルドと実行の流れ

# ローカルビルド
$ docker build -t api-server:1.0 .

# 検証: イメージサイズ確認
$ docker images api-server:1.0
api-server          1.0             xxxxx     25MB

# 実行テスト
$ docker run -p 8080:8080 api-server:1.0

Docker Compose で複数サービスを連携させる

2つのマイクロサービスを定義します。

  • api-server: localhost:8080 でリッスン
  • worker: api-server からのリクエストを受け取り処理

docker-compose.yml で両者をネットワークで接続。サービス間通信はサービス名をホスト名として使えます。

docker-compose.yml の例

version: '3.8'

services:
  api-server:
    build: ./api-server
    container_name: api-server
    ports:
      - "8080:8080"
    environment:
      - WORKER_URL=http://worker:9090
    networks:
      - microservice-net
    depends_on:
      - worker

  worker:
    build: ./worker
    container_name: worker
    ports:
      - "9090:9090"
    networks:
      - microservice-net

networks:
  microservice-net:
    driver: bridge

重要な指定:

項目 役割
depends_on 起動順序を指定。worker が先に起動
WORKER_URL 環境変数で worker のアドレスを api-server に通知
networks 同じネットワーク上のサービス同士が通信可能

API サーバーの実装:サービス間通信

Go の net/http パッケージの ListenAndServeHandler パターンを使います。

api-server/main.go

package main

import (
	"io"
	"log"
	"net/http"
	"os"
)

var workerURL string

func init() {
	workerURL = os.Getenv("WORKER_URL")
	if workerURL == "" {
		workerURL = "http://localhost:9090"
	}
}

func handleProcess(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "POST only", http.StatusMethodNotAllowed)
		return
	}

	// worker へリクエスト送信
	resp, err := http.Post(
		workerURL+"/do",
		"application/json",
		r.Body,
	)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		log.Printf("worker request error: %v", err)
		return
	}
	defer resp.Body.Close()

	// worker からのレスポンスをクライアントへ返す
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(resp.StatusCode)
	io.Copy(w, resp.Body)
}

func main() {
	http.HandleFunc("/process", handleProcess)

	log.Println("api-server listening on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

ポイント:

  • WORKER_URL を環境変数から読み込む(Docker Compose で http://worker:9090 が渡される)
  • http.Post() で worker にリクエスト転送
  • ステータスコードとレスポンスボディをクライアントへ返す

実行結果

$ curl -X POST http://localhost:8080/process \
  -H 'Content-Type: application/json' \
  -d '{"data": "test"}'

{"status": "done", "result": "processed"}

ワーカーサービスの実装

API サーバーから POST リクエストを受け取り、処理を実行。レスポンスを返します。

worker/main.go

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"time"
)

type Request struct {
	Data string `json:"data"`
}

type Response struct {
	Status string `json:"status"`
	Result string `json:"result"`
}

func handleDo(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "POST only", http.StatusMethodNotAllowed)
		return
	}

	var req Request
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// 処理(シミュレーション)
	time.Sleep(100 * time.Millisecond)

	resp := Response{
		Status: "done",
		Result: "processed: " + req.Data,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(resp)
}

func main() {
	http.HandleFunc("/do", handleDo)

	log.Println("worker listening on :9090")
	if err := http.ListenAndServe(":9090", nil); err != nil {
		log.Fatal(err)
	}
}

Docker Compose で起動・停止

起動

$ docker compose up -d
Creating api-server ... done
Creating worker ... done

ログ確認

$ docker compose logs -f
api-server      | api-server listening on :8080
worker          | worker listening on :9090

通信テスト

$ curl -X POST http://localhost:8080/process \
  -H 'Content-Type: application/json' \
  -d '{"data": "hello"}'

{"status":"done","result":"processed: hello"}

api-server がリクエストを受け、worker に転送。worker が処理を返し、api-server がクライアントに返す。すべてがコンテナ内のネットワークを通じて動作します。

停止と削除

$ docker compose down

ネットワーク設定とサービス検出

Docker Compose の内部ネットワーク(bridge)では、各サービスはサービス名をホスト名として解決できます。

仕組み 説明
Service Discovery Docker の組み込み DNS が http://worker:9090 を自動解決
Network Isolation 同じネットワーク上のコンテナ同士のみ通信可能。外部からは接続不可
Port Mapping ホスト側のポートと コンテナ側のポートを分離

api-server と worker は同一ネットワーク上にあるため、環境変数 WORKER_URL=http://worker:9090 で直接通信できます。

本番環境への展開を考える

Docker Compose はローカル開発とテスト環境向け。本番環境では Kubernetes や Docker Swarm で運用されるのが一般的。

ただし、本記事で作ったマルチステージビルド + コンテナネットワークの基本は変わりません。イメージを Docker Hub や ECR にプッシュ、本番環境で pull して起動するだけです。

まとめ

  • マルチステージビルドで Go バイナリを 20-30MB に圧縮。イメージサイズはコンテナ管理の第一歩
  • Docker Compose で複数サービスの起動順序とネットワークを宣言。depends_on と環境変数で依存関係を管理
  • Go の net/http Handler パターンでサービス間通信。HTTP クライアント・サーバーの基本に変わりなし
  • Docker の bridge ネットワークが Service Discovery を提供。サービス名で直接通信可能
  • 開発環境と本番環境で同じイメージを使うため、差異による不具合が減る
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次