Spring Bootアプリをロードバランサ配下で2台に増やしたら、リロードするたびにログイン画面へ戻される。スケールアウトで最初にぶつかる定番のトラブルですよね。
原因はアプリのコードではなく、セッションの置き場所にあります。この記事では Spring Session と Redis でセッションを外部化し、複数インスタンスでもログイン状態が維持される構成を作ります。依存の追加から spring.session.* の設定、Spring Securityとの統合、redis-cliでの中身の確認、2インスタンスでの動作検証まで一気に進めましょう。
なお、JWTなどトークン方式との使い分けはこの記事では扱いません。トークン方式に興味がある方は Spring SecurityでJWTリフレッシュトークンを実装する方法 をどうぞ。
スケールアウトするとログインが消える理由
デフォルトのSpring Boot(Tomcat)では、HttpSession の実体は各インスタンスのJVMメモリに保存されます。ブラウザに渡るJSESSIONID Cookieはただの引換券で、本体はサーバ側にあるわけです。
この状態で2台構成にすると、ログインはインスタンスAのメモリに記録されているのに、次のリクエストがインスタンスBへ振り分けられる。BにはそのIDに対応するセッションが存在しないので、未ログイン扱いになります。これが「リロードでログインが外れる」現象の正体です。
ロードバランサのスティッキーセッションで同じインスタンスに固定する手もありますが、再起動やデプロイでセッションが消える、負荷が偏るなど、応急処置の域を出ません。正攻法は、セッションを全インスタンスから見える外部ストアに置くことです。そこで Spring Session + Redis の出番です。
Spring Sessionの仕組み - HttpSessionを透過的にRedisへ
Spring Sessionを導入すると、SessionRepositoryFilter というサーブレットフィルタが HttpSession をラップし、getAttribute / setAttribute の裏側でRedisと読み書きするようになります。
ポイントは、アプリケーションコードが標準の HttpSession APIのままでいいこと。コントローラもSpring Securityの実装も変更不要で、ブラウザに返るCookie(デフォルト名は SESSION)に対応するセッション本体がRedisに保存されるだけです。1つだけ前提があり、セッション属性はデフォルトでJDKシリアライズして保存されるため、自作のクラスをセッションに入れる場合は Serializable を実装しておく必要があります。
ちなみに、これは @Cacheable によるキャッシュやPub/Subとは独立した機能です。Redisの用途全般を知りたい方は Spring BootとRedisの統合ガイド を参考にしてください。
依存関係の追加とRedisの起動
必要な依存は2つだけです。Mavenなら次のように追加します。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
Gradleの場合はこうです。
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
バージョンはSpring BootのBOMが管理するので明示不要です。そして、この2つがクラスパスにあるだけで自動設定が働き、SessionRepositoryFilter が登録されてセッションの保存先がRedisに切り替わります。導入はこれだけです。
検証用のRedisはDockerで一行起動しておきましょう。
docker run -d --name session-redis -p 6379:6379 redis:7
Redis自体の冗長化や本番構成はこの記事のスコープ外です。コンテナ化まわりは Spring BootアプリをDockerでコンテナ化する方法 で扱っています。
spring.session.*の設定 - timeout・namespace・flush-mode
接続設定とあわせて、主要なプロパティをapplication.ymlにまとめます。
spring:
data:
redis:
host: localhost
port: 6379
session:
timeout: 30m
redis:
namespace: 'myapp:session'
flush-mode: on-save
それぞれの意味を押さえておきましょう。
spring.session.timeoutはセッションの有効期限です。ここが重要なのですが、Spring Session導入後はserver.servlet.session.timeoutより こちらが優先 されます。既存の設定が効かなくなったと混乱しがちなので要注意です。spring.session.redis.namespaceはRedisキーのプレフィックスで、デフォルトはspring:sessionです。複数アプリで同じRedisを共有するなら、アプリごとに変えてキー衝突を防ぎましょう。spring.session.redis.flush-modeはデフォルトのon-saveでレスポンス完了時にまとめて書き込みます。immediateは属性を設定するたびに即書き込む動作で、その分Redisへのアクセスが増えるため、通常はon-saveのままで問題ありません。
プロパティ設定の体系そのものは Spring Bootのプロパティ設定ガイド にまとめてあります。
CookieSerializerでCookieを設計する
本番運用ではCookieの属性も自分で決めておきたいところです。DefaultCookieSerializer をBean定義すればカスタマイズできます。
@Bean
public CookieSerializer cookieSerializer(
@Value("${app.cookie-secure:true}") boolean secure) {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSION");
serializer.setCookiePath("/");
serializer.setUseHttpOnlyCookie(true);
serializer.setUseSecureCookie(secure);
serializer.setSameSite("Lax");
return serializer;
}
Secure属性はHTTPSの本番では必須ですが、Secure CookieはHTTPでは送信されないため、ローカルのHTTP開発ではログインが維持できなくなってしまいます。上の例のようにプロパティで切り替え、ローカルでは app.cookie-secure=false を指定するのが実用的です。後半の動作検証でもこの指定を使います。
SameSiteは Lax を基本にしましょう。クロスサイト連携でどうしても必要な場合だけ None を検討しますが、None にするならSecure属性が必須という落とし穴があります。また、Cookie名がデフォルトで JSESSIONID から SESSION に変わるため、リバースプロキシやモニタリングがCookie名に依存している場合は追従を忘れずに。
Spring Securityのフォームログインと統合する
それでは実際のログインフローで確認しましょう。最小限のSecurity設定を用意します。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user").password("password").roles("USER").build();
return new InMemoryUserDetailsManager(user);
}
}
2点、検証用の割り切りがあります。まず csrf(csrf -> csrf.disable()) は、後半でcurlからCSRFトークンなしでログインPOSTを送るための検証用設定です。有効なままだとトークンなしのPOSTは403になります。ブラウザ向けの本番アプリではCSRF保護は無効化しないでください。もう1つ、withDefaultPasswordEncoder() は非推奨のデモ専用APIです。実運用のユーザー管理は UserDetailsServiceでDB認証を実装する方法 を参照してください。Security設定自体の解説は Spring SecurityでBasic認証を実装する方法 にもまとめています。
あわせて、動作確認用にログインユーザー名を返すエンドポイントを作っておくと後の検証が楽になります。
@RestController
public class MeController {
@GetMapping("/me")
public String me(Principal principal) {
return principal.getName();
}
}
統合のために特別な設定は何もいりません。ログインに成功すると、Spring Securityは認証情報を SPRING_SECURITY_CONTEXT というセッション属性に保存します。Spring Sessionが HttpSession を差し替えているので、この属性ごとRedisへ書き込まれ、認証状態が自動的に外部化されるわけです。
redis-cliでセッションの中身をデバッグする
「本当にRedisに入っているのか」は自分の目で確かめられます。ログインした状態で、redis-cliを開いてみましょう。
docker exec -it session-redis redis-cli
# セッションキーの一覧(KEYSは検証環境限定。本番はSCANを使う)
KEYS myapp:session:*
# 1) "myapp:session:sessions:4f1c..."
# 有効期限の残り秒数。spring.session.timeoutが反映されているか確認
TTL myapp:session:sessions:4f1c...
# セッション属性の確認
HGETALL myapp:session:sessions:4f1c...
# キーを消すと該当ユーザーを強制ログアウトできる
DEL myapp:session:sessions:4f1c...
デフォルト構成ではセッション1つにつき {namespace}:sessions:{セッションID} というハッシュが1つ作られ、TTLで期限が管理されます。HGETALL すると creationTime や maxInactiveInterval に加えて sessionAttr:SPRING_SECURITY_CONTEXT が見えるはずです。値はJavaシリアライズされたバイナリなので読めなくて正常ですが、この属性が存在すること自体が「認証状態がRedisに載った」証拠になります。
DEL による強制ログアウトは、不正アクセスが疑われるユーザーのセッションを即座に切りたい、といった運用場面で覚えておくと役に立ちますよ。
2インスタンスでセッション共有を動作検証する
いよいよ本題の検証です。同じアプリをポート違いで2プロセス起動し、片方でログインしたCookieがもう片方でも通用するかを確かめます。ローカルはHTTPなので、--app.cookie-secure=false でSecure属性をオフにして起動するのがポイントです。
# 別ターミナルでそれぞれ起動(ローカルHTTP検証のためSecureをオフ)
./mvnw spring-boot:run \
-Dspring-boot.run.arguments="--server.port=8080 --app.cookie-secure=false"
./mvnw spring-boot:run \
-Dspring-boot.run.arguments="--server.port=8081 --app.cookie-secure=false"
# 8080でログインし、Cookieをファイルに保存
curl -v -c cookies.txt -d 'username=user&password=password' \
http://localhost:8080/login
# 同じCookieで8081の/meにアクセス
curl -b cookies.txt http://localhost:8081/me
ログインの成否は -v の出力で判定できます。成功なら 302 が返り、Location ヘッダがトップページ(http://localhost:8080/)を指します。失敗時は Location が /login?error になるので分かりやすいですよ。あわせて cat cookies.txt で SESSION Cookieが保存されていることも確認しておきましょう。
そのうえで最後のcurlが user を返せば成功です。8080で作られたセッションを8081がRedisから読めている、つまり冒頭の「リロードでログインが外れる」問題が解決したことになります。
Docker Composeでまとめて起動する場合の構成例も置いておきます。
services:
redis:
image: redis:7
app1:
build: .
ports:
- '8080:8080'
environment:
SPRING_DATA_REDIS_HOST: redis
depends_on:
- redis
app2:
build: .
ports:
- '8081:8080'
environment:
SPRING_DATA_REDIS_HOST: redis
depends_on:
- redis
うまくいかないときは、両インスタンスのRedis接続先が同一か、namespace の値が揃っているか、CookieのパスやSecure属性がローカル環境と合っているか、の3点を確認してみてください。
タイムアウトとログアウト時のRedisの挙動
セッションの終わり方も見ておきましょう。
タイムアウトの場合は、TTLが満了した時点でRedisのキーが失効します。次のアクセスではセッションが見つからず、未認証としてログイン画面へ誘導されます。明示的なログアウトの場合は、Spring Securityのlogout処理がセッションを無効化し、Redisのキーがその場で削除されます。ログアウト直後に KEYS を打つとキーが消えているのを確認できるはずです。
タイムアウト値は、セキュリティ要件と利便性のトレードオフで決めます。短くすれば安全ですが再ログインが増えるので、業務システムなら30分前後、長期のログイン維持が必要なサービスならRemember-Meやリフレッシュトークン方式の併用を検討しましょう。
まとめ
依存を2つ追加して spring.session.* を設定するだけで、HttpSession がRedisに外部化され、複数インスタンスでもログイン状態が維持されるようになりました。
spring.session.timeoutはserver.servlet.session.timeoutより優先される- 複数アプリでRedisを共有するなら
namespaceを分ける - Cookieは
CookieSerializerでHttpOnly・Secure・SameSiteまで設計する - 困ったらredis-cliで
KEYSとTTLを叩けば状況が分かる
セッション以外のRedis活用は Spring BootとRedisの統合ガイド を、本番に向けたコンテナ構成は Dockerコンテナ化の記事 をあわせてどうぞ。