🧑‍🎓

【文化祭】決済/チケット システムをハイブリットクラウドで運営した話

2024/01/05に公開

2023年度に行われた所属高校の文化祭にて全校生徒,教職員,来場者,保護者にアカウントを与え決済/チケットシステムを運営したのでその技術的な話をまとめて行きたいと思います。

作ったもの

公開ページ

文化祭の概要やタイムテーブル、マップなどを掲載しています。
https://youtu.be/7BH9f2S0Cu8

マイページ

マイページからチケット発行や金券のQRコードのダウンロードなど様々な機能を利用することができます。
https://youtu.be/RQwOgH0tuR4

開発の目的

各ユーザー目線での開発目的です。

全ユーザー

・アカウントの作成
・アカウントの設定(名前,クラス,etc)
・アカウントの削除
・パスワードの再設定
・公開ページの閲覧

生徒サイド(一般)

・招待者用のチケットの発行
・PTAから配布されている金券のダウンロード
・アリーナのチケット枠の予約
※チケットや金券は全てQRコードです。

生徒サイド/教職員(運営)

・招待者用チケットのスキャン/管理
・金券のスキャン/管理
・動画ページのURL/公開範囲の管理
・アリーナのライブ配信ページのURL/公開範囲の管理
・一般公開ページのホップやテキストの管理
・特別招待チケットの作成/管理
・上記のロール割当機能
・ロールの一括管理機能

一般来場者/保護者

・アリーナのチケット予約機能

技術スタック

アプリケーション

自宅のkubernetes cluster上にDockerコンテナがデプロイされて動作しています。

WEBサーバー

NodeJS×Expressで全てのページが動作しています。Reactの採用も検討しましたが、本校のほぼ全生徒がChromebookを利用しておりブラウザサイドでのレンダリングが低速なユーザーが多いためマイページの一部の機能を除きサーバーサイドでのレンダリングを行っています。

APIサーバー

NodeJS×Graphqlで動作しています。ORMには定番のPrismaを使用しています。

グローバルIPアドレス検索(β版)

後述しますが、ユーザーのリクエストから最短位置のCDNを検索するためのサーバーです。こちらはGoを用いて構築されています。

インフラ&データベース

大体の機材をヤフオクで購入しています。

自宅kubernetes cluster

自宅に4台の物理サーバーを建て、Clusterを構築しています。また、物理筐体のメンテナンスに備え、全てのNodeが仮想化されておりProxmox上で動作しています。これにより定期的なVM全体のバックアップや物理筐体清掃時のライブマイグレーションが可能です。

また、自宅のKubernetes ClusterについてはKubernetes Meetup Tokyo 61で発表機会をいただいており、そちらでも詳しくお話しています。

https://www.youtube.com/watch?v=C4u2QtGymTw&t=8127s

自宅ネットワーク

YamahaRTX1200をNURO光のONUに接続しDMZとして公開することで各種サーバーを公開しています。また内部ネットワークにはラズパイ上に構築したOpenVPNサーバーからアクセスできます。これにより外出環境からでも安全なアクセスを実現しています。

自宅ストレージ

RAID1構成のNASが1つあります。VMのバックアップデータやログデータなどはこちらに保存されています。またrcloneを用いて学校が契約しているGoogle Driveに定期的に同期されており、自宅が燃えても安心な構成です。

CDN兼分散バックアップ用VPS

今回のWEBサイトは短期間に大量のアクセスが予想されるため静的データのアクセスを分散するためのCDN網を様々なクラウドサービス業者(AWS,さくらVPS,Xserver)のVPS上にて分散して構築しています。CDN網を自作した理由は将来的には動的な画像を瞬時にパージ可能なCDN網を実現するための実験を行なうためです。各VPSにはMicro Kubernetesがインストールされており単体Node上でMinIO(S3互換なオープンソースのブロックストレージソフトウエア)とNginxが動作しています。

データベース

さくらインターネットのVPS上でMySQLが動作しています。自宅が燃えてもデータが保護できる点や個人情報保護の観点からDBではオンプレミスを採用していません。

Cloudflare DNS

Proxyは行っておらずDNS機能のみを使用しています。変更の反映が早く、登録料金が無料であるため重宝しています。

開発スケージュール

2022年
11月 要件定義/アーキテクチャやライブラリの選定
12月 APIの仕様決定やインフラに関する検討
2023年
1月 Illustratorでマイページなどのデザインを開始
2月 マイページの基幹プログラムの作成を開始
4月 WEBサイトの機能について各所から承認がおりる
7月 マイページの基幹プログラムの作成が終了
8月前半 マイページの各種機能を作成
8月後半 k8sとVPSへの自動デプロイプログラムを作成/テストリリース
9月前半 追加機能の作成,機能に関する改修/負荷試験
9月後半 リリース

工夫した点

マイページのデザイン

デザインは全てIllustratorを用いて行いました。いつもデザインは行き当たりばったりになってしまうことが多く、初期段階から多くの人にアドバイスをいただいて制作しました。また開発時の手間をなるべく減らすため共通化可能なパーツを多く使うコンポーネント設計を心がけました。

