Dockerの素振り
バックエンド何もわからんのでDockerを素振りする。
まずはnginxでHTMLを配るだけ。
適当なディレクトリに Dockerfile
と html/index.html
を用意する。HTMLの内容は適当。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test index</title>
</head>
<body>
Hello from nginx on Docker!
</body>
</html>
nginxのイメージを引っ張ってきて html
の内容を /usr/share/nginx/html
にコピーする、という内容。
FROM nginx:latest
COPY html /usr/share/nginx/html
nginx-test
という名前でビルドする
docker build -t nginx-test .
nginx-test
を somenginx
という名前 (--name
) のコンテナとしてデーモンで (-d
) 立ち上げる。 ポート (-p
) はホストの 8080 をコンテナの 80 に割り当てる。
docker run --name somenginx -d -p 8080:80 nginx-test
ebb14cb14956f8ac7154a1553aaeec4b2ea45daa1ab3a9ebad6a53808ee8b91c
みたいな感じのIDが表示される。localhost:8080に接続していつものHTMLが表示されたら🙆。
スタイルシート…CSSを追加しよう。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test index</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
Hello from nginx on Docker!
</body>
</html>
body {
background-color: #111;
color: #fff;
font-family: sans-serif;
font-weight: 700;
}
さて、html の内容は Dockerfile の COPY
でイメージに突っ込まれているので、変更した後にはビルドし直さないといけない。docker build -t nginx-test .
でビルドして、新しいコンテナを作り直す。somenginx
の名前はさっき作ったやつがまだ持っているので、別の名前をつけるか削除するのがいい。docker stop somenginx
でコンテナを止めて docker rm somenginx
で削除するのが素直な感じだが、docker rm --force somenginx
でrunningなコンテナを直接削除できる。
なんやかんやの後に
docker run --name somenginx -d -p 8080:80 nginx-test
でコンテナを起動。いい感じのスタイルがついてたら成功。
ブラウザの 開発シャツルール 開発者ツールのネットワーク欄から、/
と style.css
が取得されているのがわかる。ついでに favicon.ico
を取ろうとして404を喰らっているのもわかるだろう。
一旦コンテナを止めて docker start somenginx -a
すると、コンテナの標準出力がターミナルにつながる。要するにログが見える。この状態でもう1回リクエストを送るとこんな雰囲気のログが出るはず。
172.17.0.1 - - [22/Feb/2021:06:41:34 +0000] "GET / HTTP/1.1" 200 354 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" "-"
172.17.0.1 - - [22/Feb/2021:06:41:34 +0000] "GET /style.css HTTP/1.1" 200 97 "http://localhost:8080/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" "-"
2021/02/22 06:41:34 [error] 25#25: *1 open() "/usr/share/nginx/html/favicon.ico" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /favicon.ico HTTP/1.1", host: "localhost:8080", referrer: "http://localhost:8080/"
172.17.0.1 - - [22/Feb/2021:06:41:34 +0000] "GET /favicon.ico HTTP/1.1" 404 153 "http://localhost:8080/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" "-"
次、Reactアプリをnginxで同じように配る。ReactアプリといってもただのHTMLとCSSとJavaScriptの集合体なのでなんも恐ろしいことはなく、ただHTMLをnginxに配らせるだけ。webpackやBabelと殴り合ってると時間の無駄なので、ビルドはすべてParcelにやらせる。
Dockerfileのマルチステージビルドについて説明する。1個前の例ではすでにある静的なファイルを配信した。それに対して現代フロントエンドではそれらのファイルはソースコードから生成される。TSXファイルをBabelで素のJSに変換したり、Reactその他依存ライブラリをwebpackでバンドルしたりしなければいけない。この手順もDockerにやらせたいとする。するとwebpackなどのツールがイメージに突っ込まれてしまい重くなる。この宇宙でもっとも重い物体が node_modules
であるという事実はインターネット上においてよく知られている。
そこでマルチステージビルド。「アプリケーションをビルドするイメージ」と「アプリケーションを配信するイメージ」に分けることで、配信側のイメージのサイズを小さくできる。今回のように静的HTML/CSS/JSを配信するならnginxなどのWebサーバが必要だし、JavaやNode.jsではランタイムを持っている必要があるが、GoやRustのようにネイティブバイナリを吐く処理系が使えるなら配信するイメージにはバイナリを置くだけで動かせる (はず、なんか依存とかあったらそれも必要になると思うけど)。
先にDockerfileを書いてしまう。
FROM node:lts-buster-slim AS build
WORKDIR /app
COPY . /app
RUN npm ci
RUN npm run build
FROM nginx:latest
COPY /app/dist /usr/share/nginx/html
- Alpine Linuxの使い方を覚えるのが面倒なので
node:lts-buster-slim
を使う -
AS build
とすることでこのステージにbuild
という名前を付ける。後々この名前で参照できる -
package-lock.json
を用意してnpm ci
するとnpm i
より若干早く (たぶん) Dockerのキャッシュが効きやすい -
FROM nginx:latest
でnginxベースの新しいイメージを作る -
--from=build
でbuild
のファイルシステムからビルド成果物を持ってくる
package.jsonはこんな感じ。Dockerfileで使う npm run build
を定義しておく。エントリポイントは src/index.html
で。
{
"name": "nginx-react-parcel",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "parcel build src/index.html",
"dev": "parcel serve src/index.html"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1"
},
"devDependencies": {
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.1",
"parcel-bundler": "^1.12.4",
"typescript": "^4.1.5"
}
}
エントリポイントのHTML、Reactをマウントするスクリプト、描画されるコンポーネントを用意。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docker, React, Nginx, Parcel!</title>
</head>
<body>
<div id="root"></div>
<script src="app.tsx"></script>
</body>
</html>
import ReactDOM from "react-dom";
import { Index } from ".";
ReactDOM.render(<Index />, document.getElementById("root"));
import { useState } from "react";
const useCount = () => {
const [count, setCount] = useState(0);
const increment = () => {
setCount((prev) => prev + 1);
};
const decrement = () => {
setCount((prev) => prev - 1);
};
const reset = () => {
setCount(0);
};
return [count, increment, decrement, reset] as const;
};
export const Index: React.VFC = () => {
const [count, increment, decrement, reset] = useCount();
return (
<div>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>RESET</button>
<p>{count}</p>
</div>
);
};
node_modules
以外にもParcelでのビルド時に出る .cache
と dist
は除外しておく。どうせこれらのディレクトリはコンテナの中でビルドする際に1から作られるので加えとくだけ無駄だと思う。使わないファイルはぜんぶ除去してしまってもいいのかもしれないが、面倒なのでデカめなディレクトリだけに絞る。
node_modules
.cache
dist
ところで筆者はさっきファイル名を .Dockerignore
にして動かねえ動かねえとTwitterで叫んでいた。
さっきと同じように docker build -t nginx-react-parcel .
とかでイメージをビルドする。Sending build context to Docker daemon 602.6kB
と出ていたら明らかにnode_modulesは含まれていない。これが170MBとかだった場合はnode_modulesが入っていて、若干時間がかかるはず。これを docker run --name nrp -p 8080:80 nginx-react-parcel
とかで動かして、ブラウザで localhost:8080
を開いて、イカしたReactアプリが表示されたら成功。
前のほうで動かしてたコンテナを止めておくのを忘れずに。8080
が使用中だとコンテナをcreateするのはできるがstartできない。
/app.1950b51a.js
が登場する。こいつにReactとかコンポーネントとかが入ってる。
172.17.0.1 - - [22/Feb/2021:08:56:38 +0000] "GET / HTTP/1.1" 200 317 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" "-"
+ 172.17.0.1 - - [22/Feb/2021:08:56:38 +0000] "GET /app.1950b51a.js HTTP/1.1" 200 135213 "http://localhost:8080/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" "-"
2021/02/22 08:56:38 [error] 25#25: *1 open() "/usr/share/nginx/html/favicon.ico" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /favicon.ico HTTP/1.1", host: "localhost:8080", referrer: "http://localhost:8080/"
172.17.0.1 - - [22/Feb/2021:08:56:38 +0000] "GET /favicon.ico HTTP/1.1" 404 153 "http://localhost:8080/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" "-"
今度は趣向を変えて、FastifyでWebアプリケーション的なものを作ってみる。
FROM node:lts-buster-slim AS build
WORKDIR /app
ENV NODE_ENV=development
COPY . /app
RUN npm ci
RUN npm run build
FROM node:lts-buster-slim
RUN mkdir /app && chown node:node /app
WORKDIR /app
ENV PORT=80
ENV NODE_ENV=production
USER node
COPY /app/out /app/out
COPY /app/package*.json /app/
RUN npm ci
CMD [ "node", "." ]
- (nginxでは思いっきりroot使ってた気がするけど) rootユーザでサーバ動かすのはさすがにアレなのでnodeを使う
-
build
ではrootで作業してるのでCOPY
するときに--chown
で所有者を変えないとPermission denied する -
CMD
でdocker start
時に起動するコマンドを設定する。ENTRYPOINT
と似ていてちょっと違うらしいがまだ調べてない- nginxコンテナを起動すると勝手にnginxが立ち上がるのはこれ
デモンストレーション用プロジェクトでもTypeScriptを使う静的型付け主義の鑑
{
"name": "docker-fastify",
"main": "out/main.js",
"private": true,
"scripts": {
"build": "tsc --outDir out",
"test": "echo \"Error: no test specified\" && exit 1"
},
"license": "ISC",
"devDependencies": {
"@types/node": "^14.14.31",
"typescript": "^4.1.5"
},
"dependencies": {
"fastify": "^3.12.0"
}
}
アクセスされるとユーザーエージェントを返す。user-agentなしでのアクセスには400を返す。
import Fastify from "fastify";
import { validatePort } from "./validatePort";
const port =
validatePort(process.env["PORT"]) ??
(() => {
throw new Error(`invalid $PORT: ${process.env["PORT"]}`);
})();
const fastify = Fastify({ logger: true });
fastify.get("/", async (request, reply) => {
const userAgent = request.headers["user-agent"];
if (userAgent === undefined)
throw { statusCode: 400, message: `user-agent is empty` };
return userAgent;
});
fastify.listen({ port }).catch((err) => {
console.log(`Error starting server:`, err);
process.exit(1);
});
process.once("SIGTERM", () => {
fastify.log.info(`Closing by SIGTERM`);
fastify.close();
});
process.once("SIGINT", () => {
fastify.log.info(`Closing by SIGINT`);
fastify.close();
});
-
throw
式ほしい - Promiseをreject (asyncでthrow) するとエラー扱いになる
-
docker stop
するときSIGTERMが飛ぶのでいい感じにキャッチして終了する- 一定時間停止しないとSIGKILLでぶっ殺される
export const validatePort = (port: number | string | undefined) => {
port = Number(port);
if (Number.isInteger(port) && 0 <= port && port <= 65535) return port;
return null;
};