BazelによるDockerイメージのビルド
2章ではBazelでnodejs/TypeScriptのビルドを行う方法を紹介しましたが、BazelではDockerイメージのビルドも可能です。実際に2章で使用したTypeScriptのリポジトリを実行できるイメージを作成してみましょう。
Bazelでのイメージのビルドを紹介する前に、比較用としてdocker build
でイメージを作成するための一般的な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 /build/ts_dist/ dist/
ENTRYPOINT [ "node", "/app/dist/index.js" ]
マルチステージビルドを利用することで最終的に出力するイメージのサイズを最小化する構成になっています。TypeScriptを利用するイメージを作成する方法としては一般的なものですね。
では次はBazelによるイメージのビルドを見ていきましょう。
WORKSPACE
こちらの自分が試したリポジトリを使って解説していきます。
まずはWORKSPACEです。nodejs/TypeScriptを扱うためのrules_nodejsに加えて、Dockerイメージをビルドするためのrules_dockerを追加します。
# 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のセットアップ方法が書いてあるのですが、自分の場合はそのサンプルを使用しても動きませんでした。
- https://github.com/bazelbuild/rules_docker#setup
- https://github.com/bazelbuild/rules_docker#nodejs_image
コード中のコメントにも書きましたが、代わりにbazel本体リポジトリのangularのサンプルのWORKSPACEのコードの一部を拝借しています。
rules_dockerのリポジトリのREADMEが間違っているのか、自分のBazelの理解が浅くて何かを見落としているのかは分かりませんが、自分は上記のコードでエラーなくビルドできるようになりました。
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のイメージとはイメージの層の積み重ねによって作られるものでした。これは図を見たほうが分かりやすいでしょう。
Dockerfileを使った通常のビルドでは、Dockerfile中の1コマンドから1つのレイヤーイメージが作られています。より詳細には、通常のdocker build
ではFROM
で指定したイメージのコンテナを起動して、その中でapt
によりビルドに必要なコマンドをインストールしたり(RUN
)、ホストマシンのファイルをコンテナにコピー(COPY
)することでイメージレイヤーを作成しています。
普段何も意識することなくDockerfileの中でnpm install
やnpm 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",
)
実はこのイメージ作成において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のドキュメントにもまさにこのケースのためのオプションの例が記されています。
実際に試してみましょう。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の強力なメリットであるリモートキャッシュの使い方について解説します。
-
2章で説明したようにbazeliskはbazelのラッパーコマンドです ↩︎