初期のデザイン①


デザインを始めた当初は「ダークっぽいデザイン&英語」ならなんとなくかっこよくなりそうという安直な感じでデザインを始めました。しかし、黒は文化祭っぽくないなどのご意見をいただき色を再検討することになりました。

初期のデザイン②



色の検討は得意な方に任せている間に形だけでも作ってしまおうと思い入力部分のデザインやホップアップのデザインをある程度作りました。(上の2枚のデザイン案は結局実装することができなかった機能ですがコピペしたコンポーネントが他の機能でたくさん使われています。)

初期デザイン③


色の検討を経て洗練された最終案です。文化祭らしくかわいい色使いです。

マイページのフロントエンド

Illustratorで行ったデザインを今度はHTML&CSS&JSで再現していきます。コンポーネントの比率や整合性をあらゆる画面サイズで保てるように慎重に設計を行いました。また、後述しますがorganization機能用にアカウントを複数切り替えられる機能の実装が予定されていたため、そのためのアニメーションなども実装しました。

デザインを実装

https://youtu.be/ARvyZzOlZLc
*動画でのデザインは色を変える前の旧版になっています。
アニメーションの実装にはanimejsを使用しています。また、CSSの記述にはSCSSを利用しており一度書いたコードを再利用しやすい他、変数機能を用いて色の変更などに対応しやすくしています。

//全体の背景
$background: #e7f0f5;
//ブロックごとの背景
$block-background: #fff;
//ブロックの角丸の値
$square-radius: 1vh;
//全体のフォント
$default-font: "游ゴシック体", YuGothic, "游ゴシック Medium", "Yu Gothic Medium", "游ゴシック", "Yu Gothic", sans-serif;
//デフォルトのフォントカラー
$font-color: #323232;

https://animejs.com/

バックエンドとの接続

おそらく最も大変だった作業です。ReactもjQueryも使わずにvanillaのJSだけでSPAを実装しました。SPAにこだわった理由はいくつかありますが、やはり一番大きな理由はシームレスに機能の切り替えを行いたかったからです。画面の左側は使用する機能が変わっても変化しない部分なので一度受信した共通部分を無駄にせず読み込むのは使用する機能のHTML&CSS&JSにとどめたかったのです。一部ではありますがマイページの基幹部分のプログラムを供養しておきます。

main.js
const session = document.cookie.match(new RegExp("session=([^;]*);*"))[1];
let path = location.pathname.split("/");
function addjs(URL) {
  var el = document.createElement("script");
  el.className = "add-js";
  el.src = URL;
  document.body.appendChild(el);
}
function logout() {
  window.location.href = "/login/logout";
}
async function defaultPage() {
  let orgquery = `mutation Organizatonslist($session: String!) {
  organizatonslist(session: $session) {
    organization {
      organizationid
      name
      displayname
      ownerid
    }
  }
}`;
  let orgdata = await fetch("/api", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: orgquery,
      variables: {
        session: session
      }
    })
  });
  let orgdatajson = await orgdata.json();
  let orglist = await orgdatajson.data.organizatonslist.organization;
  for (let i = 0; i < orglist.length; i++) {
    let orglistformat = `<div class="user user-off" onclick=organization("${orglist[i].name}")>
          <img src="/mypage/images/component/user-icon.webp" class="user-icon">
          <div class="text">
            <p class="user-name">${orglist[i].displayname}</h1>
            <p class="user-info">Organization</p->
          </div>
          <img class="arrow" src="/mypage/images/component/right-arrow.svg">
        </div>`;
    document.getElementById("personalcard").insertAdjacentHTML("beforeend", orglistformat);
  }
  let query = `mutation Userfind($session: String!) {
  userfind(session: $session) {
    email
    category
    session
  }
}`;
  let data = await fetch("/api", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query,
      variables: {
        session: session
      }
    })
  });
  let userfind = (await data.json()).data.userfind;
  if (userfind.category === null) {
    window.location.href = "/login";
  }
  let queries = {
    students: `mutation Userinfo($session: String!) {
  userinfo(session: $session) {
    lastname
    firstname
    grade
    class
    number
  }
}`,
    teachers: `mutation Userinfo($session: String!) {
  userinfo(session: $session) {
    lastname
    firstname
    grade
    class
  }
}`,
    general: `mutation Userinfo($session: String!) {
  userinfo(session: $session) {
    lastname
    firstname
  }
}`
  };
  let datas = await fetch("/api", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: queries[userfind.category],
      variables: {
        session: session
      }
    })
  });
  let userinfo = (await datas.json()).data.userinfo;
  let affiliation = "";
  if (userfind.category === "students") {
    let gradestring = "H";
    if (userinfo.grade < 4) {
      gradestring = "M";
    } else {
      userinfo.grade = userinfo.grade - 3;
    }
    affiliation = gradestring + userinfo.grade + "-" + userinfo.class + " #" + userinfo.number;
  } else if (userfind.category === "teachers") {
    if (userinfo.class === null || userinfo.class === undefined) {
      let affiliation = "担当クラスなし";
    } else {
      let gradestring = "H";
      if (userinfo.grade < 4) {
        gradestring = "M";
      } else {
        userinfo.grade = userinfo.grade - 3;
      }
      affiliation = gradestring + userinfo.grade + "-" + userinfo.class;
    }
  } else if (userfind.category === "general") {
    affiliation = "Visitors";
  }
  let usercard = document.getElementById("user-on");
  usercard.insertAdjacentHTML(
    "afterbegin",
    `<img src="/mypage/images/component/user-icon.webp" class="user-icon">
          <div class="text">
            <p class="user-name">` +
      userinfo.lastname +
      " " +
      userinfo.firstname +
      `</h1>
            <p class="user-info">` +
      affiliation +
      `</p>
          </div>
          <img class="arrow" src="/mypage/images/component/under-arrow.svg">`
  );
  //roles
  let rolesquery = `mutation Roles($session: String!) {
  roles(session: $session) {
    roles {
      roleid
      name
      displayname
      sort
      position
    }
  }
}`;
  let rolesdata = await fetch("/api", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: rolesquery,
      variables: {
        session: session
      }
    })
  });
  let roles = (await rolesdata.json()).data.roles.roles;
  let menu_role = [[], [], []];
  for (let i = 0; i < 4; i++) {
    menu_role[i] = roles.filter(item => item.position === i);
    menu_role[i] = menu_role[i].sort((a, b) => a.sort - b.sort);
  }
  for (let i = 0; i < 4; i++) {
    if (menu_role[i].length === 0) {
      document.getElementById(`menu-line-${i}`).remove();
    } else {
      for (let j = 0; j < menu_role[i].length; j++) {
        let format =
          `<div class="menu-app" onclick=render("${menu_role[i][j].name}","auto") id="` +
          menu_role[i][j].name +
          `"><img src="/mypage/images/app/` +
          menu_role[i][j].name +
          `.svg" class="app-icon"><p class="app-text">` +
          menu_role[i][j].displayname +
          `</p><div class="app-notice"></div></div>`;
        document.getElementById(`menu-line-${i}`).insertAdjacentHTML("beforebegin", format);
      }
    }
  }
  await anime({
    targets: "#user-on,.menu-app,.menu-line",
    opacity: 1,
    duration: 300,
    easing: "easeInOutQuad"
  });
}

