とりあえず Docker compose で Web + DB やってみる

## docker compose とは

(雑説明)

複数の Docker イメージを起動して、ネットワーク接続して、1つのアプリがやっと動く、みたいなことは珍しくない構成です。(というかそれが一般的)

そんな「このコンテナとこのコンテナを起動して、こうやってネットワークをつなぐ」を手打ちでやると、起動も停止も大変面倒なのは想像がつきます。

それをファイルにまとめたら、サッとみんな起動・停止できる、それが docker compose です。

## インストール

とりあえずコマンドを呼んでみよう。

```sh
docker compose --help
# docker: 'compose' is not a docker command.
# See 'docker --help'
```

ん？無い？

どうやらインストールしないといけないようです。公式によるインストール方法マニュアルはこちら。

https://docs.docker.com/compose/install/

僕は rootless で入れているので、apt とかを使う方法だと多分効きません。[以前別記事で buildx のインストールをしたとき](https://qiita.com/aKuad/items/caa0c1884c9c347e54de)と同じ要領で、マニュアルインストールでいきます。

:::note warn
パッケージマネージャで自動アップデートされないので、rootless モードである、実験用途であるなどの事情がなければ、推奨されない方法です。
:::

Docker の GitHub リポジトリに行き、Releases を確認。

https://github.com/docker/compose/releases

記事執筆時点で v2.31.0 が最新。ご自身の OS、アーキテクチャに合ったバイナリをダウンロードします。僕は Linux の x86_64 です。

ダウンロードしたバイナリを、所定の場所に配置して実行権限を付与し、準備完了。

(以下は Linux の場合)

```sh
mv <ダウンロードしたファイル> ~/.docker/cli-plugins/docker-compose
chmod +x .docker/cli-plugins/docker-compose
```

再び `--help`。

```sh
docker compose --help
#
# Usage:  docker compose [OPTIONS] COMMAND
#
# Define and run multi-container applications with Docker
#
# ...
```

help が出ました。おk。

## まず単品コンテナで

[これまた別記事でやった自作 WebApp の立ち上げ](https://qiita.com/aKuad/items/0bef8ba384a79c43fdec)を、compose に書いてみます。

単品でのビルドおよびコンテナ立ち上げのコマンドは以下。

```sh
docker build -t mozjpeg-webui .
docker run --rm -it -p 8000:8000 mozjpeg-webui
```

`compose.yaml` に書くとこんな感じ。

```compose.yaml
services:
  mozjpeg-webui:
    build: .
    ports:
      - 8000:8000
```

:::note
compose は、デフォルトでファイル名 `compose.yaml` or `docker-compose.yml` を探し実行します。拡張子は `.yml` でも `.yaml` でも OK。

[公式チュートリアルでは、`compose.yaml` が推奨](https://docs.docker.com/compose/intro/compose-application-model/#the-compose-file)のようです。(以前はたしか `docker-compose.yml` だった)
:::

立ち上げ。(カレントは `compose.yaml` がある場所で)

```sh
docker compose up
# [+] Building 0.0s (0/1)
# [+] Running 0/0
# [+] Building 0.3s (22/22) FINISHED
# ...
# Attaching to mozjpeg-webui-1
# mozjpeg-webui-1  | INFO:     Will watch for changes in these directories: ['/home/mozjpeg-webui/src']
# mozjpeg-webui-1  | INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
# mozjpeg-webui-1  | INFO:     Started reloader process [1] using StatReload
# mozjpeg-webui-1  | INFO:     Started server process [8]
# mozjpeg-webui-1  | INFO:     Waiting for application startup.
# mozjpeg-webui-1  | INFO:     Application startup complete.
```

`compose.yaml` で指定したイメージがビルド (build) またはプル (pull) され、イメージからコンテナが作成 (create) されます。

(上では、Buildx のキャッシュのおかげで、一瞬でビルド完了しています。)

FastAPI を使った WebApp なので、FastAPI のログが出ています。`http://localhost:8000` にアクセスすると、無事 WebAPP にアクセスできました。

`Ctrl + C` でコンテナをシャットダウン。

コンテナは残った状態で停止します。残ったコンテナを消すには以下のコマンド。

```sh
docker compose rm
# ? Going to remove mozjpeg-webui-mozjpeg-webui-1 (y/N) 
```

y を入力して削除実行。

```sh
# ? Going to remove mozjpeg-webui-mozjpeg-webui-1 Yes
# [+] Removing 1/0
#  ✔ Container mozjpeg-webui-mozjpeg-webui-1  Removed
```

## 本題 - Web コンテナと DB コンテナの連動

上のように 1つのコンテナの起動を規定するのにも使えますが、ここからが真骨頂。複数のコンテナを動かしてみます。

WebAPP でよくある形式、Web サーバとデータベースサーバのあの形をやってみます。

```mermaid
stateDiagram
    direction LR
    Client --> Web
    Web --> DB
```

### 仕様

以下のようなエンドポイントを持つ WebAPI を作ります。

- `/set/<id>`
  - `<id>` のフラグをセット (登録) する
  - `<id>` は数字、以降も同様
- `/reset/<id>`
  - `<id>` のフラグをリセット (解除) する
- `/get/<id>`
  - `<id>` のフラグがセットかリセットかを返してくれる

よく例として ToDo リストがあったりしますが、こいつはもっと単純な例です。HTML も絡みません。

### Web サーバ実装

データベースは PostgreSQL を使います。(選定理由は、MySQL は触ったことがあるが、PostgreSQL は無い、というだけ。)

Web サーバのランタイムは Deno を使います。[Deno 公式ドキュメントに、PostgreSQL にアクセスするサンプルがある](https://docs.deno.com/runtime/tutorials/connecting_to_databases/#postgres)ので、有り難く参考にさせてもらいます。

今回、Deno はメインでないので軽い実装に。リクエスト body は使わず URL パスパラメータのみ、レスポンスは JSON でも HTML でもなくプレーンテキストです。

:::note alert
簡単のため、かなり雑に作ったコードです。以下の問題を抱えているので、このコードを何も考えず使用するのは大変危険です。

- SQL インジェクション対策がされていない
  - ``queryArray(`INSERT INTO ids VALUES (${id_to_set});`)``
  - ↑の `${id_to_set}` 入力をコントロールすると、任意の SQL を実行できてしまう
- 数字入力のみ受け付けるところ、そのチェックがされていない
  - 入力次第で、本来クライアントエラーであるところがサーバエラーとなります
- コードにパスワード直書き
  - 本当なら環境変数を使うとかの配慮をすべき
  - DB はホスト (外ネットワーク) につなげないとはいえ、セキュアな部分はそんな雑に扱うべきでない
:::

<details><summary>完成品</summary>

```server.ts
import { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";

// Database init
const client = new Client({
  // hostname: "localhost",  // コンテナ上でなくローカル上なら localhost
  hostname: "postgres",
  port: 5432,
  user: "user",
  database: "test",
  password: "P05T6RE5",
  tls: { enabled: false }
});
await client.connect();
await client.queryArray("CREATE TABLE ids (id integer);")


// Server process
Deno.serve(async request => {
  const url = new URL(request.url);

  // ID set
  if(url.pathname.startsWith("/set")) {
    const id_to_set = url.pathname.split("/", 3)[2];  // '/set/<id_to_set>'
    await client.queryArray(`INSERT INTO ids VALUES (${id_to_set});`);
    return new Response(`Set OK - ID ${id_to_set}`, { status: 200 });
  }

  // ID reset
  if(url.pathname.startsWith("/reset")) {
    const id_to_reset = url.pathname.split("/", 3)[2];  // '/set/<id_to_reset>'
    await client.queryArray(`DELETE FROM ids WHERE id = ${id_to_reset}`);
    return new Response(`Reset OK - ID ${id_to_reset}`, { status: 200 });
  }

  // Get status of specified ID
  if(url.pathname.startsWith("/get")) {
    const id_to_get= url.pathname.split("/", 3)[2]; // '/get/<id_to_get>'
    const result = client.queryArray(`SELECT id FROM ids WHERE id = ${id_to_get}`);
    if((await result).rowCount !== 0) {
      return new Response(`ID ${id_to_get} is set`, { status: 200 });
    } else {
      return new Response(`ID ${id_to_get} is reset`, { status: 200 });
    }
  }

  // Otherwise
  return new Response("Not found", { status: 404 });
});
```

</details>

### Web サーバ `Dockerfile`

Deno イメージをベースに、サーバのソースをコピー。あとは起動するだけ。

```Dockerfile
FROM denoland/deno

COPY ./server.ts server.ts

CMD ["run", "--allow-net", "--allow-env", "server.ts"]
```

### `compose.yaml` 実装

Web サーバのイメージは、上の `Dockerfile` から構築するよう指示。

データベースのイメージは、Docker 公式で提供されている [`postgres`](https://hub.docker.com/_/postgres) を使うだけで OK。パスワード等は環境変数設定から可能。

他、ネットワーク設定など入れてできあがり。

:::note warn
とりあえず動いた、というようなコードです。もっと良い書き方があるかもしれません。
:::

```compose.yaml
services:
  web:
    build: .
    depends_on:
      db: # ここは Docker ドキュメント丸パクリ
        condition: service_healthy
        restart: true
    ports:
      - 8000:8000
    networks:
      - web-db-connection

  db:
    image: postgres
    healthcheck:  # ここも Docker ドキュメントほぼ丸パクリ
      test: ["CMD-SHELL", "pg_isready -U user -d test"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=P05T6RE5
      - POSTGRES_DB=test
    networks:
      - web-db-connection
    hostname: postgres


networks:
  web-db-connection:
  # オプション無しで動いた
```

### 動作チェック

アプリ起動。

```sh
docker compose up
```

`curl` で API にアクセスして確かめます。

```sh
curl http://localhost:8000/get/1
# ID 1 is reset

curl http://localhost:8000/set/1
# Set OK - ID 1

curl http://localhost:8000/get/1
# ID 1 is set

curl http://localhost:8000/reset/1
# Reset OK - ID 1

curl http://localhost:8000/get/1
# ID 1 is reset

curl http://localhost:8000/
# Not found
```

### 注意点

成功例だけまとめるとアッサリですが、裏では色々試行錯誤した点があってコッテリです。

- DB が完全に立ち上がってから Web を立ち上げないといけない (起動順に注意)
  - コンテナが立ち上がっただけでは不完全で、DB 接続受付が開始されたかチェックしないといけない
  - 幸運なことに、Docker 公式ドキュメントに PostgreSQL での例が載っていたので、これも丸パクリ
  - <https://docs.docker.com/compose/how-tos/startup-order/>
- コンテナ間通信のため `networks` やら `hostname` やらを定義しないといけない
  - 最初、その辺の設定無しで動かし、もちろんダメでした (Web サーバで ConnectionRefused 発生)
  - Docker ネットワーク周りをある程度勉強してからでないと、自分が何をしているかすら分からなくなってしまう恐れがあります
  - 結果 `networks` と `hostname` の設定だけでできるのが分かりましたが、それにたどり着くまで紆余曲折･･･どんな道を辿ったかも忘れちまった
- 再ビルドし忘れに注意 (2024/12/08 追記)
  - `compose.yaml` に `build:` があれば、`Dockerfile` を探してビルドしてくれます
  - しかし、ビルドして既に同名のイメージが存在する場合、ビルドせずそのイメージを起動してしまいます
  - ソースや `Dockerfile` に編集がなければ何ら問題ありませんが、そうでなく編集がある場合は厄介で、編集が反映されず起動されてしまいます
  - オプションを加え `docker compose up --build` とすると、毎回必ず `Dockerfile` のビルドをしてから起動してくれます

## おわり

Docker で、ネットワーク周りも仮想的に色々できるのが分かりました。これを仮想でないところを用意してやろうとすると、まあ大変だろうなぁというのは容易に想像がつきますね。

また一つ、Docker のコア機能を知れたと同時に、コンテナ環境の便利さに気付けました。