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

5年目のSIerのブログです

Jenkinsジョブ(ビルド~テスト)をDockerコンテナ上で実行する ~Docker Pipeline Pluginを使ってみる~

ビルドやテストなどの一連の作業をJenkinsで自動実行することを考える。
通常これらのジョブはJenkinsのmasterやslaveで実行されることになり、ビルドに必要な環境構築(例えばjavaのインストールやmavenのインストール)はJenkinsのmasterやslaveに対して、事前に行うことになる。そして一度環境構築したサーバは長いこと使われる。

そうなると、同一サーバで複数バージョンのjavaを入れる苦労をしたりディストリビューションの差に基づくLinuxコマンドのあるなしに翻弄されたり、ローカルにしかないファイルを参照してそのうちジョブが動かなくなったりする。

これを解決するための手段としてビルド〜テストをDockerコンテナ上で実行しようという例は多くみられ、以下の記事が導入検討の参考になる。

www.buildinsider.net

www.slideshare.net


以下、JenkinsジョブをDockerコンテナ上で実行するための方法を調べたときのメモ。
Jenkinsのpipelineが前提。使い方については昔にまとめました。

kimulla.hatenablog.com

JenkinsのDocker関連のplugin

Jenkins上でDockerコンテナを利用する方法は複数あるらしく

f:id:kimulla:20170612003857j:plain

Docker Pluginは、Dockerイメージを指定してスレーブに登録する作業が必要になるし、バージョン管理しているDockerfileからイメージを毎回生成する用途には向いてなさそう。(偏見かも)


またpluginを使わなくともJenkins上でshが実行できるので、自分でDockerコンテナを管理する方法もある。以下の記事がとても参考になった。

nulab-inc.com

現在自分がやってること


pluginは特に使わず、自身でDockerコンテナを管理している。
具体的には以下の4ステップをMakefile内で行っている。

  • Dockerイメージの生成
  • Dockerコンテナの生成とタスクの実行
  • 成果物の取り出し
  • Dockerコンテナの破棄

また、mavenやnpmのライブラリがコンテナ上で毎回全ダウンロードされないように、ホストにマウントしてキャッシュしている。

現在自分がやってることの詳細

以下にサンプルを作ったので抜粋する。
github.com

基本的にDockerfileを自分でビルドしてコンテナを起動している。
主なファイルは以下の4つ。

ファイル 説明
Dockerfile.build ビルドサーバを構築するためのDockerfile
Dockerfile.integration 結合試験を実施するためのDockerfile
Makefile 開発時にローカル環境で利用するコマンドをまとめたMakefile
Makefile.docker dockerコンテナを操作するためのコマンドをまとめたMakefile
Makefile

ビルドに必要なタスクはMakefileにまとめて記述する。

setup:
        npm --prefix client install

