dodaの技術負債を解消するコンテナ環境で動くAPIサーバー

dodaの技術負債を解消するコンテナ環境で動くAPIサーバー

こんにちは。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台を動かしています)

APIサーバー(hydrogen)は負荷に応じてインスタンスを増減させる

またこれによってサーバーが急に落ちてしまっても自動復旧するため、運用コストも下げることが出来ました。

Blue/Greenデプロイ

既存のシステムのリリースでは、起動中のサーバー1台1台に対して、LBの切り離し、対象ソースの配置、アプリケーションの起動、LBの接続をしていました。

既存システムのリリース時には、1台ずつLBからの切り離しを行う

この方法だと以下のような課題があります。

  • リリースに時間がかかる
  • 台数が減る時間が存在するため、負荷を考慮して台数を多く持つ必要がある
  • 一時的にリリース前・リリース後のインスタンスが混在する状態となるため、修正方法を考慮しないとエラーが発生する
  • 問題発生時の切り戻しも1台ずつ行う必要があるため時間がかかる

そこで今回はBlue/Greenデプロイを実現しました。
今回の方式では、リリース時には起動中のインスタンスの台数と同じ台数を、別のインタンスとして並行に起動するため比較的速くリリースが完了します。
また全台起動しヘルスチェックが通った後にLBの向き先を切り替えるため、リリース前後の状態が混在することもなく安全です。
そして問題発生時にも、切り替え前のインスタンスを一定時間保持させているため、リリース時と同様にLBの向き先を変えるだけで迅速に戻すことが可能になりました。

起動中のインスタンスと同数のインスタンスを起動し、LBの向き先を切り替える

CI/CD

既存システムではJenkinsを利用してデプロイを実施していましたが、一部手動でリリースが必要となり若干の手間でした。

今回はリリース用リポジトリにマージしたら自動でパイプラインが起動しビルド・テスト・デプロイが実行されるようになりました。
ステージング~本番リリースでは、確認したうえでリリースとしたかったため承認フローを設けることも出来ました。

ステージング環境までのCI/CD。テスト環境は承認フローはなくデプロイまでされます。

本番環境のCI/CD

ステージング環境でビルドされたコンテナイメージは、本番環境にクロスアカウントレプリケーションでコピーされます。
本番リリースの際には、リリースしたいイメージを記載したtaskdef.jsonを修正しpushすることでリリースされるという仕組みにしています。

ライブラリのキャッシュ

パイプラインを作成して困ったことが一点あり、ライブラリの取得が毎回発生しビルドが遅いという問題がありました。
こちらは取得したライブラリをS3へキャッシュすることで、2分程度速くすることが出来ました。

buildspec.yml の抜粋

cache:
  paths:
    - '/root/.gradle/caches/**/*'

CloudFormationは AWS::CodeBuild::ProjectProperties.Cache のTypeにS3を、Locationにキャッシュの保管場所を指定すれば利用可能です。

CloudFormation Templateファイル の抜粋

    Properties:
      Cache:
        Type: S3
        Location: <バケット名>/<フォルダ>

参考:AWS CodeBuild でのキャッシュのビルド


ここからアプリケーション寄りの内容です。

ログ運用

本番で問題が発生した際には基本的には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が利用可能なランタイムが出ています

参考:CodeBuild 使用可能なランタイム

クリーンアーキテクチャの導入

既存のシステムでは基本的には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のバージョンアップや上記に記載したようなリリース時点で対応出来なかったものも少しずつ対応出来ました。

今後はより多くの内製メンバーが開発から保守まで出来るようにすることを目標として引継ぎを進めていく予定です。
また同時に今回のシステムを活かしてマイクロサービス化を進めていきたいと考えています。
ぜひ一緒に開発をしたいという方がいらっしゃいましたら、お気軽にお問合せください。

プロダクト開発統括部 第1開発部 dodaサイト開発グループ リードエンジニア 齋藤 悠太

齋藤 悠太 Yuta Saito

プロダクト開発統括部 エンジニアリング部 dodaサイト開発グループ リードエンジニア

SIerや事業会社業務での開発を経験し、2020年9月にパーソルキャリアに入社。現在はdodaサイト開発に携わっている。好きな技術領域はJava、Spring、AWS。

 

※2022年9月現在の情報です。