ReactとGoで作るWebアプリの自分向けテンプレート

ReactとGoを使ってWebアプリを書くことが少しだけ増えたんですが、自分向けのメモ程度に作ったテンプレートを公開しておきます。

sandboxというリポジトリに気になったことを実験して放り込んでいます。
今回も特に頻繁に使うものでもないし、メンテする予定もないのでSandboxに置いておきました。

github.com

テンプレートのゴール

ゴールとしては、以下のように設定しました。

  • 最終的な成果物(本番向け)は、単一コンテナイメージになること
  • 開発でもコンテナを使うこと
  • 開発で利用するコンテナは、両方ともホットリロードされること

また、以下のような項目はやらない、もしくは考慮しないようにしています。

ディレクトリ構成

ディレクトリ構成はいたってシンプルな構成になっています。

.
├── Dockerfile # 本番向けのコンテナイメージ
├── backend # バックエンドアプリ(Golang)
├── frontend # フロントエンドアプリ(Vite+React+TypeScript)
└── docker-compose.yaml # 開発用のdocker-compose.yaml

dockerディレクトリで各種Dockerfileを管理するのも良いのですが、今回はシンプルさのためだけにこういった構成にしています。

frontendディレクトリを作成する

frontendディレクトリは、簡単に書くと

  1. Vite用のプロジェクトを作成する
  2. 開発用コンテナの定義を書く(Dockerfileなど)
  3. Viteの設定ファイルを書き換えてバックエンドにリクエストをプロキシさせる

のみで完結します。

1. Vite用のプロジェクトを作成する

ViteのScaffolding Your First Vite Projectを参考にしながら、frontendディレクトリを作成します。 以下は例ですが、package.jsonのnameがfrontendになってしまうので注意してください。

docker run \
  -it \
  --rm \
  -v $PWD:/app \ # カレントディレクトリをマウント
  -w /app \ # マウントしたディレクトリをWorkingDirectoryとして指定してこくことでcdなどは不要
  node:16-bullseye \
  yarn create vite frontend --template react-ts

2. 開発用コンテナの定義を作成する

開発で利用するフロントエンド用のコンテナのDockerfileとentrypoint.shを定義しておきます。
yarn install(npm iなど)をentrypoint.shで実行していますが、個人のお好みの場所でやっていいと思います。

FROM node:16-bullseye

# ENTRYPOINT.shを設定する
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

ENV APP_PATH /app
WORKDIR $APP_PATH

CMD ["yarn", "dev"]

Dockerfileに関しては特筆した何かはないので、必要に応じてカスタマイズすることで、 キャッシュの最適化のしやすさなどの恩恵が得られると思います。

entrypoint.shでは以下のようにyarn installを実行して、CMDに指定されているコマンドを実行するようにしています。

#!/usr/bin/env bash

yarn install --flat --frozen-lockfile

exec "$@" # CMDに指定されているコマンドを実行

3. Viteの設定ファイルを書き換えてバックエンドにリクエストをプロキシさせる

frontend/vite.config.tsを書き換えて、バックエンドアプリにリクエストをプロキシさせます。
これを書いておかないと、CORSでハマるのは間違いないので適切に設定しておくことをオススメします。

これが終わればフロントエンド側の準備は終了です。

export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: parseInt(process.env.PORT || '3000'),
    proxy:{
      '/api': {
        target: process.env.API_URL, // docker-compose.yamlで指定したURLに対してリクエストを投げさせる
        changeOrigin: true
      }
    }
  },
})

backendディレクトリを作成する

バックエンド側は特に依存するライブラリを追加するわけではないので、2ステップほどで完了です。

1. main.goを作成する

適当にmain.goを作成しておきます。
ここでポイントとしては、最終成果物を単一イメージにするため、Viteでビルドされたファイルを返せるようにhttp.FileServerを使うようにしておきます。

package main

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

type Pod struct {
    Namespace string `json:"namespace"`
    Name string `json:"name"`
    Status string `json:"status"`
}

func handleApiPods(w http.ResponseWriter, r *http.Request) {
    pods := []Pod{
        Pod {
            Namespace: "default",
            Name: "test-xxx",
            Status: "Running",
        },
        Pod {
            Namespace: "kube-system",
            Name: "coredns-yyy",
            Status: "Running",
        },
    }

    w.Header().Set("Content-Type", "application/json")
    result, err := json.Marshal(pods)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    w.Write(result)
}

