フューチャー技術ブログ

Vueのフロントエンドをセキュリティのしっかりしたコンテナにする

Vue.js連載です。ライトめなネタです。

Next.jsやNuxt.jsなどのサーバーサイドレンダリング必須なフレームワークであれば、Node.jsと一緒にコンテナ化するか、Vercelなどにデプロイする方法があります。こちらはJavaScriptのウェブアプリケーションなので実行環境を用意する必要があります。

一方、SPAとして作成したVue.jsなど、現代のフレームワークで作成したフロントエンドは、ビルドすると静的HTMLとJavaScriptコードになります。ただし、物理的なファイルが存在しないパスへのリクエストがあった場合にindex.htmlの内容をフォールバックとして返す必要があるため、動作させるにはそのあたりが設定可能なウェブサーバーを使う必要があります。index.htmlをロードするとブラウザ上でJavaScriptのコードが動作しますが、そのコードが内部で持っているURLのパス情報をみて適切なページを表示したり、それでも存在なければJavaScriptがエラー画面を出力します。

フューチャー作のガイドラインのWebフロントエンド設計ガイドラインのSPAのホスティングでは、いくつかホスティング方法を紹介しています。

  1. CloudFront+S3
  2. LB+S3
  3. LB+Webサーバー

このうち、LB+S3サーバーはSPAで必要なフォールバックができないのでSPA不可となっていますが、最近、ALBでパスのリライトができるようになったので、拡張子がないパスはindex.htmlにリライトとかやれば実はいけるのでは?という気が少ししていますが、それはまたの機会に試そうと思います。

これ以外には、ウェブアプリケーション側に配信機能を持たせてしまうというのも過去に技術ブログで紹介しました。比較的高速なGoとかRustならこれもありでしょう。

今回はガイドラインではSPA用によいとしている1. 3のうち、Dockerイメージを作った3番目の方法を全力で試そうと思います。

なぜDockerにするか

S3とかオブジェクトストレージにおいて配信というのがお手軽ですが、コンテナにまとめておくことでデプロイ時にまとめてフロントエンド資材を入れ替えたり、戻したりがしやすいのがメリットと考えています。また、CloudFrontはインターネット公開するサービスには良いのですが、社内システムでは使えません。また、ビルド済みフロントエンドを軽量なサーバーで配信すればリソース消費は少なくて済みます。開発時もフロントエンドを触らない人がローカルで動作検証するにはありがたいでしょう。

せっかく作るのであればセキュリティを意識したコンテナを目指します。近年、ランサムウェアが流行っています。静的なHTML/JSでアプリを作りフロントエンドを配信するだけのコンテナにしてバックエンドをプライベートネットワークの後ろ側に隠すことで攻撃面をかなり狭くできます。ですがHTMLなどが買い替えられると不正なプログラムを配る踏み台にされる可能性があるため、そうならないために次の項目にもチャレンジしてみます。

  • シェルがないDistroless
  • フロントエンドのartifactは読み込み専用で実行ユーザーでは書き換えられない

テスト用アプリケーション作成

Viteの標準的なサンプルです。

% 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を入れてページをいくつか足します。

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

ルーターを組み込みます

main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

アプリケーションのトップのレイアウト側にはページナビゲーションを起きます。

App.vue
<script setup lang="ts">
</script>

<template>
<div id="nav">
<nav>
<router-link to="/">Home</router-link>
|
<router-link to="/products">Products</router-link>
|
<router-link to="/about">About</router-link>
|
<router-link to="/contact">Contact</router-link>
</nav>
</div>

<router-view />
</template>

