10
8

More than 3 years have passed since last update.

Dockerイメージへのテスト実装例(Automated Buildsの自動テスト対応含む)

Posted at

はじめに

Docker Hub の Automated Builds にはイメージの自動ビルドにテストコードによる自動テストを追加することが出来ます(参考 Automated repository tests)。sut (system under test) という名前のサービスを記述した docker-compose.test.yml を作成して、Docker Hub と連携させた GitHub にプッシュすることで Automated Builds がそれを自動的に実行するという仕組みです。テストコードはシェルスクリプトの他、実装次第でいろんな言語やフレームワークを使うことが可能です。

docker-compose を使ってテストするためローカルでも実行することが出来ます。Docker Hub へのイメージのプッシュや Automated Builds が必ずしも必要なわけではありません。そこでこの記事ではローカルで Docker イメージのテストを行いつつ、最後に Automated Builds に対応させるという流れにしています。そして番外編として docker-compose を使わないパターンも紹介します。

この記事は私が実際に Automated Builds の自動テストを使ってみて、どのように実装するのが良いのか試行錯誤して得たノウハウをまとめたものであり、公式ドキュメントや広く使われているパターンを紹介したものではありません。個人的な考えやアイデアがかなり含まれています。良くない方法かも知れないしもっと良い方法があるかもしれませんのでご了承ください。

公式ドキュメントへのツッコミ

余談ですが公式ドキュメント Set up automated test files へのツッコミです。

sut:
  build: .
  command: run_tests.sh

提示されていたサンプルコードは残念ながらこれだけでした。必要最小限の動作と Automated Builds の仕組みと設定方法しか書かれていなかったので、ここからどんな感じで Docker イメージのテストを書いていけば良いのかよくわからず、またネットで検索しても良い記事には出会えませんでした。それがこの記事としてまとめた理由です。

テスト対象について

テスト対象について少し補足しておきます。Docker イメージのテストは docker-compose を使って行うので、正確には「Dockerイメージ」から作った「Dockerコンテナ」をテストすることになります。例えば Docker イメージのメタデータのテストなど、Docker コンテナを作らないテストもあり、本文中のやり方を応用すればそういったテストを書くことも出来ますが、この記事では原則として Docker コンテナをテストしています。なるべく適切な用語を使うよう「Docker イメージ」と「Docker コンテナ」という用語を使い分けていますが、そのせいで逆に混乱させてしまうかもしれません。この記事に関して言えばほとんど同じ意味で使っていると思って構いません。

何をテストするべきか?

さて「Docker イメージのテスト」ではなにをテストするべきでしょうか?忘れてはいけないのは Docker Hub は CI サービスではなく Docker イメージのレジストリということです。長々とアプリケーションのテストを行うためのサービスではありませんし、そのように作られてないはずです。ようするに「Docker イメージが想定通りビルドされているか」だけをテストするべきということです。

別の言い方をすれば、アプリケーションのテストでは通常行っていないと思われる Dockerfile や ENTRYPOINT で行っている処理のテストということになります。例えば COPY でイメージ内に追加したファイルが想定通りであるか RUN で追加したパッケージのコマンドが正しく実行できるか、コンテナ内部で起動したサービスにアクセスできるかなどです。

つまるところ「(Docker イメージを使って)アプリケーションのテストを行う」のと「(アプリケーションを含んだ)Docker イメージのテストを行う」のは別であるということです。Docker イメージのテストでは、やったとしてもせいぜい基本的な機能が動いているのを確認する程度(いわゆるスモークテスト)レベルで、本格的なアプリケーションのテストはローカルや専用の CI サービスなどを使って行います。

たまに Docker イメージの形で提供されているアプリケーションで ENTRYPOINT で実行するスクリプトがアプリケーションの機能そのものとなっているものを見かけることがありますが、Docker は原則としてアプリケーション実行環境の仮想化をするために使うものなので、アプリケーションそのものは環境さえ揃っていれば Docker イメージにしなくても動くように作るべきだと思います。

ディレクトリの基本構成

(公式のサンプルコードから読み取った)プロジェクトのディレクトリ構成は以下のようになると思います。

