diff --git "a/source/_posts/2025/20251023a_Vue\343\201\256\343\203\225\343\203\255\343\203\263\343\203\210\343\202\250\343\203\263\343\203\211\343\202\222\343\202\273\343\202\255\343\203\245\343\203\252\343\203\206\343\202\243\343\201\256\343\201\227\343\201\243\343\201\213\343\202\212\343\201\227\343\201\237\343\202\263\343\203\263\343\203\206\343\203\212\343\201\253\343\201\231\343\202\213.md" "b/source/_posts/2025/20251023a_Vue\343\201\256\343\203\225\343\203\255\343\203\263\343\203\210\343\202\250\343\203\263\343\203\211\343\202\222\343\202\273\343\202\255\343\203\245\343\203\252\343\203\206\343\202\243\343\201\256\343\201\227\343\201\243\343\201\213\343\202\212\343\201\227\343\201\237\343\202\263\343\203\263\343\203\206\343\203\212\343\201\253\343\201\231\343\202\213.md" new file mode 100644 index 00000000000..17e78ddaa95 --- /dev/null +++ "b/source/_posts/2025/20251023a_Vue\343\201\256\343\203\225\343\203\255\343\203\263\343\203\210\343\202\250\343\203\263\343\203\211\343\202\222\343\202\273\343\202\255\343\203\245\343\203\252\343\203\206\343\202\243\343\201\256\343\201\227\343\201\243\343\201\213\343\202\212\343\201\227\343\201\237\343\202\263\343\203\263\343\203\206\343\203\212\343\201\253\343\201\231\343\202\213.md" @@ -0,0 +1,340 @@ +--- +title: "Vueのフロントエンドをセキュリティのしっかりしたコンテナにする" +date: 2025/10/23 00:00:00 +postid: a +tag: + - Vue.js + - Docker + - コンテナ +category: + - Frontend +thumbnail: /images/2025/20251023a/thumbnail.jpg +author: 澁川喜規 +lede: "Next.jsやNuxt.jsなどのサーバーサイドレンダリング必須なフレームワークであれば、Node.jsと一緒にコンテナ化するか、Vercelなどにデプロイする方法があります。" +--- + + + +[Vue.js連載](/articles/20251016a/)です。ライトめなネタです。 + +Next.jsやNuxt.jsなどのサーバーサイドレンダリング必須なフレームワークであれば、Node.jsと一緒にコンテナ化するか、Vercelなどにデプロイする方法があります。こちらはJavaScriptのウェブアプリケーションなので実行環境を用意する必要があります。 + +一方、SPAとして作成したVue.jsなど、現代のフレームワークで作成したフロントエンドは、ビルドすると静的HTMLとJavaScriptコードになります。ただし、ファイルが存在指定なパスへのリクエストがあった場合にindex.htmlの内容をフォールバックとして返す必要があります。 + +[Webフロントエンド設計ガイドラインのSPAのホスティング](https://future-architect.github.io/arch-guidelines/documents/forWebFrontend/web_frontend_guidelines.html#spaのホスティング)では、いくつか紹介しています。 + +1. CloudFront+S3 +2. LB+S3 +3. LB+Webサーバー + +このうち、LB+S3サーバーはSPAで必要なフォールバックができないのでSPA不可となっていますが、最近、ALBでパスのリライトができるようになったので、拡張子がないパスはindex.htmlにリライトとかやれば実はいけるのでは?という気が少ししていますが、それはまたの機会に試そうと思います。 + +* classmethodブログ: [Application Load Balancer のリスナールールでトランスフォームを構成し、ターゲットにルーティングする前にホストヘッダーや URL パスを書き換えれるようになりました](https://dev.classmethod.jp/articles/application-load-balancer-url-header-rewrite/) + +これ以外には、ウェブアプリケーション側に配信機能を持たせてしま鵜というのも過去に技術ブログで紹介しました。比較的高速なGoとかRustならありでしょう。 + +* [Go 1.16のembedとchiとSingle Page Application](https://future-architect.github.io/articles/20210408/) + +今回はDockerイメージを作る、3番目の方法を試そうと思います。 + +# なぜDockerにするか + +S3とかオブジェクトストレージにおいて配信というのがお手軽ですが、コンテナにまとめておくことでデプロイ時にまとめてフロントエンド資材を入れ替えたりがしやすいのがメリットと考えています。また、CloudFrontはインターネット公開するサービスには良いのですが、社内システムでは使えません。また、ビルド済みフロントエンドを軽量なサーバーで配信すればリソース消費は少なくて済みます。開発時もフロントエンドを触らない人にとってはありがたいのではないでしょうか + +せっかく作るのであればセキュリティを意識したコンテナを目指します。近年、ランサムウェアが流行っています。静的なHTML/JSでアプリを作りフロントエンドを配信するだけのコンテナにすることで攻撃面をかなり狭くできます。ですが不正なプログラムを配る踏み台にはされる可能性があるため、次の項目にもチャレンジしてみます。 + +* シェルがないDistroless +* フロントエンドのartifactは読み込み専用で実行ユーザーでは書き換えられない + +# テスト用アプリケーション作成 + +Viteの標準的なサンプルです。 + +```bash +% npm create vite@latest +Need to install the following packages: +create-vite@8.0.2 +Ok to proceed? (y) y + + +> npx +> "create-vite" + +│ +◇ Project name: +│ sample-app +◇ Select a framework: +│ Vue +◇ Select a variant: +│ TypeScript +: +``` + +シングルページアプリケーションで正しく動作することをテストするために、vue-routerを入れてページをいくつか足します。 + +```ts router/index.ts +import { createRouter, createWebHistory } from 'vue-router' +import Home from '../pages/Home.vue' +import About from '../pages/About.vue' +import Products from '../pages/Products.vue' +import ProductDetail from '../pages/ProductDetail.vue' +import Contact from '../pages/Contact.vue' +import NotFound from '../pages/NotFound.vue' + +const routes = [ + { path: '/', name: 'Home', component: Home }, + { path: '/about', name: 'About', component: About }, + { path: '/products', name: 'Products', component: Products }, + { path: '/products/:id', name: 'ProductDetail', component: ProductDetail, props: true }, + { path: '/contact', name: 'Contact', component: Contact }, + { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router +``` + +ルーターを組み込みます + +```ts main.ts +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import router from './router' + +createApp(App).use(router).mount('#app') +``` + +アプリケーションのトップのレイアウト側にはページナビゲーションを起きます。 + +```html App.vue + + + + + +``` + +ページを適当に作りました。全部紹介する必要性はあまりないと思うので2つだけ紹介します。 + +```html pages/Home.vue + + + + + +``` + +```html pages/About.vue + + + + + +``` + +``npm run dev``で動作させて動いたら次はDockerファイルを作っていきます。 + +# Dockerfile作成 + +## nginxの設定 + +Vueアプリができたところで次はサーバーです。Rust製の[static-web-server](https://crates.io/crates/static-web-server)とか安全そうだし良さそうだなとも思ったのですが、[APIサーバーへのリクエストをプロキシするような設定がなく、今後も入らなそう](https://github.com/static-web-server/static-web-server/issues/489)ということもあり見送りました。本番デプロイだけならALBがやってくれるはずなのでstatic-web-serverでも良いかと思います。まあありきたりですがnginxにします。 + +設定ファイルとしては、SPAで必要なフォールバックを入れたのと、ログは`/var/log`とかではなく、コンソールに出力するようにしています。 + +設定ファイル上に`user www-data;`と書けばユーザーが設定できます。ただし設定しなくても`nobody`ユーザーで動作します。最初は非ルートでやるぞ!と設定していたのですが、`nobody`で十分です。 + +```sh nginx.conf +# nginx.conf + +worker_processes auto; + +error_log /dev/stderr info; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + access_log /dev/stdout; + + server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + + # /api/ へのリクエストはバックエンドにプロキシするなど、必要に応じて設定を追加 + #location /api/ { + # proxy_pass http://backend-host:3000/; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + #} + + # 存在しないファイルへのアクセスを index.html にフォールバック + location / { + try_files $uri $uri/ /index.html; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} +``` + +## Dockerfile + +Dockerfileは以下の通りです。最初Geminiに雛形をざっと作ってもらいましたが、いろいろ細かいところを後から修正しました。その際に心がけたポイントは以下の通りです + +1. ビルドステージ + * bindマウント、cacheマウントを駆使してキャッシュフレンドリーな高速ビルド +2. nginxの設定 + * こちらもbindマウント、cacheマウントで効率化 + * 最終イメージのDistrolessはシェルがなくてmkdirとかもできないので、こちらのステージですべての必要なフォルダを作ったり、ユーザーやグループの設定を引っこ抜いたり、ディレクトリの権限設定をお子なり必要ライブラリをコピーしたりも含めて全て行なっています +3. 実行イメージ + * Debianの新しいバージョンのtrixie(13)が使いたい→まだベータ扱いなのでいったん保留 + * ビルド済みのHTML/JSを持ってきたり、nginxの設定を持ってきたり + * 実行ユーザー(nobodyから書き換えられないユーザーでHTML/JSを配置 + +最初は実行イメージは別ユーザー、と思ったのですが、nginx自体、ワーカーを作ってそちらが実質的な処理を行うと言う個性になっており、それはnobodyで動きます。そのため、仮にプロセスが乗っ取られても被害はないかな、ということでやっています。 + +```Dockerfile Dockerfile +# syntax=docker/dockerfile:1 + +ARG NODE_VERSION=24.8.0 + +# ---------------------------------------------------- +# ステージ 1: ビルドステージ (Vite SPAのビルド) +# ---------------------------------------------------- +FROM node:${NODE_VERSION}-slim AS builder + +WORKDIR /app + +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/app/node_modules \ + npm ci + +COPY . . +RUN --mount=type=cache,target=/app/node_modules \ + npm run build + +# ---------------------------------------------------- +# ステージ 2: nginxの実行ファイルとライブラリの取得 +# ---------------------------------------------------- +FROM debian:bookworm-slim AS nginx-files + +RUN --mount=type=cache,target=/var/lib/apt/lists \ + --mount=type=cache,target=/var/cache/apt/archives \ + apt-get update \ + && apt-get install -y --no-install-recommends nginx-light + +RUN mkdir -p /tmp/nginx-root/usr/sbin \ + && mkdir -p /tmp/nginx-root/etc \ + && mkdir -p /tmp/nginx-root/usr/share/nginx/html \ + \ + && cp -L /usr/sbin/nginx /tmp/nginx-root/usr/sbin/ \ + && cp -a /etc/nginx /tmp/nginx-root/etc/ \ + && cp -a /etc/passwd /tmp/nginx-root/etc/ \ + && cp -a /etc/group /tmp/nginx-root/etc/ \ + \ + && mkdir -p /tmp/nginx-root/var/lib/nginx/body \ + && mkdir -p /tmp/nginx-root/var/cache/nginx/client_temp \ + && mkdir -p /tmp/nginx-root/var/run \ + \ + && chown -R nobody:nogroup /tmp/nginx-root/var/lib/nginx \ + && chown -R nobody:nogroup /tmp/nginx-root/var/cache/nginx \ + \ + && chmod 775 /tmp/nginx-root/var/lib/nginx/body \ + && chmod 775 /tmp/nginx-root/var/cache/nginx/client_temp \ + && chmod 775 /tmp/nginx-root/var/run + +RUN LIBS="$(ldd /usr/sbin/nginx | grep '=>' | awk '{print $3, $5}' | sed 's/not found//g' | sort -u)" \ + && for lib in $LIBS; do \ + if [ -f "$lib" ]; then \ + mkdir -p /tmp/nginx-root$(dirname $lib) && cp -L $lib /tmp/nginx-root/$lib; \ + fi \ + done + +# ---------------------------------------------------- +# ステージ 3: ランタイムステージ (distroless) +# ---------------------------------------------------- +FROM gcr.io/distroless/base-debian12 + +COPY --from=nginx-files /tmp/nginx-root/ / +COPY nginx.conf /etc/nginx/nginx.conf +COPY --chown=root:root --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +ENTRYPOINT ["/usr/sbin/nginx", "-g", "daemon off;"] +``` + +```sh +# ビルド +$ docker build -t vue-spa . + +# 実行 +$ docker run --rm -it -p 8080:80 vue-spa +``` + +## デバッグ実行 + +実行イメージを`gcr.io/distroless/base-debian12`から`gcr.io/distroless/base-debian12:debug`にして、 + +# まとめ + +Dockerfileは新旧の書き方がウェブには混在しているため、機会をみてはbind/cacheを使ったDockerfileをブログに書くようにしています。今回はSPAの静的HTMLのコンテナを考察して作ってみました。ビルドの効率と実行効率、セキュリティ、どれも妥協しないDockerfileを作りました。 + +明日は松本朝香さんです。 diff --git a/source/images/2025/20251023a/thumbnail.jpg b/source/images/2025/20251023a/thumbnail.jpg new file mode 100644 index 00000000000..d764b23bcd7 Binary files /dev/null and b/source/images/2025/20251023a/thumbnail.jpg differ diff --git "a/source/images/2025/20251023a/top - \343\202\263\343\203\224\343\203\274.jpg:Zone.Identifier" "b/source/images/2025/20251023a/top - \343\202\263\343\203\224\343\203\274.jpg:Zone.Identifier" new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/images/2025/20251023a/top.jpg b/source/images/2025/20251023a/top.jpg new file mode 100644 index 00000000000..bc9febe8ec9 Binary files /dev/null and b/source/images/2025/20251023a/top.jpg differ