はじめに
こちらは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
を変更します
/** @type {import('next').NextConfig} */
const nextConfig = {
+ output: "export",
};
module.exports = nextConfig;
Docker Compose
今回ローカルでは環境の再現にDocker Composeを使います
.
+ ├── docker-compose.yml
└── frontend
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を導入します!
version: '3'
services:
frontend:
# ...
+ backend:
+ build: ./backend
+ working_dir: /var/www/html
+ ├── backend
+ ├── endpoint.php # ここにphpファイルを置く
+ │ └── Dockerfile
├── docker-compose.yml
└── frontend
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でも同じ環境を用意します
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
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)
);
DROP TABLE IF EXISTS payment_histories;
DROP TABLE IF EXISTS payment_methods;
DROP TABLE IF EXISTS users;
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を使ってリバースプロキシ(ルーティングの役割)をします。
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
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接続ができない
積んだ
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 }}
frontend
のみの変更の場合、ちゃんとphp-deployがスキップされてます。
まとめ
授業の環境であろうと、頑張れば自分好みの環境を作れます。ただ、それで得られる環境はその労力に見合ったものなのか...というのは個人によると思うので、強いこだわりがある人はぜひ任意の環境づくりの参考にしてください。
-
ここではNode.jsなどのランタイムに依存しない、ブラウザ側で実行される素のJavaScriptという意味合いで用いています ↩
-
厳密に言えばあるといえばあるのですが、実用的ではないバージョンでしたので、本番環境での使用は見送りました。 ↩
-
https://nextjs.org/docs/app/building-your-application/deploying/static-exports ↩
-
ちょっと語弊があります!ごめんなさい! ↩
-
ちなみに、SSGの説明として「事前にデータを取得しておく」のような話があると思いますが、今回はフロントエンドの構築にSSGを使っていてデータの取得は閲覧時にするようにしています。よって、ルーティングが施されたSPAとも言えるかもしれませんね ↩