Docker開発環境(3): 動く手順書とCI連携
前回の続きです。
一定以上の規模のソフトウェアには必ずビルド手順書というものがあります。一例として組み込み向け Linux ファームウェアを構築するための Yocto Project のものを見てみましょう。
いろいろなことがパラパラと書いてありますが、超要約すると
- サポートしているビルドホストを用意しろ。
- apt-get でパッケージを入れろ。
- poky を git clone しろ。
- oe-init-build-env でビルド環境をセットアップしろ。
- bitbake コマンドでビルドしろ。
という感じです。
このようなビルド手順を自動化しようと思ったときに、既存のパッケージシステムの仕組みが参考になりそうです。パッケージシステムは apt (deb) 含めていろいろあるわけですが(Yocot 自体もパッケージシステムではあるのですが)、これも超簡略化すると
- 依存ソフトウェアの準備
- ソースコードの展開
- ビルド環境のセットアップ
- ビルド
- パッケージ
というような流れになることが多いと思います。configure はセットアップなのかビルドなのか、みたいな線引きの問題があったり、ソフトウェアによってはビルドとパッケージングは手順として一体化していたりなど、多少うまくはまらないケースはあると思いますが、それも「どっちかに決めて」しまえばビルドシステムとしてはとりあえず OK、として問題ないかと思います。
さて、せっかく Docker で開発環境を作るのだから、CI で自動ビルドとかもできるようにしたいと思うのが人情です。その一方で、開発者としては、ワンショットのビルドだけはできるという環境ではなく、試行錯誤しながらのインクリメンタルビルドも適切にできる環境であってほしい。また、固定のバージョンのビルドだけではなく、少し構成の異なる variant のビルドも同様にできてほしい。今回はそのような Docker 環境の構成を検討してみましょう。
動く手順書というコンセプト
ところで、手順書のとおりにコマンドを打っていると、「この手順書そのまま実行できないだろうか」と思ったりしないでしょうか。普通コマンド部分には $
でプロンプトが前置されていますし、$
で始まる行だけ抽出して実行すればできそうな気がします。
また逆に、手順書を直接動かして動くことが確認できていれば、そのまま手順書の正当性を担保できるようになります。ドキュメントの正しさの維持というのもなかなか難しいテーマのひとつだと思いますが、「動く手順書」には使うときにも作るときにもメリットがありそうです。
まずは簡単に試作をしてみましょう。操作としては extract, setup, build の 3 つを定義してみます。先程のパッケージシステムの機能とは以下のように対応します。
- 依存ソフトウェアの準備: Dockerfile
- ソースコードの展開: extract
- ビルド環境のセットアップ: setup
- ビルド: build
- パッケージ: 今回はなし
これらのレシピファイルは /etc/buildenv.d/ というディレクトリに置くことにしましょう。ファイルは markdown で置けることにします。
ソースコードを取得する。本来は git clone などで外部から取得したいところ
であるが、今回は簡単のため hello-world というディレクトリ内にファイルを
直接書くことにする。
まずはディレクトリを(なければ)作る。
$ mkdir -p hello-world
次に hello.c を作成する。
$ echo '#include <stdio.h>' > hello-world/hello.c
$ echo 'int main(void) { printf("hello, world\n"); return 0; }' >> hello-world/hello.c
このやりかたで Makefile を作るのは面倒なので、build 時に直接 gcc を叩く
ことにする。
ビルドディレクトリに移動する。
$ cd hello-world
gcc でビルドする。ここでは setup は終わっていると仮定する。
$ gcc hello.c -o hello
次にごく簡単なパーサを作ります。
#!/bin/bash
mapfile -t cmds < \
<(awk '/^[[:space:]]*\$/ {gsub("^[[:space:]]*\\$[[:space:]]*", ""); print}' \
/etc/buildenv.d/$1.*)
for c in "${cmds[@]}"; do
echo "$c"
eval "$c"
done
buildenv.sh extract
とか buildenv.sh build
とかやると、extract.md や build.md の中身をパースして実行してくれる感じですね。
最後に Dockerfile は以下のように変更します。依存パッケージとして build-essential をインストールするのと、上記で作成したファイル類をコピーしています。
FROM ubuntu:20.04
RUN \
apt-get update \
&& apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m builder
COPY buildenv.d /etc/buildenv.d
COPY entrypoint.sh /
COPY buildenv.sh /usr/local/bin/buildenv
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/bin/bash"]
entrypoint.sh については前回記事のものをそのまま使用してください。実際にビルドして動かしてみましょう。
$ docker build -t builder .
$ docker run --rm -it -v $PWD:/build -w /build builder
builder@47b4c1bf20ca:/build$ buildenv extract
mkdir -p hello-world
echo '#include <stdio.h>' > hello-world/hello.c
echo 'int main(void) { printf("hello, world\n"); return 0; }' >> hello-world/hello.c
builder@47b4c1bf20ca:/build$ . <(buildenv setup)
builder@47b4c1bf20ca:/build/hello-world$ buildenv build
gcc hello.c -o hello
builder@47b4c1bf20ca:/build/hello-world$ ./hello
hello, world
setup は一般的にディレクトリの移動や環境変数の設定を含むので、どうしても source
する必要があります。そのため . <(buildenv setup)
というちょっと特殊な呼び出し方になっています。
ともあれ意図通り extract, setup, build という流れでバイナリを作ることができました。これで骨組みは完成ですが、もうひと手間加えて使いやすくしておきましょう。Dockerfile を以下のように修正し、extract, setup, build に alias を追加してみます。
FROM ubuntu:20.04
RUN \
apt-get update \
&& apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m builder
RUN \
echo 'alias extract="buildenv extract"' >> ~builder/.bashrc \
&& echo 'alias setup=". <(buildenv setup)"' >> ~builder/.bashrc \
&& echo 'alias build="buildenv build"' >> ~builder/.bashrc
COPY buildenv.d /etc/buildenv.d
COPY entrypoint.sh /
COPY buildenv.sh /usr/local/bin/buildenv
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/bin/bash"]
これによって extract, setup, build が単に呼び出せるようになります。
$ docker run --rm -it -v $PWD:/build -w /build builder
builder@17518abde4ad:/build$ extract
mkdir -p hello-world
echo '#include <stdio.h>' > hello-world/hello.c
echo 'int main(void) { printf("hello, world\n"); return 0; }' >> hello-world/hello.c
builder@17518abde4ad:/build$ setup
builder@17518abde4ad:/build/hello-world$ build
gcc hello.c -o hello
builder@17518abde4ad:/build/hello-world$ ./hello
hello, world
さらに、この Docker コンテナは extract, setup, build のワンショットのビルドしかできないわけではありません。ファイルはホスト環境に残っているので、何度でも再入してビルドを行うことができます。
つまり、上記の状態から一度コンテナを抜けてもソースコードはカレントディレクトリに残っているので、
$ docker run --rm -it -v $PWD:/build -w /build builder
として入り直せばすぐに setup できます。
builder@7b7ebf3209f9:/build$ setup
さらに、必要があれば自分で直接 gcc を叩けます(-O3
をつけてみます)。
builder@7b7ebf3209f9:/build/hello-world$ gcc -O3 -o hello hello.c
builder@7b7ebf3209f9:/build/hello-world$ ./hello
hello, world
今回の例ではあまり関係ありませんが、前回の entrypoint.sh の効果によって、生成されるファイルは意図したパーミッションがついており、例えばビルドツールが自動生成したようなファイルをホスト環境から(root ではなく)一般ユーザーの権限で編集ができます。
もしもビルドマニュアルそのものが見たいと思えば。
builder@7b7ebf3209f9:/build$ cat /etc/buildenv.d/build.md
gcc でビルドする。ここでは setup は終わっていると仮定する。
$ gcc hello.c -o hello
のようにすれば見ることもできますね。
これで
- 必要な依存ツールが一通り揃った
- 実行可能なビルドマニュアルの含まれる
- 何度でも自由に出入りできる
- 予め用意した以外の好きなコマンドが何でも叩ける
という最高のビルド環境が誕生します。
なお、ビルド環境には「ビルド」に必要なもののみが含まれ、「編集」に必要なものは含まれません。これは Docker イメージサイズの削減もそうなのですが、編集はホスト環境の既に整備された環境で行うほうが便利だと思われるためです(人によって使うエディタも異なると思いますので)。
通常の開発スタイルは、tmux などで 2 つのウインドウを開いて、片方は編集専用、もう片方はビルド専用となることが多いと思います。
CI との連携
最後に CI との連携について考えておきます。基本的には上記で作った extract, setup, build を順番に実行すればビルドができるので、CI のタスクとして
image: builder
script:
- extract
- setup
- build
と書いておけば OK です。
というよりは逆にこう書いておけば OK になるように extract, setup, build は定義されているべきです。ビルドにはいろいろなオプションを設定することが可能な場合がありますが、単に順番にやればデフォルトの構成のビルドができるという感じですね。
もちろん、CI 環境で何かカスタマイズが必要であれば、
image: builder
script:
- extract
- setup
- echo 'foo=bar' >> .config
- build
のようにすれば大丈夫です。
docker-buildenv
ここまでで検討したようなビルド環境構築フレームワークは、docker-buildenv として既に公開されています。
docker-buildenv はこれまでに紹介した簡単なサンプルよりもより柔軟でより拡張性の高い作りになっています。
次回は docker-buildenv の紹介と、実際に Yocto のビルド環境を構築することを考えてみます。
Discussion