サーバーサイドエンジニアの田実です!
ヤプリではスマホアプリをノーコードで開発できる Yappli のプロダクトだけではなく、アプリや外部システムと連携可能な顧客管理基盤である Yappli CRM というプロダクトも提供しています。Yappli CRMのバックエンドのプログラミング言語はPHP、フレームワークはLaravelで実装されています。
Yappli CRMの立ち上げ時の開発はパートナー企業に外注していたのですが、プロダクト開発をより推進していくため、今年から弊社のエンジニアも開発に携わることになりました。しかし、今年1月時点でのYappli CRMは開発者体験に関する多くの課題があり、プロダクト開発を爆速で進めていくには難しい状態でした。そのため、弊社のエンジニアがジョインしてからは、機能開発を進めるとともに開発者体験を上げるような様々な改善も同時に行いました。
ということで、今回はYappli CRMの開発者体験を3ヶ月で爆上げした改善の話を紹介します!
当初の開発課題
ローカル開発環境が整備されておらず、PHPやMySQLが動く環境を各エンジニアがそれぞれ用意していました。READMEなどのインストール手順や全体構成、APIやバッチ処理の概要などのドキュメントもなく、新しく参画するエンジニアがどうやって開発を進めていけばよいのかわかりませんでした。
また、テストコードがないため期待通りに動いているのかどうかがコード上で保証されておらず、リグレッションテストの観点から安全にリファクタリングすることもできませんでした。設定値によって振る舞いが変わるAPIの複雑な仕様をテストコードから把握することもできませんでした。
フォーマッタも適用されていなかったので、インデントの数や種類(タブ・半角スペース)や中括弧の位置がバラバラで可読性や保守性が悪くなっていました。PHPStanなどの静的解析ツールによる型チェック・文法チェックも行っていなかったため、不要な変数・不要なコード・インポート漏れ・型間違いなど静的解析で検知できるような不具合も多数ありました。
デプロイは git pull && rsync
の方式で行われていました。この方式ではタイミングや変更量によってはダウンタイムが発生する可能性があるため、デプロイは夜間作業となっていました。また、「git pullによるコード取得」「composer installによるライブラリのインストール」「rsyncによる同期」「OPcacheのクリア」という感じで複数のコマンドを実行する必要があり、デプロイ作業が複雑化・属人化していました。その結果、リリース回数も1ヶ月に数回程度の頻度になっていました。
オブザーバビリティに関しても課題がありました。まず、ログレベルが統一されておらず、本番環境でDEBUGログが大量に出ていたり、WARN・ERRORログの監視(どのエラーが要対応なのかの整理)ができていませんでした。メトリクスの監視は行っていたものの、閾値を超えたときのアラートが弊社のエンジニアに通知されておらず全く状況がわかりませんでした。また、機能開発やインシデント時の影響範囲調査などでデータを確認する場合、bastionや本番サーバーにSSHログインして、MySQLのクライアントを叩いて確認していました。そのため、簡易的なデータの確認や共有、データを使った監視やアラート通知ができていませんでした。
アプリケーションコードも可読性・保守性・テスト容易性など様々な課題がありました。例えばスーパーグローバル変数を直接利用していたためテスト容易性がなかったり、enum系の数値がベタ書きだったため何を意味する値なのかがすぐに読み解くことができませんでした。
インフラもドキュメントが無かったため、構成を把握できない状況でした。不要なインフラを把握・整理することもできず、利用コストも想定以上になっていました。RDBにリードレプリカが設定されていないなどの冗長性の課題もありました。
改善内容
このように様々な開発課題があり、プロダクト開発を円滑に進めていくには難しい状態でした。これを状況を打破すべく様々な改善を行いました。 ここからは実際に行った改善内容について紹介していきます。
開発環境
Yappli CRMは主にPHP(Apache + mod_php)・Laravel・MySQLで構成されているシンプルなWebアプリケーションです。弊社内でもこの構成に実績があったため、まず最初にDockerやdocker-composeを使ってローカル開発環境を整備しました。 Laravelだとローカル開発環境として Laravel Sail がありますが、実際の本番環境であるApache + mod_phpの構成と異なっており*1細かいチューニングもしづらいため採用せず、Dockerfileやdocker-compose.ymlをイチから書いています。
さらに、docker-composeの起動、composerによるライブラリのインストール、DBマイグレーション、seedデータの投入など初期設定や開発で頻繁に利用するコマンドをMakeタスク化しました。
# Makefileの抜粋 .PHONY: analyse analyse: ## PHPStanによる静的解析 vendor/bin/phpstan analyse app --memory-limit 2G .PHONY: lint lint: ## フォーマットチェック vendor/bin/phpcs app tests .PHONY: up up: ## Dockerコンテナの立ち上げ docker-compose up -d
初めて参画したエンジニアがこの開発環境をすぐに利用できるようにするためREADMEファイルの整備も行いました。インストール方法やMakeタスクの使い方を記載することで、READMEファイルを読んで開発にすぐ着手できる状態にしました。
さらに、コードのディレクトリ構成やDBの各テーブルの概要、主要API・バッチのシーケンス、インフラ構成もConfluenceやdraw.ioを使ったドキュメンテーションも行いました。これにより、エンジニア間で現状の技術仕様や処理フロー・インフラ構成の認識を合わせれるようにしました。READMEにもこれらのドキュメントへのリンクを入れることで、新規参画者向けのオンボーディングの資料としても活用しています。
ローカル開発環境ではAWSやSendGridなど外部サービスを利用する機能の開発・検証が難しいため、実際のAWSのインフラを使った開発環境も構築しています。開発環境によって外部サービスとの連携を確認するだけではなく、仕様検討などのコミュニケーションが画面ベースでできるようになるメリットもあります。
さらにLaravelの開発を効率化するため、IDEの補完を効かせるための barryvdh/laravel-ide-helper や、Chrome Devtoolで簡易的なリクエスト/レスポンスのデバッグができる itsgoingd/clockwork を入れています。
自動テスト
テストコードやCI環境が整備されていなかったので、まず最初にPHPUnitで主要APIのテスト(単体テストやAPI、コマンドの結合テスト)をいくつか書き、テストを書くハードルを下げました。テストコードに限らず、0 → 1 の取り組みをするのはすでにある実装を参考にできないため、心理的なハードルが高くなりがちです。簡単な雛形を用意するとテストコードを書く際にそのコードを参照できるため、心理的なハードルが下がりテスト実装がやりやすくなると考えています。
テストコードのサンプル実装に併せてFactoryなどのユーティリティクラスや、外部APIをモックするのに便利な PHP-VCR も導入しました。カバレッジも集計し、集計結果はCIで coveralls.io に飛ばして可視化しています。年初で0%だったカバレッジですが3ヶ月くらいで28%程度くらいに上がっています。今もテストがどんどん増えており、リファクタリングを行う際は テストコードをしっかり書く → コードを変更する
という流れで安全にリファクタリングしています。カバレッジがすべてではないですが、80%を越えてくるとPHPやライブラリのアップグレードなどの大規模な変更に対しても安心して適用できると思うので、今年中には達成したいですね…!
フォーマッタがなくインデントやsyntaxがバラバラだった件に関しては PHP_CodeSniffer を使ってコード全体を整えました。全ファイルに適用するため開発中のブランチとバッティングするケースもあったのですが、その場合は開発中のコードを正としてマージしてもらい、あとで別途フォーマットを整えるようにしました。タイミングを見てCIに導入するとともに、Makeタスクに自動fixするphpcbfコマンドを入れておいて、CIでコケた場合はそのMakeタスクを叩いてコードを修正してもらうようにチーム全体に周知しました。
静的解析ツールとして PHPStan も導入しました。Laravelを利用しているので Larastan の拡張も使っています。Laravelの特性上、完璧な静的解析はできていないのですが、既存の不具合・不要な変数・不要な関数・インポート漏れなどを大量に検知できており、新規開発でもうっかりミスを防げています。 導入にあたって、明らかな不具合や影響が少ないものは一気に直しつつ、影響範囲が読めない部分や挙動が変わりそうな部分に関しては ignoreErrors を使って地道に改善しています。Level 5はメソッドや関数の引数の型チェックを行うため効果が高く、Level 6は修正範囲が多いわりにそこまで効果が高くなさそうだったので、Yappli CRMではLevel 5で運用しています。
PHPUnitによる自動テストやPHP_CodeSnifferのフォーマットチェック、PHPStanの静的解析はCircleCIで動かして、CIの通過をプルリクエストのマージ条件とすることでコードの品質を担保できるようにしました。CIの結果は circleci/slack のOrbを使ってSlackに通知しています。
また、GitHub Actionsを使ってPRのAsigneesの自動アサインも行っています。詳細は以下の記事を参照ください。
デプロイ
デプロイは git pull && rsync
による手動デプロイだったので Deployer を使ってワンコマンドでデプロイできるようにしました。Deployerはsymlink方式でダウンタイムが発生しないため、日中でも気にすることなく何回もデプロイできるようになりました。改善前は composer install
のコマンドのオプションに --no-dev
や --optimize-autoloader
が入っておらず、開発用のライブラリが入っていたり、autoloadが最適化されていない状態だったのでそのあたりもDeployer導入によって改善しています。
ルーティングのnameが重複していたりアプリケーションコードからの env()
ヘルパーの利用があったため、 最初は route:cache
や config:cache
は一時的に無効化して導入しました。
<?php task('artisan:config:cache')->disable(); task('artisan:route:cache')->disable();
env()
を config()
に変更したり、ルーティングのname重複を解消するなどアプリケーションコードを修正し、すべて修正済みであることを確認した後 route:cache
config:cache
の最適化を入れました。このように段階的に対応することでリスクを最小化しつつ着実に改善していきました。
Apache + mod_phpの組み合わせだとOPcacheのクリアも必要だったのでデプロイ時に一時的にOPcacheクリア用のエンドポイントを立てて、Deployerのタスクでそのエンドポイントを叩くことでOPcacheのクリアをしています。
<?php task('opcache:clear', function() { $webDir = '/path/to/deploy/current/public'; $randomName = bin2hex(random_bytes(20)); run("echo '<?php opcache_reset(); ?>' > $webDir/$randomName.php"); run("curl http://localhost/$randomName.php"); run("rm $webDir/$randomName.php"); })->select('role=web');
DeployerはSSHによるデプロイなので、Deployerを実行するサーバーからデプロイ先にSSHログインする権限や、SSHログインしたアカウントから git pull
できる権限が必要です。今回はSSH Agent Forwardingを使って、Deployerを実行するサーバー・ユーザの秘密鍵に対応する公開鍵を GitHubのDeploy Keys として設定し、公開鍵をデプロイ先のサーバー・実行ユーザの ~/.ssh/authorized_keys
に撒きました。これにより、デプロイ先のサーバー台数分のDeploy Keysを設定する必要なく、Deployerを実行するサーバーの分だけ設定すればよくなりました。設定後は以下のようにして ssh-agent
や ssh-add
によるSSH Agent Forwardingを使ってデプロイしています。
eval $(ssh-agent) ssh-add /home/deploy_user/.ssh/id_ed25519 vendor/bin/dep deploy
Deployerのデプロイの各タスクの前後でSlack通知を行い、デプロイ状況の可視化もしています。
<?php require 'contrib/slack.php'; option('slack', null, InputOption::VALUE_NONE, 'send notification to slack'); // Slack before('deploy', function() { if (input()->getOption('slack')) { invoke('slack:notify'); } }); after('deploy:success', function() { if (input()->getOption('slack')) { invoke('slack:notify:success'); } }); after('deploy:failed', function() { if (input()->getOption('slack')) { invoke('slack:notify:failure'); } }); set('slack_webhook', '{webhook url}'); set('slack_channel', '{チャンネル名}'); set('slack_title', '{通知タイトル}');
オブザーバリティ
ログを見るとDEBUGログが大量に出ており、適切なログ情報を確認することが難しい状況でした。DEBUGログを活用した調査タスクがほとんど無かったため、ひとまず環境変数のLOG_LEVELをinfoにしてログレベルがINFO以上のログだけ出すようにしました。
アプリケーションのエラー通知の仕組みも改善しました。改善前はアプリケーションから直接SlackのAPIを呼び出して通知していたのですが、これだと大量のエラーが発生した場合にSlackチャンネルが溢れてしまう可能性があります。Yappliのサービスではエラー管理に Sentry を利用しているため、Yappli CRMでも同様にSentryにエラーを送信するように変更し、Sentry経由でSlackに通知するように修正しました。
app('sentry')->captureException($exception);
Sentry経由でSlack通知を行うことにより、「最初に発生したエラーだけ通知する」「5分に1回通知する」といった通知設定や、エラーの解消状況の管理もできるようになりました。
インフラのメトリクスが弊社側で管理・監視できていなかった課題については、New Relic にインフラのメトリクスを送信してダッシュボードで閲覧したり、閾値を超えた場合にアラート通知できるように改善しました。
さらに簡易的にログを閲覧したり監視を行うため、Apacheのログを CloudWatch Logs や New Relic に転送しています。New RelicではHTTPステータスコードが400系500系のリクエスト数を見れるダッシュボードを作ってリリース時に傾向変化が無いかチェックできるようにしたり、エラーのレスポンス数が閾値を超えた場合にSlack通知を行う監視アラートを入れています。
DBのデータの簡易的な確認とデータを利用した監視を行うため、Redash も導入しました。これまでお客様の利用状況や影響範囲の調査を行うには、エンジニアがMySQLクライアントを使ってデータを抽出し、抽出結果のCSVをSlackなどで共有していました。Redashを導入したことにより非エンジニアでもデータを簡単に見れるようになり、データの抽出結果をクエリのリンクとして簡単に共有できるようになりました。Redashのアラート通知も活用しており、キューのストレージとして利用しているMySQLのデータを監視することで、キュー詰まりを検知してSlackにアラート通知しています。
ちなみにRedashでは個人情報を見れないように個人情報用のビュー(例: xxx__view
)を作成し、そのビューに対して権限を付与したDBユーザをデータソースとして設定しています。
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; class AddViewsForRedash extends Migration { public function up() { // 個人情報を除外したusers view DB::statement(<<<SQL CREATE VIEW users__view AS SELECT id, // ... created_at, updated_at FROM users; SQL ); } public function down() { DB::statement('DROP VIEW users__view'); } }
アプリケーションコード
スーパーグローバル変数を利用していた箇所があったので、Requestクラスのメソッドに置き換えました。Requestのインスタンスを引数としてリレーさせて利用するか、修正箇所・影響範囲が大きい場合は暫定対応としてヘルパー関数の request()
を使って置き換えています。これによりスーパーグローバル変数の管理をする必要がなくなり、テストがしやすくなりました。
<?php // before $_COOKIE['hoge']; $_REQUEST['fuga']; $_SERVER['REQUEST_URI']; // after /** @var $request Illuminate\Http\Request */ $request->cookie('hoge'); $request->get('fuga'); $request->getRequestUri();
またテーブルのID値にUUID v4である Str::uuid() を利用していたのですが、UUID v4だと時系列順に並ばないことでDBのストレージ・検索効率が悪くなるため、Laravelの Str::orderedUuid() を利用するように変更しました。
リファクタリングも多数実施しています。簡単なものだと、status系のenumの数値がベタ書きで書かれていたのをクラス定数*2に置き換えることで可読性・保守性を上げました。
<?php // before $user->type = 1; // after $user->type = User::TYPE_ADMIN;
全体的にコントローラにロジックがベタ書きされていて、モデルにロジックがあまり書かれていないドメインモデル貧血症なコードだったので、前述の静的解析やテストコードを導入した上で、クラス・関数に切り出すなどのリファクタリングも行っています。さらに、不要なAPI・クラス・関数も整理・削除も行いました。
インフラ
インフラ構造を把握するために、有識者へのヒアリングやリバースエンジニアリングを行いました。全体構成を把握した上で運用・管理・コストの面での課題を改善していきました。
改善の1つとして、不要なサービス・インスタンスの確認及び削除を実施しています。例えば、お客様のシステムに応じた個別のデータ連携バッチがあり、これらはお客様ごとに立てた個別のEC2インスタンス上で実行されていました。実行するEC2インスタンスを分けることでCPUやメモリを食い合わないようにしたり個別にスケールできるメリットはあるのですが、運用管理のコストやEC2のインスタンスのコストがその分増えてしまっていました。そのため、個別のEC2インスタンスを廃止し、個別のデータ連携バッチは共通バッチサーバーで実行するように変更することで、管理性の向上とコスト削減を行いました。EC2以外だとRedshiftクラスタの整理・削除も実施しました。利用していない時間帯はRedshiftのSchedulerを使って停止・再開するようにして、運用性を保ちつつコストを大幅に削減しました。
Yappli CRMはAurora(MySQL)を主なデータ保存先として利用しているのですが、リードレプリカが無いライター1台構成となっていました。リードレプリカがなくてもリソース的には余裕があったのですが、フェイルオーバー時のダウンタイムを短くするためにもリードレプリカを入れるように改善しました。AWS側でリードレプリカを追加したら config/database.php にリードレプリカのエンドポイントを設定することで、SELECTのクエリがリードレプリカに自動で向くようになります。
<?php # config/database.php return [ 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'read' => [ 'host' => env('DB_HOST_READ', env('DB_HOST', 'localhost')), ], 'write' => [ 'host' => env('DB_HOST_WRITE', env('DB_HOST', 'localhost')), ],
これだけだと、Writeして即Readするような処理の場合はレプリケーションが間に合わずデータの整合性が取れなくなる可能性があるため、stickyのオプションも入れています。これにより、Write系の操作をした場合は以後、リードレプリカではなくライターのインスタンスの方にSELECTするように切り替わります。便利!
'sticky' => true,
他には、GitHubリポジトリがパートナーさんの組織に紐づく形で管理されていたのですが、弊社側の組織に紐づく形にしたほうがCIやcoveralls.ioなどのサービスとの連携や設定調整がしやすくなるため、リポジトリの移行も行いました。新しく弊社側にリポジトリを切って、既存のリポジトリのコードをclone => 弊社側のリポジトリにプッシュする、という感じで移行していきました。大きめの開発が並行して行われている過渡期では、既存のリポジトリへの変更を弊社側リポジトリに定期的にマージすることで差分が発生しないようにして、大きい開発が無くなったタイミングで全体周知して一気に切り替えを行いました。
細かいところだと、各EC2インスタンスにbastion経由でSSHログインしていたのを、Session Managerを使うことでbastionを介さずにSSHログインできるようにしました。これにより公開鍵をすべてのサーバーに撒く必要がなくなり運用が楽になりました。
その他
プロダクト開発を円滑に進めるため、Jiraのプロジェクト発行・メンバ割当て・カンバンボードの管理・Slack通知の設定も行いました。 GitHubのコミット時・プルリク作成時などのSlack通知も設定しました。クレデンシャルの管理・共有もうまく行われていなかったので、1Password*3でVaultを新規作成してそこで開発に関連するクレデンシャルを運用するようにしました。
改善結果
開発環境やREADMEを始めとするドキュメントが整備されたことにより、新規参画者のオンボーディングが簡単に迅速に行えるようになりました。デプロイ改善の効果もあり、現在は多い月で月間50以上のプルリクエストがリリースされています。オブザーバビリティの改善によってリリース後の不具合検知・調査も速やかに行えるようになりました。インフラの見直しにより利用金額が月間で20万円以上削減できました。CI環境の整備やインフラの見直しにより堅牢性・冗長性が上がり、アプリケーションコードのリファクタリングにより保守性が向上しました。
まとめ
Yappli CRMの改善事例について紹介しました。振り返ってみると目新しいことは全くやっておらず、どの会社・サービスもやっているような普通の改善がほとんどなのですが、この 普通の改善
の積み重ねこそが開発を推進していく上で大事なのだと思います。
今後もお客様の課題を解決できるプロダクトを実現していくために、普通の改善
を積み上げていきます!