Docker composeでnginx・socket.io・redisを構成してみた

# はじめに
Docker Composeは複数のコンテナを使う Docker アプリケーションを、定義・実行するツールです。コマンドを１つ実行するだけで、設定した全てのサービス(Dockerコンテナ)を作成・起動します。

# ファイル構成
```sh
(Working dir)
+- docker-compose.yml
+- nginx-app/
   +- conf.d/
     +- default.conf
   +- html/
     +- index.html
+- node-app/
   +- app.js
   +- package.json
```

# 実行環境のインストール
Dockerのインストールについては [docs.docker.com](https://docs.docker.com/) を参照してください。
Docker Composeのインストールについては[こちら](https://docs.docker.com/v17.09/compose/install/#prerequisites)を参照してください。
_* [Docker for Mac](https://docs.docker.com/v17.09/docker-for-mac/install/)をインストールした場合、Docker Composeはデフォルト入っています。_

下記のコマンドを正しく表示すれば、次へ進めます。

```
$ docker -v
Docker version 18.03.1-ce, build 9ee9f40

$ docker-compose -v
docker-compose version 1.21.1, build 5a3f1a3
```

# 最初のコンテナの作成
[Nginxのオフィシャルイメージ](https://hub.docker.com/_/nginx)は[Kitematic](https://kitematic.com/)を含め、Dockerのデモストレーションにしばしば活用されています。
今回私たちもNginxのイメージから始めます。

```docker-compose.yml
version: '2'
services:
  nginx:
    image: nginx
    container_name: nginx-app
    ports:
      - "8080:80"
```
*`docker-compose.yml`の詳しい使い方は下記のページをご覧ください。
http://docs.docker.jp/compose/compose-file.html*

上記`docker-compose.yml`を作成して、`docker-compose up` を実行すれば、http://localhost:8080 でこのようなページがみれます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/87438/9cb95113-5b09-325e-7610-4f4516d7058b.png)

# ファイルの共有
Nginxのオフィシャルイメージの説明によると、先ほどのサンプルページは(コンテナの中の)`/usr/share/nginx/html`にあることがわかります。そのファイルを変更することで、カスタムページを表示できます。

## フォルダとページを作成

```sh
$ mkdir nginx-app && cd nginx-app
$ mkdir html && touch html/index.html
```

ぶっちゃけApacheのサンプルページっぽいものを作ります。

```index.html
<h1>It works!</h1>
```

## `volumes`フィールドを追加

```diff:docker-compose.yml
version: '2'
services:
  nginx:
    image: nginx
    container_name: nginx-app
    ports:
      - "8080:80"
+   volumes:
+     - ./nginx-app/html:/usr/share/nginx/html:ro
```

## Docker Composeの再起動
通常、`ctrl + c` を押せば、docker　composeが停止してくれて、また`docker-compose up`で作り直してくれますが、場合によってうまく停止できない時があります。その時は　`docker-compose stop`でコンテナを強制終了し、`docker-compose up`で再起動します。

再起動できたら、あたかもApacheのようなページが　http://localhost:8080 に表示されます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/87438/c239a230-f4df-354b-15e2-b6f3a67869ae.png)

# Nginxの設定の編集
## `/etc/nginx/nginx.conf`の確認
下記コマンドでNginxのコンテナ(nginx-app)にログインし、`nginx.conf`を確認します。

```sh
$ docker exec -it nginx-app bash
# ログインした

root@3d5880dbf667:/# cat /etc/nginx/nginx.conf
...
    include /etc/nginx/conf.d/*.conf;
...

root@3d5880dbf667:/# ls /etc/nginx/conf.d/
default.conf
```

つまり`/etc/nginx/conf.d`にある`default.conf`を読み込んでいることがわかります。

## `default.conf`を外に出す

```sh
$ cd nginx-app/ && mkdir conf.d
$ touch conf.d/default.conf
```

```default.conf
server {
    listen       80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }
}
```

```diff:docker-compose.yml
version: '2'
services:
  nginx:
    image: nginx
    container_name: nginx-app
    ports:
      - "8080:80"
    volumes:
      - ./nginx-app/html:/usr/share/nginx/html:ro
+     - ./nginx-app/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro
```

修正ができたら、Docker Composeを再起動します。

# Node.jsのコンテナの実装
いよいよアプリケーション側の実装ができるようになりました。
## 簡単なnode.jsサーバーの作成

```sh
$ mkdir node-app && cd node-app && touch app.js
$ npm init -y
```

```app.js
'use strict';

const Koa = require('koa');
const Router = require('koa-router');
const logger = require('koa-logger');

const PORT = process.env['PORT'] || 3030;
const app = new Koa();
const router = new Router();

router
  .get('/api', (ctx, next) => {
    ctx.body = '<h1>Node API</h1>';
  })

app
  .use(logger())
  .use(router.routes())
  .use(router.allowedMethods());

const server = require('http').createServer(app.callback());
server.listen(PORT);
console.log(`Server on localhost:${PORT}`);
```

```package.json
{
  "name": "node-app",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "ioredis": "^4.5.1",
    "koa": "^2.7.0",
    "koa-logger": "^3.2.0",
    "koa-router": "^7.4.0",
    "socket.io": "^2.2.0"
  }
}
```

```diff:docker-compose.yml
version: '2'
services:
  nginx:
    image: nginx
    container_name: nginx-app
+   links:
+     - node-app
    ports:
      - "8080:80"
    volumes:
      - ./nginx-app/html:/usr/share/nginx/html:ro
      - ./nginx-app/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro
+  node:
+   image: node:10
+   user: node
+   container_name: node-app
+   working_dir: /home/node/app
+   volumes:
+     - ./node-app:/home/node/app
+   environment:
+     - NODE_ENV=development
+     - PORT=3030
+   command: node app.js
+   ports:
+     - "3030:3030" # Debug用
```
*Node.jsのオフィシャルイメージの使い方は[こちら](https://github.com/nodejs/docker-node#create-a-dockerfile-in-your-nodejs-app-project)を参考してください。*

Docker composeを再起動すれば、下記ページが　http://localhost:3030/api に表示されます。
![localhost_8080_node_api.png](https://qiita-image-store.s3.amazonaws.com/0/87438/5d63e0e3-c720-31c9-215c-579ce27a5742.png)

## Nginxでnode.jsサーバーのproxyを設定

```diff:default.conf
server {
    listen       80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

+   location /node/ {
+       proxy_set_header Host $http_host;
+       proxy_set_header X-Real-IP $remote_addr;
+       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+       proxy_pass http://node-app:3030/; # docker-compose.ymlの設定と一致
+   }
}
```
Docker composeを再起動すれば、先ほどの`Node API`が書いていたページは http://localhost:8080/node/api でも表示されます。

## 既知のBug
上記の設定で、もしnode.js側のroot(`/`)にアクセスしようとする時、必ず http://localhost:8080/node/ を使わなければいけません。
最後のSlashを省略した場合、ポート番号のない http://localhost/node へリダイレクトされちゃいます。

# Socket.ioをサーバー側に追加

`app.js`に下記のコードを追加

```js
const io = require('socket.io')(server);

io.on('connection', (socket) => {
  console.log('connected');
  socket
    .on('io-ping', (msg) => {
      console.log(`io-ping: ${msg}`);
      socket.emit('io-pong', msg);
    })
})
```

`index.html`にもロジックを追加

```html
<p>
  <b>Delay:&nbsp;</b>
  <span id="delay" style="display:inline-block;width:20px;text-align:right">??</span>
  <span>ms&nbsp;</span>
  <button type="button" onclick="ping()">Ping</button>
</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.slim.js"></script>
<script>
  const url = '/';
  const socket = io(url, { path: '/node/socket.io' }); // important!

  socket
    .on('connect', msg => {
      console.log('connected');
    })
    .on('io-pong', msg => {
      console.log(msg);
      const delay = Date.now() - parseInt(msg);
      document.querySelector('#delay').innerHTML = delay;
    })

  function ping() {
    socket.emit('io-ping', Date.now())
  }
</script>
```

***PS:***　我々はnode.jsサーバーのルートを`/node/`に置けたので、ブラウザ側でsocket.ioでアクセスする場合、必ず`path: '/node/socket.io'`を明記しなければいけません。

`default.conf`も変更します。

```diff:default.conf
server {
    listen       80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location /node/ {
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

+       # for websocket
+       proxy_set_header Upgrade $http_upgrade;
+       proxy_set_header Connection 'upgrade';
+       proxy_cache_bypass $http_upgrade;

        proxy_pass http://node-app:3030/; # docker-compose.ymlの設定と一致
    }
}
```

再起動すると、http://localhost:8080 でサーバーのラグを表示してくれるアプリがみれます。
![localhost_8080.png](https://qiita-image-store.s3.amazonaws.com/0/87438/f33fdf03-3c0f-8b6d-d484-921401919a86.png)


# Redisでデータをストック
先ほど我々は Nginx/Socket.io サーバー群を構成しました。データベースも繋いていけば、完璧です。

## オフィシャルredisイメージの追加

```diff:docker-compose.yml
version: '2'
services:
  nginx:
    image: nginx
    container_name: nginx-app
    links:
      - node-app
    ports:
      - "8080:80"
    volumes:
      - ./nginx-app/html:/usr/share/nginx/html:ro
      - ./nginx-app/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro
  node:
    image: node:10
+   links:
+     - redis-db
    user: node
    container_name: node-app
    working_dir: /home/node/app
    volumes:
      - ./node-demo-app:/home/node/app
    environment:
      - NODE_ENV=development
      - PORT=3030
    command: node app.js
-   ports:
-     - "3030:3030" # Debug用
+ redis:
+   image: redis
+   container_name: redis-db
```

## node.jsサーバーの修正

```app.js
'use strict';

const Koa = require('koa');
const Router = require('koa-router');
const logger = require('koa-logger');

const PORT = process.env['PORT'] || 3003;
const app = new Koa();
const router = new Router();

router
  .get('/', (ctx, next) => {
    ctx.body = '<h2>Hello World!</h2>';
  })
  .get('/api', (ctx, next) => {
    ctx.body = '<h1>Node API</h1>';
  })
  .get('/api/:tag', (ctx, next) => {
    ctx.body = `<h1>Node API</h1><p>${ctx.params['tag']}</p>`;
  })


app
  .use(logger())
  .use(router.routes())
  .use(router.allowedMethods());

const Redis = require('ioredis');
const REDIS_HOST = process.env['REDIS_HOST'] || '127.0.0.1';
const REDIS_PORT = process.env['REDIS_PORT'] ||6379;
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
const psub = new Redis({ host: REDIS_HOST, port: REDIS_PORT });

redis.config('set', 'notify-keyspace-events', 'Ex');
const psubKey = '__keyevent@0__:expired'; // All key events & spaces in all DB
psub.psubscribe(psubKey, (err, count) => {
  if (err) {
    console.error('err', err);
  }
});

psub.on('pmessage', (pattern, channel, message) => {
  console.log('pattern', pattern);
  console.log('channel', channel);
  console.log('message', message);
});

const server = require('http').createServer(app.callback());
const io = require('socket.io')(server);

io.on('connection', (socket) => {
  console.log('connected');
  socket
    .on('io-ping', (msg) => {
      console.log(`io-ping: ${msg}`);
      socket.emit('io-pong', msg);
      redis.set(`io-ping@${msg}`, msg, 'EX', 10);
    })
  psub.on('pmessage', (pattern, channel, message) => {
    socket.emit('key-expired', message);
  });
})

server.listen(PORT);
console.log(`Server on localhost:${PORT}`);
```

## HTMLも編集

```index.html
<h1>It Works!</h1>
<p>
  <b>Delay:&nbsp;</b>
  <span id="delay" style="display:inline-block;width:20px;text-align:right">??</span>
  <span>ms&nbsp;</span>
  <button type="button" onclick="ping()">Ping</button>
</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.slim.js"></script>
<script>
  const url = '/';
  const socket = io(url, { path: '/node/socket.io' }); // important!

  socket
    .on('connect', msg => {
      console.log('connected');
    })
    .on('io-pong', msg => {
      console.log(msg);
      const delay = Date.now() - parseInt(msg);
      document.querySelector('#delay').innerHTML = delay;
    })
    .on('key-expired', msg => {
      console.log(msg);
      alert(`key-expired: ${msg}`)
    })

  function ping() {
    socket.emit('io-ping', Date.now())
  }
</script>
```

## 解説
上記ロジックはこのようなことをしています:
1. HTMLの「Ping」ボタンが押された時、タイムスタンプをRedisに保存します。
2. 10秒後、先ほど保存されたタイムスタンプがExpireして、その旨をHTMLに送信します。

![localhost_8080_の内容.png](https://qiita-image-store.s3.amazonaws.com/0/87438/aec999ce-fffb-cdf9-1bfb-7d67ab5d8bc7.png)


# 参考
https://github.com/jerrywdlee/docker_demo/tree/master/docker-compose
https://github.com/docker-library/docs/tree/master/nginx
https://medium.com/@spencerfeng/make-node-js-socket-io-angular-nginx-ssl-work-777404835e09