kazokmr's Blog

試したこと、読んだこと、見たこと、聴いたことを書きたくなったら書くブログ

HA構成のアプリのHTTPセッション管理にRedis を使う

今更な話ではありますが、AWSのECS Fargate と ALBを使った簡単なデモアプリの環境構築をTerraformで進めていて、セッション管理について調べていたら「ALB側でパーシステンス (スティッキーセッション)を実装するより、ErastiCashなどをセッションストアとして使う」というのを知りました。

個人的にHA構成の経験がほとんどなくRedisも使ったことが無かったことと、またローカル環境でHA構成の動作確認が行えると便利だと思ったので作ってみました。

構成

ソースコードGitHubで公開しています。

github.com

ローカル環境で動作確認をしたかったので、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 <イメージ>

docker コンテナで起動したアプリにアクセス

HAProxy をDockerで起動する

HAProxyのDockerイメージは公式に記載されている通り、公式イメージに haproxy.cfg を上書きしたイメージをビルドして起動します。

Docker

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でパーシステンスを実現する場合

以下のブログの内容を参考に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イメージを再ビルドして起動したところ、セッションが継続されました。

haproxyでCookieにサーバー情報を埋め込んだ場合

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を定義してあげれば良さそう

docs.spring.io

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と接続できるように以下の設定を変更しました。

  1. Docker Compose 専用の application-docker.properties を追加し、compose上のcontainer_name: redis に接続するようにホスト名を追加しました。
spring.redis.host=redis
  1. アプリケーションコンテナをビルドする前に、Maven で clean package を実行するようにしました。これは maven のTestフェーズでRedisとの接続が必要なため、ローカル環境のRedisコンテナにアクセスしてMavenビルドを実行したかったためです。

  2. アプリケーションコンテナ用の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を使ったセッションストアでアクセスした時

感想

新しい技術では無く沢山の情報もあったことやRedisのセッション管理もSpring Session Data Redisが良い感じで行ってくれるので難しくは無かった。

ただ Redisでセッション管理するにはアプリケーション側の方で設定を行うことになるので利用しているフレームワークなどによっては導入コストが掛かる場合もあると思うので、その場合はロードバランサーのパーシステンスの採用を検討した方が良いかもしれない。

これでローカルの開発環境で冗長化構成の動作確認を簡単に行うテンプレートを用意することができた。これに、HTTPS接続とRDBMSの接続も追加しておくと個人的にはより便利になりそう。