async function render(role, subpage) {
  //common-delete
  let tr = document.getElementsByClassName("common-delete");
  if (0 < tr.length) {
    [...tr].forEach(function(v) {
      return v.remove();
    });
  }
  //javascriptをリセット
  let adds = document.getElementsByClassName("add-js");
  for (let i = 0; i < adds.length; i++) {
    await adds[i].remove();
  }
  //contentをリセット
  await document.getElementById("content").remove();
  await document
    .getElementsByClassName("right-block")[0]
    .insertAdjacentHTML("beforeend", '<div id="content"></div>');
  //subpageをリセット
  await document.getElementById("subpage").remove();
  await document
    .getElementsByClassName("right-block")[0]
    .insertAdjacentHTML("afterbegin", '<div class="bar" id="subpage"></div>');
  //role_menuをリセット
  let role_menu = await document.getElementsByClassName("menu-app");
  for (let i = 0; i < role_menu.length; i++) {
    await role_menu[i].classList.remove("active");
  }
  if (role === undefined || role === null) {
    role = "";
  }
  if (subpage === undefined || subpage === null) {
    subpage = "";
  }
  let query = `mutation Userfind($session: String!, $role: String!, $subpage: String!) {
  urlcheck(session: $session, role: $role, subpage: $subpage) {
    result
  }
}`;
  let data = await fetch("/api", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query,
      variables: {
        session: session,
        role: role,
        subpage: subpage
      }
    })
  });

  let urlcheck = (await data.json()).data;
  if (urlcheck.urlcheck.result === false) {
    if (subpage === "auto") {
      let subpagesquery = `mutation Subpages($session: String!, $role: String!) {
  subpages(session: $session, role: $role) {
    subpage {
      roleid
      name
      displayname
      sort
    }
  }
}`;
      let subpagesdata = await fetch("/api", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query: subpagesquery,
          variables: {
            session: session,
            role: role
          }
        })
      });
      let subpages = (await subpagesdata.json()).data.subpages.subpage;
      await render(role, subpages[0].name);
    } else {
      let menunoew = document.getElementsByClassName("menu-app");
      let subpagesquery = `mutation Subpages($session: String!, $role: String!) {
  subpages(session: $session, role: $role) {
    subpage {
      roleid
      name
      displayname
      sort
    }
  }
}`;
      let subpagesdata = await fetch("/api", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query: subpagesquery,
          variables: {
            session: session,
            role: menunoew[0].id
          }
        })
      });
      let subpages = (await subpagesdata.json()).data.subpages.subpage;
      history.replaceState("", "", "/mypage/" + menunoew[0].id + "/" + subpages[0].name);
      await render(menunoew[0].id, subpages[0].name);
    }
  } else if (urlcheck.urlcheck.result === true) {
    //subpage
    let subpagesquery = `mutation Subpages($session: String!, $role: String!) {
  subpages(session: $session, role: $role) {
    subpage {
      roleid
      name
      displayname
      sort
    }
  }
}`;
    let subpagesdata = await fetch("/api", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        query: subpagesquery,
        variables: {
          session: session,
          role: role
        }
      })
    });
    let subpages = (await subpagesdata.json()).data.subpages.subpage;
    for (let i = 0; i < subpages.length; i++) {
      if (subpages[i].name === subpage) {
        let format =
          `<div onclick=render("${role}","${subpages[i].name}") class="bar-menu active subpage-` +
          subpages[i].name +
          `" id="` +
          String(i) +
          `"><p>` +
          subpages[i].displayname +
          `</p><img src="/mypage/images/component/bar.svg"></div>`;
        document.getElementById("subpage").insertAdjacentHTML("beforeend", format);
      } else {
        let format =
          `<div onclick=render("${role}","${subpages[i].name}") class="bar-menu subpage-` +
          subpages[i].name +
          `" id="` +
          String(i) +
          `"><p>` +
          subpages[i].displayname +
          `</p><img src="/mypage/images/component/bar.svg"></div>`;
        document.getElementById("subpage").insertAdjacentHTML("beforeend", format);
      }
    }
    //content
    let data = await (await fetch(
      "/mypage/content/" + role + "/" + subpage + "/" + session
    )).text();
    await document.getElementById("content").insertAdjacentHTML("afterbegin", data);
    let parser = await new DOMParser();
    let doc = await parser.parseFromString(data, "text/html");
    for (let i = 0; i < doc.body.getElementsByTagName("script").length; i++) {
      await addjs(doc.body.getElementsByTagName("script")[i].src);
    }
    //role_menu
    let role_menu = await document.getElementsByClassName("menu-app");
    for (let i = 0; i < role_menu.length; i++) {
      if (role_menu[i].id === role) {
        await role_menu[i].classList.add("active");
      }
    }
    history.replaceState("", "", "/mypage/" + role + "/" + subpage);

    await anime({
      targets: "#content",
      opacity: 1,
      duration: 300,
      easing: "easeInOutQuad"
    });
  }
}

