Node.jsのマイクロサービスの構築を通してDockerを学ぶ – 前編

あなたが真剣にDockerに取り組んで、その全てを学びたいと思っているのでしたら、もう探し回らなくても大丈夫です。

本稿では、Dockerがどのように機能するのか、どんな部分が話題になっているのか、そしてマイクロサービスを構築する際の基本的な開発作業にどのように役立つのかについて紹介したいと思います。

本稿では例として、ローカルで実行するコードからマイクロサービスやデータベースを実行するコンテナまで、バックエンドにMySQLを用いたシンプルなNode.jsのサービスの例を使います。

Dockerとは何か

Dockerとは要するに、(仮想マシン用のテンプレートに非常によく似ている)イメージを作成して、コンテナでイメージのインスタンスを実行できるソフトウェアです。

Dockerには、Docker Hubと呼ばれる大量のイメージのリポジトリがあり、これを利用して作業を始めたり、無料のストレージとして自分のイメージを保存したりできます。Dockerをインストールし、使いたいイメージを選び、コンテナでそのインスタンスを実行すればよいのです。

本稿では、イメージを構築し、イメージからコンテナを作成した後、更にいろいろなことを紹介したいと思います。

Dockerをインストールする

本稿を活用していただくために、まずはDockerが必要になります。

自分のプラットフォーム用のインストールガイドをdocs.docker.com/engine/installationで確認してください。

MacやWindowsのユーザでしたら、仮想マシンを使用するのが良いでしょう。私はほとんどの開発作業において、UbuntuマシンをMac OS X用Parallelsの上で動かしています。試行錯誤する際に、スナップショットを取ったり処理を細分化したり元に戻したりできるのはとても便利です。

使ってみる

以下のコマンドを入力します。

docker run -it ubuntu  

少し待つと、次のようなプロンプトが表示されます。

root@719059da250d:/#  

コマンドをいくつか試して、コンテナを終了します。

root@719059da250d:/# lsb_release -a  
No LSB modules are available.  
Distributor ID:    Ubuntu  
Description:    Ubuntu 14.04.4 LTS  
Release:    14.04  
Codename:    trusty  
root@719059da250d:/# exit  

大したことをしているようには見えませんが、多くのことが起きています。

これは、自分のマシン上でUbuntuを実行している隔離されたコンテナのbash シェルです。そこは、いろいろなものをインストールし、ソフトウェアを実行し、何でも好きなことができる自分の場所です。

何が起きているかを詳しく示した図があります。(この図は‘Understanding the Architecture’ Docker Documentation(Dockerのドキュメンテーションの”アーキテクチャを理解する”)から引用しました。素晴らしい内容です)

Docker Run Flow

  1. Dockerのコマンドを発行します。
    • docker: Dockerクライアントを起動します。
    • run: 新規コンテナを起動するコマンドです。
    • -it: コンテナにインタラクティブな入出力装置を与えるオプションです。
    • ubuntu: コンテナの元となるイメージです。
  2. ホスト(自分のマシン)上で実行されているDockerサービスは、リクエストしたイメージのコピーがローカルにあるかどうかをチェックします。このコピーは存在しません。
  3. Dockerサービスは、ubuntuという名前の利用可能なイメージがあるかどうか、公式レジストリ(Docker Hub)をチェックします。これは存在します。
  4. Dockerサービスはイメージをダウンロードし、イメージ用のローカルキャッシュに保存します(次のステップの準備です)。
  5. Dockerサービスはubuntuのイメージに基づいてコンテナを新規作成します。

次のコマンドのうちどれでもいいので試してみてください。

docker run -it haskell  
docker run -it java  
docker run -it python  

今回はHaskellを使いませんが、見てお分かりのように、環境の起動はとても簡単です。

自分のアプリやサービスやデータベースなど、必要なものを搭載した状態で自分専用のイメージを構築するのは実に簡単です。その後、Dockerをインストールしたマシンならどこでも実行が可能で、構築したイメージは予想通りに動きます。コードによって自分のソフトウェアとソフトウェアが実行される環境を構築し、簡単にデプロイできるのです。

シンプルなマイクロサービスの例を見てみましょう。

概要

マイクロサービスを1つ作ります。Node.jsとMySQLを使って、電話番号に結び付くメールアドレスのディレクトリを管理するものです。

最初に

ローカルで開発するには、MySQLをインストールしてテストデータベースを作って、それから…

…やめます。

ローカルでデータベースを作り、その上でスクリプトを実行すれば簡単に始められるのですが、煩雑になる可能性があります。コントロールできないものが山のように動き出してしまいます。実行すれば動くかもしれませんし、自分のリポジトリに登録したシェルスクリプトを使えばコントロールすることだってできるでしょう。しかし、もし、他の開発者が先にMySQLをインストールしてしまっていたら? もし自分たちが使おうとしている開発用の”users”という名前が先にデータベースで使われていたら?

