読者です 読者をやめる 読者になる 読者になる

SIerだけど技術やりたいブログ

5年目のSIerのブログです

PostgreSQLのDockerイメージの使い方

ホスト環境を汚さずにいろいろなバージョンのミドルウェアを構築するために、Dockerは非常に有効な手段だと思います。
この記事は、PostgreSQLのDockerイメージを使うために試行錯誤したときのメモです。

動作確認環境

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ docker --version
Docker version 1.10.3, build d381c64-unsupported

2017/04/03 追記
さすがにdockerのバージョンが古かったので、新しめのCE版を入れて動作確認しました。
動きが違うところはバージョンごとに書いてます。
Get Docker for CentOS - Docker Documentation

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ docker --version
Docker version 17.03.1-ce, build c6d412e

PostgreSQLの起動

$ docker run --name my-db -p 5432:5432 -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=secret -d postgres:9.6
--name my-db

--nameでコンテナ名の指定ができる。
起動しているコンテナの名前は docker ps で確認できる。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
f43f50193fa6        postgres:9.6        "docker-entrypoint..."   40 seconds ago      Up 38 seconds       0.0.0.0:5432->5432/tcp   my-db
-p 5432:5432

ポートフォワーディングしてほしければ、-pでバインディングする。
(-p ホストのポート:コンテナのポート)

# ホスト側の5432ポートに接続するとコンテナにフォワードされる
$ psql -h localhost -U dev
ユーザ dev のパスワード:
psql (9.6.1, サーバー 9.6.2)
"help" でヘルプを表示します.

dev=# \l
                                         データベース一覧
   名前    |  所有者  | エンコーディング |  照合順序  | Ctype(変換演算子) |      アクセス権
-----------+----------+------------------+------------+-------------------+-----------------------
 dev       | postgres | UTF8             | en_US.utf8 | en_US.utf8        |
 postgres  | postgres | UTF8             | en_US.utf8 | en_US.utf8        |
 template0 | postgres | UTF8             | en_US.utf8 | en_US.utf8        | =c/postgres          +

サーバロケールがen_US.utf8になっている。ja_JP.utf8にしたい人は変更が必要。
https://hub.docker.com/_/postgres/

-e POSTGRES_USER

スーパユーザ名(省略時は"postgres")

-e POSTGRES_PASSWORD

スーパユーザのパスワード(省略時はパスワードなしでログイン可)

-e POSTGRES_DB

PostgreSQL上のデータベース名(省略時はPOSTGRES_USERと同じ)
デフォルトだとユーザ名と同じデータベースが存在しないとエラーになるのであんまり変える機会なさそう。

-e PGDATA

PostgreSQLのデータの格納先ディレクトリ(省略時は/var/lib/postgresql/data)

※ -eコマンドでコンテナの環境変数を設定できる仕組みがDockerにあるらしい。
http://docs.docker.jp/engine/reference/run.html#env

以下のようなDockerfileを用意して試してみる。
docker runしたら環境変数が一覧表示されるはず。

$ cat Dockerfile
FROM centos:7
ENV HOGE hoge
ENTRYPOINT ["env"]

ビルドしてイメージを作る。

$ docker build -t xxx .

docker runしたら環境変数を一覧表示する。
ENVで指定したHOGEが出力される。

$ docker run xxx
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=c1844898c4c6
HOGE=hoge
HOME=/root

-eで環境変数を指定してみる。
Dockerfileで指定したENVが上書きされる。
また、-e FUGA=fuga で追加したFUGAも表示される。

$ docker run -e HOGE=fuga -e FUGA=fuga xxx
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=d845c635858c
HOGE=fuga
FUGA=fuga
HOME=/root

この仕組みを利用してPostgreSQLのイメージは起動時に実行されるdocker-entrypoint.sh内でふるまいを変えているっぽい。
以下、PostgreSQL9.6イメージのdocker-entrypoint.shのソース抜粋。

file_env() {
        local var="$1"
        local fileVar="${var}_FILE"
        local def="${2:-}"
        if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
                echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
                exit 1
        fi
        local val="$def"
        if [ "${!var:-}" ]; then
                val="${!var}"
        elif [ "${!fileVar:-}" ]; then
                val="$(< "${!fileVar}")"
        fi
        export "$var"="$val"
        unset "$fileVar"
}
   ...

   file_env 'POSTGRES_USER' 'postgres'
   file_env 'POSTGRES_DB' "$POSTGRES_USER"

   psql=( psql -v ON_ERROR_STOP=1 )

   if [ "$POSTGRES_DB" != 'postgres' ]; then
           "${psql[@]}" --username postgres <<-EOSQL
                   CREATE DATABASE "$POSTGRES_DB" ;
           EOSQL
           echo
   fi

   if [ "$POSTGRES_USER" = 'postgres' ]; then
           op='ALTER'
   ...

起動時の動きを確認するときは docker inspect を利用する。

$ docker inspect postgres:9.6
...
  "WorkingDir": "",
  "Entrypoint": [
    "docker-entrypoint.sh"
  ],
...

起動しているコンテナに入れば、スクリプトディレクトリ構成を手軽に確認できる。

$ docker exec -it my-db /bin/bash
root@f43f50193fa6:/# ls
bin   dev                         docker-entrypoint.sh  home  lib64  mnt  proc  run   srv  tmp  var
boot  docker-entrypoint-initdb.d  etc                   lib   media  opt  root  sbin  sys  usr

PostgreSQLへの接続(コンテナを利用してpsqlを実行する)

このほうがDocker使ってるっぽくて個人的に好み。
psqlのバージョンも気軽にサーバ側バージョンに合わせられる。

$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U dev
Password for user dev:
psql (9.6.2)
Type "help" for help.

dev=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 dev       | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
--rm

実行が終わったらpsqlを実行したコンテナを破棄する。

--link my-db:db

--link コンテナ名:ホスト名 の形式で、起動するコンテナの/etc/hostsにホスト名が設定される。(このホスト名はlink先のコンテナのIPアドレスを指す)
https://docs.docker.com/engine/userguide/networking/default_network/dockerlinks/

/etc/hostsに設定されたホスト名を利用して psql -h の接続先ホストを指定する。

PostgreSQLの停止

stopするだけ。

$ docker stop my-db

PostgreSQLの再開

コンテナの$PGDATAディレクトリにデータが残っているので前のデータはそのまま。
ただし、コンテナを消すとデータも消える。

$ docker start my-db

データの永続化

データをコンテナと切り離して永続化したい場合、PostgreSQLのデータ保存先をData Volumeに指定するといいらしい。

Data Volumeを作成する。

$ docker volume create --name pgdata
pgdata

作成されたData Volumeを確認する。

$ docker volume inspect pgdata
[
    {
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
        "Name": "pgdata",
        "Options": {},
        "Scope": "local"
    }
]

起動時にData Volumeを指定する。

$ docker run -it --name my-db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=secret -d postgres:9.6
$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres

postgres=# create table book();
CREATE TABLE

こうするとコンテナが消えてもデータは残る。
コンテナを再度作成してData Volumeを割り当てれば昔のデータがそのまま見れる。

$ docker stop my-db
$ docker rm my-db
$ docker run -it --name my-db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=secret -d postgres:9.6
$ docker run -it --rm --link my-db:db postgres psql -h db -U postgres:9.6

postgres=# \d
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+----------
 public | book | table | postgres

設定のカスタマイズ方法

設定ファイルの編集

コンテナからpostgresql.confをコピーする。

$ docker cp my-db:/var/lib/postgresql/data/postgresql.conf .

ホスト側でカスタマイズする。
今回はとりあえずmax_connectionsを100から5にしてみる。

$ cat postgresql.conf
...
max_connections = 5
...

編集した設定ファイルの配置

dockerのバージョンによってとった対処が違うので分けて記述する。

docker 1.10 向け

以下でコンテナ側にファイルを配置できると思ったけど、ダメ。

$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/var/lib/postgresql/data/postgresql.conf -e POSTGRES_PASSWORD=secret postgres:9.6
chown: changing ownership of ‘/var/lib/postgresql/data/postgresql.conf’: Permission denied

なんでなんで?ググるとこんなのが。
DockerのVolumeのアクセス権限の問題について - Qiita

volumeで設定したファイルはuid,gidが1000になるらしい。たしかに。

$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 ls -al
total 96
...
drwxr-xr-x.   2 root root  4096 Nov  5 15:38 mnt
drwxr-xr-x.   2 root root  4096 Nov  5 15:38 opt
-rw-------.   1 1000 1000 22203 Mar 31 21:08 postgresql.conf
dr-xr-xr-x. 317 root root     0 Mar 31 21:13 proc
dr-xr-x---.   2 root root  4096 Mar 15 20:00 root
...

じゃあrootで実行すればいいんでしょうね、と思ったらそんなこともなかった。

$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 id
uid=0(root) gid=0(root) groups=0(root)
$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 cat postgresql.conf
cat: postgresql.conf: Permission denied

ググるとこんなのが。selinuxの問題らしい。
Permission denied on accessing host directory in docker - Stack Overflow

ということで以下のコマンドを実行するとrootでは実行できるようになった。

$ chcon -Rt svirt_sandbox_file_t postgresql.conf
$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 cat postgresql.conf
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
#
# This file consists of lines of the form:
#
#   name = value

ということでselinuxの問題を解決してから再実行。