project/
├── Dockerfile
├── README.md
├── docker-compose.test.yml (拡張子 .test.yml で複数作成可能)
├── run_tests.sh (テストコード)
└── prog.sh (例 Dockerイメージが提供する機能のプログラム)

シンプルなプロジェクトや Docker イメージを作るためだけのプロジェクトであればこれで十分なのですが、例えば一つのプロジェクトから複数のイメージを作成する等の理由で Dockerfile が複数必要だったり、CI の設定などすでにプロジェクトルートに多数のファイルがある場合、そこに Docker イメージとそのテストに関するファイルを追加するとさらにプロジェクトルートが混沌としてきます。また一般的にプロジェクトの README.md と Docker Hub の「Overview」は異なる内容(例えば Docker タグの説明等)を表示したくなると思うのですが、上記の構成だとプロジェクトの README.md がそのまま Docker Hub に表示されてしまいます。(しかも単純に README.md を読み取るだけなのでプロジェクト内へのページや画像へのリンクは切れて、読み取るサイズにも制限があります。)

そのため次のように Docker に関するファイルを docker ディレクトリ以下に移動した形を基本構成とします。

project/
├── docker
│   ├── Dockerfile
│   ├── README.md (Docker Hub の Overview 用)
│   ├── run_tests.sh
│   └── docker-compose.test.yml
│── .env
│── README.md
└── prog.sh

また、以下のような内容の .env ファイルをしています。(注意 リポジトリに入れません)

.env
COMPOSE_PROJECT_NAME="project"

このファイルは docker-compose.test.ymldocker ディレクトリ以下に移動したために必要になったものです。 docker-compose のプロジェクト名は Docker コンテナを作成する時の名前の一部に利用されるのですが、デフォルトでは docker-compose.test.yml があるディレクトリ名(project)が使用されます。docker ディレクトリに移動すると、docker-compose のプロジェクト名が docker となってしまい、他のプロジェクトでも同じ構成にすると名前がかぶってしまうために明示的に指定しています。ただし .env は docker-compose 以外でも使われることがあるファイルで秘密情報を書き込むこともあるのでリポジトリには追加しないようにします。このような面倒があるため、docker ディレクトリではなくプロジェクト名等にしたほうがいいかもしれませんが、とりあえずこの点は重要ではないのでサンプルということでこのままにします。

この構成にした場合のローカルでのテストの実行コマンドはプロジェクトのルートディレクトリで docker-compose -f docker/docker-compose.test.yml up と入力します。任意で --build, --abort-on-container-exit, --exit-code-from 等のオプションを指定すると便利でしょう。また本番用の(テストコードを含まない) Docker イメージのビルドは docker build -f docker/Dockerfile . で行います。

テストの方針

テストの方針は大きく二通り考えられます。一つは「1. テスト対象のコンテナの中でテストを行う」やり方で、サービスを一つだけ作ります。もう一つは「2. テスト対象のコンテナの外からテストを行う」やり方で、本番用のサービスとそれに対してテストを行うサービスの二つを作るやり方です。

どちらが良いかはプロジェクトによって変わると思いますが、コンテナ内部の構成を検証したり単体のコンテナだけで完結するよう簡単なものであれば前者で、複数のコンテナを連携したりする場合や本番用イメージに手を加えることなく厳密なテストを行いたい時は後者のほうが良いのではないかと思います。

また Automated Builds 対応のために、docker-compose を使用していますが、前者であれば docker コマンドだけでテストを行うことも可能です。これについては「番外編 Automated Builds対応が不要の場合」で説明します。

1. テスト対象のコンテナの中でテストを行う

テスト対象の Docker コンテナの中でテストコードを実行し、内部のプログラムやファイルが想定通りになっているかをテストします。マルチステージビルドを使って、一つの Dockerfile から本番用のイメージとテスト用のイメージをビルドしています。(1.1 を除く)

1.1 テストコードをYAMLに直接埋め込む

一番シンプルな場合の例で docker-compose.test.yml にテストコードを直接記述します。本番用イメージを変更せずにそのままテストするためマルチステージビルドは使いません。

