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;
};