function organization(name) {
  window.location.href = "/mypage/organization/@" + name;
}
async function main() {
  await defaultPage();
  await render(path[2], path[3]);
}

main();

このプログラムでは主に
・ユーザー情報の取得
・使用可能な機能の取得
・機能の切り替え
・機能に基づいたURLの切り替え
を実装しています。

低速読み込みへの対応

ローカル環境で開発をしていると忘れがちな低速読み込み環境でのテストを行った結果、遅延を引き起こしている処理がいくつか見つかったので非同期処理やSSR部分を増やすなどの対応を行いました。
https://youtu.be/gT-SIHHkALQ
最終的にこのようにマイページの読み込みとシームレスな機能の切り替えを実現することができました。

WEBサーバーコンテナの開発体験向上

WEB開発にありがちですが、ディレクトリ管理が煩雑になってしまったり、メインのプログラムを分割するタイミングを逃し大きな技術的負債を負う場面がこれまでありました。今回のプロジェクトでは早期のそのような状況を回避するべく様々な施策を実施しました。

Expressのルーティング管理とファイル分離

app.ts
app.use('/login', require('./router/login'));
/router/login.ts
import { userfind } from "../mylib/mylib";
import { env } from "../mylib/env";

declare var require: any;
const express = require("express");
const router = express.Router();

router.get("/", async (req: any, res: any) => {
  if (await userfind(req.cookies.session) === null) {
    res.render("./login/index.ejs")
  } else {
    res.redirect("/mypage")
  }
});
#長すぎるので下記省略

export = router;

上記のようにExpressのrouter機能を用いることで機能ごとにファイルを分離しました。

関数や環境変数の読み込み

login.ts
import { userfind } from "../mylib/mylib";
import { env } from "../mylib/env";

使用頻度の高い処理や環境変数の読み込みをまとめ、各プログラムに呼び出して使用することで実装を省力化しています。

明確なディレクトリ管理

要件定義の段階で明確にディレクトリ管理方式を決定していたため、大量のファイルをスムーズに管理することができました。

