30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Docker の volume と network の初歩

Docker の volume は、コンテナが使うデータを永続化するための仕組みで、これを使うことでコンテナのライフサイクルとは別にデータを管理することができる。
また、network という機能を使うことで、コンテナ間で通信ができるようになる。
この記事では、volume と network の基本的な使い方を見ていく。

コンテナを削除すればそのなかにあるデータも削除されてしまう

基本的に Docker のコンテナは、一度作ったものを長く大切に使い続けるのではなく、作成と破棄を繰り返して使うことが多い。
そしてコンテナを破棄すれば、コンテナのなかにあるデータも当然失われてしまう。
MySQL のコンテナで試してみる。

まずコンテナを作り起動させる。

% docker run --name test_db -dit -e MYSQL_ROOT_PASSWORD=password mysql:8

以下のコマンドを入力するとパスワードを求められるので、先程MYSQL_ROOT_PASSWORDとして設定したpasswordを入力する。

% docker exec -it test_db mysql -p

そうすると MySQL に接続できるので、sample_dbというデータベースを作ってみる。

mysql> CREATE DATABASE sample_db;
Query OK, 1 row affected (0.01 sec)

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sample_db          |
| sys                |
+--------------------+
5 rows in set (0.01 sec)

mysql> exit
Bye

その後、test_dbコンテナを一度停止させてから再度起動してアクセスしても、sample_dbは存在する。

% docker stop test_db
% docker start test_db
% docker exec -it test_db mysql -p
mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sample_db          |
| sys                |
+--------------------+
5 rows in set (0.01 sec)

だがコンテナを削除し新しく作り直してからその中身を確認すると、sample_dbは存在しない。

% docker stop test_db
% docker rm test_db
% docker run --name test_db -dit -e MYSQL_ROOT_PASSWORD=password mysql:8
% docker exec -it test_db mysql -p
mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.00 sec)

今見ているコンテナは、名前こそtest_dbではあるが最初に作成したコンテナとは別のコンテナなのだから、sample_dbが存在しないのは当然といえる。

このように、コンテナを削除してしまうと、コンテナに対して行った操作やそれによって生まれたデータは、全て失われてしまう。

volume を使ってデータを永続化する

Docker が提供する volume という機能を使うことで、データを永続化できる。
volume を使うと、コンテナではなくホストマシンにデータを保存するようになる。そしてそれでいて、コンテナのなかにデータが存在するかのように扱うことができるので、コンテナから簡単にデータを利用することができる。

まず、先ほど作成したtest_dbはもう使わないので、停止し削除する。

% docker stop test_db
% docker rm test_db

volume を使うためにはまず、docker volume create volumeの名前で volume を作る必要がある。
今回はmysql_volumeという volume を作ることにする。

% docker volume create mysql_volume

そしてコンテナを作る際に、-v ボリューム名:コンテナの記憶領域のパスというオプションをつければいい。

% docker run --name first_db -dit -e MYSQL_ROOT_PASSWORD=password -v mysql_volume:/var/lib/mysql mysql:8

上記の例では、-v mysql_volume:/var/lib/mysqlというオプションをつけて、first_dbという名前のコンテナを作っている。

「コンテナの記憶領域のパス」として/var/lib/mysqlを指定しているが、こうすることで、volume に保存されているデータを、コンテナの/var/lib/mysqlに存在するかのように扱うことができる。シンボリックリンクのようなもので、/var/lib/mysqlには実体は存在せず、あくまでも volume にデータが保存される。そして volume はコンテナではなくホストマシンに存在するため、このコンテナが破棄されたところで、データは残り続ける。
MySQL コンテナの場合、データは/var/lib/mysqlに保存される。そのため SQL でデータに対して何らかの操作を行うと、/var/lib/mysqlに対して操作を行うことになり、それはつまり volume に対して操作を行うことになる。

実際に操作を行って確認してみる。

まずfirst_dbに接続してsample_dbというデータベースを作る。

% docker exec -it first_db mysql -p
mysql> CREATE DATABASE sample_db;
Query OK, 1 row affected (0.01 sec)

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sample_db          |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

次にfirst_dbを削除する。

% docker stop first_db
% docker rm first_db

そしてsecond_dbという新しいコンテナを作る。この際、先程と同様にmysql_volumeをコンテナと紐付けるようにする。

