跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

Docker入門から実践まで

コンテナ・イメージ・Dockerfile・ネットワーク・永続化・Docker Composeを理解する

稻, 草人

Webアプリケーションを開発していると、「ローカルでは正常に動くのに、サーバーにデプロイすると動かない」ということがある。例えば、手元のNode.jsは22なのにサーバーは18だったり、MySQLのバージョンが違ったり、新しいメンバーが開発環境を用意するだけで半日かかったりする。

Dockerは、こうした実行環境の差を小さくし、アプリケーションのビルド・配布・実行を標準化するための仕組みだ。ただし、「アプリをコンテナに入れる」だけでは使いこなせない。イメージとコンテナ、Dockerfile、ネットワーク、データの永続化、Composeの関係まで理解しておく必要がある。

この記事では基本概念からコマンド、Web開発でよく使う設定まで整理し、最後にNginx、Node.js+MySQL、Vue/React+Viteの3つの実践例を紹介する。


目次

1. Dockerとは何か

Dockerは、コンテナ化されたアプリケーションをビルド・配布・実行するためのプラットフォームだ。プログラムと必要な依存関係をイメージ(Image)としてまとめ、それをもとにコンテナ(Container)を起動する。

従来のデプロイでは、サーバー側でNode.jsのインストール、依存パッケージの導入、環境変数の設定、コードの配置などを個別に行うことが多かった。

従来の方法
サーバー
 ├─ Node.jsをインストール
 ├─ 依存パッケージをインストール
 ├─ 環境変数を設定
 ├─ ソースコードを配置
 └─ アプリケーションを起動

Dockerを使う場合
Dockerfile → docker build → Image
                                ↓
                       Registry(必要に応じて)
                                ↓
                            docker run
                                ↓
                            Container

同じイメージを使えば、開発環境と本番環境の差を減らせる。ただし、すべての差がなくなるわけではない。CPUアーキテクチャ、外部サービス、設定、データベースのバージョンなどには引き続き注意が必要だ。

LinuxコンテナはLinuxカーネルを利用する。macOSやWindowsのDocker DesktopでLinuxコンテナを動かす場合、通常は内部のLinux仮想化環境を使用する。

2. Dockerと仮想マシンの違い

仮想マシンは仮想化されたハードウェア上で独立したゲストOSを動かす。一方、一般的なLinuxコンテナはホスト側のLinuxカーネルを共有する、隔離されたプロセスとして動作する。

スクロールできます
比較項目Dockerコンテナ従来の仮想マシン
分離する対象プロセスと実行環境仮想コンピューター全体
OSカーネル通常はホストのカーネルを共有ゲストOSが独自のカーネルを持つ
起動時間一般に短い一般に長い
リソース消費比較的小さい比較的大きい
主な用途アプリ配布・マイクロサービス・CIOS単位の検証・異なるOSの実行

Dockerの動作を理解するうえで、Linuxの次の3つの仕組みは押さえておきたい。

  • Namespaces:プロセスID、ネットワーク、マウントポイント、ユーザーIDなどの見え方を分離する。
  • cgroups:CPUやメモリなどのリソース使用量を制御・計測する。
  • レイヤー化されたファイルシステム:イメージの共通部分を再利用し、コンテナごとに書き込み可能なレイヤーを持たせる。LinuxではOverlayFSがよく使われる。

例えばCPUとメモリを制限してNginxを起動できる。

docker run -d --name limited-nginx --memory=256m --cpus=0.5 nginx:alpine

コンテナは軽量だが、仮想マシンとまったく同じセキュリティ境界を提供するわけではない。

3. Image・Container・Registryの関係

3.1 Image(イメージ)

イメージはコンテナを作成するための読み取り専用テンプレートで、アプリケーション、ライブラリ、ランタイム、設定などを含む。

docker pull nginx:alpine
docker images

nginxはイメージ名、alpineはタグ(Tag)だ。イメージ自体が実行されるわけではない。

3.2 Container(コンテナ)

コンテナはイメージから作られる実行インスタンス。同じイメージから複数のコンテナを起動できる。

docker run -d --name web1 nginx:alpine
docker run -d --name web2 nginx:alpine

それぞれ別のプロセス・書き込みレイヤー・ネットワーク空間を持つ。なお、この例ではホストにポートを公開していない。

3.3 Registry(レジストリ)

レジストリはイメージを保存・配布するサービス。Docker Hub、GitHub Container Registry、Amazon ECRなどがある。

