Dockerを使用して、Web三層構造を実現する

## 概要

Web, APP, DBの３つのコンテナを作成し、それぞれのコンテナにWeb三層構造のそれぞれの責務に分割する。
`Nginx ⇄ APP ⇄ DB`

## 背景

この構成に変更する目的は、以下の二つ
1. 元の構成では、`app.jar`に静的リソースを含める必要があった
1. 負荷分散を実現したい

それぞれについて具体的に見ていく。

### 1. 元の構成では、`app.jar`に静的リソースを含める必要があった

元々は、SpringBootのThymeleafでフロントエンド部分を作成していた。
しかし、SpringBootのフロントエンド部分をThymeleafで実現した場合、Javaのビルド後の成果物(`app.jar`)に静的ファイルが含まれてしまうことになる。

この場合、フロントエンドのソースを変更した場合にも、`app.jar`を作り直す必要があり、ホットリロードが非効率になってしまう。

**フロントエンドのソースの変更後の具体的な手順**
1. Vue, TypeScriptのファイルをJavaScriptにトランスパイルし、適切なディレクトリに配置する
1. アプリケーションのビルドを行い、`app.jar`を作成する

フロントエンドのソースのみを変更した場合には、フロントエンドのトランスパイルのみで済ませたい。

### 2. 負荷分散を実現したい

元の構成の場合には、フロントエンドのソースも`app.jar`に含まれている。
そのため、フロントエンドとバックエンドとでサーバーを分割することができず、単純な画面描画などの場合にも同じサーバーへのアクセスが必要になってしまう。

## Web三層構造を実現する

まずは、最終的な成果物を示す。
<img width="500" alt="Web三層構造.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3791376/781ffaf1-e1c7-59b5-8d83-eafd30ec7cce.png">

**point**
1. Webブラウザからのリクエストは、Nginxコンテナが受け付ける
1. 必要であれば、APPやDBコンテナへのリクエストが行われる
1. フロントエンドのソースは、Node.jsコンテナ（仮名）でトランスパイルし、Nginxコンテナに配置する

**対応手順**
1. Node.jsのコンテナを作成する
1. Nginxコンテナを作成し、ソースを配置する
1. APPコンテナとの連携を図る
1. DBコンテナとの連携を図る
1. CORS問題を対応する

