この記事は書きかけです。
概要
皆さん Web はお好きでしょうか。筆者はずっとフロントと esbuild プラグインばかりを書いてきてバックエンドはサボってきたのでそろそろバックエンドを触れるようになろうとおもいました。この記事はバックエンドに入門するために行ったことの記録です。
今回は Docker Compose を用いてすべてのコンポーネントを作成します。すなわち nginx、API サーバー (Go)、データベース (PostgreSQL)、Redis をそれぞれ別のコンテナで作成しルーティングします。
成果物のレポジトリは以下のものになります。
Step 1. 仮設 API サーバーを作る
サーバーを書く
今回は REST API サーバーを Go で書いていきます。まず api
ディレクトリで go mod init main
してプロジェクトを作り、main.go
にサーバーのコードを記述していきます。一旦 /ping
に対するリクエストに応答するだけのサーバーを作ります。簡単のためコマンドライン引数を扱わずに環境変数を使います。
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func ping(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "pong")
}
func main() {
addr := os.Getenv("ip")
port := os.Getenv("port")
if addr == "" {
addr = "0.0.0.0"
}
if port == "" {
port = "8080"
}
http.HandleFunc("/ping", ping)
log.Printf("Server listening on %v:%v", addr, port)
log.Fatal(http.ListenAndServe(addr+":"+port, nil))
}
この状態で go run main.go
をするとサーバーの待ち受けが開始します。問題がなければ localhost:8080/ping
に GET することで pong と返ってくるはずです。
私の環境では docker compose でログを見るとき fmt.Printf
を使うとフラッシュされずログが出力されませんでしたが、log.Printf
を使うことで出力されるようになりました。
Docker, Docker Compose の設定
次に開発環境の Dockerfile を書きます。練習なので root ユーザーでいいとします。 Go のインストールや PATH の設定を行っておきます。
FROM ubuntu:24.04 AS base
RUN apt update && apt upgrade -y
FROM base AS dev
ARG USERNAME
WORKDIR /tmp
RUN apt install -y wget tar
RUN wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
RUN tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
ENV PATH $PATH:/usr/local/go/bin
WORKDIR /api
CMD ["/bin/bash"]
次にプロジェクトのルートディレクトリで docker-compose.yml
を書きます。
services:
api-dev:
build:
context: './api'
dockerfile: './Dockerfile'
target: dev
volumes:
- type: bind
source: ./api
target: /api
working_dir: /api
ports:
- target: 8081
published: 8081
tty: true
stdin_open: true
これにより開発用のコンテナの設定ができました。今回はローカルにある go
を使ってしまうのであまり使いませんが......。コンテナを立ち上げて go run
すると待受が開始するはずです。今回はコンテナの 8080 ポートをホストの 8080 ポートに転送しているので先程と同様に localhost:8080
でアクセスできるはずです。
$ docker compose up api-dev -d
$ docker compose exec api-dev /usr/local/go/bin/go run /api/main.go
$ curl localhost:8080/ping
# pong
本番環境を作る
開発環境ができたので本番環境を作っていきます。Dockerfile に build
ステージを追加してビルドを実行します。さらに prod
ステージを追加して build
ステージで作成したバイナリをコピーしてきます。これにより build
ステージに開発環境は入らずバイナリだけが存在する状況にします。
# 追加
FROM dev AS build
COPY . /api
RUN go clean
RUN go build -o /tmp/main /api/main.go
FROM base AS prod
COPY --from=build /api/main /api/server
CMD ["/api/server"]
docker-compose.yml
にもサービスを追加しておきます。
# 追記
services:
api:
build:
context: './api'
dockerfile: './Dockerfile'
target: prod
ports:
- target: 8080
published: 8080
これで docker compose up api
するだけでサーバーが待ち受けしてくれるようになりました。
Air を導入する
Air は Go のライブリロードを提供するツールです。ソースファイルの変更が検出されると再ビルド・実行を自動で行ってくれます。API サーバーの編集を試行錯誤しているときに便利です。
$ go install air
$ air init
次に Dockerfile を編集します。
WORKDIR /tmp
RUN apt install -y wget tar
RUN wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
RUN tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
ENV GOPATH /root/go
ENV PATH $PATH:/usr/local/go/bin:$GOPATH
RUN go install github.com/air-verse/air@latest
WORKDIR /api
COPY ./go.mod ./go.sum ./
RUN go mod download
CMD ["/root/go/bin/air"]
2. nginx を設定する
Docker Compose のコンテナ間ネットワークの動作確認
コンテナ間ネットワークの知識がなかったので動作確認をしました。まず適当にクライアント用コンテナを用意します。
FROM ubuntu:24.04
RUN apt update && apt upgrade -y
RUN apt install -y iputils-ping net-tools dnsutils curl wget
# 追記
services:
client:
build:
context: './client'
dockerfile: './Dockerfile'
command: /bin/bash
tty: true
stdin_open: true
クライアントコンテナ内で色々実行してみます
$ ping api -c 3
PING api (172.23.0.3) 56(84) bytes of data.
64 bytes from web-backend-practice-api-1.web-backend-practice_default (172.23.0.3): icmp_seq=1 ttl=64 time=0.039 ms
64 bytes from web-backend-practice-api-1.web-backend-practice_default (172.23.0.3): icmp_seq=2 ttl=64 time=0.044 ms
64 bytes from web-backend-practice-api-1.web-backend-practice_default (172.23.0.3): icmp_seq=3 ttl=64 time=0.043 ms
--- api ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2047ms
rtt min/avg/max/mdev = 0.039/0.042/0.044/0.002 ms
$ nslookup api
Server: 127.0.0.11
Address: 127.0.0.11#53
Non-authoritative answer:
Name: api
Address: 172.23.0.3
$ curl api:8080/ping
pong
docker コマンドでネットワークも見てみます
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
b177407351c4 bridge bridge local
158aba97f065 host host local
83d9f6d78a10 none null local
d0c0268df987 web-backend-practice_default bridge local
api と client が同一の bridge ネットワークに接続されているということがわかります。DNS が設定されていて api
や client
のホスト名を IP アドレスに解決してくれるみたいです。
nginx を導入
# 追記
services:
nginx:
image: nginx:latest
ports:
- target: 3000
published: 3000
depends_on:
- api
tty: true
stdin_open: true
この状態で localhost:3000
にアクセスすると nginx の welcome ページが表示されます。次にリバースプロキシを設定して /api
へのアクセスを API サーバーへ転送するようにします。設定ファイルを記述し /etc/nginx/conf.d
にマウントします。
server {
listen 3000;
server_name localhost;
location /api/ {
proxy_pass http://api:8080/;
}
}
services:
nginx:
image: nginx:latest
ports:
- target: 3000
published: 3000
+ volumes:
+ - type: bind
+ source: './nginx'
+ target: '/etc/nginx/conf.d'
depends_on:
- api
tty: true
stdin_open: true
proxy_pass
の最後にスラッシュ /
をつけると /api/foo/bar
が api:8080/foo/bar
へ転送されるようになります。スラッシュを付けないと api:8080/api/foo/bar
へ転送されるようになります。私はこれに 15 分ぐらい嵌りました。
ここまで設定すると localhost:3000/api/ping
へのアクセスで pong と返ってくるようになります。
3. DB を追加する
Postgres を導入する
続いて DBMS を追加します。今回は PostgreSQL を使用します。理由はなんとなくモダンな気がするからです。まずは公式の Postgres イメージを用いて db
サービスを追加します。DB サーバーのユーザー名、パスワード、データベース名は .env
ファイルに書いておきます (Compose は勝手に .env
ファイルを読み込んでくれます)。POSTGRES_USER
, POSTGRES_DB
環境変数を設定しておくことで DB サーバー起動時にユーザー・データベースがあるかを調べない場合には作成してくれます。
# 追記
db:
image: postgres:16
ports:
- target: 5432
published: 5432
environment:
POSTGRES_USER: $POSTGRES_USER
POSTGRES_PASSWORD: $POSTGRES_PASSWORD
POSTGRES_DB: $POSTGRES_DB
次に api
サービスと api-dev
サービスにも環境変数の設定を追加します。nginx の設定時と同様にホスト名に db
を指定すると立ち上がっている postgres コンテナに接続できます。
services:
api:
build:
context: './api'
dockerfile: './Dockerfile'
target: prod
+ depends_on:
+ - db
+ environment:
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: $POSTGRES_USER
+ DB_PASS: $POSTGRES_PASSWORD
+ DB_NAME: $POSTGRES_DB
ports:
- target: 8080
published: 8080
# ...
api-dev:
build:
context: './api'
dockerfile: './Dockerfile'
target: dev
volumes:
- type: bind
source: ./api
target: /api
+ depends_on:
+ - db
+ environment:
+ PORT: 8081
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: $POSTGRES_USER
+ DB_PASS: $POSTGRES_PASSWORD
+ DB_NAME: $POSTGRES_DB
working_dir: /api
ports:
- target: 8081
published: 8081
# ...
4. API を作成する
次に API サーバーに DB を使う機能を実装していきます。まずはできるだけ簡単な実装をするために ORM や大規模なルーターライブラリを使わずに実装していきます。今回はパスパラメータを用意に扱えるようにするために gorilla/mux
を使用します。
サーバーの設計 (概要)
Repository モデルでは、サーバーを次のようなコンポーネントに階層的に分離して構成します。
- Router: HTTP リクエストをパスなどに基づいて Controller に振り分ける
-
Controller (Handler): HTTP リクエストのパース・レスポンスの作成を行う
- 処理の詳細は Usecase に分離する
- Usecase: 各リクエストに対応する一連の処理を行う
- Repository: DB に対する処理を行う
例えばユーザーと商品、ユーザーの購入した商品の発送状況を管理する API は次のように構成することができそうです。
サーバーの扱うデータのモデルを設計
今回は ToDo アプリを作ってみます。API の使用者から見たユーザーと ToDo を次のように定義します。ユーザーは name
によって識別され、ToDo は ref
によって識別されます。
データベースの設計
データベースでは API の外側とは異なりユーザー・ToDo は primary key である id
によって識別するようにします。これは id
が SERIAL
(連番; MySQL でいう AUTO INCREMENT
) であることから id
が推測されるのを回避するためです。
今回は DB の migration ファイルのような形で作成します。このスクリプトは冪等 (ソフトウェアの文脈での冪等 = 何度実行しても結果が変わらない) です。
#!/bin/bash
set -euo pipefail
function query {
psql -d "$POSTGRES_DB" -U "$POSTGRES_USER" -w -c "$1"
}
query 'DROP TABLE IF EXISTS users CASCADE;'
query 'CREATE TABLE IF NOT EXISTS users(
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);'
query 'DROP TABLE IF EXISTS todos CASCADE;'
query 'CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
ref UUID DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);'
API の設計
API は次のように設計します。
メソッド | エンドポイント | 機能 | body |
---|---|---|---|
(any) | /ping |
`pong と返答する | |
POST | /users |
ユーザーを作成 | {"name": string, "display_name": string} |
DELETE | /users/{userName} |
ユーザーを削除 | |
GET | /users/{userName}/todos |
ユーザーの ToDo 一覧を取得 | |
POST | /users/{userName}/todos |
ユーザーの ToDo を作成 | {"name": string} |
PATCH | /users/{userName}/todos/{todoRef} |
ToDo の状態を更新 | {"done": boolean } |
DELETE | /users/{userName}/todos/{todoRef} |
ToDo を削除 |
モデルの作成
Go のコードでは DB に対応する entity.User
, entity.Todo
と API に対応する model.User
, model.Todo
を作成します。
repository の作成
ユーザーの ToDo 一覧を取得する機能を例として見ていきます。repository は DB を操作する役割を持っており、このメソッドはユーザーの ID を指定して ToDo の一覧を返します。ポイントは次の点です。
-
sql.DB.Query
で複数行のクエリを発行できる- クエリ内に
$1
のように指定して可変長引数で実際の値を指定する- SQL injection 対策
-
sql.DB.QueryRow
で単一行のクエリを発行できる
- クエリ内に
-
sql.Rows.Next
で次の行を選択できる -
sql.Rows.Scan
で現在の行をパースできる-
sql.Rows.Scan
でパースする順番はsql.DB.Query
で指定した順になる
-
type TodoRepository struct {
db *sql.DB
}
func (r *TodoRepository) ListByUserID(userID uint) ([]entity.Todo, error) {
// クエリを発行
rows, err := r.db.Query(`SELECT id, ref, user_id, title, done FROM todos WHERE user_id = $1;`, userID)
if err != nil {
return nil, err
}
// クエリの結果の各行をパース
todos := make([]entity.Todo, 0)
for rows.Next() {
todo := entity.Todo{}
if err = rows.Scan(&todo.ID, &todo.Ref, &todo.UserID, &todo.Title, &todo.Done); err != nil {
return nil, err
}
todos = append(todos, todo)
}
return todos, nil
}
usecase の作成
次に usecase を見ていきます。usecase では API の入力である userName
を受け取って API の出力である model.Todo
のリストを返しています。複数の repository の機能を呼び出すことで一連の処理を実装しています。
type TodoUsecase struct {
userRepo UserRepository
todoRepo TodoRepository
}
func (u *TodoUsecase) ListByUser(userName string) ([]model.Todo, error) {
// ユーザー名からユーザー ID を取得
user, err := u.userRepo.GetByName(userName)
if err == sql.ErrNoRows {
return nil, NoUserError
} else if err != nil {
return nil, err
}
// ユーザー ID から ToDo 一覧を取得
todoEntities, err := u.todoRepo.ListByUserID(user.ID)
if err != nil {
return nil, err
}
// ToDo の entity から model に変換
todos := make([]model.Todo, 0, len(todoEntities))
for _, todoEntity := range todoEntities {
todos = append(todos, *model.NewTodoFromEntity(&todoEntity, userName))
}
return todos, nil
}
handler (contoller) を作成
handler では HTTP リクエストを処理しますが、実装の詳細は usecase に投げます。以下の例は少々複雑ですが、ToDo 一覧機能に対して net/http
の http.Handler
を実装しています。
type ListByUserNameHandler struct {
todoUsecase TodoUsecase
UserNameKey string
}
func NewListByUserNameHandler(todoUsecase TodoUsecase, userNameKey string) ListByUserNameHandler {
return ListByUserNameHandler{
todoUsecase: todoUsecase,
UserNameKey: userNameKey,
}
}
func (h ListByUserNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// パスパラメータの解析
vars := mux.Vars(r)
userName := vars[h.UserNameKey]
if userName == "" {
handler.RespondError(w, "User name must not be empty.", http.StatusBadRequest)
return
}
// ビジネスロジックは usecase に隠蔽
todos, err := h.todoUsecase.ListByUser(userName)
if err == NoUserError {
handler.RespondError(w, "User not found.", http.StatusNotFound)
return
} else if err != nil {
log.Printf("Failed to get user. %v", err)
handler.RespondError(w, "Failed to get user.", http.StatusInternalServerError)
return
}
// JSON を 200 で返す
res, err := json.Marshal(todos)
if err != nil {
log.Printf("Failed to marshal JSON: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(http.StatusOK)
w.Write(res)
}
リクエストしてみる
他の機能も実装していき簡易的なスクリプトで思った通りの結果になっているかを確認します。
PROTOCOL="${PROTOCOL:-http}"
HOST="${HOST:-localhost:8080}"
ORIGIN="$PROTOCOL://$HOST"
# ユーザーを作成
curl -sSL \
-X POST \
-H "Content-Type: application/json" \
-d '{"name": "user1", "display_name": "User 1"}' \
"$ORIGIN/users"
# ToDo を作成
curl -sSL \
-X POST \
-H "Content-Type: application/json" \
-d '{"title": "Todo 1"}' \
"$ORIGIN/users/user1/todos"
# ToDo 一覧を取得
curl -sSL \
-X GET \
"$ORIGIN/users/user1/todos"
5. 負荷テスト
K6 を使って負荷テストをしてみます。
細かい実装やフロントより先に k6 を使ってみたかったのでやります。まずは /api/ping
に対する負荷テストを書いてみます。GitHub からバイナリをダウンロードし、k6 new ping.js
でテンプレートを生成します (Docker にすればよかったですね)。
次の設定ファイルはユーザー数が 0 -(10秒)→ 100 -(10秒)→ 200 -(10秒)→ 0
と変化するように設定したものです。options
に設定したものはコマンドライン引数で上書きできるようなので、複数のテストで同じケースにすることができそうですね。
import http from "k6/http";
import { sleep } from "k6";
export const options = {
stages: [
{ duration: "10s", target: 100 },
{ duration: "10s", target: 200 },
{ duration: "10s", target: 0 },
],
};
export default function () {
http.get("http://localhost:3000/api/ping");
sleep(1);
}
実行結果
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: ping.js
output: -
scenarios: (100.00%) 1 scenario, 200 max VUs, 1m0s max duration (incl. graceful stop):
* default: Up to 200 looping VUs for 30s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)
data_received..................: 520 kB 17 kB/s
data_sent......................: 276 kB 9.1 kB/s
http_req_blocked...............: avg=65.95µs min=625ns med=4.79µs max=37.13ms p(90)=14.38µs p(95)=284.98µs
http_req_connecting............: avg=49.92µs min=0s med=0s max=37.02ms p(90)=0s p(95)=196.66µs
http_req_duration..............: avg=2.54ms min=507.41µs med=1.93ms max=13.99ms p(90)=4.82ms p(95)=5.82ms
{ expected_response:true }...: avg=2.54ms min=507.41µs med=1.93ms max=13.99ms p(90)=4.82ms p(95)=5.82ms
http_req_failed................: 0.00% ✓ 0 ✗ 3134
http_req_receiving.............: avg=78.46µs min=4.14µs med=52.86µs max=3.1ms p(90)=159.8µs p(95)=229.75µs
http_req_sending...............: avg=31.5µs min=2.21µs med=17.73µs max=1.58ms p(90)=62.26µs p(95)=104.86µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=2.43ms min=486.89µs med=1.85ms max=13.6ms p(90)=4.67ms p(95)=5.67ms
http_reqs......................: 3134 103.412894/s
iteration_duration.............: avg=1s min=1s med=1s max=1.03s p(90)=1s p(95)=1s
iterations.....................: 3134 103.412894/s
vus............................: 14 min=14 max=199
vus_max........................: 200 min=200 max=200
running (0m30.3s), 000/200 VUs, 3134 complete and 0 interrupted iterations
default ✓ [======================================] 000/200 VUs 30s
送られたHTTP リクエスト数が 3134、平均待ち時間が 2.54ms、失敗したリクエストの数が 0 であることなどがわかります。
今度は TODO リストを取得する負荷テストを書いてみます。setup
関数で初期化処理、teardown
関数で終了処理を書くことができます。初期化処理は VU 共通のため複数ユーザーを生成しておきます。
import http from "k6/http";
import { sleep } from "k6";
export const options = {
stages: [
{ duration: "10s", target: 100 },
{ duration: "10s", target: 200 },
{ duration: "10s", target: 0 },
],
};
const numUsers = 10;
const numTodos = 10;
export function setup() {
const userIds = [];
for (let i = 0; i < numUsers; i++) {
const res = http.post(`http://localhost:3000/api/users?name=user${i}`);
const userId = res.json().id;
if (typeof userId !== "number") {
throw new Error("userId is not a number");
}
for (let i = 0; i < numTodos; i++) {
http.post(`http://localhost:3000/api/users/${userId}/todos?title=aaa`);
}
userIds.push(userId);
}
return { userIds };
}
export default function ({ userIds }) {
const userId = userIds[Math.floor(Math.random() * userIds.length)];
http.get(`http://localhost:3000/api/users/${userId}/todos`);
sleep(1);
}
試しに VU 数を増やしてみると nginx のワーカー数が足りなくなるといった挙動が観察でき面白いです。
これ以降は未実装です。
ORM を使う
- SQLBoiler or gorm
API 仕様書を使う
- OpenAPI
Router を使う
- echo
セッションの実装
- redis を使ってセッションを実装
- TODO へのアクセスをユーザーでフィルタリング
- ログインは実装しない (このユーザーのセッションをくださいって言ったらもらえる)
- 名前を unique にする
キャッシュの実装
- redis を使ってキャッシュしてみる
自動スケール
- API サーバー分散できるようにする
- 分散 DB を使う (かも)
- 実際に auto scaling するかは微妙
(おまけ) フロントを作る
- 適当
- React か Next か Lit か VanillaJS か......
- Svelte とか Remix 使ってみてもいい
- Next とか Remix 使うには小さすぎるかも
(おまけ) Server sent events
- 複数タブで TODO を編集したら同期する
- フロントでの optimistic update が正当化される
TODO
- DB を永続化する (volume を使う)
- API サーバー起動時に DB サーバーの起動を待つようにする
- nginx 起動時に API サーバー起動を待つようにする or なくても起動するようにする
- CI を書く
他にやりたかったこと
- SQL のパフォーマンスチューニング
- ある程度でかくて複雑な DB じゃないとできないため
- GraphQL での API 実装
- graph 使うほどデータ自体が複雑でないため
- AWS などへの CD
- お金がかかるので......
参考記事・文献
- GoでWebサーバーを構築
- Docker Compose入門 (3) ~ネットワークの理解を深める~ | さくらのナレッジ
- docker-composeでNginx(リバースプロキシ)を立てて、ローカルでHTTPS通信をやってみた |SHIFT Group 技術ブログ
- Nginxのproxy_passを設定する時の注意点 #nginx - Qiita
- Go の net/http で Web サーバーを立てる #初心者 - Qiita
- go - How to execute an IN lookup in SQL using Golang? - Stack Overflow
- insert - PostgreSQL function for last inserted ID - Stack Overflow
- database/sql で INSERT したレコードのIDを取得する #Go - Qiita
- 負荷テストツール「k6」入門
- Test lifecycle | Grafana k6 documentation