ステップ1:テスト用データベースサーバを作る-Docker

これはDockerの優れたユースケースです。Docker上で本番用のデータベースは動かすつもりはありません(サンプルでAmazonRDSだけ使うかもしれません)。それでも、開発用のDockerコンテナとしてまっさらなMySQLのデータベースを即座に稼働させることができます。開発マシンには手を入れず、何をしても全てをコントロールし再利用できる状態を保ち続けられるのです。

以下のコマンドを実行します。

docker run --name db -d -e 
MYSQL_ROOT_PASSWORD=123 -p 3306:3306 
mysql:latest  

これは、123というルートのパスワードでポート3306からのアクセスを許可するMySQLインスタンスの実行を促します。

  1. docker runはエンジンにイメージを実行したいと伝えます(イメージは最終段階のmysql:vlatestで登場します)。
  2. --name dbはコンテナにdbと名付けます。
  3. -d(または--detach)はデタッチのことです。つまり、コンテナをバックグラウンドで実行します。
  4. -e MYSQL_ROOT_PASSWORD=123(または--env)は環境変数のことで、Dockerに環境変数を与えることを伝えます。この設定に従った変数は、デフォルトのルートのパスワードを設定するためにMySQLイメージがチェックをかけるものです。
  5. -p 3306:3306(または--publish)はコンテナ内部から外部へ出るポート3306を割り当てることをエンジンに伝えます。

最後の部分は、たとえMySQLのデフォルトのポートであるとしても、極めて重要です。もし割り当てを明確に記述しなければ、そのポートからのアクセスがブロックされてしまいます(なぜなら、アクセスの存在を知らせなければ、コンテナは隔離された状態だからです)。

この機能による戻り値はコンテナID、つまりコンテナへの参照値で、これを使って動作の停止、再スタート、コマンドの発行などを行います。どのコンテナが動いているか見てみましょう。

$ docker ps
CONTAINER ID  IMAGE         ...  NAMES  
36e68b966fd0  mysql:latest  ...  db

重要な情報はコンテナ ID、イメージ、名称です。このイメージに接続し、どうなるか見てみましょう。

$ docker exec -it db /bin/bash

root@36e68b966fd0:/# mysql -uroot -p123  
mysql> show databases;  
+--------------------+
| Database           |
+--------------------+
| information_schema |
+--------------------+
1 rows in set (0.01 sec)

mysql> exit  
Bye  
root@36e68b966fd0:/# exit  

これも非常に優れたものです。

  1. docker exec -it dbは、db(コンテナID、あるいはコンテナID最初の数文字も使用可)と名付けたコンテナ上でコマンドを実行することをDockerに伝えます。-itはインタラクティブな入出力装置があることを示します。
  2. mysql -uroot -p123コマンドは、実際にコンテナ内のプロセスとして実行します。今回の場合は単純にMySQLのクライアントです。

データベース、テーブル、ユーザなど、必要なものを何でも作ることができます。

テストデータベースを完成させる

ここまででMySQLをコンテナ内部で実行し、既にDockerの効果的な利用方法をいくつか紹介していることになりますが、ここで一息入れてサービスの方に移りましょう。ひとまず、test-databaseというフォルダを作り、データベースの開始・終了、テストデータのセットアップを行うスクリプトを入れます。

test-database\setup.sql  
test-database\start.sh  
test-database\stop.sh  

開始は、簡単です。

#!/bin/sh

# Run the MySQL container, with a database named 'users' and credentials
# for a users-service user which can access it.
echo "Starting DB..."  
docker run --name db -d \  
  -e MYSQL_ROOT_PASSWORD=123 \
  -e MYSQL_DATABASE=users -e MYSQL_USER=users_service -e MYSQL_PASSWORD=123 \
  -p 3306:3306 \
  mysql:latest

# Wait for the database service to start up.
echo "Waiting for DB to start up..."  
docker exec db mysqladmin --silent --wait=30 -uusers_service -p123 ping || exit 1

# Run the setup script.
echo "Setting up initial data..."  
docker exec -i db mysql -uusers_service -p123 users < setup.sql  

このスクリプトは、分離されたコンテナの中(つまりバックグラウンド)にあるデータベースイメージをユーザセットアップと共に実行します。ユーザセットアップは、usersというデータベースにアクセスして、データベースサーバが立ち上がるのを待ち、setup.sqlスクリプトを実行して初期データを設定します。

setup.sqlは、以下のことを行います。

