Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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などにデプロイする方法があります。"
---

<img src="/images/2025/20251023a/top.jpg" alt="" width="800" height="630">

[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:
[email protected]
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
<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つだけ紹介します。

```html 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>
```

```html 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](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を作りました。

明日は松本朝香さんです。
Binary file added source/images/2025/20251023a/thumbnail.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Binary file added source/images/2025/20251023a/top.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading