Chapter 03

Dockerイメージ作成

Kesin11
Kesin11
2020.11.08に更新

BazelによるDockerイメージのビルド

2章ではBazelでnodejs/TypeScriptのビルドを行う方法を紹介しましたが、BazelではDockerイメージのビルドも可能です。実際に2章で使用したTypeScriptのリポジトリを実行できるイメージを作成してみましょう。

Bazelでのイメージのビルドを紹介する前に、比較用としてdocker buildでイメージを作成するための一般的なDockerfileのコードを示します。

Dockerfile
# TypeScript build stage
FROM node:12-alpine AS ts-builder
WORKDIR /build

COPY package*.json ./
RUN npm install

COPY tsconfig.json .
COPY src/ src/
RUN ls
RUN ./node_modules/typescript/bin/tsc 

# Setup production stage
FROM node:12-alpine
WORKDIR /app

COPY package*.json ./
RUN npm install --production

COPY --from=ts-builder /build/ts_dist/ dist/

ENTRYPOINT [ "node", "/app/dist/index.js" ]

マルチステージビルドを利用することで最終的に出力するイメージのサイズを最小化する構成になっています。TypeScriptを利用するイメージを作成する方法としては一般的なものですね。

では次はBazelによるイメージのビルドを見ていきましょう。

WORKSPACE

こちらの自分が試したリポジトリを使って解説していきます。
https://github.com/Kesin11/bazel-playground/tree/master/typescript_docker

まずはWORKSPACEです。nodejs/TypeScriptを扱うためのrules_nodejsに加えて、Dockerイメージをビルドするためのrules_dockerを追加します。

WORKSPACE
# Bazel workspace created by @bazel/create 2.2.0

# Declares that this directory is the root of a Bazel workspace.
# See https://docs.bazel.build/versions/master/build-ref.html#workspace
workspace(
    # How this workspace would be referenced with absolute labels from another workspace
    name = "typescript_docker",
    # Map the @npm bazel workspace to the node_modules directory.
    # This lets Bazel use the same node_modules as other local tooling.
    managed_directories = {"@npm": ["node_modules"]},
)

# Install the nodejs "bootstrap" package
# This provides the basic tools for running and packaging nodejs programs in Bazel
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
    name = "build_bazel_rules_nodejs",
    sha256 = "4952ef879704ab4ad6729a29007e7094aef213ea79e9f2e94cbe1c9a753e63ef",
    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.2.0/rules_nodejs-2.2.0.tar.gz"],
)

# The npm_install rule runs yarn anytime the package.json or package-lock.json file changes.
# It also extracts any Bazel rules distributed in an npm package.
load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
yarn_install(
    # Name this npm so that Bazel Label references look like @npm//package
    name = "npm",
    package_json = "//:package.json",
    yarn_lock = "//:yarn.lock",
)

#
# ----ここから追加----
# ref: https://github.com/bazelbuild/rules_nodejs/blob/700374ab91530702584ccb501113670878ec8047/examples/angular/WORKSPACE#L102-L123
#
http_archive(
    name = "io_bazel_rules_docker",
    sha256 = "4521794f0fba2e20f3bf15846ab5e01d5332e587e9ce81629c7f96c793bb7036",
    strip_prefix = "rules_docker-0.14.4",
    urls = ["https://github.com/bazelbuild/rules_docker/releases/download/v0.14.4/rules_docker-v0.14.4.tar.gz"],
)

load("@io_bazel_rules_docker//repositories:repositories.bzl", container_repositories = "repositories")

container_repositories()

load("@io_bazel_rules_docker//repositories:deps.bzl", container_deps = "deps")

container_deps()

load("@io_bazel_rules_docker//repositories:pip_repositories.bzl", "pip_deps")

pip_deps()

load("@io_bazel_rules_docker//nodejs:image.bzl", nodejs_image_repos = "repositories")

nodejs_image_repos()

イメージのビルドに必要なrulesは、http_archiveでio_bazel_rules_dockerをダウンロードするところから下の部分です。rules_dockerのリポジトリのREADMEにnodejs用のイメージを作成する場合のWORKSPACEのセットアップ方法が書いてあるのですが、自分の場合はそのサンプルを使用しても動きませんでした。

コード中のコメントにも書きましたが、代わりにbazel本体リポジトリのangularのサンプルのWORKSPACEのコードの一部を拝借しています。

rules_dockerのリポジトリのREADMEが間違っているのか、自分のBazelの理解が浅くて何かを見落としているのかは分かりませんが、自分は上記のコードでエラーなくビルドできるようになりました。

BUILD

次は実際にイメージのビルドをするためのコードが書かれているBUILDを見ていきましょう。

BUILD
load("@npm//@bazel/typescript:index.bzl", "ts_project")
load("@io_bazel_rules_docker//nodejs:image.bzl", "nodejs_image")
load(":jest.bzl", "jest_test")
load("@io_bazel_rules_docker//container:container.bzl", "container_bundle", "container_image")
load("@io_bazel_rules_docker//docker/package_managers:download_pkgs.bzl", "download_pkgs")
load("@io_bazel_rules_docker//docker/package_managers:install_pkgs.bzl", "install_pkgs")

ts_project(
  name = "lib",
  srcs = glob(["src/**/*.ts"]),
  tsconfig = "tsconfig.json",
  deps = [
    "@npm//@types/lodash",
  ],
  declaration = True,
  source_map = True,
)

jest_test(
  name = "jest_test",
  srcs = glob(["__tests__/**/*.test.ts"]),
  jest_config = "jest.config.js",
  deps = [
    "src",
    "tsconfig.json",
    "@npm//@types/lodash",
    "@npm//lodash",
    "@npm//ts-jest",
  ],
)

# ここまでは2章のts_projectを使ったサンプルと同一

#
# Build container
#

# apt-getするイメージレイヤー
download_pkgs(
  name = "pkgs",
  image_tar = "@nodejs_image_base//image",
  packages = ["curl"],
)
install_pkgs(
  name = "pkgs_image",
  image_tar = "@nodejs_image_base//image",
  installables_tar = ":pkgs.tar", # .tarを追加する必要がある
  output_image_name = "pkgs_image",
)

# ビルド後の.jsを追加するイメージレイヤー
nodejs_image(
  name = "nodejs_image",
  entry_point = ":src/index.ts",
  base = ":pkgs_image",
  # npm deps will be put into their own layer
  data = [
    ":lib",
    "@npm//lodash"
  ],
)

# create時間を正しくするためのstampを行うためだけにレイヤーを追加する
container_image(
    name = "release_image",
    base = ":nodejs_image",
    stamp = True,
)

# イメージ名とタグを付ける
container_bundle(
  name = "container",
  images = {
    "ts_bazel:latest": ":release_image",
    "ts_bazel:newst": ":release_image",
  },
)

BazelによるイメージのビルドはDockerfileを使用しません。代わりにイメージレイヤーを1つ1つ重ねることでDockerのイメージを作成します。コードの解説の前にそもそもDockerのイメージレイヤーについて復習していきましょう。

Dockerイメージの仕組み

そもそもDockerのイメージとはイメージの層の積み重ねによって作られるものでした。これは図を見たほうが分かりやすいでしょう。

https://docs.docker.jp/engine/userguide/storagedriver/imagesandcontainers.html

Dockerfileを使った通常のビルドでは、Dockerfile中の1コマンドから1つのレイヤーイメージが作られています。より詳細には、通常のdocker buildではFROMで指定したイメージのコンテナを起動して、その中でaptによりビルドに必要なコマンドをインストールしたり(RUN)、ホストマシンのファイルをコンテナにコピー(COPY)することでイメージレイヤーを作成しています。

https://www.slideshare.net/zembutsu/what-isdockerdoing

普段何も意識することなくDockerfileの中でnpm installnpm run buildを書いていると思いますが、その裏で行われていることはコンテナの中で直接ビルドを行い、その成果物を含んだイメージレイヤーを重ねていることになります。

これに対して、Bazelのイメージ作成は全く方法が異なります。

Bazelにおけるイメージのビルド

Bazelによるイメージのビルドはホストマシンの中でビルドを行い、その成果物をイメージレイヤーとして重ねるという方法で実現されています。実際にコンテナを動かす必要がないのでdocker自体がインストールされていなくてもビルドが行えます。ここが通常のdocker buildとの大きな違いです。それではこの大きな違いを踏まえつつ、先程のBUILDを解説していきます。

# apt-getするイメージレイヤー
download_pkgs(
  name = "pkgs",
  image_tar = "@nodejs_image_base//image",
  packages = ["curl"],
)
install_pkgs(
  name = "pkgs_image",
  image_tar = "@nodejs_image_base//image",
  installables_tar = ":pkgs.tar", # .tarを追加する必要がある
  output_image_name = "pkgs_image",
)

https://github.com/bazelbuild/rules_docker/blob/master/docker/package_managers/README.md

実はこのイメージ作成においてcurlをインストールする必要性は全くないのですが、説明のためのサンプルとしてインストールしています。

ここではcurlのパッケージをダウンロード、インストールしてイメージレイヤーを作成しています。@nodejs_image_base//imageはrules_dockerにより提供されているnodejsを動かすためのベースイメージで、そこに対してcurlのパッケージをインストールしています。

正直、この方法が正しいのかあまり自信はありません。というのも、READMEにはdownload_pkgsとinstall_pkgsのリファレンスへのそっけないリンクしか存在しないのです・・・。このコードはexampleのコードを参考にしています。というかそれぐらいしか参考にできるものが見つけられませんでした(少なくとも現時点では)。

# ビルド後の.jsを追加するイメージレイヤー
nodejs_image(
  name = "nodejs_image",
  entry_point = ":src/index.ts",
  base = ":pkgs_image",
  # npm deps will be put into their own layer
  data = [
    ":lib",
    "@npm//lodash"
  ],
)

ここは特に難しいところもないでしょう。先ほどcurlをインストールしたイメージレイヤー(:pkgs_image)にts_projectの成果物である:libとlodashをコピーし、ENTRYPOINTを設定したイメージレイヤーを作成しています。

# create時間を正しくするためのstampを行うためだけにレイヤーを追加する
container_image(
    name = "release_image",
    base = ":nodejs_image",
    stamp = True,
)

# イメージ名とタグを付ける
container_bundle(
  name = "container",
  images = {
    "ts_bazel:latest": ":release_image",
    "ts_bazel:next": ":release_image",
  },
)

最終的にイメージを作成するのはcontainer_bundleなのですが、そのままだとイメージの作成日時が1970年になってしまうので、間にcontainer_imageでstamp = Trueをしておきます。stampを有効にするといくつかの変数がセットされて、creation_timeもデフォルトの0の代わりに現在時刻のタイムスタンプが使われるようになるようです。

https://github.com/bazelbuild/rules_docker/blob/master/README.md#container_image-1 (creation_timeの項目を参照)

最後はcontainer_bundleで、ここでビルドしたイメージにタグをセットします。タグは複数設定が可能です。

それでは自分が用意したサンプルのリポジトリで実際に実行してみましょう。

$ npm run build

> @ build /home/codespace/workspace/bazel-playground/typescript_docker
> bazel run --platforms=@build_bazel_rules_nodejs//toolchains/node:linux_amd64 //:container

INFO: Invocation ID: e50398fa-f1fc-4db3-b0db-f3f2b4f2065b
INFO: Analyzed target //:container (1 packages loaded, 17 targets configured).
INFO: Found 1 target...
Target //:container up-to-date (nothing to build)
INFO: Elapsed time: 2.340s, Critical Path: 0.06s
INFO: 0 processes.
INFO: Build completed successfully, 4 total actions
INFO: Build completed successfully, 4 total actions
Loading legacy tarball base typescript_docker/pkgs_image.tar...
Loaded image: pkgs_image:latest
Loaded image ID: sha256:7e513f7a62ac4970f4d4c6d12ff17c3f763593e59fac8a62c3403b21a01da293
Loading legacy tarball base typescript_docker/pkgs_image.tar...
Loaded image: pkgs_image:latest
Loaded image ID: sha256:7e513f7a62ac4970f4d4c6d12ff17c3f763593e59fac8a62c3403b21a01da293
Tagging 7e513f7a62ac4970f4d4c6d12ff17c3f763593e59fac8a62c3403b21a01da293 as ts_bazel:latest
Tagging 7e513f7a62ac4970f4d4c6d12ff17c3f763593e59fac8a62c3403b21a01da293 as ts_bazel:next

docker imagesを実行してみると実際にイメージが作成されていることが確認できます。

$ docker images
REPOSITORY                                         TAG                 IMAGE ID            CREATED             SIZE
ts_bazel                                           latest              7e513f7a62ac        5 days ago          170MB
ts_bazel                                           next                7e513f7a62ac        5 days ago          170MB

クロスプラットフォームビルド

ここまででイメージのビルドはできたのですが、最後のnpm run buildで実行されたコマンドに記されている--platforms=@build_bazel_rules_nodejs//toolchains/node:linux_amd64オプションについて説明します。