$ chcon -Rt svirt_sandbox_file_t postgresql.conf
$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/var/lib/postgresql/data/postgresql.conf -e POSTGRES_PASSWORD=secret postgres:9.6
The files belonging to this database system will be owned by user "postgres".
This user must also own the server process.

The database cluster will be initialized with locale "en_US.utf8".
The default database encoding has accordingly been set to "UTF8".
The default text search configuration will be set to "english".

Data page checksums are disabled.

initdb: directory "/var/lib/postgresql/data" exists but is not empty
If you want to create a new database system, either remove or empty
the directory "/var/lib/postgresql/data" or run initdb
with an argument other than "/var/lib/postgresql/data".

またエラー。

PostgreSQLのinitdb実行前にPGDATAにファイルがおいてあるのが悪いっぽい。
Dockerがコンテナの/var/lib/postgresql/dataにVolumeを追加したあとにENTRYPOINT(docker-entrypoint.sh)が実行されるため、その中でinitdbした時にこけてると思われる。
https://github.com/docker-library/postgres/issues/105

PostgreSQL側でpostgresql.confを別ディレクトリから参照可能にするパラメータがあるため、それを利用する。

$ chcon -Rt svirt_sandbox_file_t postgresql.conf
$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/etc/postgresql.conf -e POSTGRES_PASSWORD=secret  postgres:9.6 -c config_file=/etc/postgresql.conf
...
LOG:  autovacuum launcher shutting down
LOG:  shutting down
LOG:  database system is shut down
 done
server stopped

PostgreSQL init process complete; ready for start up.

LOG:  could not open configuration file "/etc/postgresql.conf": Permission denied
FATAL:  configuration file "/etc/postgresql.conf" contains errors

またまたエラー。

結局ファイルのuid,gidが1000なのでPostgreSQLを実行するユーザからは権限がない。
そのため、コンテナ内のpostgresユーザがアクセスできるようにファイルのパーミッションを変える必要がある。つまり、Dockerfileを用意する必要がある。

$ cat Dockerfile
FROM postgres
COPY postgresql.conf /etc/postgresql.conf
RUN chown -R postgres /etc/postgresql.conf

Dockerfileをビルドし、起動時に -c config_file=/etc/postgresql.conf を指定する。

$ docker build -t dev-postgres -f Dockerfile .
$ docker run -it --name my-db -e POSTGRES_PASSWORD=secret -d dev-postgres -c config_file=/etc/postgresql.conf

PostgreSQLに接続してmax_connectionsが変わってるか確認してみる。

$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres
postgres=# show max_connections;
 max_connections
-----------------
 5
(1 row)

いけた!!!

Docker version 17.03.1-ce, build c6d412e 向け

以下でコンテナ側にファイルを配置できると思ったけど、ダメ。

$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/var/lib/postgresql/data/postgresql.conf -e POSTGRES_PASSWORD=secret postgres:9.6
The files belonging to this database system will be owned by user "postgres".
This user must also own the server process.

The database cluster will be initialized with locale "en_US.utf8".
The default database encoding has accordingly been set to "UTF8".
The default text search configuration will be set to "english".

Data page checksums are disabled.

initdb: directory "/var/lib/postgresql/data" exists but is not empty
If you want to create a new database system, either remove or empty
the directory "/var/lib/postgresql/data" or run initdb
with an argument other than "/var/lib/postgresql/data".

昔のバージョンのようにselinuxの問題はないが、やっぱりPostgreSQLのinitdb実行前にPGDATAにファイルがおいてあるのが悪いっぽい。
Dockerがコンテナの/var/lib/postgresql/dataにVolumeを追加したあとにENTRYPOINT(docker-entrypoint.sh)が実行されるため、その中でinitdbした時にこけてると思われる。
https://github.com/docker-library/postgres/issues/105

PostgreSQL側でpostgresql.confを別ディレクトリから参照可能にするパラメータがあるため、それを利用する。

$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/etc/postgresql.conf -e POSTGRES_PASSWORD=secret -d postgres:9.6 -c config_file=/etc/postgresql.conf
bf55391afe2ae709e84efd81025b4d7f542f3db4bb42d13c348807cc9342b47f
$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres
Password for user postgres:
psql (9.6.2)
Type "help" for help.

postgres=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
 template1 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +

特にpostgresql.confファイルをchownしなくても実行できる。昔のバージョンよりだいぶ楽!!!

PostgreSQLの日本語化

ロケールを追加し、LANG環境変数に設定する。

$ cat Dockerfile
FROM postgres:9.6
RUN localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
ENV LANG ja_JP.utf8

Dockerfileをビルドし、起動する。

$ docker build -t dev-postgres -f Dockerfile .
$ docker run -it --name my-db -e POSTGRES_PASSWORD=secret -d dev-postgres 

$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres
Password for user postgres:
psql (9.6.2)
Type "help" for help.

postgres=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 postgres  | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 |
 template0 | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres

永続化やら設定のカスタマイズをまとめると・・・

設定をカスタマイズするときの起動について、最終的には以下のDockerfileを用意しとけばよさそう。
(Docker version 17.03.1-ce, build c6d412e だとpostgresql.confファイルをDockerfile内でCOPYしてchownしなくてもいいが、どうせ日本語ロケールに設定するためにDockerfile作るのでコレで)

$ cat Dockerfile
FROM postgres:9.6
RUN localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
ENV LANG ja_JP.utf8
COPY postgresql.conf /etc/postgresql.conf
RUN chown -R postgres /etc/postgresql.conf

そしてこう!

  # postgresql.confを取り出すために一時的なコンテナを起動
$ docker run --name tmp -d postgres:9.6
  # postgresql.confを取り出す
$ docker cp tmp:/var/lib/postgresql/data/postgresql.conf .
  # postgresql.confを適当に編集(例としてmax_connectionsを50にする)
  # 編集したpostgresql.confを配置したイメージを作成
$ docker build -t dev-postgres -f Dockerfile .
  # 永続化するためにData Volume作成
$ docker volume create pgdata
  # コンテナを起動
$ docker run -it --name my-db -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data -d dev-postgres -c config_file=/etc/postgresql.conf
  # PostgreSQLに接続
$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U dev
Password for user dev:
psql (9.6.2)
Type "help" for help.

dev=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 dev       | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 |
 postgres  | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 |
 template0 | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 | =c/postgres          +

  # 自分で修正したpostgresql.confの内容が反映されている
dev=# show max_connections;
 max_connections
-----------------
 200
(1 row)

手順を簡素化する

上記の例(ビルド~起動)までのコマンドは起動引数が多くてちょっと大変。
docker-compose.ymlを使うと楽だよ、と教えてもらったので使ってみた。

まずインストール
Install Docker Compose - Docker Documentation

$ docker-compose --version
docker-compose version 1.11.2, build dfed245

docker-compose.ymlファイルを用意。

$ cat docker-compose.yml
version: "2"
volumes:
   pgdata:
     driver: 'local'
services:
  my-db:
    build: .
    image: "dev-postgres"
    container_name: "my-db"
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: "dev"
      POSTGRES_PASSWORD: "secret"
    command: 'postgres -c config_file="/etc/postgresql.conf"'
    volumes:
      - pgdata:/var/lib/postgresql/data

postgresql.confをローカルに置いてさえ置けば、以下のコマンドでDockerfileのビルドから起動までやってくれるようになった。引数がコード化されている点がいい。

$ docker-compose up --build -d
docker-compose up --build -d
Building my-db
Step 1/5 : FROM postgres
 ---> 9910dc9f2ac0
Step 2/5 : RUN localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
 ---> Using cache
 ---> 3e2faf03d333
Step 3/5 : ENV LANG ja_JP.utf8
 ---> Using cache
 ---> 4b8b89e1d0f7
Step 4/5 : COPY postgresql.conf /etc/postgresql.conf
 ---> Using cache
 ---> e40af9553b4a
Step 5/5 : RUN chown -R postgres /etc/postgresql.conf
 ---> Using cache
 ---> 70e2b24824d7
Successfully built 70e2b24824d7
my-db is up-to-date

ただし、psql実行時の --linkでコンテナ間リンクを貼れなくなった。docker-composeはコンテナグループごとにネットワーク作るとかなんとか・・・また土日に調べる。

ひとまずホスト側にあるpsqlで接続する。

$ psql -h localhost -U dev
ユーザ dev のパスワード:
psql (9.6.1, サーバー 9.6.2)
"help" でヘルプを表示します.

dev=# \l
                                         データベース一覧
   名前    |  所有者  | エンコーディング |  照合順序  | Ctype(変換演算子) |      アクセス権
-----------+----------+------------------+------------+-------------------+-----------------------
 dev       | postgres | UTF8             | ja_JP.utf8 | ja_JP.utf8        |
 postgres  | postgres | UTF8             | ja_JP.utf8 | ja_JP.utf8        |
 template0 | postgres | UTF8             | ja_JP.utf8 | ja_JP.utf8        | =c/postgres          +

docker-composeで起動した場合、各アプリケーションごとにネットワークが設定される。

$ docker-compose ps
Name               Command               State           Ports
-----------------------------------------------------------------------
my-db   docker-entrypoint.sh postg ...   Up      0.0.0.0:5432->5432/tcp

