diff --git a/.dockerignore b/.dockerignore index 3ce1de8e..e9ab0162 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,12 @@ **/bin/ **/obj/ **/out/ + +# ホスト側の node_modules は持ち込まない。 +# Dockerfile の build ステージが `npm ci` で Linux 向けに入れ直すため、 +# ここを除外しないと後続の `COPY . .` がホスト (別 OS/アーキテクチャの可能性がある) +# のバイナリで上書きしてしまう。ビルドコンテキストの転送量削減にもなる。 +**/node_modules/ **/.vs/ **/.vscode/ **/.idea/ @@ -18,6 +24,12 @@ .env .env.* +# DB バックアップ (PHI を含む)。scripts/backup-db.sh の出力先で .gitignore でも除外している。 +# 除外しないと `COPY . .` が患者データを build ステージのレイヤに焼き込んでしまう。 +# 最終イメージには残らないが、ローカルのレイヤキャッシュや --cache-to/--target build の +# エクスポート経由で外部へ出得るため、ビルドコンテキストに載せない。 +var/backups/ + # Git and CI artifacts .git/ .github/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6175db99..a4b1c1f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,14 @@ jobs: with: dotnet-version: '8.0.x' - # フロントエンド型チェック / TypeScript ビルド用に Node をセットアップ - - name: Setup Node 20 + # フロントエンド型チェック / TypeScript ビルド用に Node をセットアップ。 + # バージョンは Dockerfile の build ステージが使う Node と揃えること。 + # 揃っていないと、同じ CompileTypeScript ターゲットを別バージョンで動かすことになり、 + # 「CI は緑だがイメージのビルドでは失敗する(逆もある)」という食い違いが起きる。 + - name: Setup Node 22 uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '22' cache: 'npm' # tsc などの devDependencies をインストール (package-lock.json から再現性のあるインストール) @@ -39,3 +42,85 @@ jobs: - name: Test run: dotnet test --no-build --configuration Release --verbosity normal + + # コンテナイメージのビルドと起動確認を行うジョブ。 + # build-and-test は SDK 上で直接ビルド/テストするだけなので、Dockerfile の + # ベースイメージが壊れても検知できない。実際に issue となったのは次のケース: + # アプリは net8.0 を対象にビルドされ、生成される runtimeconfig.json は + # Microsoft.AspNetCore.App 8.0.0 を要求する。rollForward は既定の "Minor" で + # メジャー跨ぎのロールフォワードをしないため、ランタイム段を aspnet:9.0 に + # 上げると 8.0 ランタイムが無くなり、コンテナは起動時に落ちる。 + # それでも build-and-test は緑のままなので、この誤りが素通りしてしまう。 + # そこで「イメージを実際にビルドして起動し /health が 200 を返すこと」までを + # CI で担保し、ベースイメージとアプリの TFM のズレを機械的に検出する。 + docker-image: + name: Docker image build & smoke test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + # Dockerfile からイメージをビルドする。ここが失敗すればベースイメージや + # publish 手順の破損をそのまま検知できる。 + - name: Build image + run: docker build -t incident-insight:ci . + + # ビルドしたイメージをバックグラウンドで起動する。 + # - Audit__HashSalt: Production では未設定だと fail-closed で起動失敗する仕様のため、 + # スモークテスト専用の使い捨て値を渡す(本番の秘密情報ではない)。 + # - DB は既定の SQLite なので追加のサービスコンテナは不要。 + # コンテナ内の 8080 番をホストの 8080 番へ割り当てる。 + # TypeScript の生成物がイメージに入っているか検証する。 + # wwwroot/js/*.js は Git 管理外の tsc 生成物なので、SkipTsBuild=true が + # 混入したり tsconfig の outDir/include が壊れたりすると、JS を含まない + # イメージが出来上がる。/health は DB 接続しか見ないため 200 を返してしまい、 + # この退行を素通りさせる。そこで publish 出力を直接確認する。 + - name: Verify TypeScript output is present in image + run: | + docker run --rm --entrypoint sh incident-insight:ci -c \ + 'ls -1 wwwroot/js/dashboard.js wwwroot/js/analytics.js wwwroot/js/site.js' + + - name: Start container + id: start_container + run: | + docker run -d --name incident-insight-ci \ + -p 8080:8080 \ + -e Audit__HashSalt=ci-smoke-test-throwaway-salt \ + incident-insight:ci + + # /health は認証不要で DB 接続確認まで行うため、起動確認に使う。 + # 起動には数秒かかる(マイグレーション適用があるため)ので、最大 60 秒ポーリングする。 + # 200 を受け取った時点で成功として抜ける。 + # コンテナが落ちている場合は待ち続けても無駄なので即座に打ち切る。 + # ランタイム不一致で起動直後に終了するケース(このジョブが主に狙う退行)では、 + # 60 秒待って「応答なし」と報告するより「異常終了した」と報告する方が原因に直結する。 + - name: Wait for /health + run: | + for i in $(seq 1 60); do + if [ "$(docker inspect -f '{{.State.Running}}' incident-insight-ci)" != "true" ]; then + exit_code=$(docker inspect -f '{{.State.ExitCode}}' incident-insight-ci) + echo "コンテナが起動待ちの途中で終了しました (exit code: ${exit_code})。" >&2 + exit 1 + fi + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/health || true) + if [ "$code" = "200" ]; then + echo "/health が 200 を返しました (${i} 秒目)。" + exit 0 + fi + sleep 1 + done + echo "/health が 60 秒以内に 200 を返しませんでした (最後の応答: ${code})。" >&2 + exit 1 + + # 失敗時のみコンテナログを出力する。起動失敗の原因(ランタイム不一致・ + # 設定不足など)は stdout に出るため、ログが無いと原因究明ができない。 + # コンテナ生成前(イメージビルド失敗など)には実行しない。無条件に走らせると + # "No such container" で二重に赤くなり、本当の失敗理由が埋もれるため。 + - name: Dump container logs on failure + if: failure() && steps.start_container.outcome == 'success' + run: docker logs incident-insight-ci + + # 成否にかかわらずコンテナを後片付けする。 + - name: Clean up container + if: always() + run: docker rm -f incident-insight-ci || true diff --git a/Dockerfile b/Dockerfile index de560aaa..f1ba6c10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,43 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src +# TypeScript をコンパイルするために Node/npm を build ステージへ持ち込む。 +# .csproj の CompileTypeScript ターゲットが `npm ci` と `npx --no-install tsc` を実行するため、 +# Node が無いと publish が "npm: not found" (MSB3073) で失敗する。 +# SkipTsBuild=true で回避してはいけない: wwwroot/js/*.js は tsc の生成物で Git 管理外のため、 +# スキップすると JS を一切含まないイメージが出来上がり、ダッシュボード/分析のグラフが動かなくなる。 +# apt ではなく公式 node イメージからコピーするのは、取得元とメジャーバージョンを +# 明示でき、apt のリポジトリ状態に依存せずに済むため。 +# なお `22-bookworm-slim` は 22.x のパッチ更新で中身が変わる可動タグであり、 +# ビルドがバイト単位で再現可能になるわけではない (完全固定が必要なら digest を付ける)。 +# メジャーバージョンは CI の actions/setup-node と揃えること (.github/workflows/ci.yml)。 +# 揃っていないと「CI は緑だがイメージでは失敗する」という食い違いが起きる。 +# sdk:8.0 は Debian 12 (bookworm) ベースなので、glibc を揃えるため node も bookworm 系を使う。 +COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules +# npm / npx は node_modules 内の CLI スクリプトへのシンボリックリンクとして提供される。 +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + # Restore を先に走らせるために csproj だけ先にコピーしてレイヤキャッシュを効かせる。 COPY IncidentInsight.sln . COPY src/IncidentInsight.Web/IncidentInsight.Web.csproj src/IncidentInsight.Web/ COPY tests/IncidentInsight.Tests/IncidentInsight.Tests.csproj tests/IncidentInsight.Tests/ -RUN dotnet restore src/IncidentInsight.Web/IncidentInsight.Web.csproj +# packages.lock.json も一緒にコピーする。これが無いと RestorePackagesWithLockFile=true により +# NuGet はロックファイルを「検証」せず「新規生成」してしまい、CI の +# `dotnet restore --locked-mode` が守っている固定が、実際に配布されるイメージでは +# 効かなくなる (ロック更新漏れの PR でも docker build だけ通ってしまう)。 +COPY src/IncidentInsight.Web/packages.lock.json src/IncidentInsight.Web/ +COPY tests/IncidentInsight.Tests/packages.lock.json tests/IncidentInsight.Tests/ +# --locked-mode で CI と同じ「ロックファイルと不一致なら失敗」の挙動に揃える。 +RUN dotnet restore src/IncidentInsight.Web/IncidentInsight.Web.csproj --locked-mode + +# npm の依存も同様に、マニフェストだけ先にコピーしてレイヤキャッシュを効かせる。 +# ここで node_modules を用意しておくと、後続の publish 内の CompileTypeScript が +# `npm ci` をスキップし (Condition="!Exists(node_modules)")、ソース変更のたびに +# 依存を取り直さずに済む。 +COPY package.json package-lock.json ./ +RUN npm ci COPY . . RUN dotnet publish src/IncidentInsight.Web/IncidentInsight.Web.csproj \ diff --git a/README.md b/README.md index 0490d260..f4520ecf 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,15 @@ docker run --rm -p 8080:8080 \ -e ASPNETCORE_ENVIRONMENT=Production \ -e Database__Provider=postgres \ -e ConnectionStrings__DefaultConnection="Host=...;Database=...;Username=...;Password=..." \ + -e Audit__HashSalt="<32 文字以上のランダム文字列>" \ incident-insight:latest ``` +`Audit__HashSalt` は Production では必須です(未設定だと fail-closed で起動に失敗します)。 +監査ログの個人名を HMAC-SHA256 で擬似匿名化する鍵で、空だと擬似匿名化が容易に破られます。 +値はコミットせず、シークレット管理から注入してください。ローテーションすると過去のハッシュとの +相関が失われるため、実施時は runbook に記録します。 + イメージは非 root (`app` ユーザ) で起動し、`8080/tcp` を listen します。 ### ヘルスチェック diff --git a/docs/deployment.md b/docs/deployment.md index 7352dd39..edf5726c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,9 +8,15 @@ docker run --rm -p 8080:8080 \ -e ASPNETCORE_ENVIRONMENT=Production \ -e Database__Provider=postgres \ -e ConnectionStrings__DefaultConnection="Host=...;Database=...;Username=...;Password=..." \ + -e Audit__HashSalt="<32 文字以上のランダム文字列>" \ incident-insight:latest ``` +`Audit__HashSalt` は Production では必須です(未設定だと fail-closed で起動に失敗します)。 +監査ログの個人名を HMAC-SHA256 で擬似匿名化する鍵で、空だと擬似匿名化が容易に破られます。 +値はコミットせず、シークレット管理から注入してください。ローテーションすると過去のハッシュとの +相関が失われるため、実施時は runbook に記録します。 + ### Health check - `GET /health` が利用できます(DB接続確認込み)。