% docker run --name second_db -dit -e MYSQL_ROOT_PASSWORD=password -v mysql_volume:/var/lib/mysql mysql:8

そうすると、既にsecond_dbのなかにsample_dbが存在している。

% docker exec -it second_db mysql -p
mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sample_db          |
| sys                |
+--------------------+
5 rows in set (0.01 sec)

first_dbに対して行った操作(sample_dbの作成)はmysql_volumeという volume に保存され、そのデータはfirst_dbとは別に管理されている。そのため、first_dbが削除されても残り続ける。
そしてsecond_dbを作成する際にmysql_volumeと紐付けたので、既にsecond_dbにはsample_dbが存在しているのである。

network によるコンテナ間の通信

network という機能を使うと、コンテナ間で通信ができるようになる。
例として Nginx のコンテナと Node.js のコンテナを用意し、両者間で通信できるようにしてみる。

Nginx コンテナの準備

まずnginxディレクトリとnodeディレクトリを作り、そこに必要なファイルを入れていくことにする。

% mkdir nginx
% mkdir node

そして以下のコマンドを実行し、nginx/userディレクトリにデータを用意する。このデータを Nginx コンテナにコピーして使うことになる。

% mkdir nginx/user
% echo "{\"id\": 1, \"name\": \"Alice\"}" > nginx/user/1.json
% echo "{\"id\": 2, \"name\": \"Bob\"}" > nginx/user/2.json
% echo "{\"id\": 3, \"name\": \"Carol\"}" > nginx/user/3.json

最後に、以下の内容のnginx/Dockerfileを作成。

FROM nginx:latest
WORKDIR /usr/share/nginx/html
COPY ./user ./user

Dockerfile の書き方については特に説明しないので、下記を参照。

numb86-tech.hatenablog.com

そして以下のコマンドを実行し、sample_nginx_imageというイメージを作る。

% docker build -t sample_nginx_image ./nginx

そしてそのイメージから、sample_nginxというコンテナを作る。

% docker run --name sample_nginx -d -p 8084:80 sample_nginx_image

8084:80でポートマッピングしたので、ホストマシンの8084ポートを経由して、Nginx コンテナの80ポートにアクセスできる。

% curl localhost:8084/user/1.json
{"id": 1, "name": "Alice"}
% curl localhost:8084/user/2.json
{"id": 2, "name": "Bob"}
% curl localhost:8084/user/3.json
{"id": 3, "name": "Carol"}
% curl localhost:8084/user/4.json
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.21.3</center>
</body>
</html>

だがこれは、ホストマシンとコンテナの通信である。
繰り返しになるが、network を使うことで、コンテナとコンテナで通信できるようになる。

Nginx コンテナが問題なく動いていることを確認できたので、sample_nginxは、停止、削除しておく。

% docker stop sample_nginx
% docker rm sample_nginx

network の作成

docker network create ネットワークの名前で、ネットワークを作成できる。

% docker network create sample_network

そして作成されたネットワークとコンテナを紐付けることで、同一のネットワークに紐付けられているコンテナ同士で通信できるようになる。

まず、sample_nginx_imageを元にしたコンテナを、sample_networkと紐付ける形で作成する。
--net ネットワークの名前オプションを付けることで、そのネットワークと紐付ける形でコンテナを作れる。

% docker run --name sample_nginx -d --net sample_network sample_nginx_image

Node.js コンテナの作成

あとは、sample_networkと紐付ける形で、Node.js のコンテナを作ればよい。

以下の内容のnode/index.jsnode/package.jsonを作る。

import fetch from "node-fetch";
import http from "http";

http
  .createServer(async function (req, res) {
    let result;
    try {
      const fetchResponse = await fetch("http://sample_nginx:80/user/1.json"); // 通信するコンテナの名前を host 部分に書く
      result = await fetchResponse.json();
    } catch (e) {
      result = e.message;
    }
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(`${JSON.stringify(result)}\n`);
  })
  .listen(3000);
{
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "node-fetch": "3.2.3"
  }
}

重要なのはnode/index.jsfetchの部分。
ここで、リクエスト先のホストをsample_nginxとしている。こうすることで、sample_nginxコンテナと通信が行われる。

最後にnode/Dockerfileを作成し、そこからsample_node_imageというイメージを作成する。