docker-compose.test.yml
version: "3"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    entrypoint: /bin/sh -eux -c
    command:
    - |
      exec 1>&2
      type awk                                        # awkコマンドが実行可能かつ存在するか?
      type /usr/bin/awk                               # awkコマンドが/usr/bin/awkに存在するか?
      file /etc/apache2/apache2.conf                  # apache2.confが存在するか?
      apache2 --version                               # apache2 --versionが実行できるか?
      grep VERSION_CODENAME=buster /etc/os-release    # VERSION_CODENAME=がbusterであるか?
      pidof apache2                                   # apache2が起動しているか?
      nc -vz localhost 80                             # TCP80ポートをオープンしているか
      stat -c "%a %U:%G" /etc | grep "755 root:root"  # アクセス権、ユーザー、グループの確認
docker/Dockerfile
FROM alpine as release
COPY ./prog.sh /usr/local/bin/

公式のサンプルとは違い buildcontextdockerfile を定義しています。これは Docker に関連するものを docker ディレクトリ以下に移動したために必要となったものです。

entrypoint はテストコードをdocker-compose.test.ymlに直接埋め込むために必要です。これにより標準で ENTRYPOINT が定義されているは上書きされます。必要な場合はテストコードから実行してください。またテストコードは docker-compose.test.yml ファイルの一部なので docker-compose によるパース処理が行われます。$ は特殊な文字として扱われるので $$ とエスケープして書く必要があります。

この例ではテストコードをシェルスクリプト (/bin/sh) で記述していますが、テスト対象のイメージに含まれているものであれば他の言語も使用できます。しかし言語そのものならまだしもテスティングフレームワークが本番用イメージに含まれていることはまずないので使える言語は限られると思います。個人的には Docker イメージのテストでそこまで複雑なことが必要になるとは思わないので /bin/sh/bin/bash で十分だと思います。

本筋から少しずれますが、シェルスクリプトに関しての補足です。/bin/sh のオプションの e は(テストの)コマンドが失敗したら直ちに中断させるためのもので、これを前提とするとテストコードがシンプルとなります。例えば上記のコードに if 文は存在しませんが手抜きではありません。コマンドが失敗すればそこで中断します(もちろん if 文を使って複雑な検証もできます)。u は未定義の変数を使用した場合にエラーにするためのもので、分かっている上でならなくても構いません。x はこれから実行するコマンドを出力する機能で実行前のログとして使えます。各コマンドの出力は実行後のログとしてあえて出力しています。例えば type awk の出力は awk is /usr/bin/awk という”ログ”です。exec 1>&2 は標準出力を標準エラー出力に変更しています。こうすることで x による出力とコマンド実行結果の出力順が混ざらなく(混ざりにくく)なります。

1.2 テストコードを別ファイルに分離する

テストコードが長くなったなどの理由でテストコードを別ファイルに分離する方法です。マルチステージビルドを使ってテストコードを含んだテスト用イメージを作成します。厳密に言えば本番用イメージとテスト用イメージが異なるわけですが、テストコードのファイル(シェルスクリプト)を追加しているだけなので大きな影響はないはずです。公式のサンプルコードに一番近い形です。

docker-compose.test.yml
version: "3.4"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: test
    command: run_tests.sh

マルチステージビルドを使用するので Compose ファイルの Version3.4 (Docker Engine 17.09.0) 以上が必要になります。また、テスト実行時にテスト用イメージを使うために target: test を追加しています。

docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production

本番用とテスト用の両方のイメージが含まれています。テスト用イメージは本番用イメージを親イメージとして使いテストコードのファイルを追加しています。最後の行は Docker イメージを --target オプションをつけずにビルドした場合に本番用イメージを生成するためのものです。(Dockerfile にデフォルトの target を指定する機能があればいいのですが)

run_tests.sh
#!/bin/sh -eux
exec 1>&2
bash --version

テストでは bash を実際に実行することで、bash が実行できることを確認しています。(実行権限を調べたりするよりも確実な方法です。)

なお本番用イメージにテスト用のコードを含ませないように厳密にするためにこのようにしていますが、この程度であればサイズも小さく影響も少ないので面倒であれば本番用イメージにテストコードまで含めてしまってもいいかなとも思います。

1.3 テスト用にパッケージを追加する

