Java
JavaScript
Go
docker

マイクロサービスほどじゃないけどウェブサービスを分割開発したい人向けDocker設定を集めるスレ

Futureアドベントカレンダー6日目です。昨日は @shun_shushu さんでした。

マイクロサービスまではいかなくても、gRPCなり、Swaggerなりを使って、リッチなSPAのフロントエンドと、いくつかのプロセスに分割されたバックエンドでサービスを開発したい、というニーズはあると思いますので、今までやってきた開発の反省・良かったところを踏まえて、次やるなら絶対にこうする・実際にこうし始めた!というDocker活用案です。

フロント、バックエンドのサービスを種類ごとに書いています。好きなフロントエンドと、好きなバックエンドのレシピを組み合わせて、オリジナルのdocker-compose.ymlを作る、という感じで読んでいただけるように書いています。対象言語とかも増やしたいので、この記事自体、検証結果を受けてどんどん変わっていく予定です。

ソースコードは次のリポジトリに置いておきます。本エントリーでは環境構築周りのみの説明にフォーカスするため、各サービスの実装の詳細までは踏み込みません。気になるところがあればソースを見ていただければと思います。

大前提の整理

フロント寄りの人もいれば、バックエンド寄りな人もいると思うので、僕のバイアスも入っているとは思いますが、開発環境を整備するために、ウェブサービスの2018年末時点の状況を整理しておきましょう。

ウェブの開発はホットリロードが必須

昔と違って、ウェブフロントエンドの開発のためのライブラリとか開発ツールとかもかなり複雑になってきています。また、コード量も大きくなってきており、大量のライブラリを使って開発をしていきます。フロントエンド開発界隈もそれに応じてツールを改善しており、変換結果をメモリ上にキャッシュしつつ、ソースコードの変更を監視して、変更したファイルをすばやく反映してビルドし、ページを開いているブラウザをリロードするというホットリロード、より進んだ例では、ブラウザ上の特定のモジュールのみを置き換えてさらに素早く更新するホットモジュールリプレースメントなどもあります。

このホットリロードや、ホットモジュールリプレースメントには、Node.js上で動く開発サーバというものを動かすのが一般的です。webpack-dev-serverとかその手のやつです。

たとえサーバーをGoとか他の言語で作るにしても、開発環境の整備をするときは、このフロントエンドの開発速度にブレーキをかけることはしてはいけません。

シングルページアプリケーション

React、Vue、Angularでフロントエンド開発の景色がだいぶ変わりました。jQueryで整合性を保ちつつDOMの状態管理をするよりも、これらのフレームワークに任せてしまう方が楽です。そんため、今時であれば、シングルページアプリケーションを避けて作ろうとする方が逆に大変なのではないか、と思います。因果関係は逆転しますが、シングルページアプリケーションでやるのが当然となってきていると思います。