Dockerfile --build--> Image --run--> Container
                           |
                       push / pull
                           |
                        Registry

Dockerfileは設計手順、Imageはビルド成果物、Containerは実行インスタンスと捉えると分かりやすい。

4. Dockerのインストールと動作確認

macOS・WindowsではDocker Desktopが一般的。LinuxサーバーではDocker Engineを導入する場合が多い。

インストール後は次のコマンドで確認する。

docker --version
docker compose version
docker info
docker run hello-world

dockerはCLIクライアントであり、実際のコンテナ管理はDocker Engineのデーモンが行う。内部ではcontainerdやOCIランタイムなども関係する。

hello-worldはメッセージを出力すると終了するため、docker psに表示されなくても異常とは限らない。終了済みコンテナはdocker ps -aで確認できる。

5. 基本的なDockerコマンド

5.1 イメージの操作

# イメージの取得
docker pull nginx:alpine
# ローカルのイメージ一覧
docker images
# 詳細情報
docker image inspect nginx:alpine
# イメージの履歴
docker history nginx:alpine
# イメージ削除
docker rmi nginx:alpine

5.2 Nginxコンテナの起動

docker run -d --name my-nginx -p 8080:80 nginx:alpine

ブラウザで http://localhost:8080 を開く。

  • -d:バックグラウンドで実行する。
  • --name:コンテナに名前を付ける。
  • -p 8080:80:ホスト側8080番 → コンテナ側80番のポート公開を設定する。
ブラウザ → localhost:8080 → Dockerのポート公開 → Nginxコンテナ:80

別のNginxコンテナを-p 8081:80で起動することも可能。コンテナ内部では両方とも80番を利用できる。

5.3 コンテナのライフサイクル

コマンド用途
docker ps実行中のコンテナ一覧
docker ps -a停止・終了済みを含む一覧
docker stop my-nginx停止
docker start my-nginx再起動ではなく停止済みコンテナを起動
docker restart my-nginxコンテナを再起動
docker rm my-nginx停止済みコンテナを削除
docker logs -f my-nginxログを追跡
docker statsCPU・メモリ使用量を確認
docker inspect my-nginx詳細設定を確認

コンテナの削除はイメージの削除とは別操作。ただし、コンテナの書き込みレイヤーにだけ保存したデータは削除時に失われる。

5.4 コンテナ内でコマンドを実行する

docker exec -it my-nginx sh

execは既存コンテナ内で新しいプロセスを実行する。-iは標準入力を維持し、-tは疑似端末を割り当てる。Alpine系のイメージにはbashがない場合があるため、shのほうが汎用的だ。distrolessなど、シェル自体を含まないイメージもある。

6. DockerfileでNode.jsアプリをイメージ化する

まずは外部ライブラリ不要の小さなHTTPサーバーを作る。

my-node-app/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
└── .dockerignore

package.json:

{
  "name": "docker-node-demo",
  "version": "1.0.0",
  "private": true,
  "scripts": { "start": "node server.js" }
}

ロックファイルを作る。

npm install --package-lock-only

server.js:

const http = require('node:http');

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'application/json; charset=utf-8'
  });
  res.end(JSON.stringify({
    message: 'Hello Docker!',
    timestamp: new Date().toISOString()
  }));
});

server.listen(3000, '0.0.0.0', () => {
  console.log('Server running on port 3000');
});

Dockerfile:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
命令意味
FROMベースイメージを指定
WORKDIR作業ディレクトリを設定
COPYビルドコンテキストからファイルをコピー
RUNイメージのビルド中に実行
EXPOSEコンテナが使う予定のポートを宣言。公開はしない
CMDコンテナ起動時のデフォルトコマンド
ENTRYPOINT固定の実行プログラムなどを設定
USER実行ユーザーを指定
ENV / ARG環境変数 / ビルド引数

特にRUNとCMDの違いが重要だ。RUN npm ciはビルド時、CMD ["node", "server.js"]はコンテナ起動時に実行される。

ビルドと実行:

docker build -t my-node-app:1.0 .
docker run -d --name my-node-container -p 3000:3000 my-node-app:1.0
curl http://localhost:3000

末尾の.はビルドコンテキストを表す。また、サーバーを0.0.0.0で待ち受けるのは重要。コンテナ内の127.0.0.1だけで待ち受けると、公開ポート経由で接続できないことがある。

