1. はじめに
個人開発でFastAPIアプリケーションをCloud Runにデプロイしていたとき、奇妙な現象に遭遇しました。
アクセスが減ってコンテナがシャットダウンされる際、処理中のリクエストが途中で切れてしまうのです。
ログを確認すると、「SIGKILL」という強制終了のシグナルが送られていることがわかりました。
調べていくうちに、DockerfileのCMDの書き方が原因で、Cloud Runからの正常なシャットダウン信号(SIGTERM)がアプリケーションに届いていない(または期待通りに処理されていない)ことが判明しました。
この記事では、DockerfileのCMDとENTRYPOINTの違いを理解し、Cloud Runで安全にシャットダウンするための正しい書き方を解説します。
2. 直面した課題
処理中のリクエストが途中で切れる
Cloud Runは、アクセスが減るとコンテナを自動的にシャットダウンします。
このとき、まず「SIGTERM」という信号を送り、「これから終了するので、処理中の仕事を片付けてください」と伝えます。
アプリケーションは、新しいリクエストを拒否しつつ、現在処理中のリクエストだけを完了させてから終了するのが理想です。
しかし、私のアプリケーションでは、SIGTERMでの終了処理がうまく走らず、一定時間後に強制終了(SIGKILL)が実行されていました。
結果として、処理中のリクエストが途中で切れ、ユーザーにエラーが返ってしまうことがありました。
補足:Cloud Runはインスタンス終了時にSIGTERMを送った後、短い猶予ののちにSIGKILLで強制終了します。この「猶予」は長くありません(後述します)。
Dockerfileの書き方が原因
原因を調べた結果、DockerfileのCMDの書き方に問題があることがわかりました。
最初のDockerfileは以下のようになっていました。
CMD gunicorn main:app --bind 0.0.0.0:8080 --workers 2
一見問題なさそうですが、この書き方では、シェル(/bin/sh)経由でコマンドが実行されます。
シェル自体が「SIGTERMを無視する」というより、PID 1がシェルになってしまうことで、シグナルの取り扱いが期待とズレやすいのがポイントです。
-
Cloud Run(コンテナランタイム)は、基本的にコンテナのPID 1にSIGTERMを送ります
-
シェル形式のCMDだと、PID 1がシェルになり、Gunicornは子プロセスになります
-
多くのシェルは、受け取ったシグナルを自動で子プロセスに転送しません
-
その結果、GunicornがSIGTERMを受け取れず、Graceful Shutdownできないことがあります
3. CMDとENTRYPOINTの違い
CMDの2つの書き方
DockerfileのCMDには、2つの書き方があります。
-
シェル形式:
CMD gunicorn main:app --bind 0.0.0.0:8080 -
exec形式:
CMD ["gunicorn", "main:app", "--bind", "0.0.0.0:8080"]
シェル形式では、コマンドが/bin/sh -c "gunicorn main:app ..."のように実行されます。
この場合、PID 1(プロセスID 1)はシェルになり、Gunicornは子プロセスとして起動します。
exec形式では、Gunicornが直接PID 1として起動します。
なぜPID 1が重要なのか
Linuxでは、PID 1のプロセスが特別な役割を持ちます。
Dockerコンテナでは、終了シグナル(SIGTERMなど)は基本的にPID 1に届きます。
シェルがPID 1の場合、シェルはSIGTERMを受け取りますが、それを子プロセスに自動で転送しないことが多いです。
結果として、Gunicornはシャットダウン信号を受け取れず、Graceful Shutdownが動かないまま、一定時間後に強制終了されてしまいます。
Gunicornが直接PID 1になれば、SIGTERMを直接受け取り、Graceful Shutdown(安全な終了)を実行できます。
execコマンドを使った解決策
シェル形式のCMDを使いつつ、Gunicornを直接PID 1にする方法があります。
それがexecコマンドです。
CMD exec gunicorn main:app --bind 0.0.0.0:8080 --workers 2
ここでのexecはシェルの組み込みで、シェルプロセスをGunicornプロセスに置き換えます。
これにより、GunicornがPID 1になり、SIGTERMを直接受け取れるようになります。
ただし、よりシンプルで堅いのは「最初からexec形式(JSON配列)で書く」ことです(後述します)。
4. 実装手順
Dockerfileの修正
Dockerfileの最後の行を以下のように修正します。
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# シェル形式を使うなら、先頭にexecを付けてプロセス置換を行う
CMD exec gunicorn main:app \
--bind 0.0.0.0:8080 \
--workers 2 \
--worker-class uvicorn.workers.UvicornWorker \
--timeout 120 \
--graceful-timeout 8
ここが肝で、CMDの先頭にexecを付けることでシェルがGunicornに置き換わり、GunicornがPID 1として起動します。
なお、--graceful-timeoutは**GunicornがSIGTERMを受け取ったあと、ワーカーが終了するのを待つ時間(Gunicorn内部の猶予)**です。
Cloud RunがSIGKILLを送るまでの猶予そのものを伸ばすものではありません。
そのため、Cloud Run側の強制終了までの猶予(短い)を前提に、--graceful-timeoutはそれより短く(例:8秒など)設定するのが安全です。
exec形式(推奨)
シェルを経由しないexec形式なら、execコマンド自体が不要で、より意図が明確です。
CMD ["gunicorn", "main:app",
"--bind", "0.0.0.0:8080",
"--workers", "2",
"--worker-class", "uvicorn.workers.UvicornWorker",
"--timeout", "120",
"--graceful-timeout", "8"]
PORT環境変数を使いたい場合(sh -cを使うなら必ずexec)
Cloud RunではPORT環境変数が提供されます(多くの場合8080)。${PORT}を文字列展開したい場合はsh -cを使うことがありますが、その場合も最後は必ずexecで置換するのが重要です。
CMD ["sh", "-c", "exec gunicorn main:app --bind 0.0.0.0:${PORT:-8080} --workers 2 --worker-class uvicorn.workers.UvicornWorker --timeout 120 --graceful-timeout 8"]
なお、上記はGunicornを使う場合の例です。
Uvicornを直接使う場合も同様で、sh -c経由にするならexec uvicorn ...とし、PID 1 がアプリになるようにします。
FastAPI側の設定(オプション)
FastAPIアプリケーション側でも、シャットダウン時の処理を定義できます。
from fastapi import FastAPI
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# 起動時の処理
print("Application starting up...")
yield
# シャットダウン時の処理
print("Application shutting down...")
# データベース接続のクローズなど
app = FastAPI(lifespan=lifespan)
lifespanコンテキストマネージャーを使うことで、アプリケーションの起動時とシャットダウン時に実行する処理を定義できます。
シャットダウン時には、データベース接続をクローズしたり、キャッシュをフラッシュしたりする処理を記述します。
5. 動作確認
ローカルでの確認
修正したDockerfileをビルドして、ローカルで動作確認します。
docker build -t my-app .
docker run -p 8080:8080 my-app
別のターミナルで、コンテナIDを確認します。
docker ps
コンテナにSIGTERMを送ります。
docker kill --signal=SIGTERM <コンテナID>
ログを確認すると、Gunicornが正常にシャットダウン処理を開始し、処理中のリクエストを完了させてから終了することが確認できます。
Cloud Runでの確認
Cloud Runにデプロイ後、ログを確認します。
コンテナがシャットダウンされる際、以下のようなログが出力されれば成功です。
[INFO] Handling signal: term
[INFO] Worker exiting (pid: 10)
[INFO] Shutting down: Master
「Handling signal: term」は、SIGTERMを受け取ったことを示しています。
「Worker exiting」は、ワーカープロセスが正常に終了したことを示しています。
6. CMDとENTRYPOINTの使い分け
CMDの特徴
CMDは、コンテナ起動時のデフォルトコマンドを指定します。
docker runコマンドで別のコマンドを指定すると、CMDは上書きされます。
# CMDが上書きされる
docker run my-app echo "Hello"
ENTRYPOINTの特徴
ENTRYPOINTは、コンテナを「実行可能ファイル」のように扱いたい場合に使います。
docker runで指定した引数は、ENTRYPOINTに渡されます。
ENTRYPOINT ["gunicorn"]
CMD ["main:app", "--bind", "0.0.0.0:8080"]
この場合、docker run my-app --workers 4とすると、CMDが上書きされ、gunicorn --workers 4が実行されます。
ただしこの例だと、main:app(アプリ指定)が消えるため、そのままでは起動に失敗します。
ENTRYPOINT + CMD を使う場合は、「どこまでを固定し、どこを上書き可能にしたいか」を設計した上で、上書き時に必要な引数を渡す運用が必要です。
推奨される使い方
FastAPIアプリケーションの場合、シンプルにCMDだけを使うのが一般的です。
シェル形式で書くなら、execを付けます。
CMD exec gunicorn main:app --bind 0.0.0.0:8080 --workers 2 --worker-class uvicorn.workers.UvicornWorker
より推奨はexec形式です。
CMD ["gunicorn", "main:app", "--bind", "0.0.0.0:8080", "--workers", "2", "--worker-class", "uvicorn.workers.UvicornWorker"]
exec形式の場合は、execコマンドは不要です。
シェルを経由せず、直接Gunicornが起動するためです。
7. トラブルシューティング
SIGTERMが届いているか確認する方法
Gunicornのログレベルをdebugに設定すると、受け取ったシグナルがログに出力されます。
CMD exec gunicorn main:app --bind 0.0.0.0:8080 --log-level debug
ログに「Handling signal: term」が出力されれば、SIGTERMが正しく届いています。
処理が短い猶予内に終わらない場合
Cloud Runは、終了時にSIGTERMを送った後、短い猶予ののちにSIGKILLで強制終了します。
この猶予は長くないため、Gunicornの--graceful-timeoutを大きくしても、Cloud Run側の強制終了を回避できるわけではありません。
処理時間が長い場合は、以下の対策を検討してください。
-
処理を非同期化し、バックグラウンドで実行する
-
Cloud Tasksなどのジョブキューを使う
-
処理時間を短縮する
8. まとめ
DockerfileのCMDにexecを付ける(またはexec形式で書く)ことで、Cloud Runのシャットダウン信号(SIGTERM)を正しくアプリケーションに届け、Graceful Shutdownしやすくなりました。
ポイントは以下の3つです。
-
シェル形式のCMDでは、
execを先頭に付けてプロセス置換する -
exec形式のCMDでは、
execは不要(最初からPID 1がアプリになる) -
--graceful-timeoutはGunicorn内部の猶予であり、Cloud Runの強制終了猶予を伸ばすものではない(短い猶予に合わせて調整する)
最初は「なぜ処理が途中で切れるのか」がわからず、何時間も調査に費やしました。
しかし、DockerのプロセスモデルとLinuxのシグナルの仕組みを理解することで、根本的な解決ができました。



