こんにちは。dodaサイト開発グループの齋藤です。
doda トップページリビルドプロジェクトにて、コンテナ環境で動くAPIサーバー(hydrogenと社内では読んでいます)を作成しました。
そのAPIサーバーの開発が活発化してきたため、社外向けへの知見の共有と、社内のチーム向けのドキュメントとして、プロジェクトにおいて工夫した点などをこの記事にて公開することにします。
なぜAPIサーバー(hydrogen)を作成したのか
これまでdodaではJava側でHTMLまで返すMPA(Multiple Page Application)で作られていました。
しかし今回のdodaトップページリビルドプロジェクトではSPA(Single Page Application)で作っており、APIが必要になりました。
参考:フロントエンドに関する記事はこちらです。
APIの作成は既存のシステムでも可能ですでにAPIが用意されているものもありましたが、既存のシステムには課題もあったため、今回はあえて別にAPIサーバーを用意する形を取りました。
既存のシステムの課題は以下のようなものになります。
- レガシーなコードでテストコードを書きにくく、自動テストが出来ていない。
- セッション情報などのデータをアプリケーション上に保持しているためスケールしにくい
- デプロイが半分手動
- ミドルウェアの更新がしにくい
- インフラ部分の作成・運用を内製化出来ていないため変更に時間がかかる
- インフラ部分の設計書が散らばっていて確認するのに苦労する
この状態で品質を保ちつつ高い生産性で開発するのは難しいため、上記の課題を解消する目的でAPIサーバーを作成しました。
自動テストについては記事記載時点では解消済みで、既存システムでもテストコードを書く運用にしました。
システム構成
作成したAPIサーバーのシステムは、以下の通りです。
ログの出力先が複数あるのは以下のような使い分けになっています。
送信先 | 用途 |
---|---|
CloudWatch | FluentBit/Datadog Agentのログを格納。 また検証環境ではDatadogを使えない環境もあるためアプリケーションログを格納しているものもある |
Datadog | 直近のアプリケーションログを格納 |
Kinesis/S3 | 長期保管の目的でログを格納 |
以降に工夫した点について具体的に記載していきます。
CloudFormationによるインフラリソースのソース管理
今回作成したリソースは可能な限りCloudFormationを利用して作成し、アプリケーションと同じリポジトリで管理することにしました。
これによりインフラがどうなっているかも可視化され、修正もGitの差分として確認することもできます。
またアプリケーションを開発する人もインフラを確認することができ、DevOpsを推進出来るようになったと考えています。
CloudFormationで作成しなかったリソースとその理由は以下です。
リソース | CloudFormationを利用していない理由 |
---|---|
Systems Manager Parameter Store | セキュアな情報を格納するため手作業で追加しています。 |
ECS Task/Service | 自動デプロイ出来るようにするために、taskdef.jsonとappspec.ymlを利用して管理しています。 |
CodeDeploy | 一部CloudFormartionで対応出来ていない部分があったためdeploy-group.jsonを用意してAWS CLIを利用して作成しました。 |
コンテナの利用
既存のシステムではEC2を利用してサーバーを立てていましたが、今回のサービスではECSを利用して運用しています。
既存のシステムには以下のような課題がありました。
- ミドルウェアバージョンアップに工数がかかる(コード管理出来ていない)
- インスタンスの増減が難しい(サーバーの作成に手作業が多い)
- デプロイが複雑(独自のデプロイシェルなどが複雑になっていく)
これらはAnsibleやゴールデンイメージを利用することで改善は出来ますが、コンテナにおいてはさらに簡単に実現可能になりました。
またAWSの以下のような機能を活用してより安全に効率良く運用することが出来るようになりました。
Auto Scaling
既存のシステムではインスタンスの台数を固定して用意しています。
しかしそれだと負荷の低い時間(深夜・休日など)に余分なリソースが発生し、本来は不要なコストがかかっています。
そこで今回AutoScalingを導入し、通常時は2台のインスタンスを立ち上げて、負荷が高い時間帯には自動的にインスタンスを増加させる形にしました。
AutoScaling の判断はCPU使用率を利用しています。
(1台でも耐えられる負荷ではありますが、マルチAZで運用するためにそれぞれのAZに1台ずつの計2台を動かしています)
またこれによってサーバーが急に落ちてしまっても自動復旧するため、運用コストも下げることが出来ました。
Blue/Greenデプロイ
既存のシステムのリリースでは、起動中のサーバー1台1台に対して、LBの切り離し、対象ソースの配置、アプリケーションの起動、LBの接続をしていました。
この方法だと以下のような課題があります。
- リリースに時間がかかる
- 台数が減る時間が存在するため、負荷を考慮して台数を多く持つ必要がある
- 一時的にリリース前・リリース後のインスタンスが混在する状態となるため、修正方法を考慮しないとエラーが発生する
- 問題発生時の切り戻しも1台ずつ行う必要があるため時間がかかる
そこで今回はBlue/Greenデプロイを実現しました。
今回の方式では、リリース時には起動中のインスタンスの台数と同じ台数を、別のインタンスとして並行に起動するため比較的速くリリースが完了します。
また全台起動しヘルスチェックが通った後にLBの向き先を切り替えるため、リリース前後の状態が混在することもなく安全です。
そして問題発生時にも、切り替え前のインスタンスを一定時間保持させているため、リリース時と同様にLBの向き先を変えるだけで迅速に戻すことが可能になりました。
CI/CD
既存システムではJenkinsを利用してデプロイを実施していましたが、一部手動でリリースが必要となり若干の手間でした。
今回はリリース用リポジトリにマージしたら自動でパイプラインが起動しビルド・テスト・デプロイが実行されるようになりました。
ステージング~本番リリースでは、確認したうえでリリースとしたかったため承認フローを設けることも出来ました。
ステージング環境でビルドされたコンテナイメージは、本番環境にクロスアカウントレプリケーションでコピーされます。
本番リリースの際には、リリースしたいイメージを記載したtaskdef.jsonを修正しpushすることでリリースされるという仕組みにしています。
ライブラリのキャッシュ
パイプラインを作成して困ったことが一点あり、ライブラリの取得が毎回発生しビルドが遅いという問題がありました。
こちらは取得したライブラリをS3へキャッシュすることで、2分程度速くすることが出来ました。
buildspec.yml の抜粋
cache: paths: - '/root/.gradle/caches/**/*'
CloudFormationは AWS::CodeBuild::Project
の Properties.Cache
のTypeにS3を、Locationにキャッシュの保管場所を指定すれば利用可能です。
CloudFormation Templateファイル の抜粋
Properties: Cache: Type: S3 Location: <バケット名>/<フォルダ>
ここからアプリケーション寄りの内容です。
ログ運用
本番で問題が発生した際には基本的にはDatadogでログを確認します。
この時デフォルトのログ出力方式のままだと改行ごとに分かれて出力されます。
スタックトレースなども分割されてしまうため、エラー発生時の原因調査に余計な時間がかかってしまいます。
デフォルトのログ出力 抜粋
2022-09-21 10:16:15.376 ERROR 17788 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: test] with root cause java.lang.RuntimeException: test at com.example.forblogtest.HelloController.index(HelloController.java:12) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
そのためJSON形式で出力するようにして、エラーの行は1行で出力するようにしました。
{"@timestamp":"2022-09-21T10:18:20.396+09:00","@version":"1","message":"Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: test] with root cause","logger_name":"org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/].[dispatcherServlet]","thread_name":"http-nio-8080-exec-2","level":"ERROR","level_value":40000,"stack_trace":"java.lang.RuntimeException: test\r\n\tat com.example.forblogtest.HelloController.index(HelloController.java:12)\r\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\r\n省略 "}
JSONで出力するには logstash-logback-encoder
を利用します。
このシステムではGradleを利用しているため、build.gradleに以下を追加します。
implementation 'net.logstash.logback:logstash-logback-encoder:7.2'
ただしローカルで開発する際はJSON形式では見にくいため、logback-spring.xmlを作成しprofileによって出力方式を変更するようにしています。
<?xml version="1.0" encoding="UTF-8"?> <configuration> <springProfile name="local"> <include resource="org/springframework/boot/logging/logback/base.xml" /> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </springProfile> <springProfile name="prd"> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </springProfile> </configuration>
参考:docs.spring.io - 4.9.1. Profile-specific Configuration
セキュアな情報の管理
DB接続情報などのセキュアな情報はAWS Systems Manager Parameter Storeで管理し、ECSタスク起動時に環境変数に展開するようにしています。
taskdef.json
"secrets": [ { "name": "DATASOURCE_URL", "valueFrom": "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxx:parameter/datasource_url" } ]
application.yml
spring: datasource: url: ${DATASOURCE_URL}
アカウント名・パラメータ名などは仮のものです
Fargate プラットフォームバージョン 1.4からはSecrets Managerを利用可能ですが、諸事情によりSystems Manager Parameter Storeを利用しました。
参考: - ECSタスク起動時にSystems Manager Parameter Storeから値を取得する方法 - Secrets Manager を使用して機密データを保護する
Java17/SpringBoot2.5
今回のAPIサーバーでは Java17/SpringBoot2.5が利用されています。(リリース時点。2022年9月現在はSpringBoot2.7を利用しています)
doda トップページリビルドプロジェクトは2021年6月に開始し2021年10月にリリースしました。
プロジェクトの開始時にはJava17はリリースされていなかったため、開発はJava11、SpringBoot2.5で実施していましたが、リリース直前にJava17がリリースされたため急遽Java17にバージョンアップしてリリースすることが出来ました。
開発がほぼ終わった段階でのバージョンアップだったため、新機能を多くの箇所で利用することは出来ませんでしたが、一部のコードには適用出来たのでご紹介します。
Recordsの利用
RecordsでBeanの作成が楽になりました。
Java11でのConfigurationPropertiesの設定
@ConstructorBinding @ConfigurationProperties("spring.datasource") public class DataSourceProperties { private String url; private String username; private String password; private String driverClassName; public DataSourceProperties(String url, String username, String password, String driverClassName) { this.url = url; this.username = username; this.password = password; this.driverClassName = driverClassName; } // その他各種getter }
Java17でのConfigurationPropertiesの設定
@ConstructorBinding @ConfigurationProperties("spring.datasource") public record DataSourceProperties( String url, String username, String password, String driverClassName) {}
org.springframework.boot.autoconfigure.jdbc.DataSourcePropertiesがあるため、今回のサンプルコードは本来作成不要ですが、イメージとして分かりやすいように書いています。
SpringBoot2.6からrecordを利用する場合は@ConstructorBindingは不要となっています
テキストブロック(Text Blocks)
テキストブロックはSQLを利用する箇所で利用しています。
Doma2を利用していますが、SQLファイルを用意せずにinterface上でSQLを書くようにしました。
Javaのコードを読んでいる途中で急にSQLファイルに飛ぶと探しにくいためJavaのコードの中にSQLがある方が個人的には見やすく感じます。
@Dao public interface UserDao { @Select @Sql( """ select id, name from users where id = /* id */'0' """) Optinal<User> findById(String id); }
パターンマッチング(Pattern Matching)
パターンマッチングはExceptionHandlerとして複数種類のExceptionをキャッチしつつ、同一のレスポンス(Statusやメッセージ)を返したいような場合に利用しています。
@ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler({NotFoundException.class, ExpiredException.class}) @ResponseBody public ErrorResponseBody notfoundHandle(Exception ex) throws Exception { final String RESPONSE_MESSAGE = "%s は見つかりませんでした。"; if (ex instanceof NotFoundException notFoundException) { return new ErrorResponseBody( String.format(RESPONSE_MESSAGE, notFoundException.getId()), Collections.emptyList()); } else if (ex instanceof ExpiredException expiredException) { return new ErrorResponseBody( String.format(RESPONSE_MESSAGE, expiredException.getId()), Collections.emptyList()); } else { // ここには到達しない throw ex; } }
バージョンアップで詰まったポイント
Java11からJava17にバージョンアップする際に2点ほど詰まりポイントがあったので記載します。
JEP396
今回フォーマッタにはSpotlessを利用しているのですが、Java16でリリースされたJEP396の影響で、JDKの内部クラスがカプセル化されエラーが発生しました。
Gradleタスク実行時に、javacのいくつかのクラスにアクセスできるように設定し解消させました。
build.gradleに以下設定を作成
org.gradle.jvmargs=--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
参考:https://github.com/diffplug/spotless/issues/834
CodeBuildのJava17対応
Java17がリリースされた直後は、CodeBuildでJava17対応がされていなかったと記憶しています。
そのためbuildspec.yamlに以下を記載してJava17を取得・設定してからビルドを行うようにしました。
phases: install: commands: - apt-get update -y - apt-get install -y java-17-amazon-corretto-jdk - JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto/
記事記載時点ではJava17が利用可能なランタイムが出ています
クリーンアーキテクチャの導入
既存のシステムでは基本的にはMVCで作られていますが、ルールがあまり決まっておらず、ロジックがいろいろなパッケージに散らばってしまっているような状態でした。
今回はクリーンアーキテクチャを導入してどこに何を書くかを決めてしまい、可読性を高めてかつテストしやすい状態にしました。
大枠のレイヤーとその依存関係はこちらです。
詳細なパッケージ構成になります。
今回は詳細な説明はしませんが、社内向けには1つ1つのパッケージの役割やそれぞれのレイヤーごとに考える必要がある観点や注意点などを整理したドキュメントを用意しています。
また機会があればtechtektの記事にしようと思います。
ArchUnit
クリーンアーキテクチャを導入しましたが、パッケージを用意するだけだと守ってもらえないことがあったり、意図せずルール違反してしまったりする可能性もあるため、ArchUnitを利用して対策を行いました。
ArchUnitを利用することで事前にレイヤー間(パッケージ間)の依存関係をテストコードとして定義しておいて、それに違反するコードがあった場合はテストコードを失敗させてビルド・デプロイさせないようにしました。
作成した制約の例は以下です。
- レイヤー間の依存関係
例:プレゼンテーションレイヤーは他のレイヤーから呼び出されない - 各パッケージが依存して良いパッケージを定義
例:domain.modelパッケージは外部ライブラリを呼び出さない - パッケージ内に配置してよいクラスの種類を指定
例:ドメインレイヤーのrepositoryパッケージはインターフェースのみを作成可能 - 利用可能なライブラリの指定
例:StringUtilsはApache Commons Lang3のみを利用可能 - アノテーションに関する制約
例:@RequestMappingにv1が含まれるエンドポイントはv1パッケージにのみ利用可能
レイヤー間の依存関係を定義したコードのサンプル
@Test void レイヤー構造チェック() { layeredArchitecture() .layer("Presentation").definedBy("..presentation..") .layer("Domain").definedBy( "..domain.model..", "..domain.repository..") .layer("UseCase").definedBy("..domain.usecase..") .layer("Infrastructure").definedBy("..infrastructure..") .whereLayer("Presentation") .mayNotBeAccessedByAnyLayer() .whereLayer("Domain") .mayOnlyBeAccessedByLayers("Presentation", "Infrastructure", "UseCase", "Domain") .whereLayer("UseCase") .mayOnlyBeAccessedByLayers("Presentation") .whereLayer("Infrastructure") .mayNotBeAccessedByAnyLayer() .check(CLASSES); }
暖気処理
コンテナ起動直後のリクエストへのレスポンスが遅い問題がありました。
そのため暖気を導入してSpringBoot起動時に内部的にリクエストを投げて暖気するという処理を入れています。
暖気処理はApplicationRunner
を継承したクラスを用意してWebClientで自身の対象エンドポイントに一定回数リクエストを投げることで実現しています。
参考:Using the ApplicationRunner or CommandLineRunner
ドキュメントの用意
今後、いろいろな方に開発してもらえるようにするために、各種ドキュメントの作成を行いました。
またドキュメントが腐敗しないように、基本的にはコードと同じリポジトリに配置してGit管理する形にしています。
(Gitをクローンするまでの方法についてのみ、同一リポジトリではなく別のドキュメントを用意しています)
作成したドキュメントは以下のようなものです。
- README (システムの概要と各種ドキュメントへのナビゲーション)
- システム構成について
- パッケージ構成について
- API設計ガイドライン
- コミットルール
- リリースガイドライン
- ログ設計ガイドライン
- ローカルでのDocker利用方法
- AWSリソースに関するもの(現在のリソースについての説明と修正するときの方法などを記載)
- システム運用ガイドライン
- 当システムを実際に開発し始めるまでの準備方法(クローンから各種アカウントの準備方法など)
リリース時点で対応できなかったこと
以下のようなものは対応したかったものの、リリース時点では対応が出来なかったものです。
後日別記事で記載するものもあるかもしれませんが、当記事作成時点ではほぼ対応が完了出来ているため状況も記載します。
- SonarQubeを利用したコード分析
CIのタイミングでソース連携を行う予定でしたが、社内的な事情で断念しました。代わりに静的コード解析(SpotBugs)を現在は導入済みです。 - OpenAPIでのAPI IF定義作成
現在は導入済みです。 - ライブラリの脆弱性チェック自動化
現在はOWASP Dependency-Checkを利用した仕組みを導入済みです。 - E2Eテスト
現在はTestContainersを利用した仕組みを導入済みです。 - 保守を含めて内製メンバーで管理出来るように引継ぎ
現在引継ぎ進行中です。
現在の状況
現在は既存のシステムから、今回作成したAPIサーバーへ移行するための開発が順次進んできている状況です。
私以外の人が開発していく中で、当初想定できなかった課題も多く発生し少しずつ改善も進めています。
またさらにシステムを良くするための取り組みとして、SpringBootのバージョンアップや上記に記載したようなリリース時点で対応出来なかったものも少しずつ対応出来ました。
今後はより多くの内製メンバーが開発から保守まで出来るようにすることを目標として引継ぎを進めていく予定です。
また同時に今回のシステムを活かしてマイクロサービス化を進めていきたいと考えています。
ぜひ一緒に開発をしたいという方がいらっしゃいましたら、お気軽にお問合せください。
齋藤 悠太 Yuta Saito
プロダクト開発統括部 エンジニアリング部 dodaサイト開発グループ リードエンジニア
SIerや事業会社業務での開発を経験し、2020年9月にパーソルキャリアに入社。現在はdodaサイト開発に携わっている。好きな技術領域はJava、Spring、AWS。
※2022年9月現在の情報です。