この例ではnode:22-alpineを使用しているが、実務ではサポート状況を確認し、検証済みのバージョンやダイジェストで固定する。Alpineはmusl libcを使用するため、ネイティブモジュールによってはglibc系イメージのほうが適する。

7. イメージレイヤー・ビルドキャッシュ・.dockerignore

次のDockerfileを考えてみる。

FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install

この書き方ではソースコードを1行変更するだけでもCOPY . .の結果が変わり、依存パッケージのインストールを再実行しやすくなる。

改善例:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

package.jsonやpackage-lock.jsonが変わらなければ、依存関係をインストールしたビルドキャッシュを再利用できる可能性が高くなる。

.dockerignoreも用意しておきたい。

node_modules
.git
.env
.env.*
coverage
npm-debug.log
.DS_Store

これにより不要なファイルや機密情報がビルドコンテキストに含まれにくくなる。なお、構築に必要なファイルまで除外しないように注意する。

8. データの永続化:Volume・Bind Mount・tmpfs

コンテナには書き込み可能なレイヤーがあるが、コンテナを削除するとそのレイヤーも失われる。DBのデータやアップロードファイルをそこだけに保存してはいけない。

スクロールできます
方式主な用途特徴
VolumeDBデータ・永続化データDockerが管理
Bind Mountソースコード・設定ファイルホスト上のパスを直接マウント
tmpfs一時的な機密データなど主にメモリ上に保持し、通常のディスク永続化を行わない

8.1 Volumeの例

docker volume create mysql-data

docker run -d \
  --name mysql-demo \
  -e MYSQL_ROOT_PASSWORD=example123 \
  -e MYSQL_DATABASE=sampledb \
  -v mysql-data:/var/lib/mysql \
  -p 127.0.0.1:3307:3306 \
  mysql:8.4

mysql-dataというVolumeをコンテナ内の/var/lib/mysqlへマウントしている。コンテナを削除してもVolumeを削除しなければデータを保持できる。ただし、別バージョンのMySQLとの互換性は確認が必要だ。既存データディレクトリの再利用時に初期化用環境変数だけ変更しても、DB内の既存パスワードなどは自動的には更新されない。

docker rm -f mysql-demo
# 同じVolumeを付けた新しいコンテナを作成できる

Volumeはバックアップそのものではない。 本番DBはバックアップと復元テストが必要。

8.2 Bind Mountの例

docker run -d \
  --name static-web \
  -p 8080:80 \
  --mount type=bind,source="$(pwd)/html",target=/usr/share/nginx/html,readonly \
  nginx:alpine

カレントディレクトリ内のhtmlをNginxの公開ディレクトリへ読み取り専用でマウントする。ローカルのHTMLを変更すればコンテナ側にも反映される。

ただし、マウント先に元々存在したファイルはマウントによって隠れる。例えばホストのプロジェクト全体を/appにマウントすると、イメージ内の/app/node_modulesが見えなくなる場合がある。

9. Dockerネットワーク:コンテナ同士の通信

WebシステムではNginx、API、MySQLなどを別コンテナで動かすことが多い。

Browser → Nginx → API → MySQL

基本的な開発ではbridgeネットワークを押さえればよい。ほかにhost、none、複数ホスト向けのoverlayなどがある。

独自ネットワークを作成:

docker network create my-network
docker network ls
docker network inspect my-network

同じネットワークにMySQLとクライアントを起動する。

docker run -d \
  --name my-mysql \
  --network my-network \
  -e MYSQL_ROOT_PASSWORD=example123 \
  -e MYSQL_DATABASE=sampledb \
  -v mysql-network-data:/var/lib/mysql \
  mysql:8.4

docker run --rm -it \
  --network my-network \
  mysql:8.4 \
  mysql -h my-mysql -u root -p

my-mysqlというコンテナ名をホスト名として使えるのがポイント。ユーザー定義のDockerネットワークでは名前解決機能が提供される。

9.1 localhostでハマりやすい理由

APIコンテナ内でlocalhostを指定すると、APIコンテナ自身を指す。別のMySQLコンテナには接続できない。

// 別コンテナのDBに接続するときの例
const dbHost = 'my-mysql';

同一ネットワーク内のコンテナ間通信に、ホスト側へのポート公開(ports)は必須ではない。DBを外部へ公開する必要がないなら、公開しないほうが安全だ。

Docker DesktopでホストOS側のサービスに接続する場合にはhost.docker.internalを使えることがあるが、Linux Engineでは設定が異なるため環境ごとの確認が必要だ。

