2016年6月29日
Node.jsのマイクロサービスの構築を通してDockerを学ぶ – 前編
(2016-04-19)by Dave Kerr
本記事は、原著者の許諾のもとに翻訳・掲載しております。
あなたが真剣に 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のコマンドを発行します。
docker
: Dockerクライアントを起動します。run
: 新規コンテナを起動するコマンドです。-it
: コンテナにインタラクティブな入出力装置を与えるオプションです。ubuntu
: コンテナの元となるイメージです。
- ホスト(自分のマシン)上で実行されているDockerサービスは、リクエストしたイメージのコピーがローカルにあるかどうかをチェックします。このコピーは存在しません。
- Dockerサービスは、
ubuntu
という名前の利用可能なイメージがあるかどうか、公式レジストリ(Docker Hub)をチェックします。これは存在します。 - Dockerサービスはイメージをダウンロードし、イメージ用のローカルキャッシュに保存します(次のステップの準備です)。
- 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インスタンスの実行を促します。
docker run
はエンジンにイメージを実行したいと伝えます(イメージは最終段階の mysql:vlatest で登場します)。--name db
はコンテナにdb
と名付けます。-d
(または--detach
)はデタッチのことです。つまり、コンテナをバックグラウンドで実行します。-e MYSQL_ROOT_PASSWORD=123
(または--env
)は環境変数のことで、Dockerに環境変数を与えることを伝えます。この設定に従った変数は、デフォルトのルートのパスワードを設定するためにMySQLイメージがチェックをかけるものです。-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
これも非常に優れたものです。
docker exec -it db
は、db
(コンテナID、あるいはコンテナID最初の数文字も使用可)と名付けたコンテナ上でコマンドを実行することをDockerに伝えます。-it
はインタラクティブな入出力装置があることを示します。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
これまで見てきたコードの他に、このようなものもあります。
- デバッグ用Node Indpector
- ユニットテスト用Mocha/should/supertest
- 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 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa