文系プログラマによるTIPSブログ

文系プログラマ脳の私が開発現場で学んだ事やプログラミングのTIPSをまとめています。

spring boot v1.3のdevtoolsのlive reloadとremote updateを試す

あともう一歩頑張ってくれればくっそ有用な機能なのにな〜
f:id:treeapps:20180802010416p:plain

spring boot v1.3がリリースされて、ライブリロードとリモートアップデートという機能が追加されました。

詳細はリファレンスを見て下さい。
20. Developer Tools

ちょっと使ってみて、それぞれ気づいた事がありました。

※ この記事ではLiveReloadとRemoteUpdateの設定の仕方等については言及しません。リファレンス通りに設定すれば動くので省略します。

検証環境

  • iMac Retina 5K
  • idk v1.8.0_25
  • Spring Tool Suite Version: 3.7.2.RELEASE
  • Spring boot v1.3.1.RELEASE
  • docker toolbox(docker v1.9、docker-compose v1.5.2)
  • Google chrome + Live reload plugin(LiveReload - Chrome Web Store

Live Reload

変更部分のみ差分読み込みする形ではなくリスタートなので、プロジェクトが大きくなるとリスタート処理がちょっと遅くなりそうです。今後に期待ですね。

使い勝手に関しては「ほ〜ん、凄いな」と思ったのですが、ちょっと困った事がありました。

それはですね・・・

今ライブリロードがonなのかoffなのか解りづらい

点です。アイコンが非常に解りづらいのです。以下をご覧下さい。真ん中のアイコンがライブリロードpluginです。

f:id:treeapps:20160126222926p:plain
f:id:treeapps:20160126222932p:plain

1個目の画像がオンで、2個目の画像がオフです。アイコン真ん中が◯か●かの違いです。これは見辛い・・・・

Remote update

個人的に非常に気になる機能だったので、ちょっと試してみました。

気づいた点は以下です。

  • 更新速度は遅い。
  • 更新対象URLが1個しか設定できない。

という点です。

リモートのjarを更新するために、ローカルのソースが更新されたかどうかをポーリングしているようで、このポーリングのデフォルト値は1000ms、つまり1秒です。
Appendix A. Common application properties
「spring.devtools.restart.poll-interval=100」等と設定すれば、ポーリング速度を早める事が可能です。ポーリングは調節可能ですが、問題なのは更新速度です。スッカスカのプロジェクトで1文字変更して、ローカルのdockerコンテナにリモートアップデートするのに以下のように約5秒かかりました。ちょっと遅いかな?と感じました。ローカルのdockerに対してこの更新速度だと、外部サーバに対して行ったら更に遅くなる事は間違いないでしょう。

22:42:45.790 [File Watcher] INFO  o.s.b.d.r.c.ClassPathChangeUploader - Uploaded 2 class resources
22:42:50.820 [pool-1-thread-1] INFO  o.s.b.d.r.c.DelayedLiveReloadTrigger - Remote server has changed, triggering LiveReload

更新速度よりも「更新対象URLが1個しか設定できない」という方が問題かと思います。

docker と spring boot v1.3

v1.3でリモートアップデートが可能になったという事は、ローカルのdockerのコンテナに対してリモートアップデートすれば、都度ビルドしなくてもそこそこの速度でコンテナ上のjarを更新できる!!とか考えていたのですが、ちょっと問題がありました。

リモートアップデートは以下のようにdocker-composeで試しています。

nginx-proxy:
  image: jwilder/nginx-proxy
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - "/var/run/docker.sock:/tmp/docker.sock:ro"
  restart: always
redis:
  image: redis
  expose:
    - 6379
h2:
  image: making/h2-server
web-pc:
  image: tree-tips/web-pc
  links:
    - redis
    - h2
  expose:
    - "8080"
  environment:
    - VIRTUAL_HOST=www.hoge.local
    - VIRTUAL_PORT=8080
  command: >
    --spring.datasource.url=jdbc:h2:tcp://${H2_PORT_1521_TCP_ADDR}:${H2_PORT_1521_TCP_PORT}/~/hoge

docker-machineでホストを作成し、そのホスト上に上記コンテナを作成しています。nginx -> spring-boot とリバースプロキシして、h2をDBサーバとし、redisをセッションレプリケーションで使用する、という一般的な構成です。

このdocker-compose.ymlは「web-pc」というコンテナが「1個」しか定義していません。この状態でリモートアップデートすると、問題無く更新できます。

しかし、「docker-compose scale web-pc=3」等とスケールする、もしくはdocker-compose.ymlに複数の「web-pc」を定義したらどうでしょう?devtoolsのリモートアップデートは1件のURLしか設定できないので、複数コンテナを同時に更新する事ができません。これは困りました。困ったので、とりあえず上記のように「web-pc」コンテナを1個に限定しています。

しかしこれだとローカルだけ初期インスタンス数が1、プロダクションだと3、等とステージ毎に環境を変えないといけなかったりして、非常によろしくありませんね。全ての環境で極力同じ環境にした方が、環境依存のエラーを減らす事ができます。インスタンス数が1個だと、セッションレプリケーションが効いてるのか効いてないのか解らなかったりするので、やはり環境は合わせたいものです。


更に、これはspring bootの問題ではないのですが、jwilder/nginx-proxyにもちょっと問題がありました。

jwilder/nginx-proxyとオレオレ証明書と301リダイレクト

webアプリはSSL通信したいので、以下のようにcertsフォルダにオレオレ証明書を配置してnginx-proxyにマウントします。

nginx-proxy:
  image: jwilder/nginx-proxy
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - "/Users/tree/docker/hoge/certs:/etc/nginx/certs:rw"
    - "/var/run/docker.sock:/tmp/docker.sock:ro"
  restart: always

nginx-proxyは/etc/nginx/certsに所定のファイル名で証明書があると、docker-genによって「/etc/nginx/conf.d/default.conf」内が動的に以下のように書き換えられます。(一部抜粋)

server {
    server_name _; # This is just an invalid value which will never trigger on a real hostname.
    listen 80;
    access_log /var/log/nginx/access.log vhost;
    return 503;
}
upstream www.hoge.local {
            server 172.17.0.5:8080;
}
server {
    server_name www.hoge.local;
    listen 80 ;
    access_log /var/log/nginx/access.log vhost;
    return 301 https://$host$request_uri;
}
server {
    server_name www.hoge.local;
    listen 443 ssl http2 ;
    access_log /var/log/nginx/access.log vhost;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA;
    ssl_prefer_server_ciphers on;
    ssl_session_timeout 5m;
    ssl_session_cache shared:SSL:50m;
    ssl_certificate /etc/nginx/certs/www.hoge.local.crt;
    ssl_certificate_key /etc/nginx/certs/www.hoge.local.key;
    add_header Strict-Transport-Security "max-age=31536000";
    location / {
        proxy_pass http://www.hoge.local;
    }
}

しかしこのコードには問題があります。以下の部分です。

server {
    server_name www.hoge.local;
    listen 80 ;
    access_log /var/log/nginx/access.log vhost;
    return 301 https://$host$request_uri;
}

SSL証明書を置いてしまうと、自動的にこのように80(http) -> 443(https)の301リダイレクトのコードが設定されてしまいます。

If a container has a usable cert, port 80 will redirect to 443 for that container so that HTTPS is always preferred when available.

https://github.com/jwilder/nginx-proxy

これが問題なのです。devtoolsのリモートアップデートは、ローカル -> nginx-proxy -> web-pc(spring-boot)という経路でアップデートできるのですが、ここにSSLが絡むとオレオレ証明書の場合は問題が起きます。

パターン1)Program Argumentsにhttpsを指定する場合