この方法は推奨はしません。なぜなら本番用イメージとテスト用イメージが大きく異なってしまう可能性があるのと、テスト用イメージのビルドに時間がかかるようになるからです。本番用イメージとテスト用イメージが大きく異なると本番用イメージは正しく動かないのにテスト用イメージでは動いてしまう可能性があります。とはいえ、さまざまな理由で避けられないことがあるかもしれません。

例えば Serverspec を使用してテストを書きたい(書かなくてはいけない)とします。(ちなみに私は Serverspec に関してはほぼ素人です。間違い等ありましたらすみません。)Serverspec でテストを行うために ruby や rspec などのパッケージが必要になります。このパッケージはテストのためだけに必要となるものなので本番用イメージには含めたくありません。そこで前項と同様にマルチステージビルドを使ってパッケージを追加したテスト用イメージを作成します。

前提として本番用イメージには alpine を使うことにしますが、alpine には Serverspec のパッケージがありませんでした。そのため Gemfile からライブラリをインストールしています。Serverspec (rspec) の話が絡んでいるので、軽くディレクトリ構造を説明しておきます。Gemfileとテストコードのファイルが入った spec ディレクトリを追加しています。

project/
├── docker
│   ├── Dockerfile
│   ├── README.md
│   ├── docker-compose.test.yml
│   ├── Gemfile
│   └── spec
│       ├── spec_helper.rb
│       └── test_spec.rb
│── .env
│── README.md
└── prog.sh

まず最初に docker-compose.test.yml です。シェルスクリプトの run_tests.sh の代わりに、テスト実行コマンドの rspec を呼び出すように変更します。

docker/docker-compose.test.yml
version: "3.4"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: test
    command: rspec

次に Dockerfile です。テストに必要なパッケージとライブラリをインストールしたテスト用イメージを作成します。

docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
WORKDIR /root
RUN apk add ruby-bundler ruby-rspec
COPY docker/Gemfile ./
RUN bundle config silence_root_warning 1 && bundle
COPY docker/spec ./spec

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production

イメージの依存関係に注目してください。テスト用イメージは本番用イメージが親イメージになっています。つまり本番用イメージを変更するたびに、テスト用イメージも作り直さなければいけなくなるので、パッケージとライブラリのインストールを何度も行ってしまいます。これがテスト用イメージのビルドに時間がかかるようになる理由です。

また、本番用イメージだけが必要な場合でも、テスト用イメージまでビルドしてしまいます。この問題に関しては Build Kit を有効にする(環境変数 DOCKER_BUILDKIT1 にする)ことで回避が可能です。

最後に Serverspec でテストするのに必要なファイルです。

docker/Gemfile
source 'https://rubygems.org'
gem 'serverspec'
docker/spec/spec_helper.rb
require 'serverspec'
set :backend, :exec
docker/spec/test_spec.rb
require 'spec_helper'

describe package('bash') do
  it { should be_installed }
end

このテストでは bash パッケージがインストールされていることを確認しています。ちなみに個人的な意見ですが、私はパッケージがインストールされているかのテストは不要だと考えています。なぜなら本当に確認したいのは「bash が実行できること」なので、それならば実際に bash を実行してみるのが確実で、パッケージで入れようが独自ビルドしようがインストール方法(実現手段)には興味がないからです。

1.4 テスト用にバイナリファイルを追加する

「1.3 テスト用にパッケージを追加する」の改良型とも言えるパターンです。1.3 では本番用イメージが変更されるたびにテスト用イメージも作り直されてしまいましたがそれを回避する方法です

docker-compose.test.ymlrun_tests.s は「1.2 テストコードを別ファイルに分離する」と同じなので省略します。

docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用追加ファイルイメージ
FROM alpine as builder
ENV JQ=https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
RUN wget -q $JQ -O - | install /dev/stdin /usr/local/bin/jq

# テスト用イメージ
FROM release as test
COPY --from=builder /usr/local/bin/ /usr/local/bin/
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production

テストコードを書くために jq コマンドが必要になったという想定です。「本番用イメージ」と「テスト用追加ファイルイメージ」に依存関係がないことに注目してください。時間がかかるのは jq のダウンロードでコピーだけならファイルサイズが相当大きくない限りに時間はかからないので、ダウンロード済みのイメージを作っておいて実行ファイルをコピーしています。(Dockerfile の ADD コマンドで URL から直接追加するのはやめましょう。毎回ダウンロードが行われ、時間がかかる上に相手のサーバーにも負担をかけてしまいます。)