$ docker inspect my-db
...
      "Networks": {
        "mydocker_default": {
          "IPAMConfig": null,
          "Links": null,
          "Aliases": [
            "84eb940e53ad",
            "my-db"
          ],
          "NetworkID": "8ceeb0c7592109b449fc141b24c19c108d0c5ac6c7fcacc58642d5ce6f8f3a78",
          "EndpointID": "a9e8aa8f3f4ed045ef395220833aabc3eee704731f591f77cdbddd2a2430a6a1",
          "Gateway": "172.19.0.1",
          "IPAddress": "172.19.0.2",
          "IPPrefixLen": 16,
          "IPv6Gateway": "",
          "GlobalIPv6Address": "",
          "GlobalIPv6PrefixLen": 0,
          "MacAddress": "02:42:ac:13:00:02"
        }
      }
...

$ docker network ls
NETWORK ID          NAME                  DRIVER              SCOPE
...
8ceeb0c75921        mydocker_default      bridge              local
...

そのため、psqlをコンテナ間リンクで接続したい場合は以下のようにする。

$ docker run --rm -it --net mydocker_default --link my-db:db postgres:9.6 psql -h db -U dev
Password for user dev:
psql (9.6.2)
Type "help" for help.

dev=# show max_connections;
 max_connections
-----------------
 50
(1 row)

終わりに

Dockerを使いこなすためにも、まずはLinux力をつけていきたい。

SpringのContextHolderいろいろ

Springには、…ContextHolderというクラスがある。スレッドローカルに値を保存しておくことで、情報をいろんなところから参照できるようにする。

スレッドローカルは…まあスレッド固有の値ですよね。(あたりまえ)

TomcatなどのAPサーバはリクエストごとにworkerスレッドを割り当てるので、
kimulla.hatenablog.com

@AsyncやExecutorServiceを利用して別スレッドでXXXContextHolderを利用しない限り、どこからでもスレッドローカルに登録した同じ値を参照できる。具体的なクラスは、

Spring MVC RequestContextHolder リクエスト情報
Spring Security SecurityContextHolder 認証情報

使い方

Spring Bootでリクエストと認証のログを出してみる。spring-boot-starter-securityがclasspathに含まれてるとデフォルトでDIGEST認証がかかるので、今回は認証にそれを使う。

ログ処理はHandlerInterceptorを使う。
引数にHttpServletRequestが取れるけど、わざとRequestContextHolderから取得する。

@Slf4j
public class LoggingInterceptor extends HandlerInterceptorAdapter {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
    String msg = (String) request.getParameter("msg");
    log.info("request : " + msg);

    SecurityContext sc = SecurityContextHolder.getContext();
    Authentication authentication = sc.getAuthentication();
    log.info("authentication : " + authentication);
    return true;
  }
}

HandlerInterceptorを登録する。

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter{

    @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LoggingInterceptor())
        .addPathPatterns("/**");
  }
}

リクエストするためのメソッドを用意する。

@RestController
@SpringBootApplication
public class ContextholderApplication {

  public static void main(String[] args) {
    SpringApplication.run(ContextholderApplication.class, args);
  }

  @GetMapping(value = "sample", params = "msg")
  public String param() {
    return "success";
  }
}

起動するとログに認証パスワードが表示されるので、それを使ってリクエストする。

...
2017-02-18 19:39:47.925  INFO 13184 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2017-02-18 19:39:47.955  INFO 13184 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2017-02-18 19:39:48.227  INFO 13184 --- [           main] b.a.s.AuthenticationManagerConfiguration : 

Using default security password: e5ad05bf-3daa-4a1b-91ad-81d726ed712a
curl --user user:e5ad05bf-3daa-4a1b-91ad-81d726ed712a http://localhost:8080/sample?msg=hello

ログに出る。

2017-02-18 19:40:01.634  INFO 13184 --- [nio-8080-exec-1] com.example.LoggingInterceptor           : request : hello
2017-02-18 19:40:01.634  INFO 13184 --- [nio-8080-exec-1] com.example.LoggingInterceptor           : authentication : org.springframework.security.authentication.UsernamePasswordAuthenticationToken@442b5a9f: Principal: org.springframework.security.core.userdetails.User@36ebcb: Username: user; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_USER; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_USER

うん。


・・・うん。

jenkinsのpipeline入門(jenkinsfile)

本記事について、Jenkinsのpipelineが最近(2017/2/20時点で)DSLがごっそり入れ替わったっぽいので内容が古いです。注意。


Jenkins2では、Groovy DSLを用いたpipelineの記述ができるようになったらしい。若干の時代遅れ感があるけど、最近仕事で使う機会があり、土日にわからないところを整理したのでメモる。

ジョブ定義を画面からぽちぽちするなんて時代遅れ!技術者として恥ずかしい!これこそがモダン!とか煽るつもりはまったくないけど、ジョブ定義の柔軟性が高いし、パイプラインに人の承認フローが組み込めたり、実行ノードを簡単に選べるし、何よりコード管理できるので、普通に良さそうだと思いました。情報が少ないことを除けばな!

Jenkins2のインストー

公式サイトからwarを落とすなりdockerで起動するなりして、Jenkinsをインストールする。(今回利用したのはver 2.32.1)

f:id:kimulla:20170128200815p:plain

ジョブを作る。

f:id:kimulla:20170128201726p:plain

通常はジョブをJenkinsfileに記述し、リポジトリのトップディレクトリに置いておく。
が、今回は勉強が目的なのでここにジョブを記述する。

f:id:kimulla:20170130224408p:plain

とりあえず動かす

とりあえず動かしてみる。

node {
    print "Hello World"
}

実行結果は

f:id:kimulla:20170130224816p:plain

コンソール出力で確かめる。

f:id:kimulla:20170130224632p:plain

とりあえず動いた。


node

実行ノードを指定できる。Jenkinsが複数のslaveを持つときに、nodeの引数にslave名を指定する。

slaveをひとつ作って試してみる。

左側のビルド実行状態 > 新規ノード作成 から、以下の通り作成する。

f:id:kimulla:20170130225138p:plain

slaveが作れた。

f:id:kimulla:20170130225159p:plain

nodeの引数にslave名を指定する。

node('slave') {
    print "hello world"
}

実行してコンソール出力を確かめると、slaveで実行されていることがわかる。

Started by user root
[Pipeline] node
Running on slave in /tmp\jenkins/slave\workspace\pipeline-sample
[Pipeline] {
[Pipeline] echo
hello world
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

以下のように否定も書ける。

node('!master') {
    ....
}

環境変数の参照

job内では複数の環境変数を参照できる。

node {
    print "BUILD_NUMBER: ${env.BUILD_NUMBER}"
    print "BUILD_ID: ${env.BUILD_ID}"
    print "WORKSPACE: ${env.WORKSPACE}"
    print "JENKINS_URL: ${env.JENKINS_URL}"
    print "BUILD_URL: ${env.BUILD_URL}"
    print "JOB_URL: ${env.JOB_URL}"
}

実行してコンソール出力を確かめる。

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] echo
BUILD_NUMBER: 2
[Pipeline] echo
BUILD_ID: 2
[Pipeline] echo
WORKSPACE: /var/jenkins_home/workspace/pipeline-sample
[Pipeline] echo
JENKINS_URL: http://192.168.11.100:18080/
[Pipeline] echo
BUILD_URL: http://192.168.11.100:18080/job/pipeline-sample/2/
[Pipeline] echo
JOB_URL: http://192.168.11.100:18080/job/pipeline-sample/
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

参照できる環境変数の一覧は以下から確認できる。

f:id:kimulla:20170130230007p:plain

f:id:kimulla:20170130230017p:plain

f:id:kimulla:20170130230037p:plain


変数の定義

Groovyなので、変数定義や展開ができる。

def MESSAGE='sample'

node {
    print MESSAGE
    print "展開される ${MESSAGE}"
    print '展開されない ${MESSAGE}'
}

実行してコンソール出力を確かめる。ダブルクオートは中の変数が展開されるけど、シングルクオートは展開されないことがわかる。(Groovyの構文覚えないといけない)

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] echo
sample
[Pipeline] echo
展開される sample
[Pipeline] echo
展開されない ${MESSAGE}
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

制御文

Groovyなので、制御文も書ける。

node {
    for (i in 0..9) {
        println i
    }
}

ただセキュリティの観点から、Groovy DSLは色々と機能を絞ったサンドボックス上で実行されるので、デフォルトだとRejectedAccessExceptionになる。

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException: Scripts not permitted to use staticMethod org.codehaus.groovy.runtime.ScriptBytecodeAdapter createRange java.lang.Object java.lang.Object boolean
	at org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.StaticWhitelist.rejectStaticMethod(StaticWhitelist.java:192)
	at org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor.onStaticCall(SandboxInterceptor.java:142)
	at org.kohsuke.groovy.sandbox.impl.Checker$2.call(Checker.java:180)
	at org.kohsuke.groovy.sandbox.impl.Checker.checkedStaticCall(Checker.java:177)

実行したければ Use Groovy Sandbox のチェックを外すか、

f:id:kimulla:20170130230516p:plain

Jenkinsの管理からScriptを許可する。

f:id:kimulla:20170130230420p:plain

f:id:kimulla:20170130230428p:plain


ここらへんは詳しく書いている人がいたのでそっちを参照する。

arasio.hatenablog.com


Groovyのメソッド

Groovyのメソッドも書ける。(やっぱりセキュリティにひっかかったりする)

node {
    println new Date().format("yyyyMMddHHmmssSSS")
}

実行してコンソール出力を確かめると、確かにメソッドが実行できる。

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] echo
20170128044540483
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

stage

テスト→ビルド→デプロイ、みたいにビルドのステージを定義できる。

