LoginSignup
2
1

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

Last updated at Posted at 2023-12-03

はじめに

こちらはklis(筑波大学情報学群知識情報・図書館学類) Advent Calendar 2023 4日目の記事です!
klisのアドベントカレンダーに参加するのは初めてです!よろしくお願いします〜

自己紹介

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

前置き

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

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

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

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

環境

モダンな技術を使うに当たり、最終的には本番環境(VPS)では以下のみの技術で完結させる必要があります。

  • PHP
  • (Vanilla) JS1
  • CSS
  • Linux実行ファイル

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

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

フロントエンド

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

まず、作業ディレクトリの中でNext.jsアプリケーションを構築していきます。

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

こんな感じになります

  .
+ └── 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を変更します

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

  module.exports = nextConfig;

Docker Compose

今回ローカルでは環境の再現にDocker Composeを使います

  .
+ ├── docker-compose.yml
  └── frontend
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を導入します!

docker-compose.yml
  version: '3'
  services:
    frontend:
      # ...
+   backend:
+     build: ./backend
+     working_dir: /var/www/html
+ ├── backend
+         ├── endpoint.php # ここにphpファイルを置く
+ │   └── Dockerfile
  ├── docker-compose.yml
  └── frontend
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はローカルでのインストールが可能6みたいなので、ルート権限がない自分でも実行できますね

本番環境
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でも同じ環境を用意します

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マイグレーション

データベースのマイグレーション7にはgolang-migrateを使います。

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

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

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 # サーバーに転送
  .
  ├── backend
+ ├── db
+ │   └── migrations
+ │       ├── 20231114_create_tables.up.sql
+ │       └── ...
  ├── docker-compose.yml
  └── frontend
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)
);
db/migrations/20231114_create_tables.down.sql
DROP TABLE IF EXISTS payment_histories;
DROP TABLE IF EXISTS payment_methods;
DROP TABLE IF EXISTS users;
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を使ってリバースプロキシ(ルーティングの役割)をします。

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の構成を書きます

  .
  ├── backend
+ ├── config
+ │   └── nginx
+ │       └── nginx.conf
  └── frontend
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 (失敗)

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

現実

ということを理想としていたのですが、 現実問題的にできなそうであることが発覚しました...
原因として、

  • go-migrateはTCPでDBに接続するが、本番環境のMySQLはUNIXソケットでの接続しか許可されていなそう
  • 本番環境は海外IPをbanしており、GitHub Actionsからssh接続ができない

積んだ :innocent:

GitHub Actions self-hosted runners

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

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

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

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

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

CI/CD

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

現状ラズパイ1台で動かしているため、少し時間がかかります...よって、先にdiff-checkで更新が必要かどうかを確認し、必要であれば各jobを動かすようにしました。

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

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

まとめ

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

  1. ここではNode.jsなどのランタイムに依存しない、ブラウザ側で実行される素のJavaScriptという意味合いで用いています

  2. 厳密に言えばあるといえばあるのですが、実用的ではないバージョンでしたので、本番環境での使用は見送りました。

  3. https://nextjs.org/docs/app/building-your-application/deploying/static-exports

  4. ちょっと語弊があります!ごめんなさい!

  5. ちなみに、SSGの説明として「事前にデータを取得しておく」のような話があると思いますが、今回はフロントエンドの構築にSSGを使っていてデータの取得は閲覧時にするようにしています。よって、ルーティングが施されたSPAとも言えるかもしれませんね

  6. https://getcomposer.org/doc/00-intro.md#locally

  7. https://zenn.dev/shinkano/articles/178cceea60c249

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1