シングルページアプリケーションは、フロントエンド上でURL(として見えるブラウザのアドレスバー)を書き換えつつも、ページリセットをしないで動きます。で、そのタイミングでリロードをかけると、サーバー側でそのページに対応してHTMLを返すようになっていない場合にエラーになってしまいます。シングルページアプリケーションを実装する場合は、サーバー側の協力も不可欠です(URLにハッシュ#とか、クエリーの?が入るダサいシングルページアプリケーションはこの世に存在しないものとします)。

サーバーサイドレンダリング

フロントエンドがReact、Vueの場合は、Next.js、Nuxt.jsを使うと、勝手にサーバーサイドレンダリングが付いてきます。今時はJS/CSSがヘビーなため、そのままだと、必要なリソースが読み込み終わるまで時間がかかりますが、初回表示はサーバー側でHTML/CSSを作って返すために最初に見える表示が早い、というものです。残念ながら、Angularはちょっと大変そうです。

わざわざ手間暇かけてサーバーサイドレンダリングに対応するのは、悪手であるな、とは未だに思っていますが、せっかく勝手に付いてきてパフォーマンスも良いのであれば使わない手はありません。

サーバーサイドレンダリングを効率よく行うには、サーバー側にJavaScriptエンジンが必要です。99%のケースで、これはNode.jsのことを意味しています。ごく稀に、duktapeという軽量なJSエンジンのラッパーライブラリを利用したGoのこともあります。

静的ファイル配信とセキュリティ

フロントエンドの開発サーバーと、APIサーバーを別に起動すると、ローカルでは別ポートになります。ブラウザからみると、ポートが違えば別サーバー(正確には別オリジン)です。別オリジンのウェブサイトからのAPIアクセスは、サーバーリソースの窃盗になりえるため、どのオリジンからのアクセスを明示しないとアクセスを遮断する、という機能がブラウザにはあります。CORS(クロスオリジンリソースシェアリング)というやつです。

みなさん、Real World HTTPはお手元にお持ちですよね?詳しくはP275から書かれています。

よくあるウェブサービスのローカル開発だと、このCORSの設定を開発環境だけ有効にするオプションとかがあったりします。

コンテナ

今時のシステム開発はコンテナというブロックを作って、それを組み合わせて使うのが当たり前になりつつあります。コンテナは、使うOSやら環境やらによって技術的詳細は異なりますが、擬似的なOS環境を作り、同じサービスであってもたくさんのOSのインスタンスが協力して動作するような構成を作ります。

LinuxであればcgroupなどのOS機能を使って仮想OSを作りますし、macOSやWindowsであれば、OSの提供するハードウェア仮想化機能を利用して仮想PCを動かしてLinuxを動かし、その上で仮想OSをたくさん作ります。GKEであればgVisorというサンドボックスミドルウェアの中でアプリを動かしているそうです。AWSも何かRust製のやつを発表していましたね。

みなさん、Goならわかるシステムプログラミングはお手元にお持ちですよね?詳しくはP303から書かれています。

コンテナのセオリーは次の2つです

  • 1つのコンテナには1つのプロセス
  • 環境変数でシステムの動作がコントロールできるようにコンテナイメージを作成する

コンテナを協調させて動かすには、docker-compose、Kubernetesなどを使います。ローカルだとdocker-composeが便利かと思います。

デプロイだけではなく、開発環境をコンテナで用意するということもあります。

monorepo

マイクロサービス同様に、ツールを細かくプラグインとして分割して切り出し、柔軟性を上げつつ、コアをシンプルに保とう、というのがJavaScript界隈では活発に行われています。その細かく分ける部品数が大量になった時のソフトウェアの構成の手法として広まっているのが、monorepoです。Babelが行なっているやつですね。

もちろん、monorepoにもPros/Consいろいろあります。monorepoを複数サービス入れちゃうのか、単体サービスの構成部品かによっても変わってくるでしょう。現時点でのスナップショットが簡単に手に入る点はメリットで、1つのサービスだけを入れる場合にはConsはほぼないと思います。

複数サービスを1リポジトリに入れると、共通ライブラリ的なものの非互換の更新をどうするか、みたいなのが話題になりますが、どうせ複数リポジトリに分けたとしても、共通ライブラリの中でブランチが生えまくって収集がつかなくなってひどいことになっていくのはすでに経験済みです。無理やりにでも1つにして、非互換をしたくなったらコードをコピーしてv2を作る!ぐらいの気持ちが良いのかなって思っています。マイクロサービスの、ROIは下がってもスループットは死守する、という考えはmonorepoでも同様かと思います。

クラウドサービスの利用

近頃はクラウドサービスを使って開発することが増えています。

例えば、AWSのRDSのように、ローカルのPostgreSQLで代用できるものだったり、エミュレーターが提供されているGCPのDatastoreだったりであれば、ローカル開発環境の構築は難しくはないでしょう。しかし、そういうのが提供されていないサービスの場合、そこだけはリモートで行うなり、クラウドサービスをバイパスするような設定を入れたりする必要があるかもしれません。

最終系

というわけで、最終的に目指す姿を紹介していきます。

フォルダ構成

フォルダ構成はフロントもバックエンドも全部含むmonorepoにします。

フォルダ構成
+- containers
|   +- angular-frontend
|   +- golang-api-server
|   :
+- docker-compose.yml      // 開発環境用
+- docker-compose.prod.yml // 本番環境のイメージテスト用

必要な環境はすべてdocker-compose1つで揃うようにします。

# 開発環境起動
$ docker-compose up

# 本番環境のイメージをビルド
$ docker-compose -f docker-compose.prod.yml build
# 本番環境のイメージをテスト起動
$ docker-compose -f docker-compose.prod.yml up

開発環境

開発環境はローカルのソースコードをDockerコンテナに共有し、実際のビルドはそちらで行います。すべての環境がdocker-composeコマンド1つで行えます。

同一マシンでの実行であっても、Dockerは、仮想PCを実行してLinuxを動かし(mac/Windowsの場合)、その中で実際の処理を行います。docker buildコマンドを叩くと、ローカルのファイルをこの仮想PC上にコピーし、そこで必要なファイルをADDコマンドなどで追加しつつ、イメージを作成していきます。

コンテナは箱の中で動くイメージがありますが、ボリュームという機能を使えば、ローカルの作業フォルダをそのままコンテナ内部にミラーすることができます。ソースコードをコンテナ内部でビルドし、成果物をまたローカルの作業フォルダに戻すこともできます。

今回はサーバーとして起動するので、成果物を取り出すことはありませんが、ローカルのファイルでソースコードを編集しつつ、実行はDocker内のLinuxで行います。ちなみに、AWSとかでDockerを動かせば、ビルドを外部マシンで行わせることもできます。

Screen Shot 2018-12-04 at 0.29.08.png

フロントエンドは必ず、Dev Serverを稼働させ、APIサーバーへのアクセスはそのプロキシ機能を使って後方のAPIサーバーに流すようにします。そうすると、ブラウザからはフロントもバックも、つねに1つのURLでのアクセスになります。CORSとか気にしなくてもよくなります。

注意点としては、最初にローカルのファイルをまとめてコピーしてしまうので、node_modulesとか、中間ファイルのフォルダがあると、docker buildに余計に時間がかかってしまいます。.dockerignoreというファイルを作り、dockerのサーバーに送信しないファイルを記述しておくと良いでしょう。

デプロイ用イメージのローカルテスト

本番環境はECSなり、EKSなり、GCEなり、GKEなり、AKSなりで公開していくとは思いますが、そのイメージ自体はローカルでビルドしてテストもしますよね?それらをdocker-composeでぱっと起動して試すのが良いかと思います。minikubeでローカルのKubernetesでも良いかもしれません(僕はまだ触ったことがないけど、きっとできそう)。

フロントがNext.jsなどのSSRの場合、Node.jsが必要なので、Node.jsを含むコンテナをフロントにおき、このコンテナにすべてを任せます。SSRではなく、静的HTMLが生成される場合は、Nginxと一緒にコンテナにして、静的ファイルを配信します。

裏のサーバーへのアクセスは、開発環境同様に、フロントのコンテナのプロキシ経由にします。フロントが静的HTMLのみの場合でも、裏のメインのサービスにHTMLとJSをバンドルさせ、静的HTML配信設定を行うよりは、開発環境と同様の役責のみを持たせる方がわかりやすいでしょう。ただし、バックエンドがLambda上のExpressで・・・とか、Goのバイナリにバンドルさせたいとか、GAEで・・・という場合は、1つにまとめる必要があるでしょう。

Screen Shot 2018-12-04 at 0.29.23.png

フロントエンド周りのDocker設定

まずはフロントエンドから説明していきます。

Angular

開発環境

まず開発環境ですが、専用のイメージは作らないで、nodeのコンテナイメージをそのまま使ってホットリロードを有効にします。working_dirと同じ名前のところに、ローカルのフロント
APIサーバーなどがあるなら、その名前(servicesの直下の項目名)をlinksに入れていきます。
node_modulesなどのようにぶくぶく太っていくものはvolumesを別途作って割り当てます。

docker-compose.yaml
version: '3'
volumes:
    node_modules:
services:
    angular-frontend:
        image: node:lts-slim
        command: bash -c "npm install && npm run start"
        volumes:
            - ./containers/angular-frontend:/front
            - node_modules:/front/node_modules
        working_dir: /front
        ports: 
            - "4200:4200"
        links:
            - golang-api-server # APIとか依存するものを書く

Angularのdev-serverは、localhostで起動してしまって、外の世界からアクセスできない(コンテナの外からも)ので、scriptsのstartは修正が必要です。ちょっと前の全体構成の図に書いたように、ブラウザのアクセスはまずこのコンテナが受けて後ろにプロキシするので、それも設定します。

package.json
  "scripts": {
     // --hostと--proxy-configを追加
    "start": "ng serve --host 0.0.0.0 --proxy-config proxy.json"
  }

Proxyの内容はこんな感じです。ホスト名はdocker-compose.ymlのlinksの名前ですね。/api以下のアクセスは後ろのサービスにフォワードされます。ここでは使っていませんが、URLのリライトもできます。

proxy.json
{
  "/api": {
    "target": "http://golang-api-server:8080"
  }
}

この状態で起動すると(実際にはGo側のサービスがまだ書かれてないので動かないですが)、ローカルのangular-frontend以下のソースを編集するたびに、コンテナ内部でビルドが行われます。ブラウザで表示していると、リロードが行われます。快適ですね!

本番用イメージ

プロダクション用のイメージを起動するdocker-composeファイルはこんな感じです。こちらは単にローカルのDockerfileを読み込んでポートアクセスするだけなのでシンプルです。

docker-compose.prod.yml
version: '3'
services:
    angular-frontend:
        build:
            context: ./angular-frontend
        ports: 
            - "4200:80"
        links:
            - golang-api-server

本番環境のDockerfileはこんな感じです。マルチステージビルドですが、ビルド側はnode-sassがあってgypとC++コンパイラが必要なのでちょっと複雑です。うまくいけば、ビルド済みバイナリが落ちてきてビルドしなくて済むかもなので、一応コメントアウトしています。実行側は完全にビルドが終わった静的ファイルのみなのでNginxのイメージを使っています。

containers/angular-frontend/Dockerfile
FROM node:lts-slim as builder
WORKDIR /work
#RUN apt-get update \
#    && apt-get install --no-install-recommends -y python build-essential unzip \
#    && apt-get clean
#RUN npm install -g node-gyp
ADD package.json .
ADD package-lock.json .
RUN npm ci
ADD angular.json .
ADD tsconfig.json .
ADD src src
ADD libs libs
RUN npm run build


FROM nginx:stable-alpine as runner
RUN apk add --no-cache bash sed coreutils
ADD ./nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /work/dist/env-config public
EXPOSE 80
ENTRYPOINT ["bash", "-c", "nginx -g \"daemon off;\""]

シングルページアプリケーションで必要なのはtry_filesですね。すべてのリクエストが一旦このコンテナにくるので、裏に流す必要があるリクエストはProxyします。Proxy先のホスト名はdocker-compose.prod.ymlのlinksの名前です。

containers/angular-frontend/nginx/nginx.conf
server {
  listen 80;
  server_name localhost;

  proxy_set_header    Host    $host;
  proxy_set_header    X-Real-IP    $remote_addr;
  proxy_set_header    X-Forwarded-Host       $host;
  proxy_set_header    X-Forwarded-Server    $host;
  proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
  server_tokens off;

  location / {
    root /public;
    index index.html index.htm;
    try_files $uri $uri/ /index.html =404;
  }

  location /api/ {
    proxy_pass    http://golang-api-server:8080;
  }
}

こちらの環境は最低限のリソースで起動しますが、ホットリロードはしません。

なお、コンテナの場合は環境変数で動作を変えられるようにするのがセオリーです。ですが、フロントエンド周りではビルド時のオプションでビルドする結果の成果物を作り分ける方法、実行時にサーバーに設定を取りに行く方法がよく出てきます。Angularのデプロイの話を調べると、環境変数で切り替えたいけどダメだったという話がよく出てきます。この方法については目処は立っているので、Node.jsのアドベントカレンダーの時にでも別途紹介します(長さが二倍になってしまうので)。

Next.js

ReactやVue.jsをSSRせずに使うのであれば、Angularの説明の応用でいけるでしょう。今回はサーバーサイドレンダリングを行うNext.jsについて説明します。Next.jsの場合、ビルドしてもNode.jsが必要になります。

2019年版: 脱Babel!フロント/JS開発をTypeScriptに移行するための環境整備マニュアルのエントリーの通り、TypeScriptを積極的に使って行く方向なので、本エントリーでもTypeScriptを使っていきます。Next.jsのプロジェクトの基本構成はそれに準拠します(あとでそちらにExpressの話は追記するかも)

nextコマンドを使う方法と、自前のExpress.jsのサーバーにnextの機能を組み込む方法があります。Angularと異なり、nextコマンド単体ではプロキシを設定する機能はありません。BFFとしてNext.jsを使い、HTTPハンドラを組み込むのであればExpress.jsが必要となります。とりあえず、Express.jsを使う方向で実装しておく方が良いでしょう。

フォルダ構成
+- pages             // フロントのページ
+- src               // フロントのページ以外の要素(コンポーネントとか)
+- server            // サーバーコード
|   +- server.ts     // サーバー用のエントリーポイント
|   +- tsconfig.json // サーバー用のビルド設定
+- package.json      // いつもの
+- tsconfig.js       // フロントのビルド設定
+- next.config.js    // Next.jsの設定

設定ファイルが2つありますね。ビルド時にはフロントだけじゃなくて、ついでにサーバーもビルドされるようにします。

package.json
"scripts": {
    "postbuild": ""postbuild": "tsc -p server""
}

サーバー側ではいくつかフロントの設定を上書きします。設定については2019年版: 脱Babel!フロント/JS開発をTypeScriptに移行するための環境整備マニュアルのCLIツールの説明とほぼ同じです。

server/tsconfig.json
{
    "extends": "../tsconfig.json",
    "compilerOptions": {
        "target": "es2017",
        "outDir": "../dist/server",
        "declaration": false,
    "module": "commonjs",
    "noEmit": false
    },
    "include": ["."]
}

次のコードがnextコマンド同等の最低限のExpressのコードに、プロキシ設定を足したコードです。今回も、/api以下はgolang-api-serverにフォワードします。

server/server.ts
import * as express from "express";
import * as next from "next";
import * as proxy from "http-proxy-middleware";
import { IncomingMessage, ServerResponse } from "http";

const dev = process.env.NODE_ENV !== "production";
const PORT = process.env.PORT || 3000;

const app = next({ dir: ".", dev });
const handle = app.getRequestHandler();

const main = async () => {
    await app.prepare();
    const server = express();

    // Proxy Setting
    const options = {
        target: "http://golang-api-server:8080",
        changeOrigin: false,
        ws: false // proxy websockets
    };
    server.use("/api", proxy(options));

    // Next.js Handling
    server.get("*", (req: IncomingMessage, res: ServerResponse) => {
        return handle(req, res);
    });

    server.listen(PORT, (err: Error) => {
        if (err) {
            throw err;
        }
        console.log(`> Ready on http://localhost:${PORT}`);
    });
};

main();

もちろん、BFFとしていろいろロジックを足すのも良いでしょう。passportを使って認証のコードを組み込んでもいいし、Express.jsのイベントハンドラを自分で足して、中からisomorphic-unfetchなりaxiosで後方のサービス群を使って自分でリクエストを飛ばして、結果を集約するとかやると「俺/私、BFF開発しているぜ」と自慢できます。

実行に必要なパッケージも入れます。

$ npm install --save express http-proxy-middleware
$ npm install --save-dev @types/express @types/http-proxy-middleware

開発環境

Next.jsを非production環境で実行すると、勝手にフロント関連のコードはホットモジュールリプレースメントになってくれて便利ですが、カスタムのExpressサーバー部分は手動の再起動が必要です。nodemonを使ってサーバー部分もホットデプロイされるようにしましょう。ts-nodeコマンドを使えば、ExpressのコードをTypeScriptで書くことができます。

nodemon.json
{
  "watch": [
    "server.ts",
    "server/**/*.ts",
    "next.config.js"
  ],
  "execMap": {
    "ts": "ts-node --compilerOptions '{\"module\":\"commonjs\"}'"
  }
}

必要なコマンドをインストールします。

$ npm install --save-dev nodemon ts-node
package.json
"scripts": {
    "dev": "nodemon server/server.ts"
}

docker-compose化します。

docker-compose.yaml
version: '3'
volumes:
    node_modules:
services:
    nextjs-frontend:
        image: node:lts-slim
        command: bash -c "npm install && npm run dev"
        volumes:
            - ./containers/nextjs-frontend:/front
            - node_modules:/front/node_modules
        working_dir: /front
        ports:
            - "3000:3000"
        links:
            - golang-api-server # APIとか依存するものを書く

これでdocker-compose upでNext.jsのフロントエンドが起動して、フロントのホットモジュールリプレースメント、サーバーのホットリロードが有効になります。ただし、npm installで新しいパッケージの追加までは今は見ていないので、その場合だけ再起動が必要です。

本番イメージ

こちらも、Angular同様、node-sassを使っているので、念のためコメント外せばコンパイラとnode-gypが使えるようにしています。本来はrunnerの方もそういうのが必要ですが、それはまた必要になったら考えます。これでマルチステージビルドで作成できます。

FROM node:lts-slim as builder
WORKDIR /work
#RUN apt-get update \
#    && apt-get install --no-install-recommends -y python build-essential unzip \
#    && apt-get clean
#RUN npm install -g node-gyp
ADD package.json .
ADD package-lock.json .
RUN npm ci
ADD next.config.js .
ADD .babelrc .
ADD tsconfig.json .
ADD src src
ADD pages pages
ADD server server
RUN npm run build


FROM node:lts-alpine as runner
COPY --from=builder /work/.next .next
COPY --from=builder /work/dist dist
ADD package.json .
ADD package-lock.json .
RUN npm ci --production
EXPOSE 3000
ENTRYPOINT ["node", "dist/server/server.js"]

イメージ確認用のdocker-composeも特段難しいことはありません。環境変数ぐらいですかね。

docker-compose.prod.yml
version: '3'
services:
    nextjs-frontend:
        build:
            context: ./containers/nextjs-frontend
        ports: 
            - "3000:3000"
        links:
            - golang-api-server
        environment:
            - NODE_ENV=production

なお、今回のコードの場合は必ずExpress.jsのProxyのミドルウェアを経由してしまうので、ブラウザからのアクセスで後方のウェブサービスに投げる場合はリバースプロキシであらかじめ振り分けてあげればNode.jsの負担が減ります。リバースプロキシはURLのパスを見て振り分けているだけですので、本番環境の場合はAWSとかGCPとかのロードバランサーを使ってあげると良いでしょう。

バックエンドサーバのDocker設定

Go

開発環境

開発環境の方は、以下のエントリーに書かれている内容がすべてですね。

1つのコマンドを実行するだけであれば、わざわざDockerfileを作らなくてもいいのですが、go getと、freshコマンドの2つを実行する&freshは何度も継続実行するのであれば、Dockerfileが必要です。たいした手間ではないですが作っておきます。

containers/golang-server/Dockerfile.dev
FROM golang:1.11.1

WORKDIR /src
COPY . .
ENV GO111MODULE=on

RUN go get github.com/pilu/fresh
CMD ["fresh"]

これを起動するためのdocker-compose.ymlは次の通りです。

docker-compose.yml
version: '3'
services:
    golang-api-server:
        build:
            dockerfile: Dockerfile.dev
            context: ./containers/golang-api-server
        command: fresh
        expose:
            - "8080"
        ports: 
            - "8080:8080"
        volumes:
            - ./containers/golang-api-server:/src

本番イメージ

こちらも、こちらのエントリーにまとめている内容が詳細説明になります。CGO対応でマルチステージビルド
するDockerfileになっています。多少、go modules用に書き換えました。最終成果物の実行ファイルとCGOで必要なランタイム

containers/golang-server/Dockerfile
FROM alpine:latest as builder

RUN apk add --no-cache go \
        git \
        binutils-gold \
        curl \
        g++ \
        gcc \
        gnupg \
        libgcc \
        linux-headers \
        make \
        python
ADD . /main
WORKDIR /main
ENV GO111MODULE=on
RUN go build

FROM alpine:latest as runner
RUN apk add --no-cache libc6-compat
COPY --from=builder ./main/main ./
ENTRYPOINT [ "./main" ]

これを使うdocker-composeファイルは次の通りです。

docker-compose.prod.yml
version: '3'
services:
    golang-api-server:
        build:
            context: ./containers/golang-api-server
        expose:
            - "8080"
        ports:
            - "8080:8080"

Java (SpringBoot)

OpenJDK11とSpringBoot2でもやってみます。

開発環境

SpringBootは簡単にホットリロードができます。まず、dependenciesの中に、Spring Boot DevToolsを追加します。これはjar起動の時は無効になりますが、gradlewコマンドで起動したときにライブリロードができるようになります。ただし、他の言語のように1コマンドではできず、2コマンドが必要です。

build.gradle
dependencies {
    runtimeOnly('org.springframework.boot:spring-boot-devtools')
}

これが開発環境用のdocker-composeです。build --continuousが自動ビルドになります。devtoolsが入っていると、bootRunの部分が再起動を行います。この2つを組み合わせなければなりません。

docker-compose.yml
version: '3'
volumes:
    node_modules:
    gradle:
    java_server_build:
services:
    java-api-server:
        image: openjdk:11-jdk-slim-sid
        expose:
            - "8081"
        ports:
            - "8081:8081"
        volumes:
            - ./containers/java-api-server:/src
            - gradle:/root/.gradle
            - java_server_build:/root/build
        working_dir: /src
        command: bash -c "./gradlew build --continuous & ./gradlew bootRun"

これで、ソースの変更に対してライブリロードが可能です。ブラウザの再起動は主導で行う必要があります。

本番環境

とりあえず簡単バージョンです。Jarの名前にバージョンが入ってしまうのはバッチでビルドするときに不便なので、固定名になるようにします。

build.gradle
bootJar {
   archiveName = 'fortune.jar'
}

JDKでビルドして、JREで動かします。OpenJDK11だけはなぜかAlpine版が存在しなかったので、slimを使っています。

containers/java-api-service/Dockerfile
FROM openjdk:11.0.1-jdk-slim-sid as builder
WORKDIR /work
ADD  build.gradle .
ADD  gradlew .
ADD  settings.gradle .
ADD  gradle gradle
RUN ./gradlew bootJar 2> /dev/null; exit 0
ADD  src src
RUN ./gradlew bootJar


FROM openjdk:11.0.1-jre-slim-sid as runner
COPY --from=builder /work/build/libs/fortune.jar .
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "fortune.jar"]