10. Docker Compose:複数サービスをまとめて管理する

Nginx、API、MySQL、Redisを毎回長いdocker runコマンドで起動するのは大変だ。Composeでは、複数サービスの構成をYAMLで管理できる。

現在の基本コマンドはdocker compose。古い記事に出てくるdocker-composeとは区別しておきたい。

compose.yaml:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: example123
      MYSQL_DATABASE: sampledb
    volumes:
      - db-data:/var/lib/mysql

volumes:
  db-data:

起動・停止:

docker compose up -d
docker compose ps
docker compose down

downは通常、Composeが作成したコンテナやネットワークを削除するが、名前付きVolumeは原則残る。docker compose down -vはVolumeも削除するため、DBデータがある場合は要注意。

Composeでよく使う項目:

項目役割
servicesサービス定義
image使用するイメージ
buildイメージのビルド設定
portsホストへのポート公開
environmentコンテナの環境変数
volumesデータ・ファイルのマウント
networksネットワーク設定
depends_onサービスの依存関係
healthcheck正常性確認
restart再起動ポリシー

現在のCompose Specificationでは、古い設定例にあるトップレベルのversion: "3"は必須ではない。

10.1 depends_onだけではDBの準備完了を保証しない

DBのコンテナが起動しても、MySQL内部の初期化が完了したとは限らない。単純なdepends_onだけではAPIが接続に失敗する場合がある。

services:
  app:
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: example123
    healthcheck:
      test:
        - CMD-SHELL
        - 'MYSQL_PWD="$${MYSQL_ROOT_PASSWORD}" mysqladmin ping -h 127.0.0.1 --silent'
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s

$${...}はCompose側で先に変数展開されるのを避け、コンテナ内のシェルに変数を渡すための書き方だ。アプリ側の接続リトライも併用したい。

10.2 よく使うComposeコマンド

docker compose up -d                  # バックグラウンド起動
docker compose up -d --build          # ビルドして起動
docker compose ps                     # 状態確認
docker compose logs -f                # ログ追跡
docker compose logs -f app            # appだけのログ
docker compose exec app sh            # 起動中のappでシェル実行
docker compose run --rm app npm test  # 一時コンテナでテスト
docker compose restart app            # 再起動
docker compose config                 # 設定の検証・展開結果
docker compose down                   # 停止・削除

execは既存のコンテナで実行、runはサービス設定を使った一時コンテナを作るという違いがある。

11. 環境変数と機密情報の管理

環境ごとのDB接続先やポートをソースコードに直接書き込むのは避けたい。

.envの例:

APP_PORT=3000
DB_NAME=sampledb
DB_USER=appuser
DB_PASSWORD=local-dev-password

Compose側:

services:
  app:
    image: my-node-app:1.0
    ports:
      - "${APP_PORT}:3000"
    environment:
      DB_NAME: ${DB_NAME}
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}

重要なのは、Composeの.envは主に設定値の補間に使われ、全変数が自動的に全コンテナに渡されるわけではないという点。コンテナへ渡すものはenvironmentやenv_fileなどで指定する。

また、.envを公開Gitリポジトリへコミットしないこと。本番のパスワードやAPIキーは適切なSecrets管理の仕組みを利用したい。

12. マルチステージビルドで本番イメージを小さくする

VueやReactの静的ファイルを配信するのに、本番コンテナへNode.jsや開発用依存パッケージをすべて残す必要はない。

ビルド用のNode.jsステージと、配信用のNginxステージを分ける。

# Stage 1: フロントエンドをビルド
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Nginxで静的ファイルを配信
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

ポイントはCOPY --from=builder。最終イメージには基本的にビルド成果物だけを持ち込める。

daemon off;はNginxをフォアグラウンドで動かす設定。通常、コンテナのライフサイクルはメインプロセスに結び付いている。

Next.jsなどSSRを行うフレームワークでは、単純な静的配信だけでは対応できないケースがある。構成に合わせたDockerfileを使う必要がある。


実践編:実際に動かしてみる

実践1:Docker+Nginxで静的サイトを公開する

最初はHTMLをNginxで配信するだけの構成から始めよう。難しいイメージ構築は必要ない。

手順1:ディレクトリを作る

mkdir docker-static-demo
cd docker-static-demo
mkdir html
docker-static-demo/
├── html/
│   └── index.html
└── compose.yaml

html/index.html:

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Docker Nginx Demo</title>
</head>
<body>
  <h1>Hello Docker!</h1>
  <p>Dockerで公開した最初のWebサイトです。</p>
