Tomcatを使ったWebアプリをDocker化する方法を検討

社内でDocker化を進めていますが、現在Tomcat上で動かしているJava製WebアプリをDocker化する方法を検討しています。
調べた限りでは何通りか方法があるのですが、本番稼働から開発環境までを通して期待する方法がなかったため、自分なりの方法を考えてみました。
まだ実践していませんが、こうすればできそうという方法ができたのでまとめてみました。

目標とした要件は以下の通りです。

  1. Tomcatで動作させること
  2. HTTPとAJPの両方に対応すること
  3. Dockerイメージのビルドがややこしくならないこと
  4. 開発環境もDockerで実行させること
  5. 開発時にコードの変更を簡易に素早く反映させられること

なるべく現状の動作方法を維持しつつ、開発環境にも対応させることを目標としています。


実現方法
----

早速、実現方法について紹介します。
紹介する方法はビルドにMaven、IDEにIntellij Ideaを使った環境で、それを前提とした部分もあります。
ですが、GradleやEclipseを使った場合でも多少の設定に気をつければ問題なく適用できると思います。

### 概要

想定するアプリケーションのフォルダ構成は以下になります。

```
├─ build.sh # Javaビルド用のスクリプト
├─ pom.xml
├─ Dockerfile
├─ docker-compose-dev.yml # 開発環境での起動用
├─ docker-compose.yml # 本番やStagingでの起動用
├─ springloaded-1.2.5.RELEASE.jar
├─ src
│ └─ main
│   ├─ java
│   │ ├─ Main.java
│   │ └─ controllers
│   │   └── HelloServlet.java
│   ├─ resources
│   │ ├─ config.properties
│   │ └─ logback.xml
│   └─ webapp
│     ├─ WEB-INF
│     │ └─ web.xml
│     ├─ images
│     │ └─ ・・・
│     └─ index.jsp
└─ target
  ├─ classes # Mavenでのビルド結果出力先
  │ └─ ・・・・
  └─ lib # `mvn dependency:copy-dependencies` タスクでjarをここに集める
     └─ ・・・・
```

概要としては以下の通りです。

  * DockerイメージのベースはJavaイメージを使う
  * Embeded Tomcatを使う
    * Tomcatで使うclassesフォルダにmavenのビルド出力先を指定する (`target/classes`)
    * Tomcatで使うwebappsフォルダに `src/main/webapps` を指定する
  * 依存jarを `mvn dependency:copy-dependencies` タスクで `target/lib` フォルダにまとめる
    * その上でCLASSPATHに`target/classes`と`target/lib/*`を指定する

試しに作成した実装をGithubにあげていますので、詳細は以下を参考にしてください。
https://github.com/namutaka/docker-tomcat-app/

### 解説

#### Embeded Tomcatについて

Dockerではアプリの起動時設定を環境変数から指定するので、Tomcatの設定を動的に変更しやすくするためにEmbeded Tomcatを使っています。
また、任意の場所のclassファイルやwebappsフォルダを使うためでもあります。

Tomcatに関する処理は以下の `Main` クラスだけです。
https://github.com/namutaka/docker-tomcat-app/blob/master/src/main/java/Main.java

Tomcatの`server.xml`や`context.xml`での設定をJavaコードで実装しています。`server.xml`での`Connector`タグの設定に相当する処理にてHTTPとAJPのコネクタを登録しています。
ポイントとしては、以下の部分でアプリが使う`classes`フォルダや`webapps`フォルダに任意のパスを指定できるようにしています。

```java:src/main/java/Main.java
private static Context addWebappContext(final Tomcat tomcat,
                                        final String contextPath,
                                        final String classesPath,
                                        final String webappPath) throws ServletException {
    // webapps フォルダの場所を指定
    StandardContext ctx = (StandardContext) tomcat.addWebapp(
            contextPath.replaceFirst("/$", ""), new File(webappPath).getAbsolutePath());
    ctx.setUnpackWAR(false);

    WebResourceRoot resources = new StandardRoot(ctx);
    // classes フォルダの場所を指定
    resources.addPreResources(new DirResourceSet(
            resources, "/WEB-INF/classes",
            new File(classesPath).getAbsolutePath(), "/"));

    ctx.setResources(resources);
    return ctx;
}
```

