docker-compose で、Node.js と MySQL のコンテナ間連携を行う

f:id:tercel_s:20210125195054p:plain

今年度に入った頃から複数の開発案件に並行で関わるようになり、さらにこのところ私事においても煩忙を極めていたため、物事をじっくりアウトプットできずにいた。

ようやっと4連休を手に入れたので、リハビリも兼ねて簡単な演習を行ってみよう。

今回のテーマ

今回、何をやりたいかというと、大きくは以下2点です。

  • Node.js 製のアプリケーションと MySQL をそれぞれコンテナ上で動かす
  • それぞれのコンテナを docker-compose で連携させる
動機づけ

そもそもコンテナとは、隔離されたアプリケーション実行環境であり、「よそのコンテナを意識しなくてよい」という思想が根幹にあると考えています。

一方で、システムの規模が大きくなると、アプリケーションやデータベースなど、役割ごとにコンテナを切り分け、それぞれを相互に連携させたいと思うのも自然な発想です。

Dockerfile はなんとか書けるようになったが、はてさて複数のコンテナ同士を連携させるにはどうしたらよいだろうか、という課題に対する自分なりのメモです。

……本来は桜の季節に投稿するはずでしたが、モチベーションが足りずに今頃になってしまいました。

実験環境

はじめの Node.js アプリケーション

まずはコンテナ化を考えず、普通に Node.js で簡単な REST API をサクッと作っていきます。

適当な場所に node-app というディレクトリを作り、カレントディレクトリを変更しましょう。

  • projects
    • node-app

$ mkdir node-app; cd $_
Node.js プロジェクトの作成

Node.js プロジェクトを作成します。

npm init コマンドを入力すると対話形式でプロジェクトの初期設定ができますが、ここでは簡単のため、対話を省略するための -y オプションを付けています。

$ npm init -y

すると、node-app 直下に package.json が生成されます。

  • projects
    • node-app

package.json

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
Express のインストール

続いて、Express というライブラリをインストールします。

$ npm install --save express

コマンド実行後のディレクトリ階層はこのような感じになります。

また、インストールに成功すると、package.json が自動的に書き換えられます。

  • projects
    • node-app
      • node_modules
      • package-lock.json
      • package.json

Express は、4.17.1 が入りました。

package.json

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1"
  }
}
index.js の作成

それでは、「Hello, World」を応答するバカ API を作ってみましょう。

package.json と同じ階層に index.js を作成します。

  • projects
    • node-app
      • node_modules
      • index.js
      • package-lock.json
      • package.json

空行を除けば10行にも満たない簡素なコードです。

index.js

'use strict';
const express = require('express');

const PORT = 8080;
const HOST = '0.0.0.0';

const app = express();
app.get('/', (req, res) => {
  res.send('Hello, World');
});

app.listen(PORT, HOST);
Node.js アプリのテスト実行

この段階でテスト実行してみましょう。

$ node index.js

http://localhost:8080 にアクセスすると、「Hello, World」が返ってきます。
f:id:tercel_s:20210722210049p:plain
ここからは、このアプリケーションをコンテナ上で動作させてみます。

Dockerfile, .dockerignore ファイルの作成

package.json と同じ階層に、Dockerfile.dockerignore という名前のファイルをそれぞれ作成します。 これらのファイルの名前は変えてはいけません。

  • projects
    • node-app
      • node_modules
      • .dockerignore
      • Dockerfile
      • index.js
      • package-lock.json
      • package.json

Dockerfile

FROM node:14
WORKDIR /user/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD ["node", "index.js"]

.dockerignore

node_modules
npm-debug.log
Docker イメージの作成
$ docker build . -t node-app

f:id:tercel_s:20210722211744p:plain
しばらく待つと、イメージが出来上がります。
f:id:tercel_s:20210722211849p:plain

コンテナ版 Node.js アプリケーションの実行
$ docker run -p 80:8080 -dit node-app

ここで、docker ps コマンドを叩くと、コンテナが一つ実行中になっていることが確認できます。