ディレクトリ構造
D:.
│  compose.yaml
│  production_apiDockerfile
│  production_appDockerfile
│  production_nginxDockerfile
│  production_seedDockerfile
│  README.md
│  test_apiDockerfile
│  test_appDockerfile
│  test_nginxDockerfile
│  test_seedDockerfile
│
├─.github
│  └─workflows
│          production.yml
│          test.yml
│
├─.vscode
│      extensions.json
│      settings.json
│
├─api
│  │  .env
│  │  .produciton_env
│  │  .test_env
│  │  app.ts
│  │  nodemon.json
│  │  package-lock.json
│  │  package.json
│  │  tsconfig.json
│  │
│  ├─graphql
│  │      typedefs.ts
│  │
│  ├─mylib
│  │      env.ts
│  │      mylib.ts
│  │      production_env.ts
│  │
│  ├─node_modules
│  └─prisma
│          schema.prisma
│          seed.ts
│
├─app
│  │  app.ts
│  │  compression.js
│  │  minio.ts
│  │  nodemon.json
│  │  package-lock.json
│  │  package.json
│  │  tsconfig.json
│  │
│  ├─mylib
│  │      env.ts
│  │      mylib.ts
│  │      production_env.ts
│  │      test_env.ts
│  │
│  ├─node_modules
│  ├─public
│  │  │  anime.js
│  │  │  Bahnschrift.TTF
│  │  │  bg.png
│  │  │  cropper.css
│  │  │  cropper.js
│  │  │  favicon.ico
│  │  │  gsap.js
│  │  │  jquery.js
│  │  │  jsQR.js
│  │  │  MOBO_index.otf
│  │  │  rese.css
│  │  │  ScrollTrigger.js
│  │  │  ZenMaruGothic-Medium.ttf
│  │  │  ZenMaruGothic-Regular.ttf
│  │  │
│  │  ├─login
│  │  │  ├─forget
│  │  │  │      forget.css
│  │  │  │      forget.js
│  │  │  │      forget.scss
│  │  │  │
│  │  │  ├─forgetpassword
│  │  │  │      forgetpassword.js
│  │  │  │
│  │  │  ├─index
│  │  │  │      index.css
│  │  │  │      index.scss
│  │  │  │      post.js
│  │  │  │
│  │  │  ├─logout
│  │  │  │      logout.js
│  │  │  │
│  │  │  ├─new
│  │  │  │      post.js
│  │  │  │
│  │  │  └─newuser
│  │  │          from.js
│  │  │          newuser.css
│  │  │          newuser.scss
│  │  │          post.js
│  │  │
│  │  ├─mypage
│  │  │  ├─about
│  │  │  │  └─about
│  │  │  │          about.js
│  │  │  │
│  │  │  ├─arena
│  │  │  │  ├─book
│  │  │  │  │      book.css
│  │  │  │  │      book.js
│  │  │  │  │      book.scss
│  │  │  │  │
│  │  │  │  └─list
│  │  │  │          list.js
│  │  │  │
│  │  │  ├─arena-admin
│  │  │  │  ├─book
│  │  │  │  │      book.js
│  │  │  │  │
│  │  │  │  └─list
│  │  │  │          list.js
│  │  │  │
│  │  │  ├─component
│  │  │  │      component.css
│  │  │  │      component.js
│  │  │  │      component.scss
│  │  │  │
│  │  │  ├─eve
│  │  │  │  └─book
│  │  │  │          book.js
│  │  │  │
│  │  │  ├─eve-admin
│  │  │  │      part.js
│  │  │  │
│  │  │  ├─goods
│  │  │  │  └─goods
│  │  │  │          goods.css
│  │  │  │          goods.js
│  │  │  │          goods.scss
│  │  │  │
│  │  │  ├─hiroopay
│  │  │  │  └─pay
│  │  │  │          pay.css
│  │  │  │          pay.js
│  │  │  │          pay.scss
│  │  │  │
│  │  │  ├─hiroopay-admin
│  │  │  │  ├─graph
│  │  │  │  │      graph.js
│  │  │  │  │
│  │  │  │  └─list
│  │  │  │          list.js
│  │  │  │
│  │  │  ├─images
│  │  │  │  ├─app
│  │  │  │  │      about.svg
│  │  │  │  │      application.svg
│  │  │  │  │      arena-admin.svg
│  │  │  │  │      arena.svg
│  │  │  │  │      eve-admin.svg
│  │  │  │  │      eve.svg
│  │  │  │  │      goods.svg
│  │  │  │  │      hiroopay-admin.svg
│  │  │  │  │      hiroopay.svg
│  │  │  │  │      home.svg
│  │  │  │  │      logout.svg
│  │  │  │  │      movie.svg
│  │  │  │  │      organization-content-admin.svg
│  │  │  │  │      organizationrequests.svg
│  │  │  │  │      organizationsmanage.svg
│  │  │  │  │      payscan.svg
│  │  │  │  │      photo.svg
│  │  │  │  │      presentation.svg
│  │  │  │  │      rolerule.svg
│  │  │  │  │      settings.svg
│  │  │  │  │      site.svg
│  │  │  │  │      specialticket.svg
│  │  │  │  │      studentsrole.svg
│  │  │  │  │      ticket.svg
│  │  │  │  │      ticketadmin.svg
│  │  │  │  │      ticketscan.svg
│  │  │  │  │      usersrole.svg
│  │  │  │  │      volunteer.svg
│  │  │  │  │
│  │  │  │  └─component
│  │  │  │          bar.svg
│  │  │  │          checkbox.svg
│  │  │  │          delete.svg
│  │  │  │          deny.svg
│  │  │  │          download.svg
│  │  │  │          redo.svg
│  │  │  │          reverse.svg
│  │  │  │          right-arrow.svg
│  │  │  │          set.svg
│  │  │  │          under-arrow.svg
│  │  │  │          user-icon.webp
│  │  │  │          viewfile.svg
│  │  │  │          watch.svg
│  │  │  │
│  │  │  ├─index
│  │  │  │      common2.js
│  │  │  │      global.js
│  │  │  │      index.css
│  │  │  │      index.scss
│  │  │  │      m-index.css
│  │  │  │      m-index.scss
│  │  │  │      mypage.js
│  │  │  │
│  │  │  ├─organization
│  │  │  │      common.js
│  │  │  │      index.css
│  │  │  │      index.scss
│  │  │  │
│  │  │  ├─organization-content-admin
│  │  │  │  └─about
│  │  │  │          about.js
│  │  │  │
│  │  │  ├─organizationrequests
│  │  │  │  ├─complete
│  │  │  │  │      complete.js
│  │  │  │  │
│  │  │  │  └─submit
│  │  │  │          submit.css
│  │  │  │          submit.js
│  │  │  │          submit.scss
│  │  │  │
│  │  │  ├─organizationsmanage
│  │  │  │  ├─created
│  │  │  │  │      created.js
│  │  │  │  │
│  │  │  │  ├─member
│  │  │  │  │      member.js
│  │  │  │  │
│  │  │  │  └─requests
│  │  │  │          organizationsmanage_requests_table_get.js     
│  │  │  │          requests.js
│  │  │  │
│  │  │  ├─payscan
│  │  │  │  └─scan
│  │  │  │          scan.css
│  │  │  │          scan.js
│  │  │  │          scan.scss
│  │  │  │
│  │  │  ├─rolerule
│  │  │  │  └─manage
│  │  │  │          manage.css
│  │  │  │          manage.js
│  │  │  │          manage.scss
│  │  │  │
│  │  │  ├─settings
│  │  │  │  ├─delete
│  │  │  │  │      delete.js
│  │  │  │  │
│  │  │  │  └─profile
│  │  │  │          croppper.js
│  │  │  │          profile.css
│  │  │  │          profile.js
│  │  │  │          profile.scss
│  │  │  │
│  │  │  ├─site
│  │  │  │  ├─movie
│  │  │  │  │      movie.js
│  │  │  │  │
│  │  │  │  ├─news
│  │  │  │  │      news.js
│  │  │  │  │
│  │  │  │  └─notice
│  │  │  │          notice.js
│  │  │  │
│  │  │  ├─specialticket
│  │  │  │  └─publish
│  │  │  │      │  publish.css
│  │  │  │      │  publish.js
│  │  │  │      │  publish.scss
│  │  │  │      │
│  │  │  │      └─images
│  │  │  │              add.svg
│  │  │  │              delete.svg
│  │  │  │
│  │  │  ├─studentsrole
│  │  │  │  └─manage
│  │  │  │          manage.js
│  │  │  │
│  │  │  ├─ticket
│  │  │  │  ├─history
│  │  │  │  │      history.css
│  │  │  │  │      history.js
│  │  │  │  │      history.scss
│  │  │  │  │
│  │  │  │  └─publish
│  │  │  │      │  publish.css
│  │  │  │      │  publish.js
│  │  │  │      │  publish.scss
│  │  │  │      │
│  │  │  │      └─images
│  │  │  │              add.svg
│  │  │  │              delete.svg
│  │  │  │
│  │  │  ├─ticketadmin
│  │  │  │  ├─list
│  │  │  │  │      list.js
│  │  │  │  │
│  │  │  │  └─time
│  │  │  │          time.js
│  │  │  │
│  │  │  ├─ticketscan
│  │  │  │  └─scan
│  │  │  │      │  scan.css
│  │  │  │      │  scan.js
│  │  │  │      │  scan.scss
│  │  │  │      │
│  │  │  │      └─images
│  │  │  │              check.svg
│  │  │  │
│  │  │  └─usersrole
│  │  │      └─manage
│  │  │              manage.css
│  │  │              manage.js
│  │  │              manage.scss
│  │  │
│  │  └─view
│  │      ├─comment
│  │      │  │  comment.css
│  │      │  │  comment.scss
│  │      │  │
│  │      │  └─images
│  │      │          title.svg
│  │      │
│  │      ├─index
│  │      │  │  ca.svg
│  │      │  │  fade.js
│  │      │  │  header.js
│  │      │  │  header_image0.webp
│  │      │  │  header_image1.webp
│  │      │  │  header_image2.webp
│  │      │  │  header_image3.webp
│  │      │  │  header_image4.webp
│  │      │  │  header_image5.webp
│  │      │  │  index.css
│  │      │  │  index.scss
│  │      │  │  news.svg
│  │      │  │  pre.svg
│  │      │  │  time.js
│  │      │  │  yazi.svg
│  │      │  │
│  │      │  ├─content
│  │      │  │      001.webp
│  │      │  │      002.webp
│  │      │  │      003.webp
│  │      │  │      004.webp
│  │      │  │      005.webp
│  │      │  │      006.webp
│  │      │  │      007.webp
│  │      │  │      008.webp
│  │      │  │      pr1.webp
│  │      │  │      pr2.webp
│  │      │  │      pr3.webp
│  │      │  │      wait.webp
│  │      │  │
│  │      │  └─images
│  │      │      │  ca.svg
│  │      │      │  header_image0.webp
│  │      │      │  header_image1.webp
│  │      │      │  header_image2.webp
│  │      │      │  header_image3.webp
│  │      │      │  header_image4.webp
│  │      │      │  header_image5.webp
│  │      │      │  info.svg
│  │      │      │  menu.svg
│  │      │      │  news.svg
│  │      │      │  pre.svg
│  │      │      │  theme.svg
│  │      │      │  yazi.svg
│  │      │      │
│  │      │      └─content
│  │      │              001.webp
│  │      │              002.webp
│  │      │              003.webp
│  │      │              004.webp
│  │      │              005.webp
│  │      │              006.webp
│  │      │              007.webp
│  │      │              008.webp
│  │      │              pr1.webp
│  │      │              pr2.webp
│  │      │              pr3.webp
│  │      │              wait.webp
│  │      │
│  │      ├─m-index
│  │      │  │  m-index.css
│  │      │  │  m-index.scss
│  │      │  │
│  │      │  └─images
│  │      │          news.svg
│  │      │
│  │      ├─map
│  │      │  │  map.css
│  │      │  │  map.scss
│  │      │  │
│  │      │  └─images
│  │      │          01.svg
│  │      │          02.svg
│  │      │          03.svg
│  │      │          04.svg
│  │      │          05.svg
│  │      │          06.svg
│  │      │          07.svg
│  │      │          08.svg
│  │      │          09.svg
│  │      │          title.svg
│  │      │
│  │      ├─menu
│  │      │  │  menu.css
│  │      │  │  menu.js
│  │      │  │  menu.scss
│  │      │  │  time.js
│  │      │  │
│  │      │  └─images
│  │      │          direct.svg
│  │      │
│  │      ├─movie
│  │      │  │  movie.css
│  │      │  │  movie.scss
│  │      │  │
│  │      │  └─images
│  │      │          title.svg
│  │      │
│  │      ├─timetable
│  │      │  │  timetable.css
│  │      │  │  timetable.scss
│  │      │  │
│  │      │  └─images
│  │      │          day1.svg
│  │      │          day2.svg
│  │      │          title.svg
│  │      │
│  │      └─wait
│  │          │  wait.css
│  │          │  wait.scss
│  │          │
│  │          └─images
│  │                  wait.svg
│  │
│  ├─router
│  │      login.ts
│  │      minio.ts
│  │      mypage.ts
│  │      server.ts
│  │      view.ts
│  │
│  └─views
│      │  functions.ejs
│      │  head.ejs
│      │
│      ├─login
│      │      forget.ejs
│      │      forgetpassword.ejs
│      │      index.ejs
│      │      logout.ejs
│      │      new.ejs
│      │      newuser.ejs
│      │
│      ├─mypage
│      │  │  index.ejs
│      │  │
│      │  ├─about
│      │  │      about.ejs
│      │  │
│      │  ├─arena
│      │  │      book.ejs
│      │  │      list.ejs
│      │  │
│      │  ├─arena-admin
│      │  │      book.ejs
│      │  │      list.ejs
│      │  │
│      │  ├─eve
│      │  │      book.ejs
│      │  │
│      │  ├─eve-admin
│      │  │      part1.ejs
│      │  │      part2.ejs
│      │  │      part3.ejs
│      │  │
│      │  ├─goods
│      │  │      goods.ejs
│      │  │
│      │  ├─hiroopay
│      │  │      pay.ejs
│      │  │
│      │  ├─hiroopay-admin
│      │  │      back.ejs
│      │  │      graph.ejs
│      │  │      list.ejs
│      │  │
│      │  ├─organization
│      │  │      index.ejs
│      │  │
│      │  ├─organization-content-admin
│      │  │      about.ejs
│      │  │
│      │  ├─organizationrequests
│      │  │      complete.ejs
│      │  │      submit.ejs
│      │  │
│      │  ├─organizationsmanage
│      │  │      created.ejs
│      │  │      member.ejs
│      │  │      requests.ejs
│      │  │
│      │  ├─payscan
│      │  │      scan.ejs
│      │  │
│      │  ├─rolerule
│      │  │      manage.ejs
│      │  │
│      │  ├─settings
│      │  │      delete.ejs
│      │  │      profile.ejs
│      │  │
│      │  ├─site
│      │  │      movie.ejs
│      │  │      news.ejs
│      │  │      notice.ejs
│      │  │
│      │  ├─specialticket
│      │  │      publish.ejs
│      │  │
│      │  ├─studentsrole
│      │  │      manage.ejs
│      │  │
│      │  ├─ticket
│      │  │      publish.ejs
│      │  │
│      │  ├─ticketadmin
│      │  │      graph.ejs
│      │  │      list.ejs
│      │  │
│      │  ├─ticketscan
│      │  │      scan.ejs
│      │  │
│      │  └─usersrole
│      │          manage.ejs
│      │
│      ├─server
│      │      ticket.ejs
│      │
│      └─view
│              comment.ejs
│              index.ejs
│              m-index.ejs
│              map.ejs
│              menu.ejs
│              movie.ejs
│              timetable.ejs
│              wait.ejs
│
├─ini
│      0database.sql
│      0user.sql
│
├─production
│  └─api
│          production_seed.ts
│          seed.sh
│          start.sh
│
└─proxy
        nginx.conf
        production_nginx.conf
        test_nginx.conf