このパターンが「1.3 テスト用にパッケージを追加する」の改良型である理由は、jq のような単一のバイナリだけでなく、ruby や rspec のような複数のファイルで構成される場合でも使えるからです。ただし apkapt-get を使った一般的なパッケージのインストール方法ではどこに何がインストールされるかを判断するのは面倒なので、独自ビルドで標準とは違うディレクトリ以下にインストールしたり、パッケージファイルのみをダウンロードしておいてインストールはテスト用イメージで行うなどの工夫をする必要があります。(独自ビルドを行う場合は本番用イメージとビルドを行うイメージのFROMは、同じか互換性があるイメージを使用する必要があります。)

補足ですが「テスト用追加ファイルイメージ」はファイルを提供するためだけので汎用性があり使い回すことが出来ます。例えば jq のスタティックバイナリだけを入れたイメージを作っておけば(また Docker Hub などにプッシュしておけば)以下のように短くすることが出来ます。

docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
COPY --from=jq-static /usr/local/bin/ /usr/local/bin/
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production

参考 jq のスタティックバイナリだけを入れたイメージの作り方

Dockerfile.jq
# docker build -f Dockerfile.jq -t jq-static .
FROM alpine as builder
ENV JQ=https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
RUN wget -q $JQ -O - | install /dev/stdin /usr/local/bin/jq

FROM scratch
COPY --from=builder /usr/local/bin/ /usr/local/bin/

そしてここで宣伝を一つ。私が開発している shellspec というテスティングフレームワークは、完全にシェルスクリプトで実装されているため OS 非依存で POSIX 準拠のシェルさえあれば動作します。また公式でテスティングフレームワークのみをインストールした Docker イメージ(scratch イメージをベースとしておりイメージサイズはわずか 40KB)も用意しているので、このような使い方にも適しています。

1.5 Docker コマンドを使ったテスト

Docker コンテナの中から docker コマンドを使えるようにすることで、Docker イメージ または Docker コンテナのテストを行うことができます。docker inspect を使ってメタデータのテストを行いたい等の場合を想定しています。

docker/docker-compose.test.yml
version: "3.4"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: test
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: run_tests.sh

技術的には Docker outside of Docker (DooD) と呼ばれている手法で volumes で指定している /var/run/docker.sock (UNIXドメインソケットファイル)を経由してコンテナの中からコンテナの外の Docker にアクセスしています。

docker/Dockerfile
# 本番用イメージ
FROM alpine as release
LABEL "com.example.vendor"="ACME Incorporated"
EXPOSE 80
COPY ./prog.sh /usr/local/bin/

# テスト用追加ファイルイメージ
FROM alpine as builder
ENV DOCKER=https://download.docker.com/linux/static/stable/x86_64/docker-19.03.5.tgz
RUN wget -q $DOCKER -O - | tar xz -C /opt

# テスト用イメージ
FROM release as test
COPY --from=builder /opt/docker/docker /usr/local/bin/
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production

docker コマンドが必要なためスタティックバイナリで配布されている Docker のアーカイブをダウンロードしています。

docker/run_tests.sh
#!/bin/sh -eux

exec 1>&2
cid=$(basename $(cat /proc/1/cpuset))
inspect() {
  docker inspect --format "$1" "$2"
}
[ "$(inspect '{{index .Config.Labels "com.example.vendor"}}' "$cid")" = "ACME Incorporated" ]
[ "$(inspect '{{index .Config.ExposedPorts "80/tcp"}}' "$cid")" != "<no value>" ]

このテストでは Docker イメージの LABEL と EXPOSE のテストを行っています。/proc/1/cpuset には/docker/76bd63191859659df42ea9bba0af0767969bab3ac9253db6bb5a54691a5bdc99 のような形で現在の Docker コンテナの ID が入っているのでそれを利用してコンテナの情報を取得しています。

また詳細は省略しますが、Serverspec にも docker コマンドを内部的に呼び出す docker_containerdocker_image というリソースタイプがありますので Serverspec でテストを書くこともできます。

1.6 Docker API を使ったテスト