f:id:tercel_s:20210722212229p:plain

ブラウザで http://localhost にアクセスしてみます。

先ほどは 8080番ポートを指定していましたが、今回は80番ポートに変わっていることに注意しましょう。

f:id:tercel_s:20210722212340p:plain

MySQL をコンテナで動かす

続いて、データベースのコンテナを作ります。 DBMSMySQL にしましょう。

また、コンテナを作るだけでなく、併せて以下の考慮が必要になります。

  • コンテナが停止してもデータが揮発しないよう、ボリューム(永続化領域)を作成し、マウントする
  • node-app コンテナと通信できるよう、Docker にネットワークを作る
コンテナ起動時に指定できる環境変数

はじめに、予備知識として、MySQL をベースとしたコンテナを docker run する際には、-e オプションを使用して以下の環境変数を指定できます。

環境変数 意味
MYSQL_ROOT_PASSWORD MySQLのrootユーザのパスワード
MYSQL_DATABASE デフォルトのデータベース名
MYSQL_USER データベースにアクセスできる一般ユーザ名
MYSQL_PASSWORD 上記ユーザのパスワード
MYSQL_ALLOW_EMPTY_PASSWORD root パスワードを空欄にできるかどうか。yes を設定すると空にする
MYSQL_RANDOM_ROOT_PASSWORD yes を設定するとランダムなパスワードを設定する
MYSQL_ONETIME_PASSWORD yes を設定すると、root ユーザが初回ログインしたときにパスワードの変更を要求される
ボリュームの作成

コンテナ内に保存したデータは、そのコンテナが停止すると失われてしまいます。

これを防ぐためには、ボリュームと呼ばれる永続化領域を Docker の実行環境内に作成し、MySQL コンテナを起動する際にマウントします。

まずは、適当な名前(mysqlvolume)でボリュームを作りましょう。

$ docker volume create --name mysqlvolume

データベースのデータは、/var/lib/mysql ディレクトリに保存されます。

ここを、今作ったボリュームにマウントすることで、コンテナを破棄してもデータベースの内容が失われないようにします。

今回は簡単のため、Dockerfile を作らず、MySQL のベースイメージから直接コンテナを起動しましょう。

$ docker run --name mydb -dit \
    -v mysqlvolume:/var/lib/mysql \
    -e MYSQL_ROOT_PASSWORD=aaa \
    -e MYSQL_DATABASE=potato \
    --platform linux/x86_64 \
    mysql:5.7

ここで、-v オプションを使用してボリュームマウントを行っています。

また、環境変数も同時に設定しています。

環境変数 意味 設定値
MYSQL_ROOT_PASSWORD MySQLのrootユーザのパスワード aaa
MYSQL_DATABASE デフォルトのデータベース名 potato
M1 Mac における注意点

ちなみに、--platform は M1 Mac で実行する場合に必要となるオプションです(Ubuntuなどで試す際には不要です)。

もしも --platform を明示的に指定しない場合、以下のようなエラーが発生してしまいます。
f:id:tercel_s:20210722221828p:plain

--platform オプションを指定すると、正常に Docker イメージの取得が始まります。
f:id:tercel_s:20210722221946p:plain

docker ps で、無事にコンテナが起動したことが分かります。
f:id:tercel_s:20210722222311p:plain

MySQL コンテナに入る

せっかくですので、起動した MySQL コンテナの中に入ってみましょう。

docker ps でコンテナ ID を確認したのち、以下のコマンドを打ち込みます。

$ docker exec -it [ContainerID] /bin/bash

f:id:tercel_s:20210722222815p:plain

続いて、MySQL にログインしましょう。

$ mysql -p

── と入力すると、パスワードを求められますので、先ほど設定した aaa を入力します。

無事にデータベースが動いているようです。
f:id:tercel_s:20210722222938p:plain

とりあえず、このコンテナは落としてしまって構いません。

ネットワークを作る

さて、初めに作った Node.js コンテナと、MySQL コンテナを相互に通信させたいとします。