ビルドはいつもの通りです。

docker-compose.prod.yml
version: '3'
services:
    java-api-server:
        build:
            context: ./containers/java-api-server
        expose:
            - "8081"
        ports:
            - "8081:8081"

実際には、JDK9以降はjlinkを作ってバイナリサイズを小さくするのがベストプラクティスなようです。

ストレージ周りのDocker設定

ストレージ周りは選択肢が多すぎて、まだ手順化できていないので、基本方針案だけ書いておきます。

ストレージ周りは、PostgreSQLだろうと、MySQLだろうと、気軽にDockerでイメージをダウンロードしてコンテナで起動できます。ですが、マスターや、テストデータなどの初期データのインポートをどうするかはいくつか作戦が考えられます。それぞれ、Pros/Consがあります。

  • コンテナの初期化用のエントリーポイントを利用
    • 公式のDockerコンテナは /docker-entrypoint-initdb.d フォルダ以下にシェルスクリプトなどを置いておくと、起動前にそこのsqlやら.shやら(コンテナの種類によって異なる)を実行します。MySQL、PostgreSQL、MongoDBあたりがサポートしています。
  • バッチ処理を行うコンテナを作り、docker-compose.ymlで一緒に起動する(インポートが終わったらすぐ終了)
    • linksを設定したとしても、docker-composeはlinksは依存先のDBが起動し終わる前に次のバッチのコンテナをすばやく起動してしまうため、ループを回しつつ、DBの準備を完了を待ってコードを書かなければなりません。
  • docker-compose.ymlには入れないで、外部からツールを使って入れる
    • 外部ツールは文字通り、開発環境のOS用のツールを使ったりもできますし、phpMyAdminのようなウェブサービスをコンテナで動かして使う方法もあります。
    • ただし、docker-composeでストレージを含めてすべてのサービスを完全な形で再現する、というのは実現できない。

