制約で固められた環境をできるだけモダンに踏み倒す

# はじめに
こちらは[**klis（筑波大学情報学群知識情報・図書館学類） Advent Calendar 2023**](https://adventar.org/calendars/8957) 4日目の記事です！
klisのアドベントカレンダーに参加するのは初めてです！よろしくお願いします〜

## 自己紹介

本年度から長野高専より3年次編入しました**Bony_Chops**と申します！Web系の技術に興味があります

https://bonychops.com

# 前置き
自分は[データベース技術](https://kdb.tsukuba.ac.jp/syllabi/2023/GE70901/jpn)という科目を履修しており、その授業では最終成果としてDBを用いたWebサービスを開発します。
成果物は授業で提供されるVPSを使って動かします。以下のような環境・制約です(一部セキュリティの都合上あえて曖昧な表現をしています)。

- VPS
- OS: Linux系統(amd64)
- 接続方法: SSH
- PHP/HTML/CSS/JS

今回は、この授業の環境でなるべくモダンな開発体験をできるよう、環境を構築していきます！

:::note
タイトルは大げさに**固められた**と表現していますが、タイトル向けの過剰表現です。授業のためのサーバーを用意してくださった先生にはとても感謝しています🙇
:::

## 環境
モダンな技術を使うに当たり、最終的には本番環境(VPS)では以下のみの技術で完結させる必要があります。
- PHP
- (Vanilla) JS[^1]
- CSS
- Linux実行ファイル

逆に、ローカル環境ではVPSと同じ挙動をさせるために環境を再現する必要があります。

今回は、ローカル環境では**docker-composeを使って環境を再現**し、デプロイ時には**上記の制約に適合した形で適用されるGitHub Actions**を実装して対応していこうと思います！

```mermaid
graph LR
  local[ローカル環境] --"Push"--> githubAction(GitHub Actions) --"何かしらの変換"--> prod["本番環境(VPS)"]
```

# フロントエンド
近年のWeb開発といえば、やっぱりNext.jsですよね！しかし本番環境にはNode.jsがない[^7]ため使うことができません...
しかし、Next.jsは[**SSG**](https://zenn.dev/rinda_1994/articles/e6d8e3150b312d#ssg)として使うことができます[^2]！SSGとは、静的なウェブサイトを生成する技術で、今回でいう**ReactコンポーネントをHTML/CSS/JSのみに変換する技術**です[^3]！
今回はこの仕組みを使ってサイトを構築していきます[^4]。(反対に、冒頭で示したNext.jsを用いてサーバーを構築する技術を[SSR](https://zenn.dev/rinda_1994/articles/e6d8e3150b312d#ssr)と言います)

まず、作業ディレクトリの中でNext.jsアプリケーションを構築していきます。
```shell
❯ cd db-tech-app
❯ npx create-next-app@latest
✔ What is your project named? … frontend
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*

...

Success! Created frontend at db-tech-app/frontend
```

こんな感じになります
```diff
  .
+ └── frontend
+     ├── README.md
+     ├── next-env.d.ts
+     ├── next.config.js
+     ├── node_modules
+     ├── package-lock.json
+     ├── package.json
+     ├── postcss.config.js
+     ├── public
+     ├── src
+     ├── tailwind.config.ts
+     └── tsconfig.json
```

次に、SSGにするため、 `next.config.js`を変更します

```diff_javascript:next.config.js
  /** @type {import('next').NextConfig} */
  const nextConfig = {
+   output: "export",
  };

  module.exports = nextConfig;

```

## Docker Compose
今回ローカルでは環境の再現にDocker Composeを使います
```diff
  .
+ ├── docker-compose.yml
  └── frontend
```
```mermaid
graph TB
    node["Next.js(Node.js)"]
    subgraph Docker環境
        node
    end
```
```yml:docker-compose.yml
version: '3'
services:
  frontend:
    image: node:18-buster
    working_dir: /app
    volumes:
      - ./frontend:/app
    command: ["npm", "run", "dev"]
```
今回のこの環境はローカルでのデバッグ用途に使うため、Next.jsは普通にSSRとして(`npm run dev`)起動するように設定します。

# バックエンド
バックエンドでは普通にPHPを使います！しかし、生のPHPだと少々つらいところがあります。せめてパッケージマネージャぐらいは導入したいですよね。ということで、パッケージマネージャによく使われている[Composer](https://getcomposer.org/)を導入します！



```mermaid
graph TB
    node["Next.js(Node.js)"]
    php["PHP(Apache)"]
    subgraph Docker環境
        php
        node
    end
```

```diff_yaml:docker-compose.yml
  version: '3'
  services:
    frontend:
      # ...
+   backend:
+     build: ./backend
+     working_dir: /var/www/html
```

```diff
+ ├── backend
+ 　　　　　　　　├── endpoint.php # ここにphpファイルを置く
+ │   └── Dockerfile
  ├── docker-compose.yml
  └── frontend
```

```Dockerfile:backend/Dockerfile
FROM php:7.3-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql

# Composerで必要
RUN apt-get update && apt-get install -y git zip unzip

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

COPY . /var/www/html
WORKDIR /var/www/html

# Composerのルートでの実行を許可
ARG COMPOSER_ALLOW_SUPERUSER=1
```

また、今回は本番環境でもComposerをインストールする必要があります。幸い、Composerはローカルでのインストールが可能[^5]みたいなので、ルート権限がない自分でも実行できますね
```shell:本番環境
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'e21205b207c3ff031906575712edab6f13eb0b361f2085f1f1237b7126d785e826a450292b6cfd1d64d92e6563bbde02') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"

php composer-setup.php --install-dir=~/bin --filename=composer

php -r "unlink('composer-setup.php');"
```

# データベース
本番環境ではMySQLを使っているため、Docker Composeでも同じ環境を用意します

```mermaid
graph TB
    node["Next.js(Node.js)"]
    php["PHP(Apache)"]
    db["MySQL"]
    subgraph Docker環境
        php --> db
        node
    end
```

```diff_yaml:docker-compose.yml
  version: '3'
  services:
+   db:
+     image: mysql:5
+     platform: linux/amd64
+     environment:
+       MYSQL_DATABASE: your_db
+       MYSQL_USER: user
+       MYSQL_PASSWORD: password
+       MYSQL_ROOT_PASSWORD: rootpassword
+     ports:
+       - "3306:3306"
+     volumes:
+       - db_data:/var/lib/mysql
    frontend:
      # ...
    backend:
      # ...
+     environment:  # backendでDBアクセスできるようにする
+       MYSQL_HOST: db
+       MYSQL_DATABASE: your_db
+       MYSQL_USER: user
+       MYSQL_PASSWORD: password
+       MYSQL_ROOT_PASSWORD: rootpassword

+ volumes:
+   db_data:
```

# DBマイグレーション

データベースのマイグレーション[^6]には[golang-migrate](https://github.com/golang-migrate/migrate)を使います。

https://github.com/golang-migrate/migrate

[golang-migrate](https://github.com/golang-migrate/migrate)は名前の通り、Go製のmigrateツールなのですが、**Goはクロスコンパイルに対応**しています！よって、本番環境にコンパイラがなくても、**手元でビルドしてツールをインストールすれば良い**のです。

自分のローカル環境はM1 Mac(Apple Silicon: arm64)で宛先はLinux(amd64)ということで、**OSもアーキテクチャも違います**が問題ないのがGoの良いところですね〜

```shell
❯ cd $(mktemp -d) # 適当なディレクトリを用意する
❯ git clone https://github.com/golang-migrate/migrate.git
❯ cd migrate
❯ GOOS=linux GOARCH=amd64 make # OSとアーキテクチャを指定
❯ scp ./migrate user@example.com:~/bin # サーバーに転送
```

```diff
  .
  ├── backend
+ ├── db
+ │   └── migrations
+ │       ├── 20231114_create_tables.up.sql
+ │       └── ...
  ├── docker-compose.yml
  └── frontend
```

```sql:db/migrations/20231114_create_tables.up.sql
CREATE TABLE users (
	user_id BINARY(16) PRIMARY KEY,
	name VARCHAR(255) NOT NULL
);

CREATE TABLE payment_methods (
	payment_method_id BINARY(16) PRIMARY KEY,
	payment_method_name VARCHAR(255) NOT NULL
);

CREATE TABLE payment_histories (
	payment_history_id BINARY(16) PRIMARY KEY,
	user_id BINARY(16) NOT NULL,
	payment_method_id BINARY(16) NOT NULL,
	amount INT NOT NULL,
	purpose VARCHAR(255) NOT NULL,
	transaction_date DATE NOT NULL,
	FOREIGN KEY (user_id) REFERENCES users(user_id),
	FOREIGN KEY (payment_method_id) REFERENCES 		payment_methods(payment_method_id)
);
```

```sql:db/migrations/20231114_create_tables.down.sql
DROP TABLE IF EXISTS payment_histories;
DROP TABLE IF EXISTS payment_methods;
DROP TABLE IF EXISTS users;
```

```mermaid
graph TB
    node["Next.js(Node.js)"]
    php["PHP(Apache)"]
    db["MySQL"]
    migrate["go-migrate"]
    subgraph Docker環境
        php --> db
        migrate --"起動時のみ実行"--> db
        node
    end
```

```diff_yaml:docker-compose.yml
  version: '3'
  services:
    db:
      # ...
+     healthcheck:
+       test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+       interval: 10s
+       timeout: 5s
+       retries: 5
+   migrate:
+     image: migrate/migrate
+     volumes:
+      - ./db/migrations:/migrations
+     depends_on:
+       db:
+         condition: service_healthy
+         command: ["-path=/migrations/", "-database", "mysql://user:password@tcp(db:3306)/your_db", "up"]
    frontend:
      # ...
    backend:
      # ...

# ...
```

# 環境を再現する
本番環境では、`/api`に対するアクセスをbackend(PHP), それ以外をfrontend(Next.js)にルーティングします。
Docker Composeを使ってこれを再現するために、Nginxを使ってリバースプロキシ(ルーティングの役割)をします。

```mermaid
graph LR
    p(ホストPC)
    nginx[Nginx]
    node["Next.js(Node.js)"]
    php["PHP(Apache)"]
    migrate["go-migrate"]
    db["MySQL"]
    p --":8080 -> :80"-->nginx
    subgraph Docker環境
        migrate --"起動時のみ実行"--> db
        nginx --"/api -> :80 "--> php
        nginx --"/* -> :3000"--> node
        php --> db
    end
```

```diff_yaml:docker-compose.yml
  version: '3'
  services:
    db:
      # ...
    frontend:
      # ...
    backend:
      # ...
    nginx-proxy:
      image: nginx
      volumes:
        - ./config/nginx:/etc/nginx/conf.d
      ports:
        - "8080:80"
      depends_on:
        - frontend
        - backend

# ...
```

`config/nginx/nginx.conf`にNginxの構成を書きます

```diff
  .
  ├── backend
+ ├── config
+ │   └── nginx
+ │       └── nginx.conf
  └── frontend
```

```config:config/nginx/nginx.conf
server {
    listen 80;
    server_name example.com;

    location /api {
        proxy_pass http://backend:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        proxy_pass http://frontend:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;
}
```

# CI/CD (失敗)

```mermaid
graph TD
  nb["node-build"]
  nd["node-deploy"]
  pd["php-deploy"]
  pi["php-install"]
  mg["migration"]
  subgraph GitHub Actions
    s{"開始"} --> nb
    s --> pd
    nb --> nd
    pd --> pi
    nd --> mg
    pd --> mg
  end
```

こんな感じのフローを組んで行きたいと思います！

# 現実
**ということを理想としていたのですが、** 現実問題的にできなそうであることが発覚しました...
原因として、
- go-migrateはTCPでDBに接続するが、本番環境のMySQLはUNIXソケットでの接続しか許可されていなそう
- 本番環境は海外IPをbanしており、GitHub Actionsからssh接続ができない

積んだ :innocent: 

# GitHub Actions self-hosted runners
諦めたくないので色々調べた結果、どうやら任意環境でActionsを走らせる**self hosted runners**というものがあるそうです。

同日公開の記事で以下を書きましたので、ぜひこちらもご覧ください。

https://qiita.com/BonyChops/items/a617e0f6129056de98f7?updated

上記の記事の方法に則って、ワークフローのYAMLを書き換えます。

```diff_yaml
  jobs:
    node-build-and-deploy:
-     runs-on: ubuntu-latest
+     runs-on: self-hosted
```

上記を使ってGitHub Actionsを家のRaspberry Piで動くようにしたところ、無事VPSに接続できるようになりました。

# CI/CD
最終的に以下のような構成にしました。

```mermaid
graph TD
  dc["diff-check"]
  nb["node-build-and-deploy"]
  pd["php-deploy"]
  subgraph GitHub Actions
    s{"開始"} --> dc
    dc --"frontend/に変更があった場合"--> nb
    dc --"backend/に変更があった場合"--> pd
  end
```
現状ラズパイ1台で動かしているため、少し時間がかかります...よって、先にdiff-checkで更新が必要かどうかを確認し、必要であれば各jobを動かすようにしました。

```yaml
name: Deploy

on:
  push:
    branches:
      - main # Set a branch name to trigger deployment

jobs:
  diff-check:
    runs-on: self-hosted
    # Map a step output to a job output
    outputs:
      node-diff-check: ${{ steps.node-diff-check.outputs.diff }}
      php-diff-check: ${{ steps.php-diff-check.outputs.diff }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: node-diff-check
        run: |
          DIFF=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }} -- frontend/)
          echo "diff=$DIFF" >> $GITHUB_OUTPUT
      - id: php-diff-check
        run: |
          DIFF=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }} -- backend/)
          echo "diff=$DIFF" >> $GITHUB_OUTPUT
  node-build-and-deploy:
    runs-on: self-hosted
    needs: diff-check
    if: needs.diff-check.outputs.node-diff-check
    defaults:
      run:
        working-directory: frontend
    steps:
      - uses: actions/checkout@v4

      - uses: ./.github/actions/prepare-ssh
        with:
          ssh_secret: ${{ secrets.SSH_SECRET }}
          ssh_config: ${{ secrets.SSH_CONFIG }}

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: Cache node modules
        id: cache-npm
        uses: actions/cache@v3
        env:
          cache-name: cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: ~/.npm
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-

      - name: npm ci
        run: npm ci

      - name: npm run build
        run: npm run build
        env:
          basePath: ${{ secrets.FRONTEND_BASE_URL }}
          NEXT_API_SERVER: ${{ secrets.NEXT_API_SERVER }}
      - name: Upload Artifact
        uses: actions/upload-artifact@v3
        with:
          name: frontend-out
          path:
            frontend/out
        # rsyncによるデプロイ
      - name: Deploy
        run: |
          rsync -e "ssh -o 'ConnectTimeout 10' -o 'StrictHostKeyChecking no'" -avz --exclude '/api' --exclude '/secrets' --exclude '/enshu' --delete out/ target:$SSH_PATH
        env:
          SSH_PATH: ${{ secrets.SSH_PATH }}

  php-deploy:
    runs-on: self-hosted
    needs: diff-check
    if: needs.diff-check.outputs.php-diff-check
    defaults:
      run:
        working-directory: backend
    steps:
      - uses: actions/checkout@v4

      # SSHに関する設定を構成
      - uses: ./.github/actions/prepare-ssh
        with:
          ssh_secret: ${{ secrets.SSH_SECRET }}
          ssh_config: ${{ secrets.SSH_CONFIG }}

      - name: Prepare .env
        run: echo '$DEPLOY_ENV' | envsubst > .env
        env:
          DEPLOY_ENV: ${{ secrets.DEPLOY_ENV }}

      # rsyncによるデプロイ
      - name: Deploy
        run: |
          rsync -e "ssh -o 'ConnectTimeout 10' -o 'StrictHostKeyChecking no'" -avz --delete . target:$SSH_PATH/api
        env:
          SSH_PATH: ${{ secrets.SSH_PATH }}

      - name: Dependencies install
        run: |
          ssh -o 'ConnectTimeout 10' -o 'StrictHostKeyChecking no' target "PATH=\$PATH:~/bin; cd $SSH_PATH/api && composer install --no-dev --optimize-autoloader"
        env:
          SSH_PATH: ${{ secrets.SSH_PATH }}
  # 使おうと思っていたmigrate job :(
  # migrate:
  #   runs-on: self-hosted
  #   defaults:
  #     run:
  #       working-directory: db/migrations
  #   steps:
  #     - uses: actions/checkout@v4

  #     # SSHに関する設定を構成
  #     - uses: ./.github/actions/prepare-ssh
  #       with:
  #         ssh_secret: ${{ secrets.SSH_SECRET }}
  #         ssh_config: ${{ secrets.SSH_CONFIG }}

  #     - name: Mktemp
  #       id: mktemp
  #       run: |
  #         ssh -o 'ConnectTimeout 10' -o 'StrictHostKeyChecking no' target mktemp -d -t migrate-XXXXXXXXXX
  #       env:
  #         SSH_PATH: ${{ secrets.SSH_PATH }}

  #     - name: Deploy
  #       run: |
  #         rsync -e "ssh -o 'ConnectTimeout 10' -o 'StrictHostKeyChecking no'" -avz --delete . target:{{ outputs.mktemp }}
  #       env:
  #         SSH_PATH: ${{ secrets.SSH_PATH }}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/119cf956-0ae4-3eb8-1cad-04363d4f8102.png)

`frontend`のみの変更の場合、ちゃんとphp-deployがスキップされてます。

# まとめ
授業の環境であろうと、頑張れば自分好みの環境を作れます。ただ、それで得られる環境はその労力に見合ったものなのか...というのは個人によると思うので、強いこだわりがある人はぜひ任意の環境づくりの参考にしてください。

[^1]: ここではNode.jsなどのランタイムに依存しない、ブラウザ側で実行される素のJavaScriptという意味合いで用いています
[^2]: https://nextjs.org/docs/app/building-your-application/deploying/static-exports
[^3]: ちょっと語弊があります！ごめんなさい！
[^4]: ちなみに、[SSG](https://zenn.dev/rinda_1994/articles/e6d8e3150b312d#ssg)の説明として「事前にデータを取得しておく」のような話があると思いますが、今回はフロントエンドの構築にSSGを使っていてデータの取得は閲覧時にするようにしています。よって、ルーティングが施された[**SPA**](https://zenn.dev/rinda_1994/articles/e6d8e3150b312d#spa)とも言えるかもしれませんね
[^5]: https://getcomposer.org/doc/00-intro.md#locally
[^6]: https://zenn.dev/shinkano/articles/178cceea60c249
[^7]: 厳密に言えばあるといえばあるのですが、実用的ではないバージョンでしたので、本番環境での使用は見送りました。