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

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

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 コンテナ名で停止させておく。

参考資料