kubernetes clusterへのデプロイ

kubernetes cluster上に本番環境用とテスト環境用の2つのnamespaceを作成しGithub Actionを用いてデプロイを行っています。

テスト環境へのデプロイ

Gitでブランチを分け、テストブランチにpushが行われるとActionが走りコンテナがビルドされてテスト環境への反映がArgoCDによって行われます。問題がなければそのまま本番環境ブランチにmergeされて本番環境のnamespaceにデプロイされます。

本番環境へのデプロイ

テスト環境と本番環境の違いはメトリクス機能とオートスケーリング機能が導入されている点です。初回のリリースではhpaとreplicasetが競合してしまいスケーリングがうまく行われなかったというトラブルもありましたが、無事に安定して動作させることができました。メトリクスはPrometheus+Grafanaの定番構成で可視化しログはfluentdを用いて管理していました。

また、本番環境ブランチにmergeされた場合は静的ファイルがCDN用のVPSに配信されるようになっています。

clusterごとぶっ壊れた場合の対処法

マネージドのkubernetesサービスではないのでたまにclusterが不具合を起こす場合があります。その都度原因を特定して対処しています。下記に具体的な事例を紹介します。
・NodeのNICが上がらない場合がある
→LACPで冗長化する。
・Nodeを定期的に再起動してあげないと不具合がおこりやすい
→clusterから抜けて再起動してclusterにまた入るプログラムをcronで回す。

