HA構成のアプリのHTTPセッション管理にRedis を使う
今更な話ではありますが、AWSのECS Fargate と ALBを使った簡単なデモアプリの環境構築をTerraformで進めていて、セッション管理について調べていたら「ALB側でパーシステンス (スティッキーセッション)を実装するより、ErastiCashなどをセッションストアとして使う」というのを知りました。
個人的にHA構成の経験がほとんどなくRedisも使ったことが無かったことと、またローカル環境でHA構成の動作確認が行えると便利だと思ったので作ってみました。
構成
ローカル環境で動作確認をしたかったので、Docker-compose を使って以下のような構成を用意します。
- ロードバランサ: HAProxy
- アプリケーションコンテナ: Spring Boot
- セッションストア: Redis
Spring Boot アプリ
まずはSpring Boot (Spring MVC)を使って、セッションの状態を確認できる簡単なアプリを作ります。
こちらはセッション管理するBean
package net.kazokmr.study.hasession; import org.springframework.stereotype.Component; import org.springframework.web.context.annotation.SessionScope; import java.io.Serializable; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @Component @SessionScope public class Reception implements Serializable { private static final long serialVersionUID = -3101986789734320497L; private String host; private LocalDateTime receptionAt; private String message; public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getHost() { return host; } public void setHost(String host) { this.host = host; } public String getReceptionAt() { return receptionAt == null ? null : DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss").format(receptionAt); } public void setReceptionAt(LocalDateTime receptionAt) { this.receptionAt = receptionAt; } }
コントローラではこのBeanに「状態」「ホスト名」「実行日時」を登録するメソッドとセッションを破棄するメソッドを用意してリクエストを受け付けます。
package net.kazokmr.study.hasession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpSession; import java.net.InetAddress; import java.net.UnknownHostException; import java.time.LocalDateTime; @RestController public class ReceptionController { private final Reception reception; @Autowired public ReceptionController(Reception reception) { this.reception = reception; } @GetMapping("/hello") public String hello() throws UnknownHostException { if (reception.getReceptionAt() == null) { reception.setHost(InetAddress.getLocalHost().getHostName()); reception.setReceptionAt(LocalDateTime.now()); reception.setMessage("セッション開始"); } else { reception.setMessage("セッション中"); } return String.format("%s, ", reception.getMessage()) + String.format("Host: %s, ", reception.getHost()) + String.format("受付日時: %s", reception.getReceptionAt()); } @GetMapping("/bye") public String goodbye(HttpSession session) { session.invalidate(); return "セッション切断"; } }
これをIntelliJから起動しChromeからアクセスすると以下のように出力します。
Spring Boot アプリをDockerコンテナで起動する
このアプリケーションをDockerコンテナから起動してアクセスしてみます。尚、ホスト名をわかりやすくするために起動時のコマンドに -h オプションを付けます。
docker run --name <コンテナ名> -p 8080:8080 -h docker-container-1 <イメージ>
HAProxy をDockerで起動する
HAProxyのDockerイメージは公式に記載されている通り、公式イメージに haproxy.cfg を上書きしたイメージをビルドして起動します。
FROM haproxy:2.3 COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg
haproxy.cfg はアプリケーションコンテナを3つ用意し、checkで死活監視とラウンドロビンで振り分けているだけとなります。
global daemon maxconn 256 defaults mode http timeout connect 5000ms timeout client 50000ms timeout server 50000ms frontend http-in bind *:80 default_backend servers backend servers balance roundrobin server app01 app01:8080 check server app02 app02:8080 check server app03 app03:8080 check
このhaproxyコンテナとSpring Bootアプリケーションコンテナを docker-compose で起動します。
app01 - 03 は予めDockerイメージを作成しています。またhostnameオプションを使って、どのコンテナにアクセスしているわかりやすくしました。
version: '3' services: haproxy: build: ./haproxy ports: - 80:80 app01: image: sessiontest:latest hostname: docker-container-1 app02: image: sessiontest:latest hostname: docker-container-2 app03: image: sessiontest:latest hostname: docker-container-3
結果
haproxy ではパーシステンスを行っていないためセッションが保持されません。なのでブラウザをリロードする度に、ラウンドロビン方式によって別のアプリコンテナに対して新規接続を行ってしまいます。
HAProxyでパーシステンスを実現する場合
以下のブログの内容を参考にhaproxy.cfg のbackend セクションで、JSESSIONID に振り分け先のserver追加してcookie にセットすることで2回目以降のアクセスも同じアプリケーションサーバーに接続するようにします。
Load Balancing, Affinity, Persistence & Sticky Sessions
HAProxyでHTTPロードバランシング - CLOVER🍀
backend servers balance roundrobin cookie JSESSIONID prefix nocache server app01 app01:8080 check cookie app01 server app02 app02:8080 check cookie app02 server app03 app03:8080 check cookie app03
haproxyのDockerイメージを再ビルドして起動したところ、セッションが継続されました。
Redis でセッション管理を行う
本題はこちらなので、HAProxyは、再度Cookie設定を戻してセッションが継続されない状態に戻します。
アプリをRedisに対応させる
Reidsをキャッシュストアとして利用するように Spring Boot アプリを変更します。
はじめに開発用にRedisのDockerコンテナを起動します。 Docker
docker run --name redis -p 6379:6379 -d redis
Spring Boot アプリケーション側の設定変更はこちらのチュートリアルを参考にしました。 https://docs.spring.io/spring-session/docs/current/reference/html5/guides/boot-redis.html
RedisをSessionキャッシュにするために Spring Session Data Redis を依存関係に追加します。
更にアプリケーションからRedisに接続すためのクライアントとしてJedis も追加します。 Jedisは前述のチュートリアルでは含まれていませんが、これを入れないとSpring Boot起動時にRedisに接続できずにエラーとなってしまいます。
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>
最後に application.properties で sessionの保存先をredis に変えます。
spring.session.store-type=redis
これだけでHttpSessionの保存先をRedisに変えるための springSessionRepositoryFilter beanを生成してくれるようです。 またRedisの接続先やSessionタイムアウトの管理なども application.properties に追加してカスタマイズできますが、ローカルのRedisに接続するだけであればこれだけでOKです。
詳細は前述のチュートリアルをご覧ください。
Redis に保存されているセッション情報を見る
Docker Composeで環境を作る前に、IntelliJからアプリケーションを起動してRedisにどのように格納されているか見るため、アプリにアクセスしていない状態から確認します。データの検索はRedisのクライアントツールであるRedis-cli を使用します。このツールは Redisにバンドルされているので、Redisコンテナの中に入り利用してみます。
docker exec -it redis /bin/bash root@redis:/data# redis-cli 127.0.0.1:6379>
接続したら keys * と実行すると保存されているKeyの一覧が表示されます。まだアプリケーションにアクセスしていないので (empty array) と出力されます。
127.0.0.1:6379> keys * (empty array)
今度はブラウザからアクセスしてから検索します。
127.0.0.1:6379> keys * 1) "spring:session:sessions:expires:7f57263c-0743-471f-b4cc-5e3d41b60e3d" 2) "spring:session:sessions:7f57263c-0743-471f-b4cc-5e3d41b60e3d" 3) "spring:session:expirations:1611233700000"
"spring:session:" から始まるKeyが3つ出力されました。この中で2)にセッションデータが入っているようです。
type <key>
でデータの形式を確認するとhash と出力されたので hgetall <key>
で検索します。
127.0.0.1:6379> hgetall spring:session:sessions:7f57263c-0743-471f-b4cc-5e3d41b60e3d 1) "sessionAttr:scopedTarget.reception" 2) "\xac\xed\x00\x05sr\x00%net.kazokmr.study.hasession.Reception\xd4\xf3\x87s#\xeev\x8f\x02\x00\x03L\x00\x04hostt\x00\x12Ljava/lang/String;L\x00\amessageq\x00~\x00\x01L\x00\x0breceptionAtt\x00\x19Ljava/time/LocalDateTime;xpt\x00\x11MacBook-Pro.localt\x00\x15\xe3\x82\xbb\xe3\x83\x83\xe3\x82\xb7\xe3\x83\xa7\xe3\x83\xb3\xe9\x96\x8b\xe5\xa7\x8bsr\x00\rjava.time.Ser\x95]\x84\xba\x1b\"H\xb2\x0c\x00\x00xpw\x0e\x05\x00\x00\a\xe5\x01\x15\x15\x18\x02:|FXx" 3) "creationTime" 4) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01w$\xe6\xd1\xf6" 5) "maxInactiveInterval" 6) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b" 7) "lastAccessedTime" 8) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01w$\xe6\xd1\xf6"
所々にbeanのデータが読めるのでそれっぽいですがわかりづらいですね。そこで下記の記事に書かれているようにセッションデータのシリアライズ方法をJava標準のものからJson形式にしてみます。
Spring Boot+Spring SessionでスケーラブルなステートフルWebアプリが簡単につくれるよ〜 #nginx - Qiita
余談でSpring Securiy も使うなら以下の記事のようにSpring Securityの拡張モジュールの適用もした方が良いと思います。
Spring Security 4.2 主な変更点 #Java - Qiita
package net.kazokmr.study.hasession; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; @Configuration public class HttpSessionConfig { @Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }
(注意) JSON形式に変更した結果、LocalDateTimeで持っていて受付日時のシリアライズで例外が発生してしまいました。本来なら対応するべきですが今回はString型で取得した現在時刻を保持するように変えました。
アプリを起動する前にRedisに残っているデータを全て削除して、再起動しセッションデータを検索します。
127.0.0.1:6379> hgetall spring:session:sessions:a3b43f71-3c5b-48e6-a700-807d7bcb48c5 "sessionAttr:scopedTarget.reception" "{\"@class\":\"net.kazokmr.study.hasession.Reception\",\"host\":\"MacBook-Pro.local\",\"receptionAt\":\"2021/01/21 21:50:27\",\"message\":\"\xe3\x82\xbb\xe3\x83\x83\xe3\x82\xb7\xe3\x83\xa7\xe3\x83\xb3\xe9\x96\x8b\xe5\xa7\x8b\"}" "creationTime" "1611233427618" "maxInactiveInterval" "1800" "lastAccessedTime" "1611233427618"
セッション情報がJSON形式で出力され、生成日時や最終アクセス日時なども見やすくなりました。
とこでRedisに保管されているSESSIONキーですが、これはJSESSIONIDではなく、SESSIONという専用のCookieを使っているようです。ですが、Redisで検索するとUUIDの形式で出力されますが、Chromeの開発者モードで見るとUUIDでは出力されていません。なのでChormeのCokkieからSESSION情報を検索するのは難しそうです。
RedisというかSpring Sessionを利用すると、SessionIDのCookie名が JSESSIONIDではなく SESSION
になるようです。名称などのCookieのカスタマイズは以下のように CookieSeriarizer Beanを定義してあげれば良さそう
Redis を追加したDocker Composeでアプリを起動する
docker-compose.yml にはRedisを追加し、Redis > app01-03 > HAProxy の順番に起動するように依存性を追加しました。
version: '3' services: haproxy: build: ./haproxy depends_on: - app01 - app02 - app03 ports: - 80:80 redis: container_name: redis image: redis app01: image: sessiontest:latest hostname: docker-container-1 depends_on: - redis app02: image: sessiontest:latest hostname: docker-container-2 depends_on: - redis app03: image: sessiontest:latest hostname: docker-container-3 depends_on: - redis
また Spring BootアプリのコンテナもDocker Compose のRedisと接続できるように以下の設定を変更しました。
- Docker Compose 専用の application-docker.properties を追加し、compose上のcontainer_name: redis に接続するようにホスト名を追加しました。
spring.redis.host=redis
アプリケーションコンテナをビルドする前に、Maven で clean package を実行するようにしました。これは maven のTestフェーズでRedisとの接続が必要なため、ローカル環境のRedisコンテナにアクセスしてMavenビルドを実行したかったためです。
アプリケーションコンテナ用のDockerfile の内容を変更してDockerイメージを再ビルドしました。 上記のNo.1と2に対応する形で、マルチステージビルドを止め、ENDPOINTでjarを起動時にapplication-docker.properties を参照するようにprofileのactivate 設定オプションを追加しました。
FROM openjdk:11-jre COPY ./target/*.jar . EXPOSE 8080 ENTRYPOINT ["java","-jar","ha-session-0.0.1-SNAPSHOT.jar","--spring.profiles.active=docker"]
感想
新しい技術では無く沢山の情報もあったことやRedisのセッション管理もSpring Session Data Redisが良い感じで行ってくれるので難しくは無かった。
ただ Redisでセッション管理するにはアプリケーション側の方で設定を行うことになるので利用しているフレームワークなどによっては導入コストが掛かる場合もあると思うので、その場合はロードバランサーのパーシステンスの採用を検討した方が良いかもしれない。
これでローカルの開発環境で冗長化構成の動作確認を簡単に行うテンプレートを用意することができた。これに、HTTPS接続とRDBMSの接続も追加しておくと個人的にはより便利になりそう。