Dockerベストプラクティス:gosu、exec、環境変数によるEntrypointスクリプト作成ガイド

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

背景:デフォルトのDockerfileだけでは不十分な理由

コンテナはホテルの客室によく例えられます。宿泊客を迎える前に、エアコンを付け、お湯を張り、ベッドメイキングを済ませておく必要があります。アプリケーションも同様です。リクエストの受け付けを開始する前に、環境変数の読み込み、ディレクトリの初期化、データベース接続の疎通確認などを完了させておかなければなりません。

多くのエンジニアは、DockerfileのCMDディレクティブに直接アプリケーションの起動コマンドを指定しがちです。ローカル開発環境ではこれで問題なく動作するかもしれませんが、本番環境にデプロイすると、すぐに以下の3大トラブルに直面することになります。

  • root権限でのバックグラウンド実行: これは最も危険なアンチパターンです。万が一アプリケーションにリモートコード実行(RCE)の脆弱性があった場合、攻撃者にコンテナ内のroot権限をそのまま奪われてしまいます。その結果、コンテナブレイクアウトによってホストサーバー全体を掌握されるリスクが跳ね上がります。
  • コンテナ停止時の10秒遅延(Ungraceful Shutdown): docker stopを実行しても、ターミナルが約10秒間フリーズする現象です。シェルスクリプトがSIGTERMシグナルを握りつぶしてしまうため、Dockerはタイムアウトを待って強制終了シグナル(SIGKILL)を発行せざるを得なくなります。結果として、トランザクションの未コミット終了、書き込み中ファイルの破損、ローリングアップデート時の502エラーなどが発生します。
  • 静的設定ファイルによる柔軟性の欠如: 開発、ステージング、本番の各環境ではそれぞれ異なる設定値が求められます。イメージ内に設定ファイルをハードコードしてしまうと、同一イメージの再利用が不可能になります。

私自身のプロジェクトで30以上のマイクロサービス群に標準的なEntrypointスクリプトを適用したところ、コンテナの停止時間は10秒から800ms未満へと大幅に短縮され、デプロイ時のデータベースコネクション詰まりもほぼ完全に解消されました。

ツールの選定:なぜgosuなのか?

リソースの初期設定を行うためには、コンテナをroot権限で起動する必要があります。しかし、アプリケーションを実行する段階になったら、その特権は速やかに手放さなければなりません。

なぜsudoやsuではダメなのでしょうか?sudoは余計な中間プロセスを生成し、システムシグナル(Signals)の伝伝が適切に行われないためです。一方、gosuは単一の目的のために設計されています。setuid/setgidシステムコールを介してrootから一般ユーザーへ権限を切り替え、子プロセスにPIDを明け渡すことだけに特化しています。極めて軽量かつセキュアです。

Debian/UbuntuベースのサンプルDockerfileは以下の通りです:

FROM ubuntu:22.04

# curl、SSL証明書、gosuをインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    curl \
    gosu \
    && rm -rf /var/lib/apt/lists/*

# root権限を持たない専用システムユーザーを作成
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser

WORKDIR /app

# ソースコードをコピーし、イメージ内で事前パーミッションを設定
COPY . /app
RUN chown -R appuser:appuser /app

# entrypointスクリプトをシステムのPATHへコピー
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["entrypoint.sh"]
CMD ["node", "server.js"]

entrypoint.shの実装詳細

このスクリプトは、起動プロセス全体のオーケストレーションを担います。「エラー発生時の即時停止」「動的設定ファイルの生成」「PID 1のクリーンな委譲」という3つの要件を満たす必要があります。

Dockerfileと同じディレクトリにentrypoint.shを作成します:

#!/bin/bash
# コマンドがエラー(非ゼロの終了コード)を返した場合、直ちにスクリプトを終了
set -eo pipefail

echo ">>> [1/3] 環境設定ファイルを初期化中..."
APP_PORT=${APP_PORT:-8080}
APP_ENV=${APP_ENV:-production}

# 環境変数からconfig.jsonを動的生成
cat <<EOF > /app/config.json
{
  "port": ${APP_PORT},
  "environment": "${APP_ENV}"
}
EOF

echo ">>> [2/3] 生成されたファイルの権限を設定中..."
# 起動遅延を防ぐため、大容量ボリュームに対するchown -Rは避け、新規生成ファイルのみ権限変更
chown appuser:appuser /app/config.json

echo ">>> [3/3] アプリケーションへプロセスを引き継ぎ中..."
# root権限でnodeコマンドを実行しようとした場合、appuserへ権限を降格
if [ "$1" = 'node' ] && [ "$(id -u)" = '0' ]; then
    exec gosu appuser "$@"
fi

# デバッグ用のdocker run ... bashなど、その他のコマンドもexecで実行
exec "$@"

押さえておくべき2つの技術的ポイント:

  1. gosu appuserによる権限降格メカニズム:
    コンテナはrootで起動するため、ファイルの生成、ネットワーク設定、特権ポートのバインドを問題なく行えます。前処理完了後、gosuによってappuserへ権限が降格され、Node.jsアプリケーションは非特権空間で安全に動作します。
  2. execコマンドの極めて重要な役割:
    "$@"はCMDから渡された引数を表します。execコマンドはLinuxカーネルに対して「現在のbashプロセスを新しいプロセスで置き換える」よう指示します。余分な子プロセスは生成されず、アプリケーションがコンテナ内のPID 1を直接保持することになります。

動作確認

構築が完了したら、システムが正しくPID 1を取得し、Gracefulに停止できるかを検証しましょう。

1. PID 1および実行ユーザーの確認

デモ用コンテナをビルドして起動します:

docker build -t secure-demo .
docker run -d --name test-app -e APP_PORT=3000 secure-demo

コンテナ内部からプロセス一覧を確認します:

docker exec test-app ps aux

期待される出力結果:

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
appuser      1  0.5  0.8 712340 34560 ?        Ssl  10:00   0:00 node server.js

PIDが1で、USERがappuserになっていれば設定成功です。

2. Graceful Shutdownの速度計測

Linuxのtimeコマンドを使用して、コンテナの停止にかかる時間を測定します:

time docker stop test-app

正常時の出力例:

test-app
real    0m0.421s
user    0m0.015s
sys     0m0.008s

コンテナが0.3〜1秒前後で停止すれば、アプリケーションが正常にSIGTERMを受信してプロアクティブに終了したことを意味します。もしプロンプトが返ってくるまでにきっかり10秒待たされる場合は、停止シグナルがどこかで途切れています。その際は、execキーワードを書き忘れていないか再確認してください。

3. 起動ログの確認

docker logs test-app

3ステップの準備ログがわかりやすく出力されます。環境変数の設定ミスやボリュームのマウント権限エラーが発生した場合でも、ログを見るだけですぐに原因を特定できます。

Share: