今回は、Spring BootとSpring Sessionを使用してスケーラブルなステートフルWebアプリ(HTTPセッションを使うWebアプリ)を作ってみるぞ〜
システム構成のイメージ
今回は・・・
- Webサーバー兼ロードバランサーとしてNginx
- アプリケーションサーバーとしてSpring Boot(Embedded Tomcat)
- セッションストアとしてKVS(Key Value Store)の有名どころであるRedis
を使用し、Nginx、Spring Boot(Embedded Tomcat)、RedisをそれぞれDockerコンテナ上で動かします。
なお、今回はRedisは1台構成にさせてもらいます。実際のシステムを1台構成で動かすことはあり得ませんが・・・ (Master/Slave構成によるクラスタ化は次回の宿題ということで・・・)
フレームワーク構成
Spring Bootで扱うセッション情報を透過的にRedisに保存するために、Spring SessionのRedis実装を使います。ちなみに・・・Spring SessionのRedis実装は、Spring Data Redis + Jedisを使ってRedisの操作を行う仕組みになっています。
Note:
今回はセッションストアとしてRedisを使用しますが、Spring Sessionは「Hazelcast実装」「MongoDB実装」「Pivotal GemFire実装」「JDBC実装」「
ConcurrentHashMap
実装(テスト用)」も提供しています。
作るアプリ
作るアプリは・・・ショボいです(ってかアプリとは呼べないけど)・・・ なお、この投稿で説明する内容の完成品は、GitHubで公開しています。
Helloエンドポイント
http://localhost:10080/greeting/hello?name=kazuki43zoo にアクセスしたタイミングで、セッションに「名前(?name=xxx)」「セッション作成時間」「セッション作成インスタンス名」を格納します。
- 初回アクセス → AP1で処理(Echo by ap1) ※セッション生成
- 2回目のアクセス → AP2で処理(Echo by ap2) ※Redisからセッション復元
- 3回目のアクセス → AP3で処理(Echo by ap3) ※Redisからセッション復元
- 4回目のアクセス → 再びAP1で処理(Echo by ap1) ※Redisからセッション復元
Goodbyeエンドポイント
http://localhost:10080/greeting/goodbye にアクセスするとセッションを破棄(HttpSession#invalid
)します。
再度Helloエンドポイントにアクセスすると・・・別のセッションが作成されます。(セッション作成時間がかわります)
動作確認環境
- Docker for Mac 1.12.0-a(Build: 11213)
- Nginx 1.11.5
- Redis 3.2.5
- Spring Boot 1.4.2.RELEASE (Embedded Tomcat 8.5.6)
- Spring Session 1.2.2.RELEAE
- Spring Data Redis 1.7.5.RELEASE
- Jedis 2.8.2
各種プロダクトのインストール
Docker
以下のページやインターネットに転がっている情報をもとにインストールしてください!!
Medis
Redisのクライアントツールをインストールしましょう!! 私はMedisというツールを使いましたが、なんでもOKです。コマンドLoveな方は標準でインストールされる「redis-cli」でいいでしょう。
プロジェクトの作成と起動
SPRING INITIALIZRを使ってプロジェクトを作成しましょう。Dependenciesには、「Web」「Session」「Redis」を選びます。先ほど紹介システム構成で動かす前に、作成したプロジェクトをMavenコマンドを使って動かしてみたいと思います。
Redisの起動
まず、Docker上でRedisを起動します。
$ docker run -d --name redis -p 6379:6379 redis
Spring Bootアプリの起動
次に、Spring BootアプリをMavenコマンドを使って起動します。
$ ./mvnw clean spring-boot:run
...
2016-11-13 23:53:11.945 INFO 80852 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Starting beans in phase 2147483647
2016-11-13 23:53:11.952 INFO 80852 --- [ main] s.a.ScheduledAnnotationBeanPostProcessor : No TaskScheduler/ScheduledExecutorService bean found for scheduled processing
2016-11-13 23:53:11.994 INFO 80852 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2016-11-13 23:53:11.999 INFO 80852 --- [ main] c.example.SpringSessionDemoApplication : Started SpringSessionDemoApplication in 2.659 seconds (JVM running for 6.016)
起動確認ができたら、Ctrl + C でSpring Bootを停止しましょう。
Redisの停止と削除
動作確認用に作成したRedisも削除しておきましょう。
$ docker rm -f redis
redis
Spring Bootアプリの作成
とりあえずMavneでの起動確認ができたので、いよいよアプリを作っていきましょう!!
セッションストアの指定
Spring Sessionのセッションストアを指定しましょう。Mavenでの起動確認した時に、実は以下のようなWARNログが出ていることに気がつきましたか? WARNなので設定がなくても動くみたいなのですが、WARNログが出ているのは気持ち悪いので使うセッションストア(今回はredis
)を指定します。
2016-11-13 23:53:11.319 WARN 80852 --- [ost-startStop-1] o.s.b.a.s.RedisSessionConfiguration : Spring Session store type is mandatory: set 'spring.session.store-type=redis' in your configuration
spring.session.store-type=redis
Note:
2017/1/8 追記
ちなみに・・・Spring Boot 1.5からstore-type
を指定しないとエラーになるので、今から明示的に指定するようにしておきましょう!!
Redisの接続先の指定
Dockerのリンク機能を使うと、他のコンテナで起動したプロセスの「IPアドレス」と「ポート番号」が環境変数に設定されます。Spring BootからRedisに接続する際は、Dockerのリンク機能を使い、環境変数に設定されたIPアドレスとポート番号を使用して接続するようにします。
spring.redis.host=${REDIS_PORT_6379_TCP_ADDR:localhost}
spring.redis.port=${REDIS_PORT_6379_TCP_PORT:6379}
Note:
IPアドレスは「(コンテナ名)_PORT_(コンテナ内で起動したポート番号)_TCP_ADDR」に、ポート番号は「(コンテナ名)_PORT_(コンテナ内で起動したポート番号)_TCP_PORT」に設定されます。 例えばRedisのコンテナ名が「redis」の場合は、それぞれ「REDIS_PORT_6379_TCP_ADDR」と「REDIS_PORT_6379_TCP_PORT」という環境変数に値が設定されます。
テスト時のセッションストアの指定
今回はテストは作りませんが、テストをスキップする設定を加えずにjarファイルを作成する($ ./mvnw package
する)と、Redisが起動していないとエラーになってしまいます。単体テストを行う時にRedisが必要じゃない場合は、セッションストアをインメモリ(ConcurrentHashMap
)実装に切り替えておくのがよいでしょう。
spring.session.store-type=hash_map
Redisに格納するセッション情報のシリアライズ方法の変更
今回は、Redisに格納するセッション情報をJSON形式のデータにシリアライズするようにしたいと思います。なお、デフォルトだとJava標準のシリアライズの仕組み(java.io.Serializable
)が使われます。
package com.example;
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();
}
}
Note:
Java標準の仕組み(java.io.Serializable)は実データに対してシリアライズ後のデータサイズの増加率が多い+パフォーマンスにも難点があり、別のソリューションが必要になるケースも少なくないようです。この問題を解決するソリューションのひとつとして使われるのが、セッション情報をJSONデータに変換して共有する方法になります。なお、最近リリースされたSpring Security 4.2では、Spring Securityが提供するクラスをJSONデータに変換するためのコンポーネントが提供されています。詳しくは・・・「Spring Security 4.2 主な変更点」をごらんください!!
セッションスコープのBeanの作成
今回は、セッションスコープのBeanを作成してセッションに情報を格納してみます。
package com.example;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
import java.io.Serializable;
import java.util.Date;
@SessionScope
@Component
public class GreetingInfo implements Serializable {
private static final long serialVersionUID = 8048097948251750715L;
private String name;
private Date createdAt;
private String createdBy;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Date getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Date createdAt) {
this.createdAt = createdAt;
}
public String getCreatedBy() {
return createdBy;
}
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
}
エンドポイントの作成
HelloエンドポイントとGoodbyeエンドポイントを作成します。
package com.example;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpSession;
import java.util.Date;
import java.util.Optional;
@RestController
@RequestMapping("/greeting")
public class GreetingRestController {
private final String instanceName;
private final GreetingInfo greetingInfo;
public GreetingRestController(@Value("${instance.name:ap}") String instanceName, GreetingInfo greetingInfo) {
this.instanceName = instanceName;
this.greetingInfo = greetingInfo;
}
@GetMapping("/hello")
public String hello(@RequestParam Optional<String> name) {
if (greetingInfo.getName() == null) {
greetingInfo.setName(name.orElse("anonymous"));
greetingInfo.setCreatedAt(new Date());
greetingInfo.setCreatedBy(instanceName);
}
return "Hi " + greetingInfo.getName() + ". Enter at " + greetingInfo.getCreatedAt() + " by " + greetingInfo.getCreatedBy() + " (Echo by " + instanceName + ")";
}
@GetMapping("/goodbye")
public String goodbye(HttpSession session) {
Optional<String> name = Optional.ofNullable(greetingInfo.getName());
session.invalidate();
return "Goodbye " + name.orElse("anonymous") + " (Echo by " + instanceName + ")";
}
}
Dockerfileの作成
NginxとSpring Boot用のDockerfileを作成しましょう。なお、RedisはDocker HubにあるRedisのイメージをそのまま使います。
Nginx用のconfファイルとDockerfileの作成
NginxからSpring Boot(ap1, ap2, ap3)へのプロキシ(ロードバランス)設定を行います。
upstream spring-boot {
server ap1:8080;
server ap2:8080;
server ap3:8080;
}
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
location / {
proxy_pass http://spring-boot/;
proxy_http_version 1.1;
}
#location / {
# root /usr/share/nginx/html;
# index index.html index.htm;
#}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
Docker HubにあるNginxのイメージからコンテナを作成し、さきほど作成したdefault.conf
を上書きします。
FROM nginx
COPY default.conf /etc/nginx/conf.d/default.conf
Spring Boot用のDockerfileの作成
Docker HubにあるJavaのイメージからコンテナを作成し、Mavenビルドで作成したSpring Bootのjarファイルをコピーします。
Note:
Mavenビルドは、DockerfileからDockerイメージを作成する前に行っておく必要があります。
FROM java:8
ADD target/spring-session-demo-0.0.1-SNAPSHOT.jar /opt/spring/spring-session-demo.jar
EXPOSE 8080
WORKDIR /opt/spring/
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "spring-session-demo.jar"]
Docker Composeのシステム構成ファイル(docker-compose.yml)を作成
ここまでは、Dockerイメージを作成するためのDockerfileを作成しました。ここからは、冒頭で紹介したシステム構成にするための方法を説明していきます。Dockerコマンドを使ってシステム構成に合うようにコンテナを一つずつ起動する方法もありますが・・・依存関係のあるコンテナとのリンク設定などぶっちゃけ面倒です。そういった手動で行うと面倒な設定を構成ファイル(docker-compose.yml
)に予め記載しておくことで、起動・停止などの操作を簡単にできるようにしたのがDocker Composeです。
では、今回のシステム構成に合うようにDocker Composeのシステム構成ファイル(docker-compose.yml
)の作成してみましょう。
redis:
image: redis
ports:
- "16379:6379"
ap1:
build: .
links:
- redis
command: >
--instance.name=ap1
ap2:
build: .
links:
- redis
command: >
--instance.name=ap2
ap3:
build: .
links:
- redis
command: >
--instance.name=ap3
web:
build: nginx
ports:
- "10080:80"
links:
- ap1
- ap2
- ap3
Redisの設定
Docker Hubからredisのイメージを取得してコンテナを作成する。今回は、Redisのポートをホストマシンの16379のポートにバインドします。これは、Medisを使ってRedisの中身を確認するためです。
redis:
image: redis
ports:
- "16379:6379"
# ...
Spring Bootの設定
Spring Boot用のDockerfileをビルドして作成したイメージからコンテナを作成する。Spring BootからRedisに接続するため、コンテナ間のリンク連携を指定します。今回は、ap1, ap2, ap3という名前で3つのコンテナを作成しています。
# ...
ap1:
build: .
links:
- redis
command: >
--instance.name=ap1
ap2:
build: .
links:
- redis
command: >
--instance.name=ap2
ap3:
build: .
links:
- redis
command: >
--instance.name=ap3
# ...
Note:
以下のように記載すると、Spring Bootを起動するjavaコマンドのコマンドライン引数になります。
# ... command: > --instance.name=ap1 # ...
これは、以下のjavaコメンドを実行した時と同じ動作になります。
$ java -jar spring-session-demo.jar --instance.name=ap1
Nginxの設定
Nginx用のDockerfileをビルドして作成したイメージからコンテナを作成する。NginxからSpring Bootに接続するため、コンテナ間のリンク連携を指定します。今回は、ap1, ap2, ap3という3つのコンテナと連携します。Nginxのコンテナ上では、リンク連携で指定したコンテナ名をホスト名として扱うことができます(Nginx側のhostsファイルに、Spring Bootのコンテナに割り当てられたIPアドレスとコンテナ名がマッピングされる)。
# ...
web:
build: nginx
ports:
- "10080:80"
links:
- ap1
- ap2
- ap3
本投稿では、Nginxのポートをホストマシンの10080のポートにバインドします。80はどこかで使っているかな〜と思って10080にしていますが、80をホストマシンで使っていない+80の方がいい!!という場合は、以下のようにすればOKです。
# ...
web:
build: nginx
ports:
- "80:80"
# ...
Spring Bootのビルド
Dockerコンテナを作成する準備が終わったので、Spring Bootアプリをビルドしてjarファイルを作成しましょう。
$ ./mvnw clean package
...
2016-11-14 01:46:50.548 INFO 82756 --- [ Thread-3] o.s.w.c.s.GenericWebApplicationContext : Closing org.springframework.web.context.support.GenericWebApplicationContext@792b749c: startup date [Mon Nov 14 01:46:48 JST 2016]; root of context hierarchy
Results :
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] --- maven-jar-plugin:2.6:jar (default-jar) @ spring-session-demo ---
[INFO] Building jar: /Users/shimizukazuki/git/qiita-materials/spring-boot/spring-session-demo/target/spring-session-demo-0.0.1-SNAPSHOT.jar
[INFO]
[INFO] --- spring-boot-maven-plugin:1.4.2.RELEASE:repackage (default) @ spring-session-demo ---
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6.029 s
[INFO] Finished at: 2016-11-14T01:46:51+09:00
[INFO] Final Memory: 31M/319M
[INFO] ------------------------------------------------------------------------
Docker Composeを使用したコンテナ生成
Docker Composeのコマンドを使用して、システム構成ファイル(docker-compose.yml)に指定したコンテナを生成・起動します。以下のコマンドを実行すると、イメージの作成も行います。(--build
をはずせば、イメージのビルドはせずコンテナの起動だけ行うことができます)
$ docker-compose up --build
Building ap1
Step 1 : FROM java:8
---> 69a777edb6dc
Step 2 : ADD target/spring-session-demo-0.0.1-SNAPSHOT.jar /opt/spring/spring-session-demo.jar
---> Using cache
---> ea38347bb634
Step 3 : EXPOSE 8080
---> Using cache
---> dffd5bc2ce66
Step 4 : WORKDIR /opt/spring/
---> Using cache
---> 09bfcc4f71a1
Step 5 : ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar spring-session-demo.jar
---> Using cache
---> d3b897e3cf1b
Successfully built d3b897e3cf1b
Building ap3
Step 1 : FROM java:8
---> 69a777edb6dc
Step 2 : ADD target/spring-session-demo-0.0.1-SNAPSHOT.jar /opt/spring/spring-session-demo.jar
---> Using cache
---> ea38347bb634
Step 3 : EXPOSE 8080
---> Using cache
---> dffd5bc2ce66
Step 4 : WORKDIR /opt/spring/
---> Using cache
---> 09bfcc4f71a1
Step 5 : ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar spring-session-demo.jar
---> Using cache
---> d3b897e3cf1b
Successfully built d3b897e3cf1b
Building ap2
Step 1 : FROM java:8
---> 69a777edb6dc
Step 2 : ADD target/spring-session-demo-0.0.1-SNAPSHOT.jar /opt/spring/spring-session-demo.jar
---> Using cache
---> ea38347bb634
Step 3 : EXPOSE 8080
---> Using cache
---> dffd5bc2ce66
Step 4 : WORKDIR /opt/spring/
---> Using cache
---> 09bfcc4f71a1
Step 5 : ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar spring-session-demo.jar
---> Using cache
---> d3b897e3cf1b
Successfully built d3b897e3cf1b
Building web
Step 1 : FROM nginx
---> 05a60462f8ba
Step 2 : COPY default.conf /etc/nginx/conf.d/default.conf
---> Using cache
---> c71d9085c2a5
Successfully built c71d9085c2a5
Starting springsessiondemo_redis_1
Starting springsessiondemo_ap1_1
Starting springsessiondemo_ap3_1
Starting springsessiondemo_ap2_1
Starting springsessiondemo_web_1
Attaching to springsessiondemo_redis_1, springsessiondemo_ap2_1, springsessiondemo_ap1_1, springsessiondemo_ap3_1, springsessiondemo_web_1
redis_1 | 1:C 13 Nov 16:58:26.964 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
redis_1 | _._
redis_1 | _.-``__ ''-._
redis_1 | _.-`` `. `_. ''-._ Redis 3.2.5 (00000000/0) 64 bit
redis_1 | .-`` .-```. ```\/ _.,_ ''-._
redis_1 | ( ' , .-` | `, ) Running in standalone mode
redis_1 | |`-._`-...-` __...-.``-._|'` _.-'| Port: 6379
redis_1 | | `-._ `._ / _.-' | PID: 1
redis_1 | `-._ `-._ `-./ _.-' _.-'
redis_1 | |`-._`-._ `-.__.-' _.-'_.-'|
redis_1 | | `-._`-._ _.-'_.-' | http://redis.io
redis_1 | `-._ `-._`-.__.-'_.-' _.-'
redis_1 | |`-._`-._ `-.__.-' _.-'_.-'|
redis_1 | | `-._`-._ _.-'_.-' |
redis_1 | `-._ `-._`-.__.-'_.-' _.-'
redis_1 | `-._ `-.__.-' _.-'
redis_1 | `-._ _.-'
redis_1 | `-.__.-'
redis_1 |
redis_1 | 1:M 13 Nov 16:58:26.965 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
redis_1 | 1:M 13 Nov 16:58:26.966 # Server started, Redis version 3.2.5
redis_1 | 1:M 13 Nov 16:58:26.966 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
redis_1 | 1:M 13 Nov 16:58:26.966 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
redis_1 | 1:M 13 Nov 16:58:26.966 * DB loaded from disk: 0.000 seconds
redis_1 | 1:M 13 Nov 16:58:26.966 * The server is now ready to accept connections on port 6379
ap2_1 |
ap2_1 | . ____ _ __ _ _
ap2_1 | /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
ap2_1 | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
ap2_1 | \\/ ___)| |_)| | | | | || (_| | ) ) ) )
ap2_1 | ' |____| .__|_| |_|_| |_\__, | / / / /
ap2_1 | =========|_|==============|___/=/_/_/_/
ap2_1 | :: Spring Boot :: (v1.4.2.RELEASE)
ap2_1 |
ap1_1 |
ap1_1 | . ____ _ __ _ _
ap1_1 | /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
ap1_1 | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
ap1_1 | \\/ ___)| |_)| | | | | || (_| | ) ) ) )
ap1_1 | ' |____| .__|_| |_|_| |_\__, | / / / /
ap1_1 | =========|_|==============|___/=/_/_/_/
ap1_1 | :: Spring Boot :: (v1.4.2.RELEASE)
ap1_1 |
ap3_1 |
ap3_1 | . ____ _ __ _ _
ap3_1 | /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
ap3_1 | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
ap3_1 | \\/ ___)| |_)| | | | | || (_| | ) ) ) )
ap3_1 | ' |____| .__|_| |_|_| |_\__, | / / / /
ap3_1 | =========|_|==============|___/=/_/_/_/
ap3_1 | :: Spring Boot :: (v1.4.2.RELEASE)
ap3_1 |
ap2_1 | 2016-11-13 16:58:29.631 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : Starting SpringSessionDemoApplication v0.0.1-SNAPSHOT on 20c483ea8628 with PID 1 (/opt/spring/spring-session-demo.jar started by root in /opt/spring)
ap2_1 | 2016-11-13 16:58:29.647 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : No active profile set, falling back to default profiles: default
ap2_1 | 2016-11-13 16:58:29.812 INFO 1 --- [ main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@736e9adb: startup date [Sun Nov 13 16:58:29 UTC 2016]; root of context hierarchy
ap3_1 | 2016-11-13 16:58:29.791 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : Starting SpringSessionDemoApplication v0.0.1-SNAPSHOT on 29000e858ceb with PID 1 (/opt/spring/spring-session-demo.jar started by root in /opt/spring)
ap3_1 | 2016-11-13 16:58:29.915 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : No active profile set, falling back to default profiles: default
ap1_1 | 2016-11-13 16:58:29.949 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : Starting SpringSessionDemoApplication v0.0.1-SNAPSHOT on 8ea568d485e7 with PID 1 (/opt/spring/spring-session-demo.jar started by root in /opt/spring)
ap1_1 | 2016-11-13 16:58:30.016 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : No active profile set, falling back to default profiles: default
ap3_1 | 2016-11-13 16:58:30.151 INFO 1 --- [ main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@736e9adb: startup date [Sun Nov 13 16:58:30 UTC 2016]; root of context hierarchy
ap1_1 | 2016-11-13 16:58:30.315 INFO 1 --- [ main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@736e9adb: startup date [Sun Nov 13 16:58:30 UTC 2016]; root of context hierarchy
ap1_1 | 2016-11-13 16:58:33.533 INFO 1 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
ap2_1 | 2016-11-13 16:58:33.563 INFO 1 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
ap3_1 | 2016-11-13 16:58:33.642 INFO 1 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
ap1_1 | 2016-11-13 16:58:35.221 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8080 (http)
ap1_1 | 2016-11-13 16:58:35.277 INFO 1 --- [ main] o.apache.catalina.core.StandardService : Starting service Tomcat
ap1_1 | 2016-11-13 16:58:35.281 INFO 1 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/8.5.6
ap2_1 | 2016-11-13 16:58:35.314 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8080 (http)
ap2_1 | 2016-11-13 16:58:35.343 INFO 1 --- [ main] o.apache.catalina.core.StandardService : Starting service Tomcat
ap2_1 | 2016-11-13 16:58:35.346 INFO 1 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/8.5.6
ap2_1 | 2016-11-13 16:58:35.591 INFO 1 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
ap2_1 | 2016-11-13 16:58:35.591 INFO 1 --- [ost-startStop-1] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 5786 ms
ap3_1 | 2016-11-13 16:58:35.586 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8080 (http)
ap3_1 | 2016-11-13 16:58:35.617 INFO 1 --- [ main] o.apache.catalina.core.StandardService : Starting service Tomcat
ap3_1 | 2016-11-13 16:58:35.619 INFO 1 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/8.5.6
ap1_1 | 2016-11-13 16:58:35.746 INFO 1 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
ap1_1 | 2016-11-13 16:58:35.751 INFO 1 --- [ost-startStop-1] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 5435 ms
ap3_1 | 2016-11-13 16:58:35.928 INFO 1 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
ap3_1 | 2016-11-13 16:58:35.933 INFO 1 --- [ost-startStop-1] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 5782 ms
ap2_1 | 2016-11-13 16:58:36.455 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean : Mapping servlet: 'dispatcherServlet' to [/]
ap2_1 | 2016-11-13 16:58:36.463 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'characterEncodingFilter' to: [/*]
ap2_1 | 2016-11-13 16:58:36.464 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'springSessionRepositoryFilter' to: [/*]
ap2_1 | 2016-11-13 16:58:36.464 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
ap2_1 | 2016-11-13 16:58:36.465 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'httpPutFormContentFilter' to: [/*]
ap2_1 | 2016-11-13 16:58:36.465 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'requestContextFilter' to: [/*]
ap1_1 | 2016-11-13 16:58:36.615 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean : Mapping servlet: 'dispatcherServlet' to [/]
ap1_1 | 2016-11-13 16:58:36.622 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'characterEncodingFilter' to: [/*]
ap1_1 | 2016-11-13 16:58:36.634 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'springSessionRepositoryFilter' to: [/*]
ap1_1 | 2016-11-13 16:58:36.634 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
ap1_1 | 2016-11-13 16:58:36.634 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'httpPutFormContentFilter' to: [/*]
ap1_1 | 2016-11-13 16:58:36.635 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'requestContextFilter' to: [/*]
ap3_1 | 2016-11-13 16:58:36.928 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean : Mapping servlet: 'dispatcherServlet' to [/]
ap3_1 | 2016-11-13 16:58:36.937 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'characterEncodingFilter' to: [/*]
ap3_1 | 2016-11-13 16:58:36.939 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'springSessionRepositoryFilter' to: [/*]
ap3_1 | 2016-11-13 16:58:36.940 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
ap3_1 | 2016-11-13 16:58:36.940 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'httpPutFormContentFilter' to: [/*]
ap3_1 | 2016-11-13 16:58:36.940 INFO 1 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'requestContextFilter' to: [/*]
ap1_1 | 2016-11-13 16:58:37.169 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@736e9adb: startup date [Sun Nov 13 16:58:30 UTC 2016]; root of context hierarchy
ap1_1 | 2016-11-13 16:58:37.330 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting/hello],methods=[GET]}" onto public java.lang.String com.example.GreetingRestController.hello(java.util.Optional<java.lang.String>) throws java.net.UnknownHostException
ap1_1 | 2016-11-13 16:58:37.332 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting/goodbye],methods=[GET]}" onto public java.lang.String com.example.GreetingRestController.goodbye(javax.servlet.http.HttpSession) throws java.net.UnknownHostException
ap1_1 | 2016-11-13 16:58:37.340 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
ap1_1 | 2016-11-13 16:58:37.342 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],produces=[text/html]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
ap2_1 | 2016-11-13 16:58:37.387 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@736e9adb: startup date [Sun Nov 13 16:58:29 UTC 2016]; root of context hierarchy
ap1_1 | 2016-11-13 16:58:37.430 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap1_1 | 2016-11-13 16:58:37.430 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap1_1 | 2016-11-13 16:58:37.494 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap2_1 | 2016-11-13 16:58:37.687 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting/goodbye],methods=[GET]}" onto public java.lang.String com.example.GreetingRestController.goodbye(javax.servlet.http.HttpSession) throws java.net.UnknownHostException
ap2_1 | 2016-11-13 16:58:37.695 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting/hello],methods=[GET]}" onto public java.lang.String com.example.GreetingRestController.hello(java.util.Optional<java.lang.String>) throws java.net.UnknownHostException
ap2_1 | 2016-11-13 16:58:37.710 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],produces=[text/html]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
ap2_1 | 2016-11-13 16:58:37.714 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
ap3_1 | 2016-11-13 16:58:37.787 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@736e9adb: startup date [Sun Nov 13 16:58:30 UTC 2016]; root of context hierarchy
ap2_1 | 2016-11-13 16:58:37.818 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap2_1 | 2016-11-13 16:58:37.818 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap2_1 | 2016-11-13 16:58:37.956 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap3_1 | 2016-11-13 16:58:38.017 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting/hello],methods=[GET]}" onto public java.lang.String com.example.GreetingRestController.hello(java.util.Optional<java.lang.String>) throws java.net.UnknownHostException
ap3_1 | 2016-11-13 16:58:38.019 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting/goodbye],methods=[GET]}" onto public java.lang.String com.example.GreetingRestController.goodbye(javax.servlet.http.HttpSession) throws java.net.UnknownHostException
ap3_1 | 2016-11-13 16:58:38.029 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],produces=[text/html]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
ap3_1 | 2016-11-13 16:58:38.030 INFO 1 --- [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
ap3_1 | 2016-11-13 16:58:38.123 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap3_1 | 2016-11-13 16:58:38.134 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap3_1 | 2016-11-13 16:58:38.248 INFO 1 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
ap1_1 | 2016-11-13 16:58:38.357 INFO 1 --- [ main] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup
ap1_1 | 2016-11-13 16:58:38.388 INFO 1 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Starting beans in phase 2147483647
ap1_1 | 2016-11-13 16:58:38.417 INFO 1 --- [ main] s.a.ScheduledAnnotationBeanPostProcessor : No TaskScheduler/ScheduledExecutorService bean found for scheduled processing
ap1_1 | 2016-11-13 16:58:38.566 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
ap1_1 | 2016-11-13 16:58:38.583 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : Started SpringSessionDemoApplication in 9.943 seconds (JVM running for 10.97)
ap2_1 | 2016-11-13 16:58:38.919 INFO 1 --- [ main] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup
ap2_1 | 2016-11-13 16:58:38.936 INFO 1 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Starting beans in phase 2147483647
ap2_1 | 2016-11-13 16:58:39.063 INFO 1 --- [ main] s.a.ScheduledAnnotationBeanPostProcessor : No TaskScheduler/ScheduledExecutorService bean found for scheduled processing
ap3_1 | 2016-11-13 16:58:39.109 INFO 1 --- [ main] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup
ap3_1 | 2016-11-13 16:58:39.122 INFO 1 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Starting beans in phase 2147483647
ap3_1 | 2016-11-13 16:58:39.142 INFO 1 --- [ main] s.a.ScheduledAnnotationBeanPostProcessor : No TaskScheduler/ScheduledExecutorService bean found for scheduled processing
ap2_1 | 2016-11-13 16:58:39.250 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
ap3_1 | 2016-11-13 16:58:39.263 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
ap3_1 | 2016-11-13 16:58:39.285 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : Started SpringSessionDemoApplication in 10.671 seconds (JVM running for 11.669)
ap2_1 | 2016-11-13 16:58:39.291 INFO 1 --- [ main] c.example.SpringSessionDemoApplication : Started SpringSessionDemoApplication in 10.883 seconds (JVM running for 11.864)
5コンテナ分のログが入り乱れているためやや見ずらいですが・・・redis, ap1, ap2, ap3, webが起動しました。念のためDockerのコマンドを使ってコンテナの状態を確認してみましょう。以下のような状態になっていればOKです。
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5442dff2d1ab springsessiondemo_web "nginx -g 'daemon off" 9 minutes ago Up 16 seconds 443/tcp, 0.0.0.0:10080->80/tcp springsessiondemo_web_1
8ea568d485e7 springsessiondemo_ap1 "java -Djava.security" 9 minutes ago Up 17 seconds 8080/tcp springsessiondemo_ap1_1
20c483ea8628 springsessiondemo_ap2 "java -Djava.security" 9 minutes ago Up 17 seconds 8080/tcp springsessiondemo_ap2_1
29000e858ceb springsessiondemo_ap3 "java -Djava.security" 9 minutes ago Up 17 seconds 8080/tcp springsessiondemo_ap3_1
29f2beae104c redis "docker-entrypoint.sh" 13 hours ago Up 17 seconds 0.0.0.0:16379->6379/tcp springsessiondemo_redis_1
Redisの状態確認
アプリにアクセスする前の、Medisを使ってRedisの状態を確認してみます。ダウンロードしたアプリを実行し、Portに「16379」を入力して「Connect」しましょう。
とりあえず、何も格納されていません。
Helloエンドポイントへアクセス
では、実際にHelloエンドポイントへアクセスしてみましょう。
Redisの状態も確認してみましょう。(Medisのレフトメニューの中にある「更新マーク」をクリックしてください)
Typeが「HASH」になっているデータが、セッション情報を保持しているエントリーです(本例だと「spring:session:sessions:a99324e3-0073-4966-9a57-6d63202ceeea」です)。セッション情報には、「作成日時(creationTime)」「最終アクセス日時(lastAccessedTime)」「無効化までの最大時間(maxInactiveInterval)」「セッション属性(sessionAttr:セッション属性のkey名)」の項目を保持しており、今回はセッションスコープのBeanを使用しているため、セッション属性のKey名は「scopedTarget.Bean名」になっています。
{
"@class": "com.example.GreetingInfo",
"name": "kazuki43zoo",
"createdAt": [
"java.util.Date",
1479057288861
],
"createdBy": "ap1"
}
Goodbyeエンドポイントへアクセス
Goodbyeエンドポイントへアクセスしてセッションを破棄してみます。
Redisの中を確認してみると・・・実際のセッション情報を保持しているエントリーが残っていますが・・・これはバグではありません。Spring Sessionは、セッション破棄とセッション有効期限切れのタイミングでイベントリスナーに対してイベントを発行する機能を持っており、イベントリスナーは破棄または有効期限切れになったセッション情報にアクセスすることができます。この機能をRedisの仕組みを使って実現しようとすると、セッション情報を保持するエントリーを物理的に削除するタイミングを遅らせる必要があるのです(すぐには消えませんが、一定間隔でパージされるので気にする必要はありません)。
なお、「無効化までの最大時間(maxInactiveInterval)」をみると「0」になっており、論理的にはセッション情報が無効化されていることがわかります。
Dockerコンテナの停止
Ctrl + Cで停止できます。
...
^CGracefully stopping... (press Ctrl+C again to force)
Stopping springsessiondemo_web_1 ... done
Stopping springsessiondemo_ap1_1 ... done
Stopping springsessiondemo_ap2_1 ... done
Stopping springsessiondemo_ap3_1 ... done
Stopping springsessiondemo_redis_1 ... done
$
まとめ
しょぼいアプリだったためあまり実感がわかないかもしれませんが・・・すごく簡単にスケーラブルなステートフルWebアプリが作れたと思います。Spring Sessionを使えば、アプリ(+フレームワーク)の実装を一切変更することなく、簡単にセッションストアを切り替えることができてしまいます!! ほんの数年前までは、オンプレミス環境で予め設計したスケール(繁忙期に耐えられるスケール=通常時は過剰スペックでリソースはスカスカ・・・)でシステムを稼働させるスタイルが当たり前だった気もしますが、Cloud全盛の現在では、システムへのアクセス数に応じてスケールアウト・スケールインを自動で行ってくれるサービスが当たり前のように提供されています。そういった環境で動かすアプリケーションでは、HTTPセッションをRedisなどを使って高速に共有することが求められると思います。
ちなみに・・・今回はRedisを一台構成にしましたが、(冒頭でも触れたとおり・・)本来であればMaster/Slave構成でクラスタ化する必要があります。Spring Data Redisって、Redis Sentinelには対応しているみたいですが・・・・更新はMasterへ、参照はSlaveへアクセスという部分を透過的にやってくれる仕組みってあるのかな?(過去記事で「ないよ」というのは見かけたけど・・あると便利だよな〜)