リモートアップデートする際は、Program ArgumentsにURLを指定し、Mainにorg.springframework.boot.devtools.RemoteSpringApplicationを指定します。このURLにhttps://www.hoge.com等とhttpsを指定すると、当然オレオレ証明書なので以下のように証明書のベリファイエラーになります。

23:21:53.024 [File Watcher] INFO  o.s.b.d.r.c.ClassPathChangeUploader - Uploaded 2 class resources
Exception in thread "File Watcher" java.lang.IllegalStateException: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at org.springframework.boot.devtools.remote.client.ClassPathChangeUploader.onApplicationEvent(ClassPathChangeUploader.java:107)
	at org.springframework.boot.devtools.remote.client.ClassPathChangeUploader.onApplicationEvent(ClassPathChangeUploader.java:56)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.invokeListener(SimpleApplicationEventMulticaster.java:163)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:136)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:381)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:335)
	at org.springframework.boot.devtools.classpath.ClassPathFileChangeListener.publishEvent(ClassPathFileChangeListener.java:68)
	at org.springframework.boot.devtools.classpath.ClassPathFileChangeListener.onChange(ClassPathFileChangeListener.java:64)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.fireListeners(FileSystemWatcher.java:230)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.updateSnapshots(FileSystemWatcher.java:223)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.scan(FileSystemWatcher.java:183)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.access$100(FileSystemWatcher.java:41)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher$1.run(FileSystemWatcher.java:150)