build: setup
        npm --prefix client run build
        cp -R client/dist/* server/src/main/resources/static
        mvn -f server/pom.xml package -DskipTests=true

使い方

# server/target配下にjarができる
make build
Dockerfile.build

ビルドサーバをDockerイメージとして構築する。
タスクはMakefileにまとまっているので、ENTRYPOINTにmakeを指定する。

FROM openjdk:8u131-jdk-alpine

RUN apk update && apk upgrade && \
    apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make

RUN curl -Ls "https://github.com/dustinblackman/phantomized/releases/download/2.1.1/Dockerized-phantomjs.tar.gz" | tar xz -C /
WORKDIR /build
COPY . /build

ENTRYPOINT ["make"]
CMD ["help"]
Makefile.docker

Dockerの操作が煩雑(イメージの作成~コンテナの生成~ビルドの実行~成果物の取り出し~破棄)なのでMakefileにまとめている。

# ホストで複数のDockerを起動できるようにイメージ/コンテナ名にidを付与する
ID = 'default'
BUILD_SERVER_IMAGE = sample-base-build-server-$(ID)
BUILD_CONTAINER    = sample-build-$(ID)
CACHE_PATH='/tmp/docker/cache'

base:
        docker build -t $(BUILD_SERVER_IMAGE) -f Dockerfile.build .

build: base
        docker run --name $(BUILD_CONTAINER) -v $(CACHE_PATH)/.m2:/root/.m2 -v $(CACHE_PATH)/.node_modules:/build/client/node_modules $(BUILD_SERVER_IMAGE) build || Docker rm $(BUILD_CONTAINER)
        docker cp $(BUILD_CONTAINER):/build/server/target server
        docker rm $(BUILD_CONTAINER

使い方

# コンテナ内でビルドしてホスト環境のserver/target配下にjarをコピーしてくる
make ID=1 -f Makefile.docker build
Jenkinsfile

Makefile.dockerを実行するだけ。

node {
  stage('build') {
      sh 'make ID=${BUILD_ID} -f Makefile.docker build'  
      archiveArtifacts 'server/target/*jar'
  }
  ...
}

自分でDockerを管理する問題点

成果物の取り出しやコンテナの破棄を作り込む(考慮する)のがめんどくさい。
対応できる範囲だけど、なんだかんだめんどくさい。

ジョブ実行時のファイルパスについても考慮が必要で、コンテナ内でビルドしたときのパスとJenkinsのworkspaceのパスが違うとlintツールの出力結果で問題になることがある。
例えばcheckstyleのテスト結果(checkstyle-result.xml)は各javaファイルへのリンクがフルパスで記述されるので、lint実行時のDockerコンテナ内のパスとJenkinsのworkspaceのパスが違うと出力レポートのリンクが開けないなんてこともある。

f:id:kimulla:20170611235823p:plain

f:id:kimulla:20170612002455p:plain

ただこれはコンテナのworkspaceをDocker起動時のディレクトリと同じにすれば解決する問題。

でも

つもりつもると・・・めんどくさい!

Docker Pipeline Plugin

そこでDocker Pipeline Pluginですよ!!!

CloudBees Jenkins Enterprise User Guide

動作確認環境

  • Jenkins version 2.46.3
  • Docker version 17.03.1-ce, build c6d412e

使い方(scripted pipeline)

新しめのバージョンだと、Global Variable Referenceに載ってる。

Global Variable Referenceはここ。
f:id:kimulla:20170611235425p:plain

f:id:kimulla:20170611235434p:plain

既存イメージを利用する場合

Pipelineプロジェクトを作成する。

f:id:kimulla:20170611235500p:plain

とりあえずGlobal Variable Referenceに記述されてる動きを確認するために、scripted pipelineで記述する。


f:id:kimulla:20170612004723p:plain

node {
    sh 'id'
    docker.image('openjdk:8u131-jdk').inside() {
        sh 'java -version'
    }
}

ビルド実行をクリックする。

f:id:kimulla:20170611235530p:plain

コンソール出力を読む。

f:id:kimulla:20170612004738p:plain


裏でDockerコマンドを実行してるのがわかる。

ユーザーanonymousが実行
[Pipeline] node
Running on master in /home/kimura/.jenkins/workspace/sample
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ id
uid=1000(kimura) gid=1000(kimura) groups=1000(kimura),10(wheel),987(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[Pipeline] sh
[sample] Running shell script
+ docker inspect -f . openjdk:8u131-jdk
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat openjdk:8u131-jdk
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ java -version
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-1~bpo8+1-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
[Pipeline] }
$ docker stop --time=1 c98ccf713004056fc3a59cebe1b4cd1f924653c63e0ba2301ed0fa9ed86f976d
$ docker rm -f c98ccf713004056fc3a59cebe1b4cd1f924653c63e0ba2301ed0fa9ed86f976d
[Pipeline] // withDockerContainer
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

コンテナ起動部分について抜粋し、さらに見ていく。


ローカル環境で実行したときと同じ環境になるように、

  • -wコマンドでコンテナのworkspaceをJenkinsのworkspaceと合わせている
  • -vコマンドでJenkinsのworkspaceをコンテナのworkspaceディレクトリにマウントしている
  • -uコマンドでJenkinsの起動ユーザでコンテナを実行している
...
+ id
uid=1000(kimura) gid=1000(kimura) groups=1000(kimura),10(wheel),987(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
...
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat openjdk:8u131-jdk
...

あーなるほどー、そうすればよかったのか。


inside(){...}内で記述した...はコンテナ内で実行されている。

[Pipeline] sh
[sample] Running shell script
+ java -version
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-1~bpo8+1-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
[Pipeline] }

コンテナが残らないようにstopしてrmしている。

...
[Pipeline] }
$ docker stop --time=1 a900ace94102ba4096ddeec5d172ae010d16379a8d0c486dbf8058c01dc7271b
$ docker rm -f a900ace94102ba4096ddeec5d172ae010d16379a8d0c486dbf8058c01dc7271b
...

既存のイメージを使う場合、docker.image(...).inside(...)を使えばいい感じにDockerを裏で実行してくれる。

Dockerfileからイメージをビルドする場合

Dockerfileからイメージを自分でビルドしたい場合に使うコマンド。

通常はcheckout scmでチェックアウトしたDockerfileを利用するが、とりあえずworkspaceにDockerfileを用意する。

$ pwd
/home/kimura/.jenkins/workspace/sample
$ ls -l
合計 4
-rw-rw-r--. 1 kimura kimura 528  61 14:35 Dockerfile.build
$ cat Dockerfile.build
FROM openjdk:8u131-jdk-alpine

RUN apk update && apk upgrade && \
    apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make
RUN curl -Ls "https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz" | tar xz -C /
node {
    docker.build("${BUILD_ID}", "-f Dockerfile.build .").inside() {
        sh 'node -v'  
    }
}

第一引数はイメージ名、第二引数はコマンド引数になる。
Dockerfileからビルドでき、またコマンドが実行されていることがわかる。

...
[sample] Running shell script
+ docker build -t 38 -f Dockerfile.build .
Sending build context to Docker daemon  2.56 kB

Step 1/3 : FROM openjdk:8u131-jdk-alpine
 ---> c7105179e75b
Step 2/3 : RUN apk update && apk upgrade &&     apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make
 ---> Using cache
 ---> 0a22744743d5
Step 3/3 : RUN curl -Ls "https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz" | tar xz -C /
 ---> Using cache
 ---> 9b99b208c53e
Successfully built 9b99b208c53e
[Pipeline] dockerFingerprintFrom
[Pipeline] sh
[sample] Running shell script
+ docker inspect -f . 38
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat 38
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ node -v
v6.10.3
[Pipeline] }
$ docker stop --time=1 00cb2f603ca6eb3ea558bb82e906c6075f8593a5f2cb52115ae86ff4c3d82726
$ docker rm -f 00cb2f603ca6eb3ea558bb82e906c6075f8593a5f2cb52115ae86ff4c3d82726
...
Docker Pipeline Pluginの注意点

docker.image(...).inside(...)で実行するコマンドはDockerfileに記述したユーザではなくて、jenkinsの起動ユーザになる。

sampleのpipelineプロジェクトのworkspaceにDockerfileを置いてみる。

$ pwd
/home/kimura/.jenkins/workspace/sample
$ cat Dockerfile.build
FROM openjdk:8u131-jdk-alpine

USER root

Jenkinsのスクリプトに以下を書いて実行する。

node {
    sh 'id'
    docker.build("${BUILD_ID}","-f Dockerfile.build .").inside() {
        sh 'id'
    }
}

コンソール出力を見ると、userがrootではなくて1000になってる。
パーミッションで困ることがあるかも。困る場合は -u で実行ユーザを明示的に指定しちゃえばよさそう。

...
[sample] Running shell script
+ id
uid=1000(kimura) gid=1000(kimura) groups=1000(kimura),10(wheel),987(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[Pipeline] sh
[sample] Running shell script
+ docker build -t 5 -f Dockerfile.build .
Sending build context to Docker daemon 2.048 kB

Step 1/2 : FROM openjdk:8u131-jdk-alpine
 ---> c7105179e75b
Step 2/2 : USER root
 ---> Using cache
 ---> 4dc2ef524f48
Successfully built 4dc2ef524f48
[Pipeline] dockerFingerprintFrom
[Pipeline] sh
[sample] Running shell script
+ docker inspect -f . 5
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat 5
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ id
uid=1000 gid=1000
...
ホスト環境にライブラリをキャッシュするときの注意点

Jenkinsのworkspace以外のホストディレクトリにキャッシュさせる場合、存在しないディレクトリにマウントするとアクセス権がrootになってしまうので注意。
Add ability to mount volume as user other than root · Issue #2259 · moby/moby · GitHub

以下、動きを確かめる。

dir1は作っておき、dir2をDockerコンテナ起動時の-vに指定する。

$ ls -al
合計 4
drwxrwxr-x.  3 kimura kimura   33  61 19:24 .
drwx------. 30 kimura kimura 4096  61 19:20 ..
drwxrwxr-x.  2 kimura kimura    6  61 19:21 dir1
-rw-rw-r--.  1 kimura kimura    0  61 19:20 hello.txt

起動時に-vに指定して生成されたdir2はrootになっていることがわかる。

]$ docker run -it --rm -u 1000:1000 -v $(pwd)/dir1:$(pwd)/dir1 -v $(pwd)/dir2:$(pwd)/dir2 -w
$(pwd) alpine /bin/sh
/home/kimura/sample $ ls -al
total 0
drwxr-xr-x    4 root     root            28 Jun  1 10:24 .
drwxr-xr-x    3 root     root            19 Jun  1 10:24 ..
drwxrwxr-x    2 1000     1000             6 Jun  1 10:21 dir1
drwxr-xr-x    2 root     root             6 Jun  1 10:24 dir2

なので、ライブラリをキャッシュするディレクトリは事前に作っておくと安全だと思う。
以下のようにするとうまくいった。(本当はユーザごとのディレクトリを指定したほうが安全)

  sh 'mkdir -p /tmp/docker/cache/.node_modules || true'
  sh 'mkdir -p /tmp/docker/cache/.m2 || true'
  docker.build("${BUILD_ID}", "-f Dockerfile.build .").inside("-v /tmp/docker/cache/.m2:/var/maven/.m2 -v /tmp/docker/cache/.node_modules:${WORKSPACE}/client/node_modules") {
  ...
npm使うときの注意点

jenkinsの実行ユーザがDockerコンテナ内でコマンドを実行するため、パーミッションに注意が必要。とくにnpmを使う場合は /.npm にキャッシュディレクトリを作ろうとしてJenkins実行ユーザにパーミッションがなくて失敗することがある。回避策は npm_config_cache変数を環境変数に設定してキャッシュディレクトリの作られる場所を変えること。

npm install fails in jenkins pipeline in docker - Stack Overflow

docker.build(...).inside(...) {
    withEnv(['npm_config_cache=npm-cache']) {
        stage('build') {
            ....
        }
サンプルコード

上記をふまえてJenkinsfile内でDockerコンテナを管理してみた。

github.com

実行した結果はこちら。
ビルドの48からキャッシュさせたところ、ダウンロード時間が大幅に減ってるのがわかる。

f:id:kimulla:20170613002527p:plain

Image.withRun[(args[, command])] {…}

Dockerコンテナを起動したあとに、ホスト側で{...}のコマンドを実行する。

node {
    docker.image('postgres:9.6.3').withRun("-p 5432:5432") {
        sh 'ls -al'
    }
}

argsで指定した引数が起動引数に追加される。
Dockerコンテナの停止は{...}を抜けたあとに勝手に行われる。

...
+ docker run -d -p 5432:5432 postgres:9.6.3
[Pipeline] dockerFingerprintRun
[Pipeline] sh
[sample] Running shell script
+ ls -al
合計 8
drwxrwxr-x.  2 kimura kimura   29  61 14:36 .
drwxrwxr-x. 18 kimura kimura 4096  61 13:20 ..
-rw-rw-r--.  1 kimura kimura  528  61 14:35 Dockerfile.build
[Pipeline] sh
[sample] Running shell script
+ docker stop 4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
+ docker rm -f 4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
...

テストのために一時的にDBを上げる用途に使うんでしょう、たぶん。

使い方(declarative pipeline)

最近は scripted pipeline よりも declarative pipeline のほうがおすすめらしい。
使い方はリファレンスに載っている。

jenkins.io

既存イメージを利用する場合

scripted pipeline と同じように、とりあえずPipelineプロジェクトで進める。

pipeline {
    agent {
        docker 'openjdk:8u131-jdk'
    }
    stages() {
        stage('build') {
            steps() {
                sh 'java -version'
            }
        }
    }
}

コンソール出力をみると、 scripted pipeline と同じようなことをやってる。
Dockerコンテナ上でshが実行され、ジョブが終わったときにコンテナをstopしてrmしている。

ユーザーanonymousが実行
[Pipeline] node
Running on master in /home/kimura/.jenkins/workspace/yyy
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Declarative: Agent Setup)
[Pipeline] sh
[yyy] Running shell script
+ docker pull openjdk:8u131-jdk
8u131-jdk: Pulling from library/openjdk
Digest: sha256:90c3ac824aa5ce63d8540bb73b6d548b40dc0d536702a48e6a7f21efdc10a861
Status: Image is up to date for openjdk:8u131-jdk
[Pipeline] }
[Pipeline] // stage
[Pipeline] sh
[yyy] Running shell script
+ docker inspect -f . openjdk:8u131-jdk
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/yyy -v /home/kimura/.jenkins/workspace/yyy:/home/kimura/.jenkins/workspace/yyy:rw -v /home/kimura/.jenkins/workspace/yyy@tmp:/home/kimura/.jenkins/workspace/yyy@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat openjdk:8u131-jdk
[Pipeline] {
[Pipeline] stage
[Pipeline] { (build)
[Pipeline] sh
[yyy] Running shell script
+ java -version
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-1~bpo8+1-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
$ docker stop --time=1 3143403b1d45c91f61b64b1d983cbf0562e9fe682aee566f2cdf1f16216b9437
$ docker rm -f 3143403b1d45c91f61b64b1d983cbf0562e9fe682aee566f2cdf1f16216b9437
[Pipeline] // withDockerContainer
...
Dockerfileからイメージをビルドする場合

まずはJenkinsのworkspaceにDockerfileを用意する。

$ pwd
/home/kimura/.jenkins/workspace/yyy
$ cat Dockerfile.build
FROM openjdk:8u131-jdk-alpine

RUN apk update && apk upgrade && \
    apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make

以下のようにファイル名を指定する。

pipeline {
    agent {
        dockerfile {
            filename 'Dockerfile.build'
            args '-v /tmp/docker/cache/.m2:/var/maven/.m2'
        }
    }
    stages() {
        stage('build') {
            steps() {
                sh 'java -version'
            }
        }
    }
}

ただしDockerfileという名前でworkspace直下にある場合は指定不要。
その場合は以下のようにする。

    agent {
        dockerfile true
    }
...


コンソール出力をみると、Dockerfile.buildからイメージをビルドしているのがわかる。
また、argsに指定した引数がdocker run時に指定されているのがわかる。

ユーザーanonymousが実行
[Pipeline] node
Running on master in /home/kimura/.jenkins/workspace/yyy
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Declarative: Agent Setup)
[Pipeline] readFile
[Pipeline] sh
[yyy] Running shell script
+ docker build -t 05acb8528ede11dbba8c1b1dc5d2f3ef110d3a0a -f Dockerfile.build .
Sending build context to Docker daemon 2.048 kB

Step 1/2 : FROM openjdk:8u131-jdk-alpine
 ---> c7105179e75b
Step 2/2 : RUN apk update && apk upgrade &&     apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make
 ---> Using cache
 ---> 0a22744743d5
Successfully built 0a22744743d5
[Pipeline] dockerFingerprintFrom
[Pipeline] }
[Pipeline] // stage
[Pipeline] sh
[yyy] Running shell script
+ docker inspect -f . 05acb8528ede11dbba8c1b1dc5d2f3ef110d3a0a
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -v /tmp/docker/cache/.m2:/var/maven/.m2 -w /home/kimura/.jenkins/workspace/yyy -v /home/kimura/.jenkins/workspace/yyy:/home/kimura/.jenkins/workspace/yyy:rw -v /home/kimura/.jenkins/workspace/yyy@tmp:/home/kimura/.jenkins/workspace/yyy@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat 05acb8528ede11dbba8c1b1dc5d2f3ef110d3a0a
...
サンプルコード

とりあえずscripted pipeline pluginのコードに変えてみた。

github.com

注意点

agentをDockerにしたときにpostでdeleteDir()すると、エラーになる。

#!/usr/bin/env groovy
pipeline {

    agent {
        dockerfile {
            filename 'Dockerfile.build'
            args '-v /tmp/docker/cache/.m2:/var/maven/.m2 -v /tmp/docker/cache/.node_modules:${WORKSPACE}/client/node_modules'
        }
    }
...
     post {
         always {
             deleteDir()
         }
     }


コンソール出力のエラー内容から予想するに、agentが1つしか指定されていない場合はすべてのステップをコンテナ内で実行するため、コンテナ起動時に割り当てられたworkspaceのディレクトリをdeleteDir()で消そうとしてエラーになってるっぽい。(自信ない)

[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Declarative: Post Actions)
[Pipeline] deleteDir
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] }
$ docker stop --time=1 dddaf3a162e5d72f112fa040cd2af2012dbd0df5d25b794d17e72412787ed750
$ docker rm -f dddaf3a162e5d72f112fa040cd2af2012dbd0df5d25b794d17e72412787ed750
[Pipeline] // withDockerContainer
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
java.nio.file.FileSystemException: /home/kimura/.jenkins/workspace/zzz_plugin-declarative-CRDZ7E2TTQRHZTXPZFVEXD5PM6IOKJAECQWTPPBF52DR5B4W7TKQ/client/node_modules: デバイスもしくはリソースがビジー状態です
	at sun.nio.fs.UnixException.translateToIOException(UnixException.java:91)
	at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
	at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)
	at sun.nio.fs.UnixFileSystemProvider.implDelete(UnixFileSystemProvider.java:244)

試しにtargetディレクトリをdocker起動時にマウントして消そうとすると同じエラーが出る。

$ pwd
/home/kimura/docker/sample
$ mkdir target
$ ls -al
合計 0
drwxr-xr-x. 3 kimura kimura 19  62 07:27 .
drwxrwxr-x. 5 kimura kimura 57  61 19:36 ..
drwxrwxr-x. 2 kimura kimura  6  62 07:27 target
$ docker run -it -v $(pwd)/target:$(pwd)/target -w $(pwd) centos:7 /bin/bash
# ls
target
# ll
total 0
drwxrwxr-x. 2 1000 1000 6 Jun  1 22:27 target
# rm -rf target
rm: cannot remove 'target': Device or resource busy
#

とはいえ、scripted pipelineと似たような要領で進められそう。
declarative pipelineのほうが事後処理がやりやすいので(always, failureなどなど)、新規に作成する人は declarative pipelineで作成したほうがよさそう。

Docker Pipelin Plugin の良いところ

  • ビルドサーバのメンテナンス性向上
  • ビルドタスクの柔軟性の向上
  • バージョン管理できる
  • ほぼローカルで実行するのと変わらないくらいコンテナ起動停止が早い
  • コンテナに関するあれこれの考慮をあんまり意識せずに使える

Docker Pipelin Plugin の悪いところ

  • Jenkinsfileにビルドやテストに関するあれこれを書きすぎるとローカル環境でDockerを気軽に使えなくなりそう


ということでローカルでもDockerを利用したビルドができるように、Docker Pipeline Pluginを参考にMakefileを作ってみた。

github.com


f:id:kimulla:20170612235217j:plain

これが言いたかった

参考

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がごっそり入れ替わった(declarative pipeline)っぽいので内容が古いです。以下はscripted pipelineの書き方です。


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に閉じた操作にとどめたほうが無難だと思いました。


Dockerを使う場合の記事も書いた

kimulla.hatenablog.com

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