`APP ⇄ DB`については、以下で対応している。
- [APPコンテナの作成](https://qiita.com/basabasa0129/items/5bd6bd183b674590253d)
- [DBコンテナの作成](https://qiita.com/basabasa0129/items/7f38e53b34e7f413f512)
- [APPコンテナとDBコンテナの連携](https://qiita.com/basabasa0129/items/ba68b0cde27dff93096f)

## Node.jsのコンテナを作成する

Node.jsのコンテナの責務は、フロントエンドのソースをトランスパイルして、Nginxコンテナに配置すること。
フロントエンドのビルドは、Viteを使用している。

本番環境と開発環境とで、若干の挙動の違いがあるので、整理しておく。

**本番環境**
コンテナ内でフロントエンドのソースをトランスパイルし、Nginxコンテナとリソースを共有する。
トランスパイル後、コンテナは停止する。

**開発環境**
コンテナ内でフロントエンドのソースをトランスパイルし、Nginxコンテナとリソースを共有する。
ソースの変更に応じて、ホットデプロイを行うため、コンテナは起動し続ける。

### Dockerfile

Node.jsのDockerfileを示す。

```dockerfile
FROM node:21.7.1-bullseye-slim
WORKDIR /app

# キャッシュ機能を有効活用するためにパッケージ関連のものを先にCOPYしている。
COPY yarn.lock package.json .yarnrc.yml .
COPY .yarn/ ./.yarn/
RUN yarn install

COPY . .
CMD yarn run ${NODE_ENV}
```

**ベースイメージ**
Node.jsの公式イメージを使用する。
- 本番環境のサーバーとして利用するには向いていない
- ディストリビューションは、bullseye（Debian 11）

**キャッシュの有効化**
キャッシュの機能を有効に使うために、依存ファイルとソースファイルとで`COPY`の位置を変えている。
依存ファイルの変更は稀なので、通常時（ソースのみを修正した場合）には、6行目より前はキャッシュが利用される。

**`CMD`から実行されるスクリプト**
`package.json`は、以下のようになっている。
- 開発環境の場合は`Watch`モードでビルドを実行している
- 本番環境の場合は、ソースのビルドだけを実施し、スクリプトの実行が完了し次第、コンテナが停止する

```json
{
  "scripts": {
    "dev": "vite build --watch --mode dev",
    "production": "vue-tsc --noEmit && vite build --mode production",
  },
}
```

### docker-compose.yml

Node.jsのコンテナの`docker-compose.yml`を示す。

```yaml
web:
    container_name: web_container
    build: ./web
    volumes:
      - type: bind
        source: ./web/src
        target: /app/src
    environment:
      NODE_ENV: ${ENVIRONMENT}
```

**バインドマウント**
コンテナ上に、フロントエンドのソースをバインドマウントしている。
開発サーバーでは、ここで最新のフロントエンドのソースを検知し、フロントエンドのリビルドを実施する。

## Nginxコンテナを作成し、リソースを配置する

Nginxコンテナの責務は、Node.jsのコンテナからビルド済みのリソースを受け取り、配信すること。

### Dockerfile

NginxのDockerfileを示す。（Nginxについてはあまり深掘りしていないので、設定内容も軽め...）

```dockerfile
FROM nginx:latest
COPY ./default.conf /etc/nginx/conf.d/default.conf
```

### docker-compose.yml

Nginxの`docker-compose.yml`を示す。

```yaml
 web:
    container_name: web_container
    build: ./web
    volumes:
      - type: bind
        source: ./web/src
        target: /app/src
      - type: volume # 追加
        source: web-source
        target: /app/dist
    environment:
      NODE_ENV: ${ENVIRONMENT}
  nginx:
    container_name: nginx_container
    build: ./nginx
    volumes:
      - type: volume
        source: web-source
        target: /app
    ports:
      - 3000:80
    depends_on: # Nginxコンテナが先に起動しても問題ないため、depends_onとしている。
      - web
volumes:
  web-source:
```

**ボリュームマウント**
NginxとNode.jsのコンテナ間でのビルド済みのフロントエンドのソースの共有は、ボリュームマウントで行なっている。
そのため、Node.jsのコンテナでビルドしたリソースは、Nginxコンテナから参照できる。

**コンテナの起動順**
一応、Node.jsコンテナ、Nginxコンテナの順で起動するように設定している。
（Nginxコンテナでは、リソースが配置されるまでは404となるだけなので、あまり重要ではないが...）

## APPコンテナとの連携を図る

Axiosを使用して、フロントエンドからバックエンドへのリクエストを可能にする。
APPコンテナの設定については、こちらを参照。（TODO）

以下のように、RestControllerのパスにリクエストを投げるだけ。
```typescript
onMounted(() => {
  axios
    .get('/sample') // エンドポイントにリクエストを投げる。
    .then((response: AxiosResponse) => {
      console.log(response);
    })
    .catch((err: AxiosError) => {
      console.log(err);
    });
});
```

## CORS問題を対応する

CORSとは、ブラウザが別のオリジンに対してJavaScriptによるリクエストを送信した場合に、そのリクエストをブロックするかどうかを設定するためのもの。（[詳細](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)）

特に設定をしない場合、Axiosによるリクエスト時に以下のエラーとなった。

```bash
Access to XMLHttpRequest at 'http://localhost:8080/sample' from origin 'http://localhost' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
```

API側（SpringBoot）とNginx側のそれぞれで対応する必要がある。

1. Webブラウザは、プリフライトリクエストを送信する（`method: OPTIONS`）
1. レスポンスヘッダーからCORSの設定を確認し、元のリクエストを送信するかどうかを決める


### SpringBootでCORS設定を行う

CORSは、API側を保護するためのものであり、アクセス許可についてはAPI側で設定を行う。
リクエスト元では、この許可しているCORS設定に適合するようなリクエストを送る必要がある。

[こちら](https://b1san-blog.com/post/spring/spring-cors)を参考にした。

```java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

  /**
   * CORSの設定を許可する。
   */
  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/api/**").allowedOrigins("http://localhost:3000")
        .allowedMethods("GET", "POST", "PUT", "DELETE").allowedHeaders("*");
  }
}
```

### NginxでCORSを対応する

Nginxの設定ファイル（`default.conf`）に以下を追加した。
`/api`へのリクエストの場合には、APPサーバーへのリクエストを中継している。

```conf
location /api/ {
    proxy_pass http://localhost:8080;
}
```

## 参考文献

- https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
- https://b1san-blog.com/post/spring/spring-cors