</body>
</html>

手順2:compose.yamlを作る

services:
  web:
    image: nginx:alpine
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    restart: unless-stopped

./htmlがホストのディレクトリ、/usr/share/nginx/htmlがコンテナ内のディレクトリ。:roは読み取り専用という意味だ。

手順3:起動して確認する

docker compose up -d

http://localhost:8080へアクセスし、HTMLが表示されることを確認する。ローカルのindex.htmlを書き換えれば、再ビルドしなくても変更内容が反映される。

docker compose down

この例で覚えたいのはポート公開、Bind Mount、Compose、コンテナの起動と停止だ。

実践2:Node.js+Express+MySQLでユーザーAPIを作る

次は実際のWebアプリケーションに近い構成。GET /usersで一覧を取得し、POST /usersでユーザーを登録する。

使用する技術はExpress、mysql2、MySQL、Dockerfile、Compose、Volume、ヘルスチェックだ。

手順1:プロジェクトを準備する

mkdir docker-api-demo
cd docker-api-demo
npm init -y
npm install express mysql2
mkdir src

package.jsonのscriptsへ次を追加する(ほかの項目や依存パッケージは残す)。

{
  "scripts": {
    "start": "node src/server.js"
  }
}

最終的な構成:

docker-api-demo/
├── src/
│   └── server.js
├── package.json
├── package-lock.json
├── Dockerfile
├── .dockerignore
├── .env
├── .env.example
└── compose.yaml

手順2:APIを実装する

src/server.js:

const express = require('express');
const mysql = require('mysql2/promise');

const app = express();
app.use(express.json());

const pool = mysql.createPool({
  host: process.env.DB_HOST || 'db',
  port: Number(process.env.DB_PORT || 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/users', async (req, res) => {
  try {
    const [rows] = await pool.query(
      'SELECT id, name FROM users ORDER BY id'
    );
    res.json(rows);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Failed to fetch users' });
  }
});

app.post('/users', async (req, res) => {
  const { name } = req.body || {};
  if (typeof name !== 'string' || !name.trim() || name.length > 100) {
    return res.status(400).json({ error: 'Invalid name' });
  }

  try {
    const [result] = await pool.execute(
      'INSERT INTO users (name) VALUES (?)',

[name.trim()]

); res.status(201).json({ id: result.insertId, name: name.trim() }); } catch (error) { console.error(error); res.status(500).json({ error: ‘Failed to create user’ }); } }); async function initDatabase() { await pool.query(` CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL ) `); } async function startServer() { while (true) { try { await initDatabase(); break; } catch (error) { console.error(‘Database unavailable, retrying…’, error.message); await new Promise(resolve => setTimeout(resolve, 3000)); } } app.listen(3000, ‘0.0.0.0’, () => { console.log(‘API listening on port 3000’); }); } startServer();

ここには実務でも役立つ考え方が3つある。

  1. コネクションプール:HTTPリクエストごとに新しいDB接続を無制限に作らず、接続を再利用する。
  2. プレースホルダー付きSQL:VALUES (?)を使ってパラメーターを渡し、文字列連結によるSQLインジェクションを避ける。
  3. 起動時の接続リトライ:DBコンテナが起動していてもMySQLの準備が終わっていない場合があるため、接続可能になるまで待つ。

今回のリトライ処理は初期化段階のみ。本番では切断後の再接続、リクエストのタイムアウト、正常終了処理なども考える必要がある。

手順3:Dockerfileを作る

FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]

Node.js公式イメージにはnodeユーザーが用意されている。root権限を使わないことで、権限を必要以上に広げずにアプリを動かせる。

.dockerignore:

node_modules
.git
.env
.env.*
npm-debug.log
coverage

手順4:ローカル用の環境変数を用意する

.env.example:

DB_NAME=sampledb
DB_USER=appuser
DB_PASSWORD=replace-with-local-password
MYSQL_ROOT_PASSWORD=replace-with-root-password

コピーして実際のパスワードを設定する。

cp .env.example .env

.gitignoreへ.envを追加し、パスワードをGitに登録しないこと。

手順5:compose.yamlを作る