Bazelによるイメージのビルドは、コンテナの中で直接作業するdocker buildとは異なりホストマシンでビルドしたファイルを含むイメージレイヤーを重ねていくと先述しました。このようにビルドする場合は、イメージのOSとホストマシンのOSの違いについて考慮する必要があります。

nodejsはネイティブコードにコンパイルせず実行される言語なのでOS間の違いは受けにくいですが、npmのパッケージによってはインストール時にコンパイルが必要なものもあります。その場合はmacOS上で作られたnode_modulesをLinuxベースのイメージにコピーしても正しく動作しない、ということになります。

加えて、Bazelのnodejs_imageでイメージを作成した場合は、実行時に使用するnodejsのランタイムそのものをコンテナに自前で用意しているようです。このとき、ホストマシンのOSで動くnodejsがコンテナにコピーされます。従って、ホストマシンがmacOSの場合はmacOS用のnodejsがコンテナに持ち込まれるため、Linuxベースのコンテナの場合はnodejsそのものが起動しません。

Bazelにはクロスコンパイルを行うためのオプションが用意されており、rules_nodejsのドキュメントにもまさにこのケースのためのオプションの例が記されています。

https://bazelbuild.github.io/rules_nodejs/install.html#cross-compilation

実際に試してみましょう。macOSのマシン上でnpm run buildを使わずにbazelsik[1]経由で直接bazelコマンドを使って—-platforms無しでビルドします。

$ ./node_modules/@bazel/bazelisk/bazelisk.js run //:container
$ docker run ts_bazel:latest
ERROR[runfiles.bash]: cannot look up runfile "nodejs_linux_amd64/bin/nodejs/bin/node"  (RUNFILES_DIR="/app//nodejs_image.binary.runfiles", RUNFILES_MANIFEST_FILE="")

>>>> FAIL: The node binary 'nodejs_linux_amd64/bin/nodejs/bin/node' not found in runfiles.
This node toolchain was chosen based on your uname 'Linux x86_64'.
Please file an issue to https://github.com/bazelbuild/rules_nodejs/issues if 
you would like to add your platform to the supported rules_nodejs node platforms. <<<<

linux用のnodejsが見つけられないというエラーでコンテナの実行ができません。実際にコンテナの中に入って確認してみると、たしかにnodejs_linux_amd64は存在せずnodejs_darwin_amd64、つまりmacOSのnodejsしか存在しないことが分かります。

$ docker run -it --entrypoint=bash ts_bazel:latest
# ls /app//nodejs_image.binary.runfiles
build_bazel_rules_nodejs  nodejs_darwin_amd64  npm  typescript_docker

では次は--platforms=@build_bazel_rules_nodejs//toolchains/node:linux_amd64付きでビルドした場合のコンテナの中身を確認してみましょう。

$ npm run build
$ docker run -it --entrypoint=bash ts_bazel:latest
# ls /app//nodejs_image.binary.runfiles
build_bazel_rules_nodejs  nodejs_darwin_amd64  nodejs_linux_amd64  npm  typescript_docker

nodejs_linux_amd64が増えていることが分かります。つまりLinux用のnodejsがコンテナの中に追加されているため問題なくjsが実行できるということです。

ですが—-platformsオプションを追加したとしても、node_modulesでインストールするパッケージにOS依存のコードが含まれていた場合は上手くいかないことがドキュメントには記されています。ワークアラウンドとして、こちらのissueにてnpmのオプション追加することによりmacOSでビルドしながらもLinux向けにパッケージをインストールする方法が紹介されていました。

今回の自分のサンプルコードはlodashだけしか使っていないため試しておらずこの方法で上手くできるかどうかは分かりませんが、いずれにせよBazelによるビルドでは通常のDockerfileを使ったイメージ作成よりもOS間の違いを意識する必要があります。

3章のまとめ

Dockerイメージの作成は、多くの人にとって今まで慣れ親しんできたDockerfileによるイメージの作成と手法が全く異なるのでなかなか分かりにくいと思います。また、普段は全く意識する必要がなかったOS間の違いについても考慮する必要があるので難易度が高いです。

ここまででBazelによるビルドの基本と、Dockerイメージ作成というある程度の応用まで解説してきました。ビルド自体の解説はこの3章で終わりで、次の第4章ではいよいよBazelの強力なメリットであるリモートキャッシュの使い方について解説します。

脚注
  1. 2章で説明したようにbazeliskはbazelのラッパーコマンドです ↩︎