[2016.09.21 追記]
また、Tomcatの動作設定のほとんどは `Connector#setProperty(key, value)` メソッドでkey-valueの形式で指定するようになっています。
(参考: https://tomcat.apache.org/tomcat-8.0-doc/config/http.html)
そこで、指定した接頭語を持つシステムプロパティをそのまま`setProperty`に設定するようにしてみました。
これで大体の設定はカバーできるのではないかと思います。

```java
// Set attributes (https://tomcat.apache.org/tomcat-8.0-doc/config/http.html)
// Example:
//   http.prop.connectionTimeout = 2000
//   http.prop.bindOnInit = false
setPropertiesFromSystem(httpConnector, "http.prop.");

private static void setPropertiesFromSystem(Connector connector, String propKeyPrefix) {
    for (String key : System.getProperties().stringPropertyNames()) {
        if (key.startsWith(propKeyPrefix)) {
            String propKey = key.substring(propKeyPrefix.length());
            String propValue = System.getProperty(key);
            if (IntrospectionUtils.setProperty(connector, propKey, propValue)) {
                logger.info("{} set property {}={}", connector, propKey, propValue);
            } else {
                logger.warn("{} did not set property {}={}", connector, propKey, propValue);
            }
        }
    }
}
```

Dockerといえば設定は環境変数で行うのが基本ですが、プロパティはkeyが大文字小文字を区別していそうな上に"."(ドット)が含まれるものがあるため、環境変数では表現しづらかったのでシステムプロパティを使いました。
`docker-compose.yml` では以下のように指定できます。

```yaml
services:
  web:
    image: myapp
    environment:
      JAVA_OPTS: >
        -Dhttp.prop.connectionTimeout=2000
        -Dhttp.prop.acceptCount=20
        -Dajp.prop.connectionTimeout=2000
```

#### Dockerfileについて

イメージをビルドするための `Dockerfile` は以下のとおりです。

```Dockerfile:Dockerfile
FROM java:8-jre-alpine

RUN mkdir /app
WORKDIR /app

COPY target/lib      /app/target/lib
COPY target/classes  /app/target/classes
COPY src/main/webapp /app/src/main/webapp

ENV CLASSPATH /app/target/classes:/app/target/lib/*
ENV JAVA_TOOL_OPTIONS -Djava.security.egd=file:/dev/./urandom

EXPOSE 8080 8009

CMD java $JAVA_OPTS Main
```

必要なフォルダをCOPYしてクラスパスを指定しているのがメインとなります。
Java起動時のオプションを変更できるように、`JAVA_OPTS`という環境変数で引数を追加できるようにしています。
また、起動コマンドを特別に指定したくなったときを想定して、コマンド引数を使わず環境変数を使って `CMD` をシンプルになるようにしています。

`JAVA_TOOL_OPTIONS`はコマンド引数の変わりに暗黙に利用される環境変数です。
これを使って `SecureRandom` で使われる乱数生成機を `urandom` にするためのシステムプロパティを指定しています。
Dockerコンテナの起動は人手による操作が入らないためなのかサーバー中に動作のブレが発生しづらいようで、この指定をしなければTomcatの起動がかなり遅くなることがあります。
この乱数生成機の事情について以下を参照ください。

* Oracle への JDBC 接続に時間がかかる現象の回避方法 - ぐーたら書房
  * http://gootara.org/library/2016/02/ojdbc.html
* インフラエンジニアのメモ : JAVAの処理遅延問題【乱数生成に伴うJVMの遅延】
  * http://server-helper.doorblog.jp/archives/6284616.html


#### 起動方法

作成したDockerイメージの起動方法は以下のようにシンプルにコンテナを起動するだけです。

```yaml:docker-compose.yml
version: '2'

services:
  web:
    image: myapp
```

一方、開発時の起動方法は以下のようになります。

```yaml:docker-compose-dev.yml
version: '2'

services:
  web:
    build: .
    volumes:
      - ./target/lib:/app/target/lib:ro
      - ./target/classes:/app/target/classes:ro
      - ./src/main/webapp:/app/src/main/webapp:ro
      - ./springloaded-1.2.5.RELEASE.jar:/app/springloaded-1.2.5.RELEASE.jar:ro
    environment:
      JAVA_OPTS: -javaagent:springloaded-1.2.5.RELEASE.jar -noverify
```

アプリで使用するフォルダをvolumeマウントして手元のファイルを使うようにしています。
また、[Spring-Loaded](https://github.com/spring-projects/spring-loaded)を使うためのjarファイルもvolumeをつかってコンテナ内に入れています。
その上で、`JAVA_OPTS`環境変数にてSpring-Loadedを使うためのコマンド引数を設定しています。


### 使い方

#### 開発環境の起動

開発環境では以下のようにビルドとサーバーの起動を行います。

```bash
$ mvn \
    -DoutputDirectory=target/lib \
    -Dcompile=compile \
    clean \
    dependency:copy-dependencies
$ mvn compile
$ docker-compose -f docker-compose-dev.yml up
```

このとき、`webapps`フォルダ内の静的ファイルやJSPファイルは修正すれば即座に反映されます。
また、Javaファイルは Spring-Loaded を使っているのでIDE上でコンパイルするだけで再起動もなく反映されます。
注意として Intellij のプロジェクト設定でビルドの出力先をMavenと同じにしておくことが必要です。これはMavenのプロジェクトをImportすれば自然とそうなるはずです。

クラスの追加や`resources`フォルダ内のファイルを変更した場合は、`mvn compile`をしてから `docker-compose up`を実行しなおす必要があります。
また、`pom.xml`の依存モジールを修正した場合は最初の `dependency:copy-dependencies` からやり直しが必要です。

サンプルのプロジェクトでは、これらのビルドコマンドを `build.sh` ファイルにまとめています。


#### Dockerイメージのビルド

Dockerイメージをビルドする手順は以下の通りです。

```bash
$ mvn \
    -DoutputDirectory=target/lib \
    -Dcompile=compile \
    clean \
    dependency:copy-dependencies \
    compile
$ docker build --tag myapp:1.0 .
```

このイメージの起動は以下のようになります。

```
$ docker-compose up
```


### 実装上のポイント

最後に現時点で私が思っているDocker化にあたっての注意点を記載しておきます。
Docker化にあたっては、**Mavenのファイル置換機能や環境ごとのファイル差し替え機能は使わない方がよい**と考えています。
特に今回の方法では`webapps`フォルダ内のファイルは `src/java/webapps` を直接利用するため対象とすることができません。

Dockerアプリの実装方針として挙げられる[12factors](https://12factor.net/ja/)においても、１つのDockerイメージでどの環境向けにでも利用できるようにすべきとのことが謳われています。
これに従おうとするには、先のMavenのファイル操作機能は利用しない方がよいことになります。

ちなみに、このファイル操作機能を使わない場合はロガーモジュールとしてlogbackを使うのがおすすめです。
このロガーは設定ファイル(logback.xmlなど)で環境変数の値を直接利用することや、その値による条件判定(if文)をすることができます。
詳細は、以下を参照してください。
http://logback.qos.ch/manual/configuration_ja.html

まとめ
----

TomcatアプリのDocker化として開発環境での使い勝手を含めていい線の方法ができたのではないかと思います。
ただ、あくまでサンプルなので、これをベースに実際のDocker化を進めてみたいと思います。

なお、Tomcatを使うことを前提条件としていたのでこんな方法になっていますが、そもそもTomcat以外でもよければjettyで動作させる方が簡単です。
jettyでは、[`jetty-runner`](https://www.eclipse.org/jetty/documentation/9.3.x/runner.html)を使うことでwarファイルを直接サーバ起動することができますし、開発環境向けには[`jetty-maven-plugin`](https://www.eclipse.org/jetty/documentation/9.3.x/jetty-maven-plugin.html)を使うことでファイル更新を即座に反映させられる状態での起動ができます。
また、実際に試していませんがjettyでも[AJPが使える](https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-ajp)(jetty9から対応外になったようなので今後は使えない？)ようですし、そもそもAJPを使わずHTTPだけでもそこまで問題があるわけではありません。

今回は、現状稼働させている環境をなるべく変えないことと、本番と開発のどちらもDockerを使ったほぼ同じ動作のさせ方をすることを前提とした、ある種の縛りプレイ的な検討になっています。



補足
-----

おまけとして、既存で使えそうだった方法についての検討結果もまとめておきます。

### Tomcat公式Dockerイメージを使う方法

DockerでTomcatを動かすのであれば、まず最初に選択肢に上がるのは公式のDockerイメージだと思います。
  https://hub.docker.com/_/tomcat/

このイメージは、Tomcatの公式サイトでダウロードできるCoreのアーカイブを展開してそのまま動作させているもののようです。
これにはTomcatのmanagerやsampleアプリも入っているため、最初にそれらを削除する必要があります。

Tomcatの`conf/server.xml`や`conf/context.xml`で定義される設定を変更するには、Dockerイメージのビルド時に自身のアプリに応じたファイルで上書きします。これらのファイルは`${propname}`という表記でシステムプロパティの値に展開できるようなので、javaコマンドの`-D`オプションで動的に設定することもできます。

コンテナ内の `/usr/local/tomcat/` にtomcatが展開されているので、この中の `/webapps`内にwarファイルか、warを展開したフォルダを配置することでアプリを実行できます。

なお、このTomcatはログをlogsディレクトリ下にファイル出力するようになっていますが、Dockerの場合は基本的にすべて標準出力に出す必要があるため、Tomcat本体のログ出力設定を変更する必要があります。

Mavenでは、`mvn package`を実行することで`target/{アプリ名}/`が作成され、このフォルダを圧縮した`target/{アプリ名}.war`が作成されます。
開発中では、`docker run`時にこの`target/{アプリ名}/`フォルダをvolumeマウントすれば、`mvn package`を実行することで再起動なく変更を反映できます。
しかし、毎回`mvn package`を実行するのは手軽とは言いづらいです。

結論として、

  * 既存のmanagerやsampleを消す必要がある
  * 設定をファイルすげ替えで行う必要がある
  * 開発時の起動が手軽ではない

という点からこの方法は採用しませんでした。
ですが、もうちょっと工夫を検討すれば使いやすくできそうな気もしています。


### Heroku の Tomcat webapp runnerを使う方法

HerokuのドキュメントにてTomcatアプリをHeroku上で実行する方法が紹介されていました。
これをDocker化にも使えるのではないかとも考えました。

* Deploying Tomcat-based Java Web Applications with Webapp Runner | Heroku Dev Center
  * https://devcenter.heroku.com/articles/java-webapp-runner

ここではHeroku上でjavaアプリを実行するために、Tomcatそのものを１つのjarにまとめた`Webapp Runner`というものをつかってwarファイルを直接起動する方法が紹介されています。
この `Webapp Runner`はEmbeded Tomcatを使っ作成したモジュールのようで、以下で公開されています。
  https://github.com/jsimone/webapp-runner

Tomcatをセットアップする必要がないので簡単ですが、開発時に利用するには先のTomcatイメージを使う方法と同じくファイル更新の反映に`mvn`コマンドの実行が必要となる問題があります。

なお、最初に紹介したEmbeded Tomcatを使った`Main.java`の実装はこのモジュールを参考にさせていただいています。


### Heroku で解説されているEmbeded Tomcatを使う方法

Herokuのドキュメントでは、もう一つEmbeded Tomcatを使う方法も紹介されています。
これもHerokuでTomcatアプリ実行させるための方法ですがDockerにも応用できると思います。

* Create a Java Web Application Using Embedded Tomcat | Heroku Dev Center
  * https://devcenter.heroku.com/articles/create-a-java-web-application-using-embedded-tomcat#

`Webapp Runner`を使わず、自前で`Webapp Runner`でやっているようなTomcatの初期化を行う方法です。
この方法では`mvn package`を実行する必要がなく、Tomcatからは "src/main/webapp/" と "target/classes" を直接参照させるようになっています。

実行させるには、Mavenの`appassembler-maven-plugin`プラグインの機能で、アプリのjarファイルと実行用スクリプトを作成し、それを利用しています。

これは期待にかなり近い方法ですが、開発時の起動時に`mvn`コマンドの実行が必要となるためそのままでは不便さが残ります。

今回最初に紹介した方法は、これをベースに開発環境での起動方法を改良したものという感じになっています。


改版履歴
----

2016.09.21: Tomcatの動作設定方法について追記
2016.09.22: setPropertiesFromSystemのプロパティ設定を`IntrospectionUtils.setProperty()`に変更