services:
  api:
    build: .
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DB_HOST: db
      DB_PORT: 3306
      DB_USER: ${DB_USER:?DB_USER is required}
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
      DB_NAME: ${DB_NAME:?DB_NAME is required}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Required}
      MYSQL_DATABASE: ${DB_NAME:?Required}
      MYSQL_USER: ${DB_USER:?Required}
      MYSQL_PASSWORD: ${DB_PASSWORD:?Required}
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test:
        - CMD-SHELL
        - 'MYSQL_PWD="$${MYSQL_ROOT_PASSWORD}" mysqladmin ping -h 127.0.0.1 --silent'
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s
    restart: unless-stopped

volumes:
  mysql-data:

この例ではnetworksを明示していない。Composeは通常、プロジェクト専用のデフォルトネットワークを作成し、サービス名で名前解決する。そのためAPIのDB接続先はDB_HOST=dbでよい。

MySQLの3306番ポートはホストへ公開していない。APIからしか使わないDBを、わざわざ外部に公開する必要はないためだ。

手順6:ビルド・起動・動作確認

docker compose up -d --build
docker compose ps
docker compose logs -f api

ユーザー登録:

curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"Tanaka"}'

期待される結果:

{ "id": 1, "name": "Tanaka" }

ユーザー一覧:

curl http://localhost:3000/users
[
  { "id": 1, "name": "Tanaka" }
]

データ永続化の確認として一度コンテナを削除・再作成してみる。

docker compose down
docker compose up -d
curl http://localhost:3000/users

mysql-dataが残っているため、登録したデータも残るはずだ。ここでdocker compose down -vを実行するとVolumeも削除されるので注意する。

ブラウザ / curl
      ↓ localhost:3000
Express APIコンテナ(api:3000)
      ↓ Docker内部ネットワーク(db:3306)
MySQLコンテナ
      ↓
mysql-data(Volume)

この実装はあくまでローカル学習用だ。外部公開するには認証、HTTPS、権限設計、Secrets管理、バックアップ、監視などが必要になる。

実践3:Vue / React+ViteをNginxで公開する

フロントエンド開発では、Viteの開発サーバーで動かす場合と、ビルド済みファイルを本番配信する場合を分けて考えたい。

手順1:プロジェクト作成

npm create vite@latest docker-frontend-demo
cd docker-frontend-demo
npm install

画面の指示に従ってVueまたはReactを選ぶ。

docker-frontend-demo/
├── src/
├── public/
├── index.html
├── package.json
├── package-lock.json
├── vite.config.js
├── Dockerfile
├── nginx.conf
├── .dockerignore
└── compose.yaml

手順2:マルチステージDockerfile

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

手順3:Nginx設定

nginx.conf:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

try_files $uri $uri/ /index.html;はVue RouterやReact RouterのHistoryモードで重要な設定。

例えば/users/123をブラウザで直接開くと、通常のNginxはそのパスに対応する物理ファイルを探す。ファイルが存在しなければ404になるが、SPAではindex.htmlへフォールバックさせ、画面遷移をフロントエンド側で処理させる。

なお、存在しない静的ファイルまでHTMLにフォールバックさせたくない場合は、静的ファイル向けのロケーション設定を別途調整する。

手順4:Composeで起動

.dockerignore:

node_modules
dist
.git
.env
.env.*

compose.yaml:

services:
  frontend:
    build: .
    ports:
      - "127.0.0.1:8080:80"
    restart: unless-stopped
docker compose up -d --build

http://localhost:8080を開く。これはVite開発サーバーではなく、Nginxがdist内の静的ファイルを返している状態だ。ソースコードを修正したら、通常は再ビルドが必要になる。


14. 開発中のコード変更を自動反映するには?

本番向けの静的配信では修正ごとにビルドが必要だが、ローカル開発ではホットリロードしたい。

14.1 Bind Mount+Vite

services:
  frontend:
    image: node:22-alpine
    working_dir: /app
    ports:
      - "127.0.0.1:5173:5173"
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"

volumes:
  node_modules:
docker compose up

コードの変更をBind Mount経由でコンテナへ反映させ、ViteのHMRを利用する。node_modulesを独立したVolumeにしているのは、ホスト側のディレクトリマウントでコンテナ内の依存パッケージが隠れるのを避けるためだ。

上記は理解しやすさを優先した例で、毎回npm ciが実行される。実際には依存インストールをイメージ構築時に行う方法も検討したい。

14.2 Compose Watch

Compose Watchを使うと、ファイル変更時の同期・再起動・再ビルドを宣言的に設定できる。

services:
  app:
    build: .
    ports:
      - "127.0.0.1:3000:3000"
    develop:
      watch:
        - action: sync+restart
          path: ./src
          target: /app/src
        - action: rebuild
          path: ./package.json
