オンライン家庭教師”マナリンク”を支えるアーキテクチャ
こんにちは。
株式会社NoSchoolで、CTOとしてサービス開発全般を担当している齊藤(名人)です。
2020年8月11日、【オンライン家庭教師マナリンク】というWebサービスをリリースしました。
小中高校生向けに、ZOOMやチャットなどを活用したオンライン指導を受講できる家庭教師を探せるサイトです。先生方のプロフィールや指導内容を徹底的に可視化しているところがポイントです。個性的なプロフィールが多いので後ほどぜひ見てみてください。
さて、なんとか大きなトラブルなく無事にリリースできたので、今回は【オンライン家庭教師”マナリンク”を支えるアーキテクチャ】と題してWebサービスを構成している技術をざっとまとめていこうと思います。
インフラ
弊社ではインフラにAWSを利用しています。FirebaseやVercelはスモールな立ち上げには向いていますが、基幹事業を任せるインフラとなると、やはり自由度が高いAWSやGCPを選択するのが妥当です。
簡単にインフラ構成図を書いてみました。もちろんセキュリティグループや各種バージョンなどは省いていたり、若干書き換えたりしています。
前段にCDNとしてCloudFrontを置いて、フロントエンド(Nuxt.js)、バックエンド(Laravel)、画像やファイルでドメインを分けて運用していく方針にしました。
アプリケーションサーバーとしてFargateを活用したコンテナを採用しているのが今回AWSで最もチャレンジした箇所です。
これまで主力事業として運営してきた勉強質問サイトNoSchoolでは、シンプルなEC2をベースとして管理していましたが、サーバーの設定を更新するたびにAMIを作成する運用をしていまして、秘伝のタレが生成されている感がありました。
コンテナ管理にすることで、個人的に最も嬉しいのはステージング環境や本番環境をローカルでも再現可能で、かつコードベースで管理することで言語のバージョンアップなどにも踏み切りやすくなった点です。これらはリリース時点というより、運用していく過程で徐々に真価を発揮することでしょう。直近だとPHP8へのアップデートが楽しみです。
その他、RDSやRedisなど含め比較的オーソドックスな構成だと思います。LambdaもSlackへのエラー通知やCPU利用率のアラート通知などを目的として活用していますが、未だにCDK等を用いたローカル環境構築やCI/CD設計に自信が持てず、メインのアプリケーションにサーバーレスを導入してはいません。事業フェーズ的に時期尚早な気もしています。
フロントエンドにNuxt.jsを採用しているのですが、SSR時にベーシックなnuxt startコマンドですとアクセスログを取ったりレスポンスヘッダを付与することが難しいです。そこで、SSRのためにfastifyを導入してpinoでJSONベースのログを吐き出すとともに、Lambda@Edgeを利用してCloudFrontからのオリジンレスポンス時にセキュリティ関連のHTTPヘッダを付与するようにしました。それらはaws-cdkにてデプロイしています。
Lambda@Edgeをcdkでデプロイするコードは概ね下記の通りです。結構手間取りました。
const addSecurityHeaders = new NodejsFunction(this, 'add-security-headers', {
role: new Role(this, 'AllowLambdaServiceToAssumeRole', {
assumedBy: new CompositePrincipal(
new ServicePrincipal('lambda.amazonaws.com'),
new ServicePrincipal('edgelambda.amazonaws.com')
),
}),
// ここでこの設定が必要なのが罠なのでIssueをcdkにあげており、対応予定とのこと
awsSdkConnectionReuse: false,
})
const config = getConfig()
const distribution = new CloudFrontWebDistribution(this, config.distributionName, {
defaultRootObject: '',
// 中略
behaviors: [{
lambdaFunctionAssociations: [{
eventType: LambdaEdgeEventType.ORIGIN_RESPONSE,
lambdaFunction: new Version(this, 'OriginRequestVersion', {
lambda: addSecurityHeaders,
}),
}],
CDKをGitHub Actionsでデプロイするymlは下記の感じでシンプルです。
- name: cdk deploy
run: |
ENV=$ENV npx cdk deploy --v --require-approval never
working-directory: ./aws/lambda_edge/cdk_lambda_edge
プログラミング言語とフレームワーク
前述の通りフロントエンドをNuxt.js、バックエンドをLaravelで構成しています。こちらはマナリンクがNoSchoolの一部機能だった時代からずっとこのスタックで書いています。
所感としては、両者ともほどほどに自由度が高く使い勝手が良いと思っています。他方でNuxt(及びVue)にはTypeScriptフレンドリーではない仕様が散見され、Laravelにはマジックメソッド等が乱立してIDEの活躍を狭めていたりと、保守性という点で結構罠が多いと感じています。NuxtはVue3に公式が対応するまで我慢ですね。Laravelは7以降機能追加のペースが激しいものの適度に無視するのも大事そうです。
PHPは7.4系を使っているのですが、クラスのプロパティに型が付けられるのが当たり前なのですがとてもいいですね。
TypeScriptは以下の書籍がとてもおすすめです。ジェネリクスの理解の解像度が上がるので、大半のライブラリのコードが読みやすくなります。
プログラムの保守性
以前Qiitaにも書いたように、自分なりにフレームワークの言うとおりにするのではなく保守性を考えて作っていくことを心がけています。
特に弊社のようなスタートアップですと、次々機能を作って次々消すので、消しやすいように作るのが最も重要だと今のところは考えています。
悩んでいる点として、去年の年末頃から課金機能をサイト上に実装しており、それ以降は特に実装がごちゃごちゃしてしまいがちです。というのも、課金の瞬間って、ユーザーのステータスと、購入したオンライン指導のステータスと、オンライン家庭教師の売上が一気に更新されますし、同時に決済ベンダーへのリクエストが成功したことも保証しないといけないわけで、一度に大量の性質が違うデータが更新され、いずれかが失敗すると適切に巻き戻さないといけない仕様があります。これをコードで表現するのがかなり難しいなと思っています。それに、月額課金だと1ヶ月毎に更新が走るので、そのときに先生の売上も更新するという仕様も入ってきます。
この点はまだ理想的な解にたどり着いていないのですが、後述するようにテストコードを徹底的に書いて資産にしていくことで、バグを防ぎつつ設計を改善していくつもりです。
自動テスト・自動デプロイ
マナリンクではソースコードをリリースする前に自動テストが走るようになっています。
自動テストはJestだったりPHPUnitといったライブラリを使って、プログラミング言語でテストを記述する技術です。プログラムを使ってプログラムをテストできるので、人力でテストすることによる考慮漏れや時間の無駄をある程度防ぐことができます。
もちろんデザインがなんか変、といった人間の感覚的な部分などはテスト不可能なので、パターンを網羅する部分を自動テストに任せて、動きをざっと通しで見るのは人間の手で、と考えています。
自動テストをプログラムで書く時間はもちろん一時的には勿体ないのですが、一度テストを書けば、以降のリリースで毎回、悪影響していないことを自動で確認できるので、一種の資産になります。
原理的にプログラミングという行為は、その瞬間はお金にならず、リリース後に収益になるものですので、考え方としては投資に近いと思っています。そのため、さっさとリリースすることと、そのリリースにちゃんと投資回収できる継続的な品質価値をもたせることを並列で考えています。
とはいえとにかく早くリリースしてユーザーの反応を見るのも大事なわけなので、リリースした後にちょっとテストコードもっと良くする時間1時間だけください、とかは適宜判断することはあります。
自動デプロイも同じです。本番環境のサーバーにリリースするのを手動で行うと疲れているときなどにミスが起きがちです。自動デプロイを組むのは最初は苦労するのですが、すぐに恩恵を受けることができるので頑張って組みました。
インフラの話とつなげると、AWS Fargateに対してNuxtとLaravelのアプリケーションをリリースしています。具体的に言えば、GitHub Actionsにて、各アプリケーションの専用コンテナのビルドと、ECRへのプッシュ、最後にECSのタスク定義を更新してCodeDeployを稼働させるところまでを組んでいます。
課題は残っていまして、短時間に連続で何度もデプロイすると2回目以降のデプロイが失敗することと、デプロイ失敗時にSlack等に通知する仕組みまでは作りきれていないのでデプロイに失敗したことに気が付かず、リリースしたつもりになってしまいます。いずれ時間を作って解消するつもりです。
所感としては、DockerとGitHub Actionsについては相当色々学び直しになりました。GitHub Actionsは分岐や環境変数をふんだんに活用する必要がありましたし、Dockerについてもalpine Linux特有の罠とか、php-fpmの設定が何故か反映されずコンテナ内に入り込んでtopコマンドを叩いてプロセスを調べる羽目になったりと、久々にサーバーの勉強をした気分です。普段やらないので久々に触ると感覚がついていかないですね。
エラーログ
サービスリリース後にまず重要になるのはエラーログの扱いです。エラーログを記録しておくことで、トラブル発生時に原因を即座に突き止める一助となります。
ログは基本的にCloudWatchに収集しており、CPU利用率等のサーバー自体のメトリクスとともに、SNSを経由してLambda→Slack通知したり、CloudWatchログをそのままLambdaへのトリガーとしてSlack通知しています。このへんはSNSに統一していく予定です。
ログは原則JSON化するほうが望ましいです。CloudWatchでログを探すときに、フリーワード検索ではなくJSON構造に基づいて検索できるからです。前述の通りNuxtはpinoを用いてJSON化したログを投げていますし、Laravelは下記のようにstderrに\Monolog\Formatter\JsonFormatterを噛ませたログを流すことでJSON化できます。
'stderr' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => StreamHandler::class,
'tap' => [App\Logging\JsonProcessor::class],
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
],
class JsonProcessor
{
public function __invoke($monolog)
{
// フォーマットを指定
$formatter = new \Monolog\Formatter\JsonFormatter();
// urlの追加
$web = new WebProcessor();
foreach ($monolog->getHandlers() as $handler) {
$handler->setFormatter($formatter);
$handler->pushProcessor($web);
}
}
}
リリースした自分への自己評価的なもの
マナリンクは既存サービスのNoSchoolから切り出す格好で誕生しており、旧ドメインからのリダイレクトを設定する必要もあったのですが、そちらはAWS ALBでアプリケーション改修不要でポチポチ実現できて滞りなく実施できました。
旧サービスNoSchoolでのメンテナンス時間も2時間弱で終了し、マナリンク公開後も特に大きなトラブルなく稼働しているので、一安心です。
自己評価としては、当初からドメイン移行はアプリケーションのみならずGoogle Analyticsやユーザー告知などもあるから2ヶ月弱くらい見ましょうと言っていたのですが、ほんとに6月中旬から初めて2ヶ月弱で終わったので上出来かなと思っています。これまでも、2週間と見積もったものが大外れしたり、リリース直前になって次々ずれ込んでいくといった炎上、丸1日以上治らない障害とかはほとんど起きていないので、【マイナス面を防ぐ品質】にはこれからもこだわっていきたいです。
同時に、【プラスに働く品質】について、我々の1stリリースはどうしてもある程度速度優先で80%のクオリティで出すことが多いのですが、結局そのあとも当面リファインされないことがほとんどなので、僕としてはその最初の80%のクオリティを上げられるように地力と勘(何を満たせば最もユーザーの満足度が高いかを見極める力)を伸ばしていきたいなと思っています。
---
読んでいただいてありがとうございました!
もしよろしければQiitaやTwitterのフォローお願いします。