func main() {
    http.Handle("/", http.FileServer(http.Dir("./public"))) # 本番向けイメージでViteでビルドされたファイルを返せるようにする
    http.HandleFunc("/api/pods", handleApiPods) # テスト用のエンドポイント

    port := os.Getenv("PORT")
    if (len(port) == 0) {
        port = "8080"
    }
    log.Println("Listening on :" + port + "...")
    err := http.ListenAndServe(":" + port, nil)
    if err != nil {
        log.Fatal(err)
    }
}

2. 開発用コンテナの定義を作成する

フロントエンドと同様にDockerfileとentrypoint.shを作成します。
Golang側でもできるだけ、ホットリロードしてほしいのでAirをインストールしておきます。

FROM golang:1.17-bullseye

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

ENV APP_PATH /go/src/app
WORKDIR $APP_PATH

# ホットリロードさせたいのでAirを入れる
RUN go install github.com/cosmtrek/air@latest
CMD ["air"]

また、こちらでも同様にentrypoint.shで依存関係を解決させます。
依存ライブラリが多い場合は、docker-compose.yamlでvolumesの定義をしっかり書いておいた方が良さそうです。

#!/usr/bin/env bash

go mod tidy

exec "$@"

これらのファイルができれば一旦は完了です。 go.modやgo.sumは開発用コンテナ起動時に作成されるはずですが、できない場合は自分でgo mod initなどを実行してください。

開発用のdocker-compose.yamlを作成する

frontendディレクトリとbackendディレクトリを作り終えたので、後はdocker-compose.yamlを作れば開発に必要なファイルの作成は終わりです。
ここもかなり手抜きをしているので、ボリュームのキャッシュなどをうまく利用したい人は随時追記するのが良いです。

version: '3.9'

services:
  frontend:
    build:
      context: frontend
    environment:
      PORT: 3000
      API_URL: http://backend:8080 # backendコンテナにリクエストされるように設定
    ports:
      - 3000:3000
    volumes:
      - ./frontend:/app
  backend:
    build:
      context: backend
    environment:
      PORT: 8080
    volumes:
      - ./backend:/go/src/app

これで開発環境は整いました。 ViteやAirを使っているため、どちらのコンテナでもうまくホットリロードができ、コンテナを一々再起動する手間がかなり省けているように感じています。

開発環境にアクセスする

docker-compose up -d を実行して、開発環境を起動します。 起動後に http://localhost:3000` にアクセスするとフロントエンドアプリが表示され、
http://localhost:3000/api/pods にアクセスするとバックエンドアプリにリクエストがいくのが確認できると思います。

本番向けのDockerfileを作成する

さて、本番向けのDcokerfileを作成します。
こちらも特筆するようなことはないですが、フロントエンド・バックエンドをそれぞれビルドして、最終成果物のイメージには必要最小限のファイルのみを渡すようにしています。
scratchにしていることで、最終成果物のファイルは数MB程度のイメージになるため、非常に軽量です。

# Backend
FROM golang:1.17-bullseye as backend
WORKDIR /go/src/app
# 今回は外部ライブラリを使用していないため、go.sumが存在しないが、存在する場合は以下のようにしておく
# COPY backend/go.mod backend/go.sum ./
COPY backend/go.mod ./
RUN go mod tidy
COPY backend .
ARG CGO_ENABLED=0
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build \
    -o /go/bin/main \
    -ldflags '-s -w'

# Frontend
FROM node:16-bullseye as frontend
WORKDIR /app
COPY frontend/package.json frontend/yarn.lock ./
RUN yarn install --frozen-lockfile --ignore-optional
COPY frontend .
RUN yarn build

# App
FROM scratch
WORKDIR /app
COPY --from=backend /go/bin/main ./main
COPY --from=frontend /app/dist ./public
CMD ["./main"]

以下のようなコマンドでイメージをビルドして、動作確認すれば分かるかと思いますが、単一イメージで動作できるようになっています。

docker build -t react-golang .
docker run --rm -p 8080:8080 react-golang

おわりに

自分向けのReactとGoで作るWebアプリテンプレートを簡単に紹介しました。 説明はかなり省いていることもあるので、sandboxリポジトリの内容を見ながら、動かしながら試してもらえると良さそうです。