docker compose up --watch

対応するComposeバージョンが必要。プロジェクトやフレームワークに合わせて、Bind MountとWatchを使い分ければよい。

15. よくあるエラーと調べ方

15.1 コンテナが起動直後に終了する

docker ps -a
docker logs コンテナ名

まず終了ステータスとログを確認する。よくある原因は起動コマンドの誤り、設定ファイルの不足、依存ライブラリの問題、アプリケーションの例外など。

15.2 ポートがすでに使用されている

bind: address already in use
lsof -i :8080
docker ps

別のプログラムが8080番を使っているなら、Compose側を8081:80などに変更する。

15.3 APIからMySQLへ接続できない

Error: connect ECONNREFUSED 127.0.0.1:3306

コンテナ内のlocalhostが何を指すか確認する。ComposeならDBホスト名は通常サービス名のdb。また、DBの初期化完了前にAPIが接続していないかログを確認する。

docker compose ps
docker compose logs db

15.4 Dockerfileを変更したのに反映されない

既存コンテナを再起動するだけでは、新しいDockerfileでイメージを再ビルドしたことにはならない。

docker compose up -d --build

Bind Mountによってイメージ内のファイルが隠れていないかも確認する。

15.5 Apple Siliconでアーキテクチャが合わない

MシリーズMacでx86_64専用のイメージを使うと、exec format errorなどが発生する場合がある。

docker buildx imagetools inspect nginx:alpine

必要に応じて対応プラットフォームを指定できる。

docker run --platform linux/amd64 ...

ただしエミュレーションによって性能が低下することがある。arm64対応イメージがあるなら、通常はネイティブなものを優先したい。

15.6 Dockerがディスク容量を使いすぎる

docker system df
docker system prune
docker image prune -a

prune系のコマンドは削除対象を理解してから使う。特にdocker system prune --volumesなど、Volumeを含む削除操作には慎重になる必要がある。

16. 本番環境で意識したいセキュリティ

Dockerを使えば自動的に安全になるわけではない。最低限、次を確認したい。

  1. rootで実行しない:可能ならUSER nodeなど、必要最小限の権限で動作させる。
  2. 秘密情報をイメージに埋め込まない:DockerfileへDBパスワードやAPIキーを直書きしない。
  3. 不要なポートを公開しない:DBは内部ネットワークでのみ利用し、ローカル向けポートは127.0.0.1に束縛する。
  4. latest任せにしない:検証済みのバージョンやDigestを固定する。
  5. Docker Socketを安易にマウントしない:/var/run/docker.sockへのアクセスはホスト側Dockerの制御につながることがある。
  6. リソースを制限する:CPU・メモリ・ログ容量、ヘルスチェックや監視も考える。
  7. バックアップと復元手順を用意する:Volumeの存在だけではバックアップにならない。

例えばローカルのみでDBポートを公開する場合:

ports:
  - "127.0.0.1:3307:3306"

CPUとメモリの制限例:

docker run -d --name web-limited --memory=512m --cpus=1 nginx:alpine

17. DockerとGit・CI/CD・Kubernetesの関係

Dockerを覚えると、GitHub ActionsなどのCI/CDともつながりやすくなる。

Gitへpush
  ↓
GitHub Actionsでテスト
  ↓
Dockerイメージをビルド
  ↓
GHCR / ECRなどのRegistryへpush
  ↓
サーバーがイメージをpullしてデプロイ

手作業でサーバーにログインしてgit pull・npm installを繰り返す方法より、デプロイ対象のイメージを明確にできる。ただしDBマイグレーションやロールバック、Secrets管理も同時に設計する必要がある。

Kubernetesは、コンテナ化されたアプリケーションを複数ノードで管理するためのオーケストレーションシステムだ。レプリカ管理、ローリングアップデート、サービスディスカバリーなどを担当する。

なお、現在のKubernetesがDocker Engineをコンテナランタイムとして必須としているわけではない。CRIを通じてcontainerdやCRI-Oなどを使用でき、Dockerで構築したOCI互換イメージも利用できる。

DockerとComposeを十分理解してからKubernetesへ進めばよいと思う。

18. 小さな実験:Node.jsのバージョンを使い分ける

DockerならローカルOSにそれぞれのNode.jsを直接インストールしなくても、異なるバージョンの実行環境を試せる。

docker run --rm node:18-alpine node --version
docker run --rm node:22-alpine node --version