どのようなデータを入れるかについても、いくつか選択肢があります。

  • 初期データもダンプでまとめていれる
    • 速度や再現性では一番良いと思われる。各DBイメージはファイルの保存先がREADMEに書かれているので、そのフォルダをあらかじめボリュームに切り出しておき、マウントだけすればインポートもいらない
  • スキーマとデータを分けて入れるか
    • マイグレーションツールを利用してバージョン管理されたスキーマを入れる方がバージョン管理の面では良いでしょう。
    • マスターデータではなく、トランザクション系のテーブルの場合は、スキーマだけ入れれば良いこともある
    • Elasticsearchを検索インデックスとして使っているが、マスターのデータがRDBという場合、RDBから取り出して、インデックスを作り直す機能をアプリケーションが持っていた方が、古いデータを気軽に決してリセットしたり、スキーマ変更の再の再生成が捗るでしょう。

インポートのためのツールも、データベース固有のツールを使う手もありますし、ORMとかでいろいろ便利なコードが作られているのであれば、本番コードとインピーダンスミスマッチがなくなるためにチームによっては望ましいとなるかもしれません。

まとめ

いろいろな言語、フレームワーク、ストレージなどが絡んでくるのですが、コンテナという単位で見ていけば怖くはなくなってきますよね。とりあえず、React (Next.js)、Angular、Go/Pythonの4例を紹介しました。今後もExpress.jsのサーバー、Python (Pyramid? Django)の例など、気が向いたら増やしていきます。また、ストレージ周りも具体的なコードにしていきたいです。

なお、現在は「開発環境用」「イメージ確認用」としていますが、「自分が手を加えたいソースコードのところだけはホットデプロイできるように開発モードで起動して、それ以外は省メモリに本番ビルドを使ったイメージを起動」みたいなハイブリッドができてもいいのかなという気がします。単なるymlファイルなので、そういうのを生成するヘルパーツールがあると良いかな、と思いました。

明日は @hichika さんです。