FROM node:16
WORKDIR /scripts

COPY package.json ./

RUN npm i -G yarn
RUN yarn install

COPY index.js ./
CMD ["yarn", "run", "start"]
% docker build -t sample_node_image ./node

そして、sample_networkと紐付ける形でコンテナを作成、起動する。

% docker run --name sample_node --net sample_network -d -p 8080:3000 sample_node_image

sample_nginxsample_nodesample_networkに紐付けられているため、両者間で通信ができるようになり、fetchで情報を取得できるようになった。

curl で Node.js コンテナにアクセスしてみると、sample_nginxから取得したデータが返されていることを確認できる。

% curl http://localhost:8080
{"id":1,"name":"Alice"}

Node.js コンテナと MySQL コンテナを接続する

最後により実践的な内容として、Node.js コンテナと MySQL コンテナの間でやり取りできるようにしてみる。また、MySQL コンテナには volume を紐付け、データが永続化されるようにする。

まずは volume と network の作成。

% docker volume create app_volume
% docker network create app_network

そして、これらと紐付けた形で、MySQL コンテナを作成する。

% docker run --name app_db -dit -e MYSQL_ROOT_PASSWORD=password -v app_volume:/var/lib/mysql --net app_network mysql:8

そして作成されたコンテナの MySQL に接続し、動作確認用のテーブルやデータを用意する。

% docker exec -it app_db mysql -p

mysql> CREATE DATABASE sample_db;
Query OK, 1 row affected (0.00 sec)

mysql> USE sample_db;
Database changed
mysql> CREATE TABLE users (id INT AUTO_INCREMENT, name TEXT NOT NULL, PRIMARY KEY (id));
Query OK, 0 rows affected (0.02 sec)

mysql> INSERT INTO users(name) VALUES('Alice');
Query OK, 1 row affected (0.02 sec)

具体的には、sample_dbというデータベースを作成し、そのなかにusersというテーブルを作成、そしてそこに 1 件のレコードを作成している。

mysql> SELECT * FROM users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

Node.js コンテナからこのデータを取得できるようにするのが、ゴールである。

あとで必要になるのでポート番号も確認しておく。

mysql> show variables like 'port';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| port          | 3306  |
+---------------+-------+
1 row in set (0.01 sec)

MySQL 側の準備はこれで完了。

次は、Node.js コンテナの準備。
以下の内容でindex.jspackage.jsonDockerfileを作成する。

import fetch from "node-fetch";
import http from "http";
import mysql from "mysql2";

http
  .createServer(async function (req, res) {
    let result;
    try {
      const connection = mysql.createConnection({
        host: "app_db", // MySQL のコンテナの名前
        port: 3306, // 先程調べたポート番号
        user: "root",
        password: "password",
        database: "sample_db", // 接続したいデータベースの名前
      });
      connection.connect();
      result = await connection
        .promise()
        .query("SELECT * FROM users;")
        .then(([rows]) => {
          return rows;
        })
        .catch((err) => err);
    } catch (e) {
      result = e.message;
    }
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(`${JSON.stringify(result)}\n`);
  })
  .listen(3000);
{
  "type": "module",
  "license": "MIT",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "mysql2": "2.3.3",
    "node-fetch": "3.2.3"
  }
}
FROM node:16
WORKDIR /scripts

COPY package.json ./

RUN npm i -G yarn
RUN yarn install

COPY index.js ./
CMD ["yarn", "run", "start"]

index.jsのなかで MySQL との接続を行っているが、その際にhostとしてapp_dbを指定することで、app_dbコンテナと通信できるようになる。もちろん、Node.js コンテナとapp_dbコンテナが同一のネットワーク(今回の場合はapp_network)に紐付いていることが前提となる。

イメージを作成し、そこからコンテナを作成、起動する。

$ docker build -t app_node_image .
$ docker run --name app_node -d -p 8080:3000 --net app_network app_node_image

無事、Node.js を経由して MySQL からデータを取得することができた。