カレントディレクトリにindex.jsがあれば、次のように実行できる。

docker run --rm \
  -v "$(pwd):/app" \
  -w /app \
  node:22-alpine \
  node index.js

-vはディレクトリのマウント、-wは作業ディレクトリを指定するオプション。macOS側のネイティブモジュールがLinuxコンテナでそのまま使えるとは限らないので、依存パッケージはコンテナ向けにインストールするほうが確実だ。

19. Dockerコマンド早見表

コマンド用途
docker --versionCLIのバージョン確認
docker infoDocker環境情報
docker pull IMAGEイメージを取得
docker imagesローカルイメージ一覧
docker build -t NAME .イメージをビルド
docker run IMAGEコンテナを作成して起動
docker ps起動中コンテナ一覧
docker ps -a全コンテナ一覧
docker stop NAMEコンテナ停止
docker start NAME停止済みコンテナ起動
docker restart NAMEコンテナ再起動
docker rm NAME停止済みコンテナ削除
docker exec -it NAME shシェルを起動
docker logs -f NAMEログを追跡
docker statsリソース使用状況
docker inspect NAME詳細設定を確認
docker volume lsVolume一覧
docker network lsネットワーク一覧
docker system dfディスク使用量
docker compose up -dComposeサービスを起動
docker compose up -d --buildビルドして起動
docker compose psサービス状態
docker compose logs -fサービスログ
docker compose downサービスを停止・削除
docker compose configCompose設定を検証

20. 間違えやすいポイントを整理

docker runとdocker start:runは新しいコンテナを作って起動し、startは作成済みのコンテナを起動する。

docker execとdocker attach:execは新しいプロセスを実行し、attachは既存メインプロセスの入出力につなぐ。調査では通常execを使う。

docker buildとdocker compose build:前者は指定したビルドコンテキストから直接ビルドし、後者はComposeのサービス設定を使ってビルドする。

EXPOSEとports:EXPOSEはイメージの宣言、portsはホストへの公開。

restartとrebuild:コンテナの再起動ではDockerfileの変更は反映されない。イメージの再ビルドが必要。

TagとDigest:nginx:alpineのようなタグが指すイメージ内容は更新され得る。Digestを使えば特定の内容を識別できる。

downとdown -v:後者はVolumeまで削除し得るため、データ消失に注意する。

21. おすすめの学習順序

いきなりKubernetesへ進むより、小さなアプリで一通り実験するほうが理解しやすい。

  • 第1段階:イメージとコンテナ — hello-world、Nginx、ps、logs、execを試す。
  • 第2段階:Dockerfile — Node.js APIをイメージ化し、RUNとCMD、ビルドキャッシュを理解する。
  • 第3段階:ネットワークと永続化 — MySQLへの接続とVolumeによるデータ保持を検証する。
  • 第4段階:Composeとデプロイ — API+DBを管理し、ヘルスチェックやViteのマルチステージビルドを試す。
  • 第5段階:CI/CD — GitHub Actionsなどでイメージのビルド・配布を自動化する。

22. 参考になる公式ドキュメント

古い解説には以前のCompose構文や推奨されなくなった設定が残っている。仕様を確認したい場合は公式ドキュメントを参照したい。

まとめ

Dockerを理解するうえで中心になるのは、Image・Container・Dockerfile・Volume・Network・Composeの6つだ。

最初は「アプリケーションをコンテナの中で動かすためのツール」だと思っていた。しかし、実際に使ってみると、Dockerが標準化しているのは実行環境だけでなく、ビルドからテスト、配布、デプロイまでの流れでもあると分かる。

もちろんDockerを覚えたからといって、LinuxやNginx、Node.js、MySQLの知識が不要になるわけではない。例えばAPIがDBにつながらないとき、原因がポート公開なのか、コンテナのlocalhostの理解違いなのか、それともDBの準備未完了なのかを切り分けるには、それぞれの基礎知識が必要になる。

Webエンジニアなら、まず使い慣れたNode.jsやVueの小さなプロジェクトをコンテナ化し、次にMySQLを追加し、最後にComposeで一つのシステムとして管理してみるのがおすすめだ。

コマンドを暗記することよりも、「どこで何が動いていて、データと通信がどうつながっているか」を自分で説明できることが大切だと思う。


※ サンプルはローカル学習・開発環境向け。実際の本番運用では、認証・HTTPS・Secrets管理・ログ管理・監視・バックアップ・障害復旧などを別途設計する必要がある。