この記事はプログラミング学習者が学習した内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。
前提条件
この記事はフルスタックでWebアプリケーションを作成中に実装したDockerの内容に関する記事です。
開発環境ではcompose.ymlファイルを使ってコンテナを起動して、本番環境についてはAWSで実装を予定しています。
使用したPC
MacBookAir M2のCPU
使用した主要なgemなど
バックエンド
Ruby: 3.4.1.0-slim-bookworm(Dockerイメージ)
Rails: 8.1.3.1
フロントエンド
Node.js: 24.21.0-bookworm-slim(Dockerイメージ)
TypeScript: 5.9.3
React: 19.2.8
Next.js: 16.3.6
データベース
PostgreSQL: 18.6-bookworm(Dockerイメージ)
実装概要
フロントエンドとバックエンドのどちらもマルチステージビルド構成にして、できるだけ使用するコンテナイメージを小さくすることでイメージを本番環境へPushするためのビルド時間短縮とローカルストレージの容量節約などを目的としています。
また、DockerHubから取得するDockerイメージはbookworm(標準版)ではなく、slim版を採用してベースのイメージを小さくすることにしました。
postgresとvalkeyについては公式イメージにslim版が無かったので、標準版(bookworm)を使うことにしました。
別の軽量系イメージとしてalpine版があり、Dockerイメージを小さくすることで下記の問題を解決するために一定の効果があります。
- ローカルストレージの圧迫
- Docker HubやAWS ECRなどのレジストリにpush/pullする際に時間がかかる
- ビルドに時間がかかる
但し、alpine版は以下の欠点があります。
-
musl libcとglibcの互換性問題 - パッケージ(
apk)のバージョン固定の困難さ - マルチステージビルドによる alpine の優位性の低下
slimを使うメリット
-
DebianやUbuntuをベースにしつつ、サイズを大幅削減 - alpineとは違って基本的な機能は残しているので、互換性が高い
- 公式の言語イメージ(Python, Node.js など)にも
slimが用意されている
標準のモデル(bookworm)を使うとイメージは1GBを超えますが、軽量版のslimやalpineを使えば200MB前後くらいに抑えることができます。以上が、標準版ではなく、また軽量版としてalpineではなく、slimを使うことにした理由です。
Dockerfileの実装詳細と解説
フロントエンド
FROM node:24.21.0-bookworm-slim AS base
WORKDIR /app
# 設定ファイルだけキャッシュさせる。変更が無ければ初回以降のインストールが早くなる
COPY package*.json ./
FROM base AS development
# lockfileに従ってインストール。lockfileを更新せず、再現性重視
RUN npm ci
# ローカルからコンテナへソースコードをコピー
COPY . .
# Next.jsの開発サーバーポートを開放
EXPOSE 3000
# コンテナ起動時にNext.jsを立ち上げる
CMD ["npm", "run", "dev"]
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build
# 本番環境ではbaseから引き継がず、ビルドステージから取得する
FROM node:24.21.0-bookworm-slim AS production
WORKDIR /app
# Next.jsとNode.jsを最高速度・最小リソースで動かす本番モードへ切り替え。レンダリング処理やメモリ使用を最小限に抑える
ENV NODE_ENV=production
# node server.jsが検知できるようにIPアドレスを指定
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
# Standalone出力された自己完結型ファイル群を全てコピー。コンテナ乗っ取り対策でファイルの権限を作成したユーザーにする
COPY --from=builder --chown=node:node /app/.next/standalone ./
# 動的アセット(CSS/JS/画像など)をstandalone内のパスへコピー。コンテナ乗っ取り対策でファイルの権限を作成したユーザーにする
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
# 実行ユーザーをrootからnodeユーザーに変更
USER node
# Next.jsの開発サーバーポートを開放
EXPOSE 3000
# .next/standaloneをapp直下にコピーしているのでこのコマンドで実行
CMD ["node", "server.js"]
baseの部分では
package.jsonとpackage-lock.jsonをコンテナ側にコピーします。
変更が無い場合、2回目からのインストールが早くなります。
つまり、設定ファイルだけを先にコピーすることでソースコード変更時とのビルドを区別し、変更が無い場合は後の工程でnpm ciを使っているので、インストール作業が発生せずにビルド時間を短縮できます。
development(開発環境)では
npm install ではなく、npm ci を使って必要なファイルをインストールします。前者はpackage-lock.jsonを参考にするが場合によっては更新してインストールする、一方で後者はpackage-lock.jsonから忠実にインストールします。Dockerの場合、ビルド内容の再現性保証のために後者を選択します。
builder(ビルドステージ)では、
開発環境と同様に2回目以降のインストールはpackage-lock.jsonが変更された場合のに実行されます。そして、ソースコードをコンテナ側へコピーした後にイメージをビルドします。
production(本番環境)では、
ビルドステージからデータの一部を取得します。
Next.jsでは本番実行に必要な最小限のnode_modulesとサーバーコードだけを./next/standaloneに書き出す機能があり、これを使うことで本番環境にnode_modulesを入れる必要がなくってイメージの大きさを大幅に小さくすることができます。
TypeScirptに下記の設定が必要です。
onst nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
};
コンテナ起動時はnode server.jsコマンドで起動することで npm や Next.js CLI プロセスを挟まず、不要な node_modules を一切読み込まないため、メモリ消費量が削減され、コンテナの起動速度も高速になります。但し、standaloneで起動する場合、127.0.0.1(ローカルホスト)しかリッスンしないため、HOSTNAME="0.0.0.0"と別途実装してリッスン範囲を広げる必要があります。
動的ファイル(CSS/JS/画像ファイルなど)はstandaloneには含まれないため、別途コピーする必要があります。
また、セキュリティ面を考慮して本番環境ではnodeというユーザーを使ってコンテナを運用するようにしています。DockerfileでUSERを設定しない場合、全てrootユーザーで実行されていますので、仮にコンテナを乗っ取られてしまったら、rootユーザー権限で何でも実行されてしまいます。一方で、nodeユーザーはNode.jsに最初から設定されている一般ユーザーで権限は限られています。ビルドステージからファイルをコピーする際にファイル権限をこのnodeユーザーに指定することでコンテナ乗っ取り時の被害をappディレクトリ周辺に限定できます。
バックエンド
FROM ruby:3.4.10-slim-bookworm AS base
WORKDIR /myapp
# 推奨パッケージをインストールせず、ライブラリのインデックスリストは削除してイメージのスリム化
RUN apt-get update -qq && apt-get install -y --no-install-recommends libpq-dev tzdata && rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV TZ=Asia/Tokyo
# Gemfile類は先にコピーして、bundle installのキャッシュ用
COPY Gemfile Gemfile.lock ./
FROM base AS development
# 開発ツールのインストール(gcc, makeなど)
RUN apt-get update -qq && apt-get install -y --no-install-recommends build-essential git && rm -rf /var/lib/apt/lists /var/cache/apt/archives
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0"]
FROM base AS builder
RUN apt-get update -qq && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists /var/cache/apt/archives
# 開発とテストのgemは除外してインストールする
RUN bundle config set --local without 'development test' && bundle install
COPY . .
FROM ruby:3.4.10-slim-bookworm AS production
WORKDIR /myapp
RUN apt-get update -qq && apt-get install -y --no-install-recommends libpq-dev tzdata && rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV TZ=Asia/Tokyo
# Railsに本番環境として動作させる。development,testグループのGemをインストールから外す。ログをファイルではなく標準出力
ENV RAILS_ENV=production BUNDLE_WITHOUT="development:test" RAILS_LOG_TO_STDOUT="1"
# コピーするファイルの権限者となるユーザーとグループを作成する。グループはシステムが使うグループ。Gemのロード・起動時のエラー対策でホームディレクトリ作成
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
# Gemのデータだけをコピーして、容量が重いコンパイラを省く。コンテナ乗っ取り対策でファイルの権限を作成したユーザーにする
COPY --from=builder --chown=rails:rails /usr/local/bundle /usr/local/bundle
# 本番環境にだけ必要なコード、Gem設定が適用されたソースコードを取得する
COPY --from=builder --chown=rails:rails /myapp /myapp
# 実行ユーザーをrootからrailsユーザーに変更
USER rails
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0"]
ステージ共通の内容として
--no-install-recommendsオプション使うことで必要なパッケージだをけインストールして、推奨パッケージなどはインストールを避けることでイメージをできるだけ軽量にします。
rm -rf /var/lib/apt/lists /var/cache/apt/archivesでファイルを削除するのはapt-get updateでパッケージごとの取得可能なリストはapt-get installを実行した後は使用することがないので、イメージビルドする時には削除してイメージを軽量化します。
baseでは
フロントエンドの場合と同様にパッケージ管理のファイルだけをコンテナ内にコピーして、GemfileとGemfile.lockが更新された時のみインストールして、変更が無い場合はインストールをスキップできるのでビルド時間を短縮できます。
builder(ビルドステージ)で
RUN bundle config set --local without 'development test' && bundle installのコマンドでは開発環境とテスト環境のグループに設定されたGemをインストール対象から外しています。これにより本番環境で使わないGemをインストールせずに済むので、Dockerイメージを軽量化することができます。
production(本番環境)では
フロントエンドと同様にユーザーとユーザーグループを用意して、ビルドステージからコピーするGemとソースコードは用意したユーザーの所有物とすることで、権限を抑えることができます。これによりコンテナを仮に乗っ取られてもroot権限で実行されることを防げきます。
また、ビルドステージからGemのデータだけを取得して起動に必要ないコンパイラはコピー対処から恥しています。ソースコードについても本番環境に必要なコードだけをコピーしてイメージの軽量化を目指しています。
services:
db:
image: postgres:18.6-bookworm
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: treasure_box_development
volumes:
- db:/var/lib/postgresql
ports:
- '127.0.0.1:5432:5432'
networks:
- backend_net
valkey:
image: valkey/valkey:9.1.2
# サーバー起動コマンド 60秒に1回以上変更があれば永続化保存(コンテナ停止時にメモリが消えるため) ログを推奨程度で表示
command: valkey-server --save 60 1 --loglevel notice
volumes:
- valkey-data:/data
healthcheck:
test: ['CMD', 'valkey-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports:
- '127.0.0.1:6379:6379'
networks: # 内部DNS(名前解決)でIPアドレスが変わってもOK 他のDockerプロジェクトから分離
- backend_net
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
command: npm run dev
volumes:
- ./frontend:/app # バインドマウント(ホストPCと接続)
- /app/node_modules # 匿名ボリューム(ホストPCと同期せず、Docker内部に別途保存)
- /app/.next # 匿名ボリューム(ホストPCと同期せず、Docker内部に別途保存)
ports:
- '127.0.0.1:8000:3000' # ホスト側ポート(左側)がbackendと重複しないように。コンテナ側は他コンテナと独立しているので重複問題無し
environment:
API_BASE_URL: http://backend:3000/api/v1
tty: true
stdin_open: true
networks:
- frontend_net
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: development
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
environment:
DATABASE_HOST: db
DATABASE_USER: postgres
DATABASE_PASSWORD: password
VALKEY_URL: redis://valkey:6379/0
networks:
- frontend_net
- backend_net
volumes:
- ./backend:/myapp
- bundle:/usr/local/bundle
ports:
# ホスト側ポート(左側)がfrontendと重複しないように。コンテナ側は他コンテナと独立しているので重複問題無し
- '127.0.0.1:3000:3000'
depends_on:
db:
condition: service_started
valkey:
condition: service_healthy
tty: true
stdin_open: true
volumes:
bundle:
db:
valkey-data:
networks:
frontend_net:
driver: bridge # 同一ネットワーク内でのみ通信可
backend_net:
driver: bridge
コンテナを起動するにあたり、開発環境はcompose.ymlを使い、本番環境はAWSで上述のDockerfileから生成したDockerイメージをECRにPushしてECSを使います。
AWSの実装はterraformで行いますが、詳細の解説は一旦割愛しますので、この記事に後から内容を追加するか、別の記事で掲載しようと考えています。
compose.ymlの解説は下記の通りです。
portsの指定方法
'127.0.0.1:5432:5432'と記述していますが、他の解説記事では'5432:5432'と実装しているものが多いと思います。これはIPアドレスを指定しない場合、全てのIPアドレスの5432を受け入れるという設定になりますので、127.0.0.1と指定することで対象をローカルホストに限定することでセキュリティ対策しています。
networkの導入理由
frontend_net: frontend、backendサービスが所属
backend_net: backend、db、valkeyサービスが所属
backendサービスが中継役になって接続します。
- セキュリティ考慮
仮にfrontendのコンテナが乗っ取られてもdbやvalkeyといったデータが保管されているコンテナへアクセスすることはできません。 - 名前の解決
カスタムネットワークの中であればDockerに内蔵されたDNSサーバーが自動的に有効化されます。
また、コンテナを再起動してIPアドレスが変わってもサービス名を指定することで接続できます。 - 他のプロジェクトとの分離
同じホスト上の別アプリとの干渉を防止します。 - 本番環境の再現(AWS)
AWSのパブリック・プライベートサブネット分離と同じ構造をローカルで再現しています。これにより本番環境へ移行する時もコンテナ同士の依存関係やアクセス可否による認識ズレに起因する障害を防止することができます。
最後まで読んでいただきありがとうございます。
参考にしたサイト
Dockerのマルチステージビルドでimageを軽量化する
Differences Between Standard Docker Images and Alpine \ Slim Versions
https://zenn.dev/yuichi_ai/articles/npm-ci-vs-install-complete-guide#はじめに-──-ciで-npm-install-を使っていませんか?
output - Next.js