node {
    stage('build') {
        print "build"
    }
    
    stage ('test') {
        print "test"
    }
    
    stage ('deploy') {
        print "deploy"
    }
}

実行するとステージごとにステータスが見れる。

f:id:kimulla:20170130231201p:plain


あるステージで失敗した場合、ステージの表示が赤になる。
error という処理を途中で中断したいときに使うコマンドを使って失敗させてみる。

node {
    stage('build') {
        print "build"
    }
    
    stage ('test') {
        error "failed"
    }
    
    stage ('deploy') {
        print "deploy"
    }
}

f:id:kimulla:20170130231304p:plain

コンソール出力からもERRORが起きてることがわかる。

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] stage
[Pipeline] { (build)
[Pipeline] echo
build
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (test)
[Pipeline] error
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
ERROR: failed
Finished: FAILURE

throw new Exceptionでも同じことができる。
ただしコンソール出力にスタックトレースが出る点がerrorコマンドと異なる。

node {
    stage('build') {
        print "build"
    }
    
    stage ('test') {
        throw new Exception("failed")
    }
    
    stage ('deploy') {
        print "deploy"
    }
}
Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] stage
[Pipeline] { (build)
[Pipeline] echo
build
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (test)
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException: Scripts not permitted to use new java.lang.Exception java.lang.String
	at org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.StaticWhitelist.rejectNew(StaticWhitelist.java:187)
	at org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor.onNewInstance(SandboxInterceptor.java:130)
	at org.kohsuke.groovy.sandbox.impl.Checker$3.call(Checker.java:191)

※ errorコマンドも内部ではAbortExceptionという例外をスローしてるっぽいけど、正直よくわかってない。


失敗しても処理を継続したい場合

何を失敗とするのかはstepによるけど、各stepは、失敗した場合にExceptionをスローするようになっている。

失敗しても処理を継続したい場合は、try-catchすればいい。

node {
    stage('stage1') {
        try{
            print "try"
            error "failed"
        } catch(Exception e) {
            print "catch"
        } finally {
            print "finally"
        }
    }
}

ただしこの場合は例外をにぎりつぶしてるので、ビルド結果がsuccessになってしまう。

f:id:kimulla:20170130231623p:plain

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] stage
[Pipeline] { (stage1)
[Pipeline] echo
try
[Pipeline] error
[Pipeline] echo
catch
[Pipeline] echo
finally
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

currentBuild.result

例外ハンドリングしつつ全体のステータスをfailにしたい場合は、currentBuild.resultを利用する。

node {
    stage('stage1') {
        try{
            print "try"
            error "failed"
        } catch(Exception e) {
            currentBuild.result = 'FAILURE'
        } 
    }
    stage('stage2') {
        print "stage2"
    }
}

こうすると、次のステージに進みつつ全体をFAILUREにできる。

f:id:kimulla:20170130231659p:plain


ステージは進めるけどステージごとに成功、失敗を表示させる、というのはできないらしい。ただし、JenkinsのJIRAに上がってるのでそのうち対応される感はある。
https://issues.jenkins-ci.org/browse/JENKINS-26522

もしステージ内でエラーハンドリングして次のステージに進まずに失敗させたければ、例外を投げるかerrorを使うと良さそう。


sh

引数に与えられたシェルスクリプトを実行する。

node {
    sh "date"
}  

実行してコンソール出力を確かめる。

Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] sh
[pipeline-sample] Running shell script
+ date
Wed Jan 18 22:57:12 UTC 2017
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

shは別シェルとして実行されるため、シェル変数は引き継がれない点に注意。

node {
    sh "HOGE=xxx"
    sh "$HOGE"
}
Started by user root
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] sh
[pipeline-sample] Running shell script
+ HOGE=xxx
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
groovy.lang.MissingPropertyException: No such property: HOGE for class: groovy.lang.Binding
	at groovy.lang.Binding.getVariable(Binding.java:63)
	at org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor.onGetProperty(SandboxInterceptor.java:224)
        at org.kohsuke.groovy.sandbox.impl.Checker$4.call(Checker.java:241)
	at org.kohsuke.groovy.sandbox.impl.Checker.checkedGetProperty(Checker.java:238)
	at org.kohsuke.groovy.sandbox.impl.Checker.checkedGetProperty(Checker.java:221)
	at com.cloudbees.groovy.cps.sandbox.SandboxInvoker.getProperty(SandboxInvoker.java:28)

exit codeが0以外の場合、例外が投げられる。

node {
    stage('stage1') {
        sh "true"
    }
    
    stage('stage2') {
        sh "false"    
    }
}

f:id:kimulla:20170130232701p:plain

コマンドを実行した結果が欲しい場合は以下のようにする。