CDN関連

オリジンサーバーからはSSRされたHTMLとSPA用のAPIのみを返し、その他の静的ファイルは契約したVPSを用いて作られたCDNから配信されています。これによりオリジンサーバーの負荷を最小限に抑えています。

死活監視

各サーバーに5秒にオリジンサーバーから死活監視リクエストを送っています。死活監視リクエストの応答には静的ファイルのバージョン情報が含まれており、Podの更新が遅れているVPSはCDNから一時的に除外されるシステムです。

静的ファイルの指定

このCDNはオリジンサーバー上でHTMLをSSRする時に静的ファイルのリクエストをブラウザ側から送るサーバーをランダムにすることで帯域負荷を分散しています。

  <img src="cdn1.example.com/images/img1.png">
  <img src="cdn2.example.com/images/img1.png">
  <img src="cdn3.example.com/images/img1.png">
#SQL上に保存されているVPSの一覧から死活監視に引っかかったドメインをランダムに返す

EJS上に関数を定義しリクエストを行うサーバーをランダムにしています。

グローバルIPアドレス検索(β版)

送られてきたリクエスト元のIPアドレスから最短距離のVPSを検索するための機能です。この機能はベータ版です。

本番起こったトラブル

本番には多くのアクセスがあり、予想されないトラブルが起こりました。

