Open27

Dockerの素振り

バックエンド何もわからんのでDockerを素振りする。

まずはnginxでHTMLを配るだけ。

適当なディレクトリに Dockerfilehtml/index.html を用意する。HTMLの内容は適当。

html/index.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 にコピーする、という内容。

Dockerfile
FROM nginx:latest
COPY html /usr/share/nginx/html

nginx-test という名前でビルドする

docker build -t nginx-test .

nginx-testsomenginx という名前 (--name) のコンテナとしてデーモンで (-d) 立ち上げる。 ポート (-p) はホストの 8080 をコンテナの 80 に割り当てる。

docker run --name somenginx -d -p 8080:80 nginx-test

ebb14cb14956f8ac7154a1553aaeec4b2ea45daa1ab3a9ebad6a53808ee8b91c みたいな感じのIDが表示される。localhost:8080に接続していつものHTMLが表示されたら🙆。

スタイルシート…CSSを追加しよう。

html/index.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>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    Hello from nginx on Docker!
  </body>
</html>
html/style.css
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 であるという事実はインターネット上においてよく知られている。

https://www.reddit.com/r/ProgrammerHumor/comments/6s0wov/heaviest_objects_in_the_universe/?utm_source=share&utm_medium=web2x&context=3

そこでマルチステージビルド。「アプリケーションをビルドするイメージ」と「アプリケーションを配信するイメージ」に分けることで、配信側のイメージのサイズを小さくできる。今回のように静的HTML/CSS/JSを配信するならnginxなどのWebサーバが必要だし、JavaやNode.jsではランタイムを持っている必要があるが、GoやRustのようにネイティブバイナリを吐く処理系が使えるなら配信するイメージにはバイナリを置くだけで動かせる (はず、なんか依存とかあったらそれも必要になると思うけど)。

先にDockerfileを書いてしまう。

Dockerfile
FROM node:lts-buster-slim AS build
WORKDIR /app
COPY . /app
RUN npm ci
RUN npm run build

FROM nginx:latest
COPY --from=build /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=buildbuild のファイルシステムからビルド成果物を持ってくる

package.jsonはこんな感じ。Dockerfileで使う npm run build を定義しておく。エントリポイントは src/index.html で。

package.json
{
  "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をマウントするスクリプト、描画されるコンポーネントを用意。

src/index.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>Docker, React, Nginx, Parcel!</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="app.tsx"></script>
  </body>
</html>
src/app.tsx
import ReactDOM from "react-dom";
import { Index } from ".";

ReactDOM.render(<Index />, document.getElementById("root"));
src/index.tsx
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>
  );
};

npm cipackage-lock.json を強く信頼してパッケージをインストールするので、1回ホストで npm i して package-lock.json を作っておく。ところで、この手順で node_modules も誕生する。このディレクトリには環境依存なバイナリが入っていることがあり[1]COPY . /app の部分でうっかりLinuxコンテナに突っ込んで使うと痛い目を見る場合がまれによくある。そこで node_modules をDockerから除外する設定を行う。それが.dockerignoreである。

脚注
  1. sharpとか ↩︎

node_modules 以外にもParcelでのビルド時に出る .cachedist は除外しておく。どうせこれらのディレクトリはコンテナの中でビルドする際に1から作られるので加えとくだけ無駄だと思う。使わないファイルはぜんぶ除去してしまってもいいのかもしれないが、面倒なのでデカめなディレクトリだけに絞る。

.dockerignore
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アプリケーション的なものを作ってみる。

Dockerfile
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 --chown=node:node --from=build /app/out /app/out
COPY --chown=node:node --from=build /app/package*.json /app/
RUN npm ci
CMD [ "node", "." ]
  • (nginxでは思いっきりroot使ってた気がするけど) rootユーザでサーバ動かすのはさすがにアレなのでnodeを使う
  • build ではrootで作業してるので COPY するときに --chown で所有者を変えないとPermission denied する
  • CMDdocker start 時に起動するコマンドを設定する。ENTRYPOINT と似ていてちょっと違うらしいがまだ調べてない
    • nginxコンテナを起動すると勝手にnginxが立ち上がるのはこれ

デモンストレーション用プロジェクトでもTypeScriptを使う静的型付け主義の鑑

package.json
{
  "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でぶっ殺される
validatePort.ts
export const validatePort = (port: number | string | undefined) => {
  port = Number(port);
  if (Number.isInteger(port) && 0 <= port && port <= 65535) return port;
  return null;
};
ログインするとコメントできます