create table directory (user_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, email TEXT, phone_number TEXT);  
insert into directory (email, phone_number) values ('homer@thesimpsons.com', '+1 888 123 1111');  
insert into directory (email, phone_number) values ('marge@thesimpsons.com', '+1 888 123 1112');  
insert into directory (email, phone_number) values ('maggie@thesimpsons.com', '+1 888 123 1113');  
insert into directory (email, phone_number) values ('lisa@thesimpsons.com', '+1 888 123 1114');  
insert into directory (email, phone_number) values ('bart@thesimpsons.com', '+1 888 123 1115');  

stop.shスクリプトは、コンテナを止めて取り除きます(コンテナは、すぐに再起動できるよう、デフォルトでDockerにその状態が保存されます。この例ではこの機能は特に必要ありません)。

#!/bin/sh

# Stop the db and remove the container.
docker stop db && docker rm db

これは、後ほどもっと単純化して、さらに良い感じに整えます。リポジトリのステップ1のブランチを見て、現時点のコードを確認してください。

ステップ2:Node.jsでマイクロサービスを作成する

本稿では、Docker習得に焦点を当てていますので、Node.jsのマイクロサービスに長い時間をかけるつもりはありません。その代わりに、この領域と要点について説明します。

test-database/          # contains the code seen in Step 1  
users-service/          # root of our node.js microservice  
- package.json          # dependencies, metadata
- index.js              # main entrypoint of the app
- api/                  # our apis and api tests
- config/               # config for the app
- repository/           # abstraction over our db
- server/               # server setup code

これを少しずつ見て行きましょう。最初に見るセクションは、repositoryです。データベースアクセスをある種のクラスやアブストラクションに含めると、テスト目的でモックするのに便利かもしれません。

//  repository.js
//
//  Exposes a single function - 'connect', which returns
//  a connected repository. Call 'disconnect' on this object when you're done.
'use strict';

var mysql = require('mysql');

//  Class which holds an open connection to a repository
//  and exposes some simple functions for accessing data.
class Repository {  
  constructor(connection) {
    this.connection = connection;
  }

  getUsers() {
    return new Promise((resolve, reject) => {

      this.connection.query('SELECT email, phone_number FROM directory', (err, results) => {
        if(err) {
          return reject(new Error("An error occured getting the users: " + err));
        }

        resolve((results || []).map((user) => {
          return {
            email: user.email,
            phone_number: user.phone_number
          };
        }));
      });

    });
  }

  getUserByEmail(email) {

    return new Promise((resolve, reject) => {

      //  Fetch the customer.
      this.connection.query('SELECT email, phone_number FROM directory WHERE email = ?', [email], (err, results) => {

        if(err) {
          return reject(new Error("An error occured getting the user: " + err));
        }

        if(results.length === 0) {
          resolve(undefined);
        } else {
          resolve({
            email: results[0].email,
            phone_number: results[0].phone_number
          });
        }

      });

    });
  }

  disconnect() {
    this.connection.end();
  }
}

//  One and only exported function, returns a connected repo.
module.exports.connect = (connectionSettings) => {  
  return new Promise((resolve, reject) => {
    if(!connectionSettings.host) throw new Error("A host must be specified.");
    if(!connectionSettings.user) throw new Error("A user must be specified.");
    if(!connectionSettings.password) throw new Error("A password must be specified.");
    if(!connectionSettings.port) throw new Error("A port must be specified.");

    resolve(new Repository(mysql.createConnection(connectionSettings)));
  });
};

恐らく、同じことをするためにもっと良い方法がたくさんあるでしょう。しかし、基本的に、以下のような方法でRepositoryオブジェクトを作成できます。

repository.connect({  
  host: "127.0.0.1",
  database: "users",
  user: "users_service",
  password: "123",
  port: 3306
}).then((repo) => {
  repo.getUsers().then(users) => {
    console.log(users);
  });
  repo.getUserByEmail('homer@thesimpsons.com').then((user) => {
    console.log(user);
  })
  //  ...when you are done...
  repo.disconnect();
});

repository/repository.spec.jsファイルにユニットテストのセットもあります。リポジトリができたので、サーバを作成することができます。以下は、server/server.jsです。

server/server.js:
//  server.js

var express = require('express');  
var morgan = require('morgan');

module.exports.start = (options) => {

  return new Promise((resolve, reject) => {

    //  Make sure we have a repository and port provided.
    if(!options.repository) throw new Error("A server must be started with a connected repository.");
    if(!options.port) throw new Error("A server must be started with a port.");

    //  Create the app, add some logging.
    var app = express();
    app.use(morgan('dev'));

    //  Add the APIs to the app.
    require('../api/users')(app, options);

    //  Start the app, creating a running server which we return.
    var server = app.listen(options.port, () => {
      resolve(server);
    });

  });
};