ペイメントシステムの管理機能の不具合

発生した問題

ペイメントシステムの管理機能の一部で大量の処理が行われ、処理全体にボトルネックが発生しました。そのため、半日ほどペイメントシステム周りが停止していまいました。(その他のシステムとマイクロサービスを分けておいたので他のシステムに影響は出ませんでした。)

解決策

ペイメントシステム周りの処理アルゴリズムを改善し、処理時間を短縮しました。また、他の機能に影響が及ばないようにするためペイメントシステムのマイクロサービスをVPSに移行しました。これによりローカルの他のマイクロサービスに影響が及ばないようにしました。(この作業は本番1日目と2日目の間に徹夜で行われたました。)

配信ページの不具合

発生した問題

配信ページにアクセスした際にWEBサーバーのPodが落ちて502エラーが発生することがあった。限定公開配信を表示するか否かを決定するプログラムの非同期処理でエラーが発生していたことが原因でした。

解決策

すぐに問題を修正し、デプロイを行いました。負荷が高い中でのデプロイでしたが無事に修正できました。

今後の課題

技術面

・NextJS,Reactの導入
・OpenTelemetryの導入
・Istioの導入

人材面

・継続的な技術人材の育成(企画,技術,デザインの各方面で)
→今後の主な課題は人材の育成になると思っています。今年度は個人で11万行くらいのプログラムを書いてしまったので今後は段階的に後輩に引き継いだ行く必要があります。

まとめ

昨年から開始したオンラインでのチケット発行から発展し、今年度は多くの機能を実装することができました。すべてのレイヤーで新たな技術に挑戦し、ユーザーに新しい価値を提供できたと思います。しかし、一方で不具合を被ってしまったユーザーも決して少ないわけではありません。今後もより早く,より正確なシステム開発に努めてまいります。

謝辞

本システムは多くの関係者の温かいご支援の下で成り立っています。
・素材の提供
・デザインや言葉遣いのアドバイス
・各種の周知
・負荷試験への協力
・予算編成
・応援の暖かなお言葉
などで多くの関係者の皆様にお世話になりました。
改めて感謝申し上げます。

Discussion