node {
    OS = sh returnStdout: true, script: 'uname'
    print OS
}
[Pipeline] node
Running on master in /var/jenkins_home/workspace/pipeline-sample
[Pipeline] {
[Pipeline] sh
[pipeline-sample] Running shell script
+ uname
[Pipeline] echo
Linux

[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

input

パイプラインに、人のチェックを組み込める。

node {
    stage('test') {
        print "this is test"
    }
    
    stage('permit'){
        input 'Ready to go?'
    }
    stage('deploy') {
        print "start"
    }
}

実行してみると、permitのstageで止まる。

f:id:kimulla:20170130232841p:plain

permitのstageにカーソルを合わせると、人のチェックができる。

f:id:kimulla:20170130232923p:plain

input ステップを実行する前に、mattermostやmailに通知しておけばチェック依頼も自動化できる。当然、mattermostやmailに通知するステップもある。


その他のStep

便利なステップが山ほどある。
https://jenkins.io/doc/pipeline/steps/

が、もっとお手軽に、Pipeline Syntaxでstepを作成できる。

f:id:kimulla:20170130233145p:plain

値を打ち込めばDSLを生成してくれる。

f:id:kimulla:20170130233204p:plain


その他のpluginを呼ぶには

調べてもよくわからなかったけど、現状、pipeline pluginに対応したものしか呼べないらしい。

stackoverflow.com

なので今時点(2017/1/30)では、emotional-jenkins-pluginを入れてJenkinsおじさんを怒らせることができないっぽい。

なんだって?jenkinsおじさんが怒ってくれないだと?使うわけないだろ!こんなもん!!!…とお怒りの皆さま、安心してください。

emotional-jenkins-pluginにpull requestが出されてるので、そのうち使えるんじゃないかと思います。
https://github.com/jenkinsci/emotional-jenkins-plugin/pull/2

またpipelineはJenkinsの標準機能なので、他のpluginもそのうち対応されていくと思いたい。

最後に

柔軟性が高いし、パイプライン中に人の承認フローが組み込めたり、実行ノードが簡単に選べるので良さげ。ただし、あまり柔軟性を持たせてもビルド職人を生むだけなので、意識的にDSLに閉じた操作にとどめたほうが無難だと思いました。


makeのshell関数の実行タイミングは直感と異なる

makeのshell関数の実行タイミングは直感と異なる

Makefile作成時にshell関数の実行タイミングが直感と異なっていてハマったのでメモ。

やりたかったこと

これ。
www.rhoboro.com

sphinxドキュメントをビルドするときに、なるべくビルド環境を汚さず、CIツールへの依存も減らしたかった。

想定する流れ。

  • makeでDockerをビルド・実行しsphinxドキュメントを作成(docker build ,docker run)
  • コンテナに生成された成果物をローカルに持ってくる(docker cp)
  • コンテナを削除(docker rm)

このとき、コンテナの削除を以下のコマンドで削除できるかと思ったらできず。

build:
        docker build -t xxx .
        docker run --name yyy xxx 
        ...
        docker rm $(shell docker ps -aqf name=yyy)

以下のように $(shell docker ps -aqf name=yyy) の実行結果が空になる。なぜ…。

docker rm
docker: "rm" requires a minimum of 1 argument.
See '/usr/bin/docker-current rm --help'.

Usage:  docker rm [OPTIONS] CONTAINER [CONTAINER...]

Remove one or more containers
make: *** [build] エラー 1

原因

上から順に処理が実行されるのではなく、まず初めにshell関数が実行される。

例えば以下の場合、

task:
        touch sample.txt
        echo $(shell ls)
        rm sample.txt

上から順にコマンドが実行されないので、echo $(shell ls) の結果にsample.txtが含まれない。

$ make
touch sample.txt
echo Makefile shell.sh
Makefile shell.sh
rm sample.txt

シェルスクリプトの場合

シェルで別コマンドの実行結果を利用するといえばコマンド置換($(),``)。コマンド置換はそのコマンドが書かれた行で実行される。

#!/bin/sh

touch sample.txt
echo $(ls)
rm sample.txt

上から順にコマンドが実行されるので、 echo $(ls) の結果にsample.txtが含まれる。

$ ./shell.sh
Makefile sample.txt shell.sh

解決策

makeでlinuxと同じような順序で実行したければ、シェルのコマンド置換を利用する。
そのためには、$をエスケープすればいい。

task:
        touch sample.txt
        echo $$(ls)
        rm sample.txt
$ make
touch sample.txt
echo $(ls)
Makefile sample.txt shell.sh
rm sample.txt

はーもう悩むのめんどくさいし、シェルでいいものはシェルで書くことにする。

ブロッキングとかノンブロッキングを理解したい

Spring Framework5では「Reactive」対応が目玉だと言われているが、そもそもその前段のブロッキングやノンブロッキングというのが何なのか、いまいちしっくりこなかったので基本から調べた。(今回は特にネットワーク部分に絞って調べた)

また、tomcatの実装ではブロッキングI/OやノンブロッキングI/Oをどのように使っているのか、servlet3.1のノンブロッキングI/Oとは何なのかについても調べた。

調べたら余計にわからないところが増えたけど、とりあえず今の理解をまとめる。
ご指摘ありましたら是非ともコメントお願いします。

ブロッキングI/O

I/Oをする際(read,write)に、処理がブロックされる。

実装方法(ソケット)

クライアント SocketChannel
サーバ ServerSocketChannel

コード例

8080ポートにリクエストが来たらリクエスト内容をレスポンスとして返事する、エコーサーバを作ってみる。

Socketのaccept()を実行した時点で処理がブロックされ、次の行が実行されずに待機する。(ここがブロッキング)
socketをaccept()しているスレッドでリクエストに対する処理をすると、処理中は別のリクエストが受け付けられなくなる。そのため、最大でも1つのリクエストしか同時に処理できない。

@Slf4j
public class BlockingAndSingleEchoServer {
  public void start() {
    try (ServerSocketChannel ssc = ServerSocketChannel.open();) {
      ssc.socket().bind(new InetSocketAddress(8080));
      log.info("サーバを起動しました");
      while (true) {
        try (SocketChannel sc = ssc.accept();//ブロックされる
           BufferedReader in = new BufferedReader(new InputStreamReader(sc.socket().getInputStream()));
           PrintWriter out = new PrintWriter(sc.socket().getOutputStream(), true);
        ) {
          String line;
          while ((line = in.readLine()) != null) {
            log.info("echo " + line + " to " + sc.socket().getRemoteSocketAddress());
            out.println(line);
          }
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}


具体的な処理は別スレッドで実行すると、あるリクエストの処理中にも平行して別のリクエストを処理することができる。

public class BlockingAndMultiUserServer  {
  public void start() {
    try (ServerSocketChannel channel = ServerSocketChannel.open()) {
      channel.socket().bind(new InetSocketAddress(8080));
      while (true) {
        // Socketがcloseされるとレスポンスが書き込めないので
        // レスポンスを返却するスレッドでcloseする
        final Socket socket = channel.socket().accept();//ブロックされる
        new Thread(new HelloWorldTask(socket)).start();
      }
    } catch (IOException e) {
        e.printStackTrace();
    }
  }


レスポンスを返却する処理で忘れずにsocketをクローズする。

class EchoTask implements Runnable {
  private final Socket socket;

  EchoTask(Socket socket) {
    this.socket = socket;
  }

  @Override
  public void run() {
    try (BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
       PrintWriter out = new PrintWriter(socket.getOutputStream(), true);) {
      String line;
      while ((line = in.readLine()) != null) {
        log.info("recieved " + line + " from " + socket.getRemoteSocketAddress());
        log.info("echo " + line + " to " + socket.getRemoteSocketAddress());
        out.println(line);
      }
    } catch (IOException e) {
      e.printStackTrace();
    } finally {
      try {
        socket.close();
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
}

ブロッキングI/Oで何がいいか

  • read/writeすればブロックして処理が完了するのを待つため、シンプルにコーディングできる

ブロッキングI/Oで何が困るか

  • 複数リクエストを処理するためには、リクエストに対する具体的な処理は別スレッドで実行するしか方法がない。
  • スレッドを生成するためにはメモリを割り当てないといけないため、同時に処理したいリクエスト数分だけメモリが必要になる。(仮に1スレッドに1MB使うとすると10Kリクエストを同時にさばくためには10GB必要になる)
  • ソケットの読み書きが終わるまでスレッドがブロックされるため、低速なネットワークの場合に非効率。

ノンブロッキングI/O

I/Oをする際(read,write)に、処理がブロックされない。

実装方法(ソケット)

利用するクラスはブロッキングI/Oと同じだけど、実装方法が違う。

クライアント SocketChannel
サーバ ServerSocketChannel

コード例(サーバ側)

ノンブロッキングI/Oは読み書きしたときに読み書きができない状態ならば、即座にリターンされる。そのため、読み書き可能かどうかをSelectorを使って監視して、読み書きできるときにだけ処理する。

@Slf4j
public class NonBlockingEchoServer {
  public void start() {
    try (ServerSocketChannel ssc = ServerSocketChannel.open();
       Selector selector = Selector.open();) {
      // ノンブロッキングモードにしてSelectorに受付チャネルを登録する
      ssc.configureBlocking(false);
      ssc.socket().bind(new InetSocketAddress(8080));
      ssc.register(selector, SelectionKey.OP_ACCEPT);
      log.info("サーバが起動しました " + ssc.socket().getLocalSocketAddress());

      // チャネルにイベントが登録されるまで待つ
      while (selector.select() > 0) {
        for (Iterator it = selector.selectedKeys().iterator(); it.hasNext(); ) {
          SelectionKey key = (SelectionKey) it.next();
          it.remove();

          if (key.isAcceptable()) {
            doAccept((ServerSocketChannel) key.channel(), selector);
          } else if (key.isReadable()) {
            doRead((SocketChannel) key.channel(), selector);
          } else if (key.isWritable()) {
            byte[] message = (byte[]) key.attachment();
            doWrite((SocketChannel) key.channel(), selector, message);
          }
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }


ソケットを受け付けたときのメソッド。
読み込み可能かどうかを監視するためにSelectorにchannelを登録する。

  private void doAccept(ServerSocketChannel ssc, Selector selector) {
    try {
      SocketChannel channel = ssc.accept();
      log.info("connected " + channel.socket().getRemoteSocketAddress());
      channel.configureBlocking(false);
      channel.register(selector, SelectionKey.OP_READ);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }


読み込み可能になったときのメソッド。
読み込みにはByteBufferを使う。つらい。
読み込み終わったらレスポンスに書き込むので、Selectorにchannelを登録する。

  public void doRead(SocketChannel channel, Selector selector) {
    try {
      ByteBuffer buffer = ByteBuffer.allocate(1024);

      // ソケットから入力を読み込む
      // コネクションが切れていればチャネルをクローズし、読み込めなければリターンする
      int readBytes = channel.read(buffer);
      if (readBytes == -1) {
        log.info("disconnected " + channel.socket().getRemoteSocketAddress());
        channel.close();
        return;
      }
      if (readBytes == 0) {
        return;
      }

      // 入力されたメッセージを取り出し、チャネルに登録する
      buffer.flip();
      byte[] bytes = new byte[buffer.limit()];
      buffer.get(bytes);

      String line = new String(buffer.array(), "UTF-8").replaceAll(System.getProperty("line.separator"), "");
      log.info("recieved " + line + " from " + channel.socket().getRemoteSocketAddress());

      channel.register(selector, SelectionKey.OP_WRITE, bytes);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }


書き込み可能になったときのメソッド。
書き込みにもByteBufferを使う。ほんとつらい。
全てを書き込めるとは限らないのでhasRemaining()で残りがあれば再度書き込む。
全部レスポンスに書き出したらまた読み込みを受け付ける。

  public void doWrite(SocketChannel channel, Selector selector, byte[] message) {
    try {
      ByteBuffer byteBuffer = ByteBuffer.wrap(message);
      channel.write(byteBuffer);
      ByteBuffer restByteBuffer = byteBuffer.slice();

      // ログに送信したメッセージを表示する
      byteBuffer.flip();
      byte[] sendBytes = new byte[byteBuffer.limit()];
      byteBuffer.get(sendBytes);
      String line = new String(sendBytes, "UTF-8").replaceAll(System.getProperty("line.separator"), "");
      log.info("echo " + line + " to " + channel.socket().getRemoteSocketAddress());

      // メッセージを最後まで出力したら入力を受け付ける
      if (restByteBuffer.hasRemaining()) {
        byte[] restBytes = new byte[restByteBuffer.limit()];
        restByteBuffer.get(restBytes);
        channel.register(selector, SelectionKey.OP_WRITE, restBytes);
      } else {
        channel.register(selector, SelectionKey.OP_READ);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

コード例(クライアント側)

Telnetでもいいけど今回はJavaでクライアントを用意する。
コンソールから1行読み込み、サーバに送信する。
サーバからレスポンスが返ってきたらログに出し、コンソールからまた1行読み込む。

@Slf4j
public class Client {
  public static void main(String[] args) {
     try (
        Socket socket = new Socket("localhost", 8080);
        PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        BufferedReader keyIn = new BufferedReader(new InputStreamReader(System.in));
    ) {
      log.info("サーバに接続しました " + socket.getLocalSocketAddress() + " to " + socket.getRemoteSocketAddress());

      String input;
      while ((input = keyIn.readLine()).length() > 0) {
        out.println(input);
        String line = in.readLine();
        if (line != null) {
          log.info("recieved " + line + " from " + socket.getRemoteSocketAddress());
        } else {
          break;
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }  
  }
}


サーバを起動する。

java -jar target\server-1.0-SNAPSHOT-jar-with-dependencies.jar


クライアントを起動する。(x2)

java -jar target\client-1.0-SNAPSHOT-jar-with-dependencies.jar
aaaa //コンソールから入力する
23:24:57.086 [main] INFO Client - send aaaa to localhost/127.0.0.1:8080


1スレッドで複数クライアントの接続を同時に処理できることがわかる。
また、ソケットにデータを流すたびに(読み込み可能になるたびに)リクエストが処理される。

11:54:54.988 [main] INFO server.nonblocking.NonBlockingEchoServer - サーバが起動しました /0:0:0:0:0:0:0:0:8080
11:55:05.855 [main] INFO server.nonblocking.NonBlockingEchoServer - connected /127.0.0.1:58961
11:55:10.026 [main] INFO server.nonblocking.NonBlockingEchoServer - connected /127.0.0.1:58964
11:55:16.350 [main] INFO server.nonblocking.NonBlockingEchoServer - recieved aaaaa from /127.0.0.1:58961
11:55:16.350 [main] INFO server.nonblocking.NonBlockingEchoServer - echo aaaaa to /127.0.0.1:58961
11:55:19.156 [main] INFO server.nonblocking.NonBlockingEchoServer - recieved bbbbb from /127.0.0.1:58964
11:55:19.157 [main] INFO server.nonblocking.NonBlockingEchoServer - echo bbbbb to /127.0.0.1:58964
11:58:29.292 [main] INFO server.nonblocking.NonBlockingEchoServer - recieved ccccc from /127.0.0.1:58961
11:58:29.292 [main] INFO server.nonblocking.NonBlockingEchoServer - echo ccccc to /127.0.0.1:58961

ノンブロッキングI/Oで何がいいか

  • 1つのスレッドで複数のリクエストを処理することが可能
  • 低速なネットワークでも効率的に処理できる

1つのスレッドで複数のリクエストを処理することが"可能"と書いたのは、実際には、リクエストごとにスレッドを割り当てるアーキテクチャ(Tomcatなど)と本当に1つのスレッドで複数のリクエストを処理するアーキテクチャ(Node.jsなど)の2通りあるため。

シングルスレッドのイベントループモデルの場合、あるリクエストを処理している間は
別のリクエストが処理されないため、重たい処理をする用途には向かない(と思う)。

ノンブロッキングI/Oで何が困るか

  • 実装が難しい

Tomcatではどうなっているか

TomcatブロッキングI/OやノンブロッキングI/Oの仕組みをどう利用しているのか調べた。

動作確認環境

BIO Tomcat 8.0.33
NIO Tomcat 8.5.8
Servlet3.0 Tomcat 8.5.8
Servlet3.1 Tomcat 8.5.8

Tomcatの仕組み

Tomcatは役割ごとに複数のコンポーネントから構成されている。(外部からの接続を受け付けるコネクタや実行エンジン部分など)

この本にめちゃめちゃ詳しく載ってるので、中身を知りたい方は必読。
https://www.amazon.co.jp/%e8%a9%b3%e8%a7%a3-tomcat-%e8%97%a4%e9%87%8e-%e5%9c%ad%e4%b8%80/dp/4873117054

ブロッキングI/O

Tomcat7以前のデフォルトのHTTP1.1のコネクタであるHttp11Protocol(JIoEndpoint+Http11ConnectionHandler)はブロッキングI/Oを利用している。
https://tomcat.apache.org/tomcat-8.0-doc/config/http.html#connector_comparison

f:id:kimulla:20161210011429p:plain

JIoEndpointクラスのAcceptorでsocketをaccept()して、workerスレッドでリクエスト処理を実行する。

protected class Acceptor extends AbstractEndpoint.Acceptor {
  @Override
  public void run() {
    // Loop until we receive a shutdown command
    while (running) {
      ...
      // (socket.accept()で待ち受ける)
      socket = serverSocketFactory.acceptSocket(serverSocket);
      ...
      // 
      if (!processSocket(socket)) {
        countDownConnection();
        // Close socket right away
        closeSocket(socket);
      }
      ... 
    }

  protected boolean processSocket(Socket socket) {
    ...
    //別スレッドで実行する
    getExecutor().execute(new SocketProcessor(wrapper));
    ...
    return true;
  }
..
public class DefaultServerSocketFactory implements ServerSocketFactory {
  ...
  @Override
  public Socket acceptSocket(ServerSocket socket) throws IOException {
    return socket.accept();
  }
  ...
}
HTTP Keep alive

HTTP Keep aliveを実現するためにはSocketを開きっぱなしで次のリクエストを待たないといけないため、workerを占有して次のリクエストを待つことになる。

f:id:kimulla:20161210011859p:plain

HTTP Keep aliveを実現するために、リクエスト処理が完了したあともスレッドがRUNNINGのままになる。

f:id:kimulla:20161208232936p:plain

今までありがとうBIOコネクタ

Tomcat8.5.0からはBIOコネクタが無くなり、デフォルトはNioコネクタになった。
今までありがとうBIOコネクタ!あったばっかりだけど達者でな!

ノンブロッキングI/O

TomcatのHttp11NioProtocol(+NioEndpoint+Http11ConnectionHandler)はノンブロッキングI/O。ソースコードの抜粋が難しいので図だけ。

f:id:kimulla:20161210012011p:plain

SocketProcessorの実行タイミング

本当にSocketがReadableになったらSocketProcessorを起動するのか確かめる。

まずHTTPリクエストをラインごとに送信できるクライアントを用意する。

@Slf4j
public class Client {
  public static void main(String[] args) {
  try {
      try (
          Socket socket = new Socket("localhost", 8080);
          PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
          BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
          BufferedReader keyIn = new BufferedReader(new InputStreamReader(System.in));
      ) {
        log.info("サーバに接続しました " + socket.getLocalSocketAddress() + " to " + socket.getRemoteSocketAddress());

        String input;
        while (!(input = keyIn.readLine()).equals("enter")) {
          out.println(input);
          log.info("send " + input + " to "  + socket.getRemoteSocketAddress());
        }

        while (true) {
          input = in.readLine();
          if (input == null) {
            break;
          }
          log.info(input);
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}


コンソールから以下のHTTPリクエストを1行ずつ送信してみる。

GET / HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.99 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: ja,en-US;q=0.8,en;q=0.6


このとき、NioEndpointのPollerにブレークを打つと、1行送信のたびにworkerがリクエスト解析処理を始めるのがわかった。

以下がSelectorの監視イベントをループして拾ってるところ。

public class Poller implements Runnable {
  @Override
  public void run() {
    ...
   Iterator<SelectionKey> iterator =
     keyCount > 0 ? selector.selectedKeys().iterator() : null;
    ...
    // Walk through the collection of ready keys and dispatch
    // any active event.
    while (iterator != null && iterator.hasNext()) {
      SelectionKey sk = iterator.next();
      NioSocketWrapper attachment = (NioSocketWrapper)sk.attachment();
      // Attachment may be null if another thread has called
      // cancelledKey()
      if (attachment == null) {
        iterator.remove();
      } else {
        iterator.remove();
        processKey(sk, attachment);
      }
    }//while
    ...
  }
...
}
Tomcatはリクエストごとにworkerスレッドを消費する

PollerがSelectorを監視するスレッドは共有されるが、リクエスト解析以降の処理はリクエストごとにworkerスレッドを占有する。そのため、worker数の上限が同時処理数の上限になる。

tomcatコネクタについてはこれが参考になる。
http://events.linuxfoundation.org/sites/events/files/slides/tomcat%20connectors.pdf

HTTP Keep alive

NioはSelectorを利用すれば読み込み可能になったタイミングで通知されるため、リクエストがあったタイミングでSocketProcessorを割り当てることができる。
そのため、HTTP Keep aliveのためにworkerを占有しなくて済む。ここがNioにしてよかった一番のポイントな気がする。

f:id:kimulla:20161210012113p:plain

1つのリクエスト処理が完了したあとは、スレッドがWAITになる。

f:id:kimulla:20161208233402p:plain

同時処理数の上限はworker数のため、BIOと比較してHTTP Keep aliveして次のリクエスト待ちになっているソケット分を節約できるようになった。

Servlet3.0の非同期処理

重たい処理や長い処理をするときにworkerスレッドを解放するための仕組み。
ロングポーリングやSSEなどでHTTPのコネクションを維持しているとworkerスレッドを占有するため、同時リクエスト数の上限にひっかかる可能性がある。

f:id:kimulla:20161210012123p:plain

そのため、workerスレッドは重たくない処理だけで使うようにする。

レスポンスをまとめて書き込むパターン。

f:id:kimulla:20161210012159p:plain

chunckedで送るパターン。

f:id:kimulla:20161210013317p:plain

コード例

ServletのdoGet()の処理は何もせずにとりあえず抜ける。
別スレッドを起動してAsyncContextを利用してレスポンスを書き込む。

今回はchunckedで送る方法を試す。

@Slf4j
@WebServlet(urlPatterns = "/", asyncSupported = true)
public class EchoServlet extends HttpServlet {
  Executor executor = new ScheduledThreadPoolExecutor(10);

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("begin doGet");
    AsyncContext ac = req.startAsync();
    executor.execute(new SlowTask(ac));
    log.info("end doGet");
  }
}

@Slf4j
class SlowTask implements Runnable {
  private AsyncContext ac;

  SlowTask(AsyncContext ac) {
    this.ac = ac;
  }

  @Override
  public void run() {
    log.info("begin AsyncContext#start");

    try {
      PrintWriter writer = ac.getResponse().getWriter();
      for (int i = 0; i < 5; i++) {
        writer.println("task :" + i);
        writer.flush();
        log.info("send chuncked data");
        TimeUnit.SECONDS.sleep(2);
      }
    } catch (IOException | InterruptedException e) {
      e.printStackTrace();
    }
    ac.complete();
    log.info("end AsyncContext#start");
  }
}

動作確認

curlでリクエストを送るとちょっとずつ受信されるのがわかる。

$ curl localhost:8080/async-context/
task :0 // チャンクでちょっとずつ返ってくる
task :1 // チャンクでちょっとずつ返ってくる
task :2 // チャンクでちょっとずつ返ってくる
task :3 // チャンクでちょっとずつ返ってくる
task :4 // チャンクでちょっとずつ返ってくる


またログを見るとSlowTaskが別スレッドで実行されていることがわかる。

22:26:45.003 [http-nio-8080-exec-1] INFO example.com.echo.EchoServlet - begin doGet
22:26:45.009 [http-nio-8080-exec-1] INFO example.com.echo.EchoServlet - end doGet
22:26:45.009 [pool-1-thread-1] INFO example.com.echo.SlowTask - begin AsyncContext#start
22:26:45.013 [pool-1-thread-1] INFO example.com.echo.SlowTask - send chuncked data
22:26:47.015 [pool-1-thread-1] INFO example.com.echo.SlowTask - send chuncked data
22:26:49.016 [pool-1-thread-1] INFO example.com.echo.SlowTask - send chuncked data
22:26:51.018 [pool-1-thread-1] INFO example.com.echo.SlowTask - send chuncked data
22:26:53.019 [pool-1-thread-1] INFO example.com.echo.SlowTask - send chuncked data
22:26:55.021 [pool-1-thread-1] INFO example.com.echo.SlowTask - end AsyncContext#start

疑問

結局はアプリケーション内でスレッドを生成するんだから、APサーバ全体で見たときのスレッド生成コストは変わらない気がする。
アプリケーション内でスレッドを生成せずに、workerスレッド数の上限を上げればいいと思った。いまいち理解できてない。

Servlet APIブロッキングAPI

HttpServletRequest,HttpServletResponseのreadやwriteをするためのAPIとして
HttpServletRequest#getInputStream(),HttpServletResponse#OutputStream()がある
が、これはブロッキングAPI

なぜなら、ServletInputStreamの実装クラスはInputStream#read()を実装しないといけない。http://docs.oracle.com/javaee/7/api/javax/servlet/servletinputstream.html

This is an abstract class that a servlet container implements. Subclasses of this class must implement the java.io.InputStream.read() method.

で、InputStreamのAPIhttps://docs.oracle.com/javase/jp/8/docs/api/java/io/inputstream.html#read--

入力ストリームからデータの次のバイトを読み込みます。バイト値は、0 - 255の範囲のintとして返されます。ストリームの終わりに達したために読み込むバイトがない場合は、-1が返されます。入力データが読み込めるようになるか、ストリームの終わりが検出されるか、または例外が発生するまで、このメソッドはブロックされます。

つまりブロッキングAPI。HttpServletResponse#OutputStream()も同様。

Nioコネクタのマニュアルを見ても、HTTPヘッダを読み込むまではノンブロッキングだが、肝心のHTTPボディを読み込む箇所はブロッキングな処理らしい。
Tomcatのマニュアルhttp://tomcat.apache.org/tomcat-8.0-doc/config/http.html#connector_comparison

疑問

Tomcatサーバはリクエストを読み込み終わる前にHttpServletを実行するけど、なんでなんだろう。Servletの仕様書やTomcatのリファレンスに記載が見当たらないので正解はよくわからないけど、無限にリクエストボディを送り続けられたときに処理できなくなるからかな?リクエストボディの値が処理で必要になるとも限らないので、HttpServletで実際に使いたいときに読み込めばよい、ということかな?

Servlet3.1のノンブロッキングI/O

HTTPのボディに対する読み書きをノンブロッキングにするための仕組み。
https://blogs.oracle.com/wlc/entry/javaee_c116

実装方法

読み込み ReadListner
書き込み WriteListener

コード例

とりあえず読み込みしたらログに出すだけ。

@Slf4j
@WebServlet(urlPatterns = "/non-blocking", asyncSupported = true)
public class NonBlockingIOServlet extends HttpServlet {

  @Override
  public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("begin doGet");
    AsyncContext ctx = req.startAsync();
    ServletInputStream input = req.getInputStream();
    input.setReadListener(new NonBlockingReadListener(input, ctx));
    log.info("end doGet");
  }
}

@Slf4j
class NonBlockingReadListener implements ReadListener {
  private final ServletInputStream inputStream;
  private final AsyncContext ctx;

  NonBlockingReadListener(ServletInputStream inputStream, AsyncContext ctx) {
    log.info("ReadListener is initialized");
    this.inputStream = inputStream;
    this.ctx = ctx;
  }

  @Override
  public void onDataAvailable() throws IOException {
    log.info("onDataAvaliable");

    StringBuilder sb = new StringBuilder();
    int len = -1;
    byte b[] = new byte[1024];

    while (!inputStream.isFinished() && inputStream.isReady() && (len = inputStream.read(b)) != -1) {
      String data = new String(b, 0, len);
      log.info("recieved : " + data);
    }
  }

  @Override
  public void onAllDataRead() throws IOException {
    log.info("onAllDataRead");
    ctx.complete();
  }

  @Override
  public void onError(Throwable throwable) {
    log.info("onError : " + throwable);
    ctx.complete();
  }
}


またクライアントを起動してリクエストを投げてみる。(Telnetでもいいです)

java -jar target\client-1.0-SNAPSHOT-jar-with-dependencies.jar
POST /servlet-api/non-blocking HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.99 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch, br
Content-Length: 210000003

request body .... //ここを1行ずつ送る
request body .... //ここを1行ずつ送る


データ到着のたびにonDataAvailable()が実行される。

12 08, 2016 11:03:34 午後 org.apache.coyote.AbstractProtocol start
情報: Starting ProtocolHandler [http-nio-8080]
23:03:48.905 [http-nio-8080-exec-1] INFO example.com.NonBlockingIOServlet - begin doGet
23:03:48.909 [http-nio-8080-exec-1] INFO example.com.NonBlockingReadListener - ReadListener is initialized
23:03:48.909 [http-nio-8080-exec-1] INFO example.com.NonBlockingIOServlet - end doGet
23:03:48.910 [http-nio-8080-exec-1] INFO example.com.NonBlockingReadListener - onDataAvaliable
23:03:48.910 [http-nio-8080-exec-1] INFO example.com.NonBlockingReadListener - recieved : request body ....

23:04:04.735 [http-nio-8080-exec-2] INFO example.com.NonBlockingReadListener - onDataAvaliable
23:04:04.735 [http-nio-8080-exec-2] INFO example.com.NonBlockingReadListener - recieved : request body ....

何がいいのか

  • HTTPのボディも効率的に読み書きできるようになるため、でかいファイルアップロードや低速なネットワークからのアクセスを効率的に処理できる。

何が困るか

  • 実装が難しい

そして時代はSpring5へ

Controllerの引数や戻り値をReactorのAPIにしとくと、Servlet3.1のノンブロッキングI/Oを利用して読み書きしてくれるようになる。
http://www.slideshare.net/takuyaiwatsuka/spring-5

結論

IOTやモバイル端末の普及により、低速なネットワークからの大量のアクセスが増えてきた昨今、cpuを効率的に利用しつつ大量のリクエストを処理できるノンブロッキングな処理というのが注目を集めている。
ただし、ノンブロッキング処理はたいがい難易度が上がるので、必要になったら採用を検討するくらいでちょうど良さそう。

ということで俺はギョウミーアプリに戻るぞー!ジョジョー!

あとがき

これ調べるのにまるまる1ヶ月くらいかかったので、虚無感がすごい。

サンプルコード

github.com

Springの@RequestScopeや@SessionScopeは結局どこに保存されるのか?我々は真相に迫った

ある日の出来事

SpringのBeanのスコープ、便利ですよね。ライフサイクル管理を任せられるのはDIコンテナを利用するメリットの大きなところだと思います。

いつもは何も考えずに以下のようにコーディングして、Springコンテナにスコープ管理を任せていました。

@Component
@SessionScope
public class User {}

が、ある日、ある疑問が…。このBeanって結局どこに格納されてるんだろう…@SessionScopeっていうくらいだからセッションに格納される?

我々は真相に迫った

まずはデバッグ

HttpSession#setAttribute(String name, Object value)にブレークを打ってみた。
そうすると、name="scopedTarget.user"のメソッド呼び出しで以下のスタックトレースが取得できた。equalsで比較したら同じインスタンスだった(厳密にいうと、今回は@Autowiredで取得できるbeanでcglibがかかるので、interfaceベースのproxyに変更してproxyを外したuserインスタンスとhttpSessionから取得したインスタンスを比較した)。

これはSessionScopeクラスがあやしそう。

"http-nio-8080-exec-1"@4,828 in group "main": RUNNING
setAttribute():137, StandardSessionFacade {org.apache.catalina.session}
setAttribute():172, ServletRequestAttributes {org.springframework.web.context.request}
get():45, AbstractRequestAttributesScope {org.springframework.web.context.request}
get():93, SessionScope {org.springframework.web.context.request}
doGetBean():340, AbstractBeanFactory {org.springframework.beans.factory.support}
getBean():197, AbstractBeanFactory {org.springframework.beans.factory.support}
getTarget():35, SimpleBeanTargetSource {org.springframework.aop.target}
getTargetObject():73, PrintRestController {com.example}
printOnlyScopeBean():62, PrintRestController {com.example}
print():54, PrintRestController {com.example}
print():30, PrintRestController {com.example}

Javadocを読んでみる

SessionScope(@SessionScopeとは違う)はScopeインタフェース(@Scopeとは違う)を実装したクラスのひとつ。そのほかにもRequestScopeなど、スコープに応じたクラスが存在するらしい。

そしてScopeインタフェースは、スコープごとのCRUD操作(SessionScopeの場合はbeanをセッションに永続化したりセッションから削除したりする)を定義する、と。

public interface Scope {
  /**
   * Return the object with the given name from the underlying scope,
   * {@link org.springframework.beans.factory.ObjectFactory#getObject() creating it}
   * if not found in the underlying storage mechanism.
   * <p>This is the central operation of a Scope, and the only operation
   * that is absolutely required.
   * @param name the name of the object to retrieve
   * @param objectFactory the {@link ObjectFactory} to use to create the scoped
   * object if it is not present in the underlying storage mechanism
   * @return the desired object (never {@code null})
   * @throws IllegalStateException if the underlying scope is not currently active
   */
  Object get(String name, ObjectFactory<?> objectFactory);

  Object remove(String name);

Scope#getの具体的な処理はAbstractRequestAttributesScope#getで定義されている。永続化されてないbeanがあれば永続化する。

public abstract class AbstractRequestAttributesScope implements Scope {

  @Override
  public Object get(String name, ObjectFactory<?> objectFactory) {
    RequestAttributes attributes = RequestContextHolder.currentRequestAttributes();
    Object scopedObject = attributes.getAttribute(name, getScope());
    if (scopedObject == null) {
      scopedObject = objectFactory.getObject();
      attributes.setAttribute(name, scopedObject, getScope());
    }
    return scopedObject;
  }

永続化先への具体的な操作はRequestAttributesクラスで行われる。
デバッグしてみたら実装クラスにはServletRequestAttributesが利用されてた。

 /**
  * ..
  * <p>Relies on a thread-bound {@link RequestAttributes} instance, which
  * can be exported through {@link RequestContextListener},
  * {@link org.springframework.web.filter.RequestContextFilter} or
  * {@link org.springframework.web.servlet.DispatcherServlet}.
  * ..
  */
 public class SessionScope extends AbstractRequestAttributesScope {

確かにServletRequestAttributesでHttpSession#setAttributeが呼ばれていた。

public class ServletRequestAttributes extends AbstractRequestAttributes {
  @Override
  public void setAttribute(String name, Object value, int scope) {
    if (scope == SCOPE_REQUEST) {
      if (!isRequestActive()) {
        throw new IllegalStateException(
         "Cannot set request attribute - request is not active anymore!");
      }
      this.request.setAttribute(name, value);
    }
    else {
      HttpSession session = getSession(true);
      this.sessionAttributesToUpdate.remove(name);
      session.setAttribute(name, value);
    }
  }

@RequestScopeも似たような処理で、書き込み先はHttpServletRequestだった。

結論

Springの@RequestScopeや@SessionScopeはどこから来てどこへ行くのか?我々は真相に迫ったが、あまり驚きはなかった。

追加

調べたことがそのままspringのリファレンスに書いてた。ちゃんと読まないとダメですね。
http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#beans-factory-scopes-custom

Springの@Asyncで非同期処理をする

@Asyncアノテーション

Spring Frameworkは、@Asyncアノテーションを付与すると別スレッドで処理を実行できるようになる。

34. Task Execution and Scheduling

サンプルコード

ChildBeanのexecute()メソッドを別スレッドで実行したいとする。

@Slf4j
@Component
class ParentBean {
  @Autowired
  ChildBean child;

  public String execute(){
      log.info("hello");
      child.execute();
  }
}

非同期にしたい処理に@Asyncを付与する。
(@Asyncはクラス単位にも付与できる)

@Slf4j
@Component
class ChildBean {
  @Async
  public String execute(){
      log.info("hello");
  }
}

非同期処理はデフォルトだと有効になっていないので、@EnableAsyncで有効化する。

@EnableAsync
@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Autowired
    Parent parent;

    @Bean
    public CommandLineRunner getCommandLineRunner() {
        return args -> {
            parent.execute();
        };
    }
}

メインメソッドを実行すると、ログからも別スレッドで実行されていることが読み取れる。

2016-11-01 22:32:21.273  INFO 8712 --- [  restartedMain] com.example.Controller                   : hello
2016-11-01 22:32:21.293  INFO 8712 --- [cTaskExecutor-1] com.example.Hoge                         : hello

スレッド生成ルールのカスタマイズ

デフォルトのスレッド生成クラスはSimpleAsyncTaskExecutorで、要求ごとにスレッドを生成する。そのため、ThreadPoolTaskExecutorなどを利用して、スレッドを生成しすぎないように、また、再利用できるように設定すべき。

34. Task Execution and Scheduling

WEBシステムで使うときの疑問点

WEBシステムで@Transactionalつけてるときなんかでも、@Asyncって使えるの?
よくわからなかったので、かるーく検証してみた。

検証の題材

1. 「タスクを登録する」ボタンを押すと、サーバ側でDBに0-10件のランダムなタスクを登録する。
2. サーバ側では、@Asyncアノテーションをつけたクラスを用意し、非同期にタスクを消化する。
3. 非同期処理を開始したら、クライアントにレスポンスをとりあえず返す
4. タスクは1sごとに1件消化し、その都度コミットする。このとき、@Transactionalをつけたメソッドでコミットされるかを調べる

f:id:kimulla:20161101225250p:plain

f:id:kimulla:20161101225254p:plain

dbにはh2を利用。h2のデフォルトのisolationレベルは「read commited」なので、コミットされたタイミングで別コネクションから参照可能になる。

ソースコード

登録と非同期更新部分を抜粋。
ソースコードgithubに。
github.com

@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping("api/tasks")
public class TaskRestController {
  final TaskService service;
  ...

  @PostMapping
  public Task execute() {
    Task task = service.register();
    service.execute(task.getId());
    return task;
  }
}

1sごとに1タスク消化してコミットする。
そのために、トランザクションの区切れを別サービスに切り出し、メソッドを呼び出す。
(privateメソッドの呼び出しだと、@Transctionalが有効にならないため。これはそもそものAOPの制約。)

@Slf4j
@Service
@AllArgsConstructor
public class TaskServiceImpl implements TaskService {
 ...

  @Override
  @Async
  public void execute(int id) {
    Task task = taskMapper.findOne(id);
    log.info(task.toString());

    // 1sごとにtaskを1こずつ消化していく
    while (task.getDone() < task.getAmount()) {
      try {
        TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      service.execute(task);
    }
  }
}

メソッドに@Transactionalをつけ、コミットの境界にする。
このメソッドが実行されたタイミングでコミットされていれば、ポーリングしているコネクションからも変更内容が見えるはず。

@Slf4j
@Service
@AllArgsConstructor
public class ExecuteServiceImpl implements ExecuteService {
  final TaskMapper taskMapper;

  @Override
  @Transactional
  public void execute(Task task) {
    task.setDone(task.getDone() + 1);
    taskMapper.update(task);
    log.info(task.toString());
  }
}

検証結果

@Transactionalが意図したとおりに、ExecuteServiceImpl#executeの単位でコミットされているっぽい。(DBの変更がポーリングで別コネクションから参照できているため)

今回利用したSpringのTxマネージャはDataSourceTransactionManager。
Javadocを見ると以下のように書かれているので、@Transactionalがついたメソッドが1スレッド内ならば、例えリクエストスレッドとは違っても動く様子。
逆に、@Transactionalついたメソッドから@Asyncと@Tranasctionalがついたメソッドを呼び出しても、呼び出し元とは別のコネクションを張る(Txが分かれる)と読める。

 ...
Binds a JDBC Connection from the specified DataSource to the current thread,
potentially allowing for one thread-bound Connection per DataSource.

@Async使うときの注意点

@Asyncを付けたメソッドは別スレッドで実行されるため、スレッドローカルで管理している値は参照できない。まあ別スレッドで実行してるんだから、あたりまえっちゃあ、あたりまえな動きですが。

@RequestScopeのBeanや@SessionScopeのBeanを@Autowiredしてみようとしたけど、以下のエラーがでた。リクエストスレッドごとにBeanを管理してるんでしょう、きっと。

2016-11-01 23:23:54.308 ERROR 6720 --- [cTaskExecutor-1] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected error occurred invoking async method 'public void com.example.services.TaskServiceImpl.execute(int)'.

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'scopedTarget.sessionBean': Scope 'session' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton; nested exception is java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet/DispatcherPortlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:355) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:197) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
        at org.springframework.aop.target.SimpleBeanTargetSource.getTarget(SimpleBeanTargetSource.java:35) ~[spring-aop-4.3.3.RELEASE.jar:4.3.3.RELEASE]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.getTarget(CglibAopProxy.java:687) ~[spring-aop-4.3.3.RELEASE.jar:4.3.3.RELEASE]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:637) ~[spring-aop-4.3.3.RELEASE.jar:4.3.3.RELEASE]
        at com.example.model.SessionBean$$EnhancerBySpringCGLIB$$161bd67b.toString(<generated>) ~[classes/:na]

そのほか、スレッドローカルな値(例えばSecurityContextHolderなど)も、参照時にエラーとなるはずなので、業務で使いたいならメソッド引数として渡す必要がある。