Caused by: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at sun.security.ssl.Alerts.getSSLException(Alerts.java:192)
	at sun.security.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1917)
	at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:301)
	at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:295)
	at sun.security.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1471)
	at sun.security.ssl.ClientHandshaker.processMessage(ClientHandshaker.java:212)
	at sun.security.ssl.Handshaker.processLoop(Handshaker.java:936)
	at sun.security.ssl.Handshaker.process_record(Handshaker.java:871)
	at sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:1043)
	at sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1343)
	at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1371)
	at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1355)
	at sun.net.www.protocol.https.HttpsClient.afterConnect(HttpsClient.java:563)
	at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:185)
	at sun.net.www.protocol.https.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:153)
	at org.springframework.http.client.SimpleBufferingClientHttpRequest.executeInternal(SimpleBufferingClientHttpRequest.java:80)
	at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48)
	at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:53)
	at org.springframework.http.client.InterceptingClientHttpRequest$InterceptingRequestExecution.execute(InterceptingClientHttpRequest.java:93)
	at org.springframework.boot.devtools.remote.client.HttpHeaderInterceptor.intercept(HttpHeaderInterceptor.java:57)
	at org.springframework.http.client.InterceptingClientHttpRequest$InterceptingRequestExecution.execute(InterceptingClientHttpRequest.java:85)
	at org.springframework.http.client.InterceptingClientHttpRequest.executeInternal(InterceptingClientHttpRequest.java:69)
	at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48)
	at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:53)
	at org.springframework.boot.devtools.remote.client.ClassPathChangeUploader.onApplicationEvent(ClassPathChangeUploader.java:102)
	... 12 more
Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:387)
	at sun.security.validator.PKIXValidator.engineValidate(PKIXValidator.java:292)
	at sun.security.validator.Validator.validate(Validator.java:260)
	at sun.security.ssl.X509TrustManagerImpl.validate(X509TrustManagerImpl.java:324)
	at sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:229)
	at sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:124)
	at sun.security.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1453)
	... 32 more
Caused by: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at sun.security.provider.certpath.SunCertPathBuilder.build(SunCertPathBuilder.java:145)
	at sun.security.provider.certpath.SunCertPathBuilder.engineBuild(SunCertPathBuilder.java:131)
	at java.security.cert.CertPathBuilder.build(CertPathBuilder.java:280)
	at sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:382)
	... 38 more
パターン2)Program Argumentsにhttpを指定する場合

では、Program Argumentsにはhttp://www.hoge.com等とhttpを指定して、リモートアップデートをhttpで行うとどうでしょう。

リモートアップデートを実行すると、先ほどの301リダイレクトが動いてしまって、リモートアップデートのhttpリクエスト -> nginx-proxyがhttpsに301リダイレクトする -> web-pcに届く、となります。実際に試してみると以下の結果になりました。

23:24:42.310 [File Watcher] INFO  o.s.b.d.r.c.ClassPathChangeUploader - Uploaded 2 class resources
Exception in thread "File Watcher" java.lang.IllegalStateException: Unexpected 301 response uploading class files
	at org.springframework.util.Assert.state(Assert.java:392)
	at org.springframework.boot.devtools.remote.client.ClassPathChangeUploader.onApplicationEvent(ClassPathChangeUploader.java:103)
	at org.springframework.boot.devtools.remote.client.ClassPathChangeUploader.onApplicationEvent(ClassPathChangeUploader.java:56)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.invokeListener(SimpleApplicationEventMulticaster.java:163)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:136)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:381)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:335)
	at org.springframework.boot.devtools.classpath.ClassPathFileChangeListener.publishEvent(ClassPathFileChangeListener.java:68)
	at org.springframework.boot.devtools.classpath.ClassPathFileChangeListener.onChange(ClassPathFileChangeListener.java:64)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.fireListeners(FileSystemWatcher.java:230)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.updateSnapshots(FileSystemWatcher.java:223)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.scan(FileSystemWatcher.java:183)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher.access$100(FileSystemWatcher.java:41)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher$1.run(FileSystemWatcher.java:150)

301は想定外だよ〜、と怒られます。

パターン1でもパターン2でも怒られてしまうので、結局SSL証明書の配置をやめました。

nginx-proxy:
  image: jwilder/nginx-proxy
  ports:
    - "80:80"
    - "443:443"
  volumes:
#    - "/Users/tree/docker/anime-music/certs:/etc/nginx/certs:rw"
    - "/var/run/docker.sock:/tmp/docker.sock:ro"
  restart: always

このdockerとオレオレ証明書の件はkeytoolで承認してしまったり、let's encryptで有効な証明書を作ってしまえば何とかなりそうですね。もしくは証明書のベリファイを無視するようカスタマイズするか、でしょうか。どうするのがスマートな解決方法なのでしょう。知りたいです。

雑感

リモートアップデート先が1URLしか設定できない件は何とかして欲しいですね。ローカルであってもdocker等で複数のインスタンスに対して更新したい要件って昨今では多いと思うので、今後に期待ですね。

もしかしたら、起動時は複数インスタンスを起動して、リモートアップデート時に1インスタンスにスケールダウンしてから更新させ、更新が終わったら再び元のインスタンス数にスケールアップする、とかしたら複数インスタンスの更新できそうな気がしないでもないですが、簡単に実現できるようになれば最高なんですけどね。。。これが実現できればローカルからプロダクションまでjavaでフルdocker環境とかできそうな予感がします。

とりあえずローカルのdockerの場合は1インスタンスで我慢すればリモートアップデートがちゃんと動くので、色々試していきたいと思います。