docker コマンドが使用できると言う話でピンときた方もいるかと思いますが(HTTP プロトコルベースの)Docker API を使うこともできます。「1.5 Dockerコマンドを使ったテスト」のちょっとした問題点は、docker コマンドのバイナリサイズが 60MB 以上とかなり大きいことです。イメージはキャッシュされるのでそれほど困らないかもしれないですが小さくて済むならそれに越したことはないでしょう。また docker inspect で使用されている go のテンプレートよりも、jq の方が慣れているという人も多いと思います。

docker-compose.test.yml は前項と同じなので省略します。

docker/Dockerfile
# 本番用イメージ
FROM alpine as release
LABEL "com.example.vendor"="ACME Incorporated"
EXPOSE 80
COPY ./prog.sh /usr/local/bin/

# テスト用追加ファイルイメージ
FROM alpine as builder
RUN apk update && apk fetch curl jq -o /var/local

# テスト用イメージ
FROM release as test
COPY --from=builder /var/local/ /var/local/
RUN apk add /var/local/*.apk
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production

今回は curl と jq を使いますが、ビルドが少し大変そうだったのでサンプルを兼ねてパッケージのダウンロードのみを行ってテスト用イメージでインストールしています。インストール処理が必要な分、単純なコピーよりも少し時間がかかってしまうようです。

docker/run_tests.sh
#!/bin/sh -eux

exec 1>&2
cid=$(basename $(cat /proc/1/cpuset))
inspect() {
  curl -sSfL --unix-socket /var/run/docker.sock "http://localhost/containers/$2/json" | jq -r "$1"
}
[ "$(inspect '.Config.Labels["com.example.vendor"]' "$cid")" = "ACME Incorporated" ]
[ "$(inspect '.Config.ExposedPorts["80/tcp"]' "$cid")" != "null" ]

実装が docker コマンドから curljq になっているだけでやっていることは変わりません。Docker というよりも単に API のテストを行っているだけなので、各種テスティングフレームワークを使うことも可能でしょう。(あ、shellspec には便利な HTTP ライブラリ等ないので結局は curl や jq を使います。jq を使わない JSON 用のマッチャーは作りたいと思っていますが。)

2. テスト対象のコンテナの外からテストを行う

テスト対象のサービスとテストを行う sut サービスを別々に定義し、テスト対象のDocker コンテナの外部からテストを行います。それぞれ別の Dockerfile を使うのでマルチステージビルドは使いません。テスト対象のイメージは本番用イメージと全く同じものが使えますし、sut サービスで使用するイメージはテスト実行の専用のイメージなので、テスト対象と同じ親イメージである必要はなくパッケージを簡単にインストールできるというメリットがあります。その反面コンテナ内部のテストはしづらくなります。

2.1 サーバーのテスト

コンテナで動くウェブサーバー等に接続できるかといったテストを行います。

docker/docker-compose.test.yml
version: "3"
services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile
  sut:
    build:
      context: .
      dockerfile: Dockerfile.test
    depends_on:
      - web
    command: run_tests.sh

sut サービス用の Dockerfile.test にはイメージにアプリケーションを含む必要がないため contextDockerfile.test と同じ場所にしています。

docker/Dockerfile
FROM debian
RUN apt-get update && apt-get install -y nginx
COPY ./prog.sh /usr/local/bin/
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
docker/Dockerfile.test
FROM alpine
RUN apk add curl
COPY run_tests.sh /usr/local/bin/

テスト対象のイメージとテスト実行用イメージが別でよいので、それぞれ別の親イメージを使用しています。キャッシュを効かせるためにマルチステージビルドでコピーしたりという工夫も必要ないため一般的なパッケージマネージャーを使ってインストールすることが出来ます。

docker/run_tests.sh
#!/bin/sh -eux

exec 1>&2
curl http://web/ # webサービスへのhttpリクエストに反応するか

この例ではテスト対象がウェブサーバーであるため curl コマンドを使ってテストしていますが、データベースサーバーなどではそれぞれ適したツールを使います。

2.2 docker コマンドを使用した外部からのテスト

「1.5 Docker コマンドを使ったテスト」と同様に外部から docker コマンドを使用してテストすることも可能です。docker inspect を使ったメタデータのテストの他、docker exec を使ってテスト対象の Docker コンテナでコマンドを実行することでコンテナ内部のテストを行うことも出来ます。テスト自体は少し面倒になりますがテスト実行用のイメージを作るのは楽になります。

docker/docker-compose.test.yml
version: "3"
services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile
  sut:
    build:
      context: .
      dockerfile: Dockerfile.test
    depends_on:
      - web
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: run_tests.sh
docker/Dockerfile
FROM debian
RUN apt-get update && apt-get install -y nginx
COPY ./prog.sh /usr/local/bin/
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
docker/Dockerfile.test
FROM docker
COPY run_tests.sh /usr/local/bin/

sutサービスで使うイメージには公式でalpineベースのDockerが入ったDockerイメージが用意されているのでそれを使うことができます。もちろん別のイメージを使用したりこのイメージにパッケージを加えて使うことも可能です。

docker/run_tests.sh
#!/bin/sh

exec 1>&2
inspect() {
  docker inspect --format "$1" "$2"
}
project=$(inspect '{{ index .Config.Labels "com.docker.compose.project"}}' $HOSTNAME)
cid=$(docker ps -q -f "label=com.docker.compose.project=$project" -f "label=com.docker.compose.service=web")

[ "$(inspect '.Config.Labels["com.example.vendor"]' "$cid")" = "ACME Incorporated" ]
[ "$(inspect '.Config.ExposedPorts["80/tcp"]' "$cid")" != "null" ]

docker exec -i $cid /bin/sh <<HERE
set -eux
exec 1>&2
/usr/sbin/nginx -V
HERE

最初にテスト対象のコンテナIDを突き止める必要があります。その後のメタデータのテストは同じです。また、docker exec でテストコードを送り込むことでテスト対象のコンテナでシェルスクリプトを実行してコンテナ内部のテスト(nginx が実行できるか?)を行っています。

2.3 Docker APIを使用したテスト

「1.6 Docker API を使ったテスト」と同様に Docker API を使うことも出来ます。curljq を使用した場合のテストは今までの応用で出来ますので、少し変わった所で Serverspec の Docker バックエンドを使用したテストを紹介します。細かい説明は省略します。

docker/docker-compose.test.yml
version: "3"
services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile
  sut:
    build:
      context: .
      dockerfile: Dockerfile.test
    depends_on:
      - web
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: rspec
docker/Dockerfile
FROM debian
RUN apt-get update && apt-get install -y nginx
COPY ./prog.sh /usr/local/bin/
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
docker/Dockerfile.test
FROM debian
WORKDIR /root
RUN apt-get update \
 && apt-get install -y ruby ruby-rspec ruby-serverspec ruby-docker-api
COPY spec ./spec
docker/spec/spec_helper.rb
require 'serverspec'
require 'docker'

container = Docker::Container.get(ENV['HOSTNAME'])
project = container.info['Config']['Labels']['com.docker.compose.project']
web = Docker::Container.all(filters: {
  label: [
    'com.docker.compose.project=' + project,
    'com.docker.compose.service=web'
  ]
}.to_json).first

set :backend, :docker
set :docker_container, web.id
docker/spec/test_helper.rb
require 'spec_helper'

describe package('nginx') do
  it { should be_installed }
end

Docker Hub の Automated Builds の設定

さて、それではローカルでテストができるようになった所で、最後に Docker Hub の Automated Builds の設定を行います。アカウントの作成やリポジトリの作成は済ませてあるという前提です。Automated Builds の設定といっても、実はプロジェクトを GitHub と関連付け BUILD RULES の設定をするだけで完了です。その点に特に難しいところは無いと思います。ただ Docker Hub の Overview をプロジェクトの README.md とは別のものにするために少し小細工をします。

まずプロジェクトのディレクトリ構造を再掲します。

project/
├── docker
│   ├── Dockerfile
│   ├── README.md (Docker HubのOverview用)
│   └── docker-compose.test.yml
│   └── hooks
│       └── build
│── .env
│── README.md
└── prog.sh

一箇所 hooks/build というファイルを追加しています。これが小細工(ワークアラウンド)です。内容は以下のようなものになります。

hooks/build
#!/bin/sh
docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_NAME" ..

# おそらくこれが標準のビルドコマンド。末尾のドットが異なります。
# docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_NAME" .

そしてもう一箇所 Docker Hub の BUILD RULES の設定を変更します。

image.png

注目すべき点は Build Context です。ビルドコンテキストとして docker ディレクトリを指定しています。ただしこのビルドコンテキストは偽のビルドコンテキストで本当のビルドコンテキストは hooks/build で一つ上のディレクトリ(つまりプロジェクトルート)を使用しています。要するにプロジェクトルートで docker build -f docker/Dockerfile . でビルドするのと同じことをしています。

このようにした理由は Docker Hub が Overview に使用する README.mdDockerfile と同じディレクトリや実際のビルドコンテキストではなく、設定の Build Context 以下にあるものを使用するためです。正直バグっぽい挙動な気もするのですが修正すると動きが変わるのでそのままなのでしょう。Overviewとして使うファイルを指定できれば良いのですが。

番外編 Automated Builds 対応が不要の場合

ここまでは Docker Hub の Automated Builds に対応させるのを前提としてコードを書いてきましたが Docker Hub にプッシュすることはない、ローカルでテストできれば十分、別の CI でテストをしたいという理由で、Automated Builds 対応が必要ない場合もあると思います。そういう場合の docker-compose を使わない実装例です。

まず「1. テスト対象のコンテナの中でテストを行う」のパターンのテストを行うこととします。「2. テスト対象のコンテナの外からテストを行う」のパターンだと複数のサービスを使うことになるので docker-compose を使用した方が楽です。

「1.2 テストコードを別ファイルに分離する」のコードをベースとします。docker-compose を使わないので、docker-compose.test.yml は作成しません。Dockerfile とテストコードのファイルのみです。ディレクトリ構成も一番シンプルな形に戻します。

project/
├── Dockerfile
├── README.md
├── run_tests.sh
└── prog.sh
Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
COPY run_tests.sh /usr/local/bin/
CMD [ "run_tests.sh" ]

# デフォルト(--target未指定時)を本番用イメージにする
FROM release as production
run_tests.sh
#!/bin/sh -eux
bash --version

あとは docker-compose の代わりに docker を使ってテストを実行します。

# 本番用イメージの作成手順
$ docker build -t project .

# テスト用イメージの作成と実行
$ docker build --target test -t project-test .
$ docker run --rm project-test

# タグ名をつけずにビルドと実行を同時行う場合
$ docker run --rm "$(docker build -q --target test .)"

「1.4 テスト用にバイナリファイルを追加する」への応用は簡単だと思いますので省略します。

さいごに

以上、私が実際に使用してるパターンや、またこういうのが欲しくなるだろうなと考えながら実装例をまとめてみました。元々この調査を始めたのは文中でも紹介した自作のテスティングフレームワークの shellspec を Docker や CI と上手く組み合わせて使うには何が必要か?を洗い出すための作業だったりします。実際にやってみると本番用イメージとテスト用イメージの依存関係の問題から時間がかかるパッケージのダウンロードとインストールが発生してそれをいかに効率よく解決するかに工夫が必要になりました。そしてそれを解決する方法としてマルチステージビルドが重要な鍵になることもわかりました。面白い発見としては scratch ベースのイメージに実行ファイルだけを入れるという使い方です。このイメージはそのままでは実行できないのでただのアーカイブですが、Docker のキャッシュの仕組みを上手く活用することが出来ます。あれこれ工夫を重ねたおかげで shellspec の方もかなり良い感じで Docker や CI と連携できるようになったと思います。(Docker 連携はまだベータ版ですが。)

ただ私は Docker イメージのテストには(自作の shellspec も含め)テスティングフレームワークは使わないと思います。なぜなら Docker イメージのテストはそれほど大規模なものにはならないはずだし、ならないようにするべきだと考えているからです。(テストの大部分はアプリケーションのテストとして行います。)「1.2 テストコードを別ファイルに分離する」のパターンを基本とし、どうしても必要な場合は何かしらの小さなパッケージを入れる程度です。そして Docker イメージのテストで主に行うであろうファイルやプロセスの確認や通信テストといった内容であればシェルスクリプトが適してるのではないでしょうか?

10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8