% curl http://localhost:8080
[{"id":1,"name":"Alice"}]`

Dockerfile に入門して Node.js アプリを作ってみる

Docker への入門の一環として、自分で Dockerfile を作成し、それを使って Node.js アプリを Docker Container で動かしてみる。

Hello World

Dockerfile を使うことで、既存の Docker Image を編集して新しい Docker Image を作ることができる。
具体的には、Dockerfileという名前のファイルにコマンドを記述していくことで、その内容に基づいた Docker Image を作成できるようになる。

例えば以下の Dockerfile では、FROMCMDというコマンドを使っている。これらのコマンドの意味は後述する。

FROM node:16
CMD [ "echo", "Hello World" ]

カレントディレクトリに上記のDockerfileがある状態で% docker build -t sample .を実行すると、sampleという名前の Docker Image が作成される。名前はもちろんsample以外でも構わない。
作られたsampleから Docker Container を作成し実行すると、Hello Worldが表示される。ちなみに--rmオプションを付けておくと、Docker Container の停止時にその Docker Container を自動的に削除してくれる。

% docker run --rm sample
Hello World

このように、

  1. Dockerfileというファイルにコマンドを記述する
  2. Dockerfileに基づいて Docker Image を作成する
  3. 作られた Docker Image から Docker Container を作成、実行する

というのが基本的な流れになる。

Dockerfile コマンドを知らないと意図した Docker Image は作れないので、まずはコマンドについて簡単に見ていく。

FROM

ゼロから Docker Image を作ることはまずなく、既存の Docker Image をベースして、そこにコマンドによるカスタマイズを重ねてオリジナルの Docker Image を作ることになる。
そのベースとなる Docker Image を指定するのが、FROMコマンド。

先程はFROM node:16と記述したが、このようにすると公式が配布している Node.js v16の Docker Image がベースになる。

CMD

これは、Docker Container の起動時に実行するコマンドを指定するためのコマンド。

そのため、以下のようにすると、Docker Container の起動時に Node.js のバージョンが表示される。

FROM node:16
CMD [ "node", "--version" ]

実際に試してみる。
まずは Docker Image の作成。

% docker build -t sample .

sampleという Docker Image は先程も作成したので既に存在するが、新しい内容でsampleという Docker Image が改めて作られるので、問題ない。
sampleから Docker Container を作成、実行すると、Node.js のバージョンが表示される。

% docker run --rm sample
v16.14.2

Docker Image の後片付け

上述したように、既に存在する Docker Image と同名の Docker Image を作成しようとした場合、特にエラーになることはなく、問題なく作成される。
ではこれまで存在した Docker Image はどうなるかというと、<none>という名前に変化する。
% docker imagesで調べてみると、<none>という名前の Docker Image が作られているはず。

この記事では基本的にsampleという名前で Docker Image を作っていく。
そうすると、<none>という名前の Docker Image がどんどん増えていく。
使うことのない Docker Image が増えても容量を圧迫するだけなので、適宜削除していく必要がある。

ひとつひとつ削除してもいいのだが、以下のコマンドを実行することで<none>という名前の Docker Image を一括で削除できる。

% docker rmi $(docker images -f "dangling=true" -q)

COPY

先程紹介したCMDコマンドは、Dockerfile にひとつしか書くことができない。
では、Docker Container の起動時に複数のコマンドを実行させたい場合は、どうすればいいのか。
シェルスクリプトを使えばよい。シェルスクリプトに実行させたいコマンドを書いておき、CMDコマンドでそのシェルスクリプトを実行することで、複数のコマンドを実行できる。

CMD ["bash", "shell.sh"]

だがそのためには、任意のシェルスクリプト(上記の例だとshell.sh)を、Docker Image に含めないといけない。
そういった時に使うのが、COPYコマンドである。
COPYコマンドを使うと、ホストにあるファイルを Docker Image にコピーすることができる。

まず、以下の内容のshell.shというファイルをカレントディレクトリに用意する。

#!/bin/bash

echo 1
echo 2
echo 3

次に、Dockerfile の内容を以下のようにして、% docker build -t sample .でビルドする。

FROM node:16
COPY shell.sh ./
CMD [ "ls" ]

ビルドされた Docker Image から Docker Container を起動するとlsコマンドが実行されるが、既存のディレクトリに加えてshell.shも存在していることが分かる。

% docker run --rm sample
bin
boot
dev
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
shell.sh
srv
sys
tmp
usr
var

Dockerfile を以下のように書き換えてビルド、実行すると、shell.shの中身が実行される。

FROM node:16
COPY shell.sh ./
CMD ["bash", "shell.sh"]
% docker run --rm sample
1
2
3

COPYコマンドは、ひとつめの引数でホスト側のパスを指定し、ふたつめの引数で Docker Image のパスを指定する。

ホスト側のパスの基準となるのは、% docker build -t sample ..の部分。ここで基準となるパスを指定する。

例えば、カレントディレクトリにあるmaterialというディレクトリにa.txtb.txtc.txtがある場合、以下のどちらの組み合わせでも、3 つのファイルを Docker Image にコピーできる。

  • COPY ./material ./と記述し、% docker build -t sample .でビルドする
    • 基準となるパスが.であり、COPYコマンドの引数が./materialなので、./material内にあるファイルがコピーされる
  • COPY ./ ./と記述し、% docker build -t sample -f Dockerfile ./materialでビルドする
    • 基準となるパスが./materialであり、COPYコマンドの引数が./なので、./material内にあるファイルがコピーされる
    • デフォルトだとビルド時に指定されたパス(このケースだと./material)にある Dockerfile を探してしまうため、-fオプションで明示的に Dockerfile を指定している

では、Docker Image のパスの基準は、どのように決まるのか。それを指定するのが、WORKDIRコマンドである。

WORKDIR

WORKDIRコマンドで指定したディレクトリが、Dockerfile でコマンドを実行する際の基準になる。
COPYにしろCMDにしろ、WORKDIRで指定したディレクトリが基準になる。
指定したディレクトリが既存の Docker Image に存在しなかった場合、自動的に作成される。

例えば以下の内容でビルド、実行を行うと、/foo/barと表示される。/foo/barpwdコマンドを実行したためである。

FROM node:16
WORKDIR /foo/bar
CMD [ "pwd" ]

WORKDIRコマンドを使わなかった場合は、これまで見てきたように/が基準のディレクトリとなる。

RUN

Docker Image のビルド時に実行するコマンドを指定するためのコマンド。
CMDは Docker Container の起動時に実行するコマンドを指定するためのものなので、そこが異なる。
また、CMDと違って複数書くことができる。
RUNで実行するコマンドの基準となるディレクトリも、WORKDIRで指定したディレクトリになる。

Node.js でウェブアプリを作る

最後に Dockerfile の実践として、Node.js アプリが起動する Docker Container を作ることにする。
アプリはどんなものでも構わないのだが、TypeScript で書かれた、Hello Worldを返すだけのウェブアプリにする。

まず、Docker は意識せずにアプリを書いていく。

必要なライブラリをインストール。

% yarn init -y
% yarn add -D typescript ts-node-dev @types/node@16

tsconfig.jsonを用意。

{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext", "DOM"],
    "esModuleInterop": true
  }
}

index.tsを用意。

import http from "http";

http
  .createServer(function (_, res) {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Hello World\n");
  })
  .listen(3000);

package.jsonscripts"start": "ts-node-dev index.ts"を書き加える。

この状態で% yarn run startを実行するとウェブサーバが起動するはずなので、curl で動作確認してみる。

% curl http://localhost:3000
Hello World

問題なく動いているので、ここから、Docker で動かしていく。

Dockerfile

まずは Dockerfile を書く。

FROM node:16
WORKDIR /scripts

COPY package.json ./
COPY yarn.lock ./
COPY tsconfig.json ./

RUN npm i -G yarn
RUN yarn install --frozen-lockfile

COPY index.ts ./

CMD ["yarn", "run", "start"]

必要なファイルをコピーしたあと、ライブラリのインストールを行う。
その後index.tsをコピーし、Docker Container の起動時にyarn run startが実行されるようにする。

Docker Image のビルドと Docker Container の起動

ビルドは今まで通り% docker build -t sample .でよい。

Docker Container の起動は、以下のようにする。

% docker run --rm -d -p 8080:3000 sample

-dで、Docker Container がバックグラウンドで実行されるようにする。
そして-p 8080:3000で、ホスト側の8080ポートと Docker Container 側の3000ポートをマッピングする。
これで、ホストの8080ポートを通して、Docker Container の3000ポートにアクセスできる。

% curl http://localhost:8080
Hello World

最後に、このままだとずっと Docker Container が起動したままなので、% docker psで Docker Container で名前を調べ、% docker stop コンテナ名で停止させておく。

参考資料