<style scoped>
#nav { margin-bottom: 1.5rem }
nav a { margin: 0 0.5rem; color: #646cff }
nav a.router-link-active { font-weight: 600 }
</style>

ページを適当に作りました。全部紹介する必要性はあまりないと思うので2つだけ紹介します。

pages/Home.vue
<template>
<div>
<h1>Home</h1>
<p>Welcome to the SPA home page.</p>
</div>
</template>

<script setup lang="ts">
</script>

<style scoped>
h1 { margin-bottom: 1rem }
</style>
pages/About.vue
<template>
<div>
<h1>About</h1>
<p>This is an example About page for the SPA.</p>
</div>
</template>

<script setup lang="ts">
</script>

<style scoped>
h1 { margin-bottom: 1rem }
</style>

npm run devで動作させて動いたら次はDockerファイルを作っていきます。

Dockerfile作成

nginxの設定

Vueアプリができたところで次はサーバーです。Rust製のstatic-web-serverとか安全そうだし良さそうだなとも思ったのですが、APIサーバーへのリクエストをプロキシするような設定がなく、今後も入らなそうということもあり見送りました。このプロキシ機能があればウェブフロントエンドとバックエンドが同じドメイン(ポート番号も含めて)動作するので、CORSを機にする必要がなくなります。もちろん、作ったイメージを本番デプロイするだけならALBがやってくれるはずなのでstatic-web-serverでも良いかと思います。ここはありきたりですがnginxにしておきます。なお、今回はイメージサイズは60MBほどになりました。static-web-serverはシングルバイナリで4MBほどらしいので小ささを極めたい場合はstatic-web-serverで試すと良いでしょう。

設定ファイルとしては、SPAで必要なフォールバックを入れたのと、ログは/var/logとかではなく、コンソールに出力するようにしています。

設定ファイル上にuser www-data;と書けばユーザーが設定できます。ただし設定しなくてもワーカーはnobodyユーザーで動作します。最初はセキュリティ強化のためにサーバーは非ルートユーザーで動かすぞ!と設定していたのですが、ワーカーさえルートでなければ実用上は問題ないため、無視底のnobodyで十分だと判断しました。なお、別ユーザーで動かすと、特権ポートの1024以下は使えないため、80番ポートでサーバーを動かすことはできなくなります。

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マウントを駆使してキャッシュフレンドリーな高速ビルド(生成AIはいつもやってくれない)
  2. nginxの設定
    • こちらもbindマウント、cacheマウントで効率化
    • 最終イメージのDistrolessはシェルがなくてmkdirとかもできないので、こちらのステージですべての必要なフォルダを作ったり、ユーザーやグループの設定を引っこ抜いたり、ディレクトリの権限設定を行ったり、nginxの動作に必要ライブラリをコピーしたりも含めて全て行なっています
  3. 実行イメージ
    • Debianの新しいバージョンのtrixie(13)が使いたい→まだベータ扱いなのでいったん保留
    • ビルド済みのHTML/JSを持ってきたり、nginxの設定を持ってきたり
    • 実行ユーザー(nobodyから書き換えられないユーザーでHTML/JSを配置
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;"]

実行するには次のようにします。

# ビルド
$ docker build -t vue-spa .

# 実行
$ docker run --rm -it -p 8080:80 vue-spa

デバッグ実行

設定を変えてみたい場合のデバッグ方法も紹介しておきます。Distrolessではセキュリティのためにシェルがイメージに含まれていませんが、デバッグ用のイメージが提供されています。

  • ランタイムをイメージをgcr.io/distroless/base-debian12からgcr.io/distroless/base-debian12:debugに変更
  • docker run --rm -it -p 8080:80 --entrypoint=sh vue-spa で、shをnginxの代わりに実行

まとめ

Dockerfileは新旧の書き方がウェブには混在しているため、機会をみてはbind/cacheを使ったモダンなDockerfileの記法をブログに書くように日頃からしていました。今回はVue連載ということで、Vue製のSPAの静的HTMLのコンテナを作ってみました。単に作るだけでは世の中の有象無象の記事と変わらないので、最新の記法を使ったビルドの効率、実行効率、セキュリティ、どれも妥協しないDockerfileを作りました。Vue以外の方にも参考にしてもらえる記事になったと思います。

明日は松本朝香さんです。