これを実現するため、以下を行います。

  • Docker にネットワークを作る
  • それぞれのコンテナを、同じネットワーク内で実行する

各コンテナにはそれぞれ異なる IP アドレスが割り振られますが、同じネットワークに所属しているコンテナ同士であれば、--name で指定したコンテナ名から IP アドレスを引くことが可能になります。

mynodenet という名前のネットワークを作ります。

$ docker network create mynodenet

なお、作成したネットワークは docker network ls コマンドで確認できます。

f:id:tercel_s:20210722224618p:plain

MySQL コンテナをネットワークに参加させる

では、再び MySQL コンテナを docker run で起動します。

--net オプションで、先ほど作ったネットワーク mynodenet を指定します。

$ docker run --name mydb -dit \
    -v mysqlvolume:/var/lib/mysql \
    -e MYSQL_ROOT_PASSWORD=aaa \
    -e MYSQL_DATABASE=potato \
    --net mynodenet \
    --platform linux/x86_64 \
    mysql:5.7

きちんと動いています。

f:id:tercel_s:20210722225152p:plain

Node.js アプリケーションを MySQL に繋ぐ

いよいよ、Node.js アプリケーションを MySQL と通信させます。

ここで、改めてカレントディレクトリを確認します。 node-app ならば OK です。

もし異なる場合は、node-appcd しましょう。

  • projects
    • node-app
      • node_modules
      • .dockerignore
      • Dockerfile
      • index.js
      • package-lock.json
      • package.json

MySQL に接続するためのパッケージのインストール

MySQL に接続するため、追加のパッケージを導入します。 以下のコマンドを入力します。

$ npm install --save mysql

成功すると、package.json が以下のように自動で書き換わります。

package.json

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "mysql": "^2.18.1"
  }
}
index.js の編集

続いて、index.js の編集です。

  • projects
    • node-app
      • node_modules
      • .dockerignore
      • Dockerfile
      • index.js
      • package-lock.json
      • package.json

ここで、接続に必要な情報は、起動時に環境変数として与えるようにします。

環境変数 意味 設定値
MYSQL_HOST MySQLのコンテナ名 mydb
MYSQL_ROOT_PASSWORD MySQLのrootユーザのパスワード aaa
MYSQL_DATABASE デフォルトのデータベース名 potato

index.js

'use strict';
const express = require('express');
const mysql = require('mysql');

const PORT = 8080;
const HOST = '0.0.0.0';

const MYSQL_HOST = process.env.MYSQL_HOST;
const MYSQL_ROOT_PASSWORD = process.env.MYSQL_ROOT_PASSWORD;
const MYSQL_DATABASE = process.env.MYSQL_DATABASE;

const app = express();
const pool = mysql.createPool({
  host: MYSQL_HOST,
  user: 'root',
  password: MYSQL_ROOT_PASSWORD,
  database: MYSQL_DATABASE
});

app.get('/', (req, res) => {
  try {
    pool.getConnection((err, connection) => {
      if (err) throw err;
      try {
        connection.query(
          'SELECT * FROM mysql.user', (error, results) => {
            res.send(results);
          }
        );
      } catch (e) {
        throw e;
      } finally {
        connection.release();
      }
    });
  } catch (e) {
    res.send('error');
  }
});

app.listen(PORT, HOST);

急激に実装量が増えました。

簡単のため、コネクションプールを使わない実装例を載せようか迷いましたが、どのみち必要になるため、きちんと書きました。

Node.js アプリケーションのコンテナイメージのビルドと起動

今ふたたび、Node.js アプリのコンテナイメージを docker build でビルドし、docker run でコンテナを起動します。

MySQL イメージ起動の際に指定した環境変数と合わせる形で -e オプションを指定し、さらに、--net オプションで MySQL と同じネットワーク名を指定します。

$ docker build . -t node-app
$ docker run -p 80:8080 \
  -dit \
  -e MYSQL_HOST=mydb \
  -e MYSQL_ROOT_PASSWORD=aaa \
  -e MYSQL_DATABASE=potato \
  --net mynodenet \
  node-app