このモジュールは、start関数をexportしています。このように使うことができます。

var server = require('./server/server);  
server.start({port: 8080, repo: repository}).then((svr) => {  
  // we've got a running http server :)
});

server.jsが、api/users/jsを使うことに気付きましたか? こちらです。

//  users.js
//
//  Defines the users api. Add to a server by calling:
//  require('./users')
'use strict';

//  Only export - adds the API to the app with the given options.
module.exports = (app, options) => {

  app.get('/users', (req, res, next) => {
    options.repository.getUsers().then((users) => {
      res.status(200).send(users.map((user) => { return {
          email: user.email,
          phoneNumber: user.phone_number
        };
      }));
    })
    .catch(next);
  });

  app.get('/search', (req, res) => {

    //  Get the email.
    var email = req.query.email;
    if (!email) {
      throw new Error("When searching for a user, the email must be specified, e.g: '/search?email=homer@thesimpsons.com'.");
    }

    //  Get the user from the repo.
    options.repository.getUserByEmail(email).then((user) => {

      if(!user) { 
        res.status(404).send('User not found.');
      } else {
        res.status(200).send({
          email: user.email,
          phoneNumber: user.phone_number
        });
      }
    })
    .catch(next);

  });
};

これらのファイルは、どちらもソースの近くにユニットテストがあります。

コンフィギュレーションが必要です。専用のライブラリを使うのではなく、シンプルなファイルが、config/config.jsの目的にかなっています。

//  config.js
//
//  Simple application configuration. Extend as needed.
module.exports = {  
    port: process.env.PORT || 8123,
  db: {
    host: process.env.DATABASE_HOST || '127.0.0.1',
    database: 'users',
    user: 'users_service',
    password: '123',
    port: 3306
  }
};

必要な場合は、コンフィギュレーションをrequire(要求)することもできます。現在、多くのコンフィギュレーションは、ハードコード化されていますが、portから分かるように、環境変数をオプションとして追加するのは簡単です。

最後のステップです。全てをまとめるindex.jsファイルとつなぎ合わせます。

//    index.js
//
//  Entrypoint to the application. Opens a repository to the MySQL
//  server and starts the server.
var server = require('./server/server');  
var repository = require('./repository/repository');  
var config = require('./config/config');

//  Lots of verbose logging when we're starting up...
console.log("--- Customer Service---");  
console.log("Connecting to customer repository...");

//  Log unhandled exceptions.
process.on('uncaughtException', function(err) {  
  console.error('Unhandled Exception', err);
});
process.on('unhandledRejection', function(err, promise){  
  console.error('Unhandled Rejection', err);
});

repository.connect({  
  host: config.db.host,
  database: config.db.database,
  user: config.db.user,
  password: config.db.password,
  port: config.db.port
}).then((repo) => {
  console.log("Connected. Starting server...");

  return server.start({
    port: config.port,
    repository: repo
  });

}).then((app) => {
  console.log("Server started successfully, running on port " + config.port + ".");
  app.on('close', () => {
    repository.disconnect();
  });
});

ちょっとしたエラー処理を行い、それが終わったら、コンフィギュレーションをロードして、リポジトリを作成し、サーバを立ち上げるだけです。

これがマイクロサービスです。全てのユーザを対象としたり、ユーザを検索したりできます。

HTTP GET /users                              # gets all users  
HTTP GET /search?email=homer@thesimpons.com  # searches by email  

コードをチェックアウトすると、使えるコマンドがいくつかあることが分かるでしょう。

cd ./users-service  
npm install         # setup everything  
npm test            # unit test - no need for a test database running  
npm start           # run the server - you must have a test database running  
npm run debug       # run the server in debug mode, opens a browser with the inspector  
npm run lint        # check to see if the code is beautiful  

これまで見てきたコードの他に、このようなものもあります。

  1. デバッグ用Node Indpector
  2. ユニットテスト用Mocha/should/supertest
  3. lint用ESLint

以上!

以下のコマンドを使って、テストデータベースを実行してください。

cd test-database/  
./start.sh

そして、サービスを実行してください。

cd ../users-service/  
npm start  

ブラウザでlocalhost:8123/usersにアクセスして、実行される様子を見てください。Dockerマシンを使っているようなら、つまり、MacかWindowsで実行しているなら、localhostは使えません。代わりにDockerマシンのIPアドレスが必要になります。docker-machine ipでIPアドレスを取得することができます。

以上、あっと言う間にサービスを構築しました。先に進む前にこのコードを見たければ、ステップ2のブランチを確認してください。

後編はこちら:Node.jsのマイクロサービスの構築を通してDockerを学ぶ – 後編