これで、node-app コンテナと mydb コンテナが同じネットワーク(mynodenet)に参加したことになります。

f:id:tercel_s:20210722232315p:plain

ふたたび、ブラウザで http://localhost にアクセスしてみましょう。

なにやら DB ユーザ情報の一覧のような JSON が表示されたら成功です。

f:id:tercel_s:20210722232411p:plain

docker-compose によるオーケストレーション

ここまでで、だいたいやりたいことは終わりました。

ただ、Node.js アプリのコンテナにしても、MySQL のコンテナにしても、起動時にいちいち大量のオプションを付けるのは非常に面倒です。

一つでも間違うと正しく連携できなくなりますし、そもそも、MySQL が起動していない状態で Node.js アプリケーションを先に起動してしまうと、コネクションがうまく張れずにエラーを吐いてしまうかもしれません。

また、MySQL をマウントするためのボリュームだったり、複数のコンテナをまとめるネットワークだったり、これらの依存関係を人間が主導で管理するのは限界がありそうです。

そこで登場するのが、docker-compose というツールです。

docker-compose のインストール

docker-compose は、Docker とは別にインストールが必要な場合があります(と思ったら、最新の Docker には普通に docker-compose がバンドルされていることがこの記事を書いている途中で判明しました。チクショー*3)。

pip を使ってインストールしましょう。

$ sudo pip3 install docker-compose
docker-compose.yaml の作成

ここで、作業ディレクトリを変えます。

node-app と同階層に、新たに compose というディレクトリを作り、docker-compose.yaml というファイルを作ります。

  • projects
    • compose
    • node-app

docker-compose.yaml

version: "3"

services: 
  mydb:
    image: mysql:5.7
    networks:
      - mynodenet
    volumes:
      - mysqlvolume:/var/lib/mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: aaa
      MYSQL_DATABASE: potato

  myap:
    depends_on: 
      - mydb
    image: node-app
    networks:
      - mynodenet
    ports:
      - 80:8080
    restart: always
    environment: 
      MYSQL_HOST: mydb
      MYSQL_ROOT_PASSWORD: aaa
      MYSQL_DATABASE: potato

networks:
  mynodenet:

volumes:
  mysqlvolume:
docker-compose の実行

docker-compose.yaml が配置されているディレクトリに cd して、以下のコマンドを実行します。

$ docker compose up -d

すると、なんということでしょう*4

ボリュームやネットワークが生成され、さらに依存関係をもつ複数の Docker イメージが順番に起動したではありませんか。

f:id:tercel_s:20210722235350p:plain

この状態で、docker ps を叩くと、確かに docker-compose によってコンテナが起動されたことが確認できます。

f:id:tercel_s:20210723000038p:plain

また、ブラウザで http://localhost にアクセスすると、わけのわからない JSON が表示されることも確認できます。

遊び終わったら、以下のコマンドでコンテナを停止させましょう。

f:id:tercel_s:20210723000613p:plain

コンテナやネットワークはリムーブされますが、ボリュームは生き残るため、再び docker-compose up したときもデータが失われることはありません。

$ docker-compose down

ここまでのまとめ

  • コンテナは本来、互いに影響しない独立したプログラムの実行環境である
  • コンテナ同士を相互に連携させたい場合、起動順や設定内容の整合などの考慮が必要である
  • これらを人間が手動管理するのは現実的ではないため、docker-compose などを用いて複数コンテナを統括するのがよい

以上。

思ったより話が長くなってしまった。

*1:M1 Macの場合、MySQL コンテナを起動する際に --platform オプションを指定しないとエラーになるという罠があります。

*2:docker-compose を利用するために必要となる場合があります。

*3:小梅大夫という芸人が、昔、エンタの神様という番組でこのようなネタを本当に披露していました。

*4:昔、「大改造 劇的ビフォーアフター」という番組でお決まりのセリフでした。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.