ENGINEER BLOG

ENGINEER BLOG

コンテナイメージを丁寧に作る

ごきげんよう、じんぐうじです。

Dockerは非常に便利で、もはや無いと生きていけない身体にされてしまっています。
普通にDockerfileを書いてビルドすれば普通に動くとは思いますが、
将来的に変更を加えることを前提に考えれば、健全なコンテナ生活を送るためにlint、testが必要です。
ということで、そのために必要な道具をいくつか見繕いました。

※イメージに余分なものを入れないとかレイヤーを薄くする話は今回はしません

Dockerfileのlintをする

Dockerfileのlinterでぱっと見つかるのはこのあたりでした。

ということで雑に書いたDockerfileに対して実行してみます。
両方ともdocker imageで提供されてるので楽できますね。

  • Dockerfile

    FROM golang AS build
    MAINTAINER jinguji
    WORKDIR /opt/
    COPY ./message.go /opt/
    RUN go build message.go
    
    FROM debian
    WORKDIR /opt/
    COPY --from=build /opt/message /opt/
    COPY message.txt /opt/
    ENTRYPOINT ["./message"]
    
  • projectatomic/dockerfile-lint実行

    $ docker run -it --rm --privileged -v $PWD:/root/ \
        projectatomic/dockerfile-lint \
        dockerfile_lint -f Dockerfile
    
    # Analyzing Dockerfile
    
    
    --------ERRORS---------
    
    Line 1: -> FROM golang AS build
    ERROR: Invalid parameters for command.. 
    Reference -> https://docs.docker.com/engine/reference/builder/
    
    
    ERROR: Required LABEL name/key 'Name' is not defined. 
    Reference -> http://docs.projectatomic.io/container-best-practices/#_recommended_labels_for_your_project
    
    
    ERROR: Required LABEL name/key 'Version' is not defined. 
    Reference -> http://docs.projectatomic.io/container-best-practices/#_recommended_labels_for_your_project
    
    
    
    --------INFO---------
    
    Line 2: -> MAINTAINER jinguji
    INFO: the MAINTAINER command is deprecated. MAINTAINER is deprecated in favor of using LABEL since Docker v1.13.0. 
    Reference -> https://github.com/docker/cli/blob/master/docs/deprecated.md#maintainer-in-dockerfile
    
    
    INFO: There is no 'EXPOSE' instruction. Without exposed ports how will the service of the container be accessed?. 
    Reference -> https://docs.docker.com/engine/reference/builder/#expose
    
    
    INFO: There is no 'CMD' instruction. None. 
    Reference -> https://docs.docker.com/engine/reference/builder/#cmd
    
  • lukasmartinelli/hadolint実行

    $ docker run --rm -i lukasmartinelli/hadolint < Dockerfile
    /dev/stdin DL4000 MAINTAINER is deprecated
    /dev/stdin:1 DL3006 Always tag the version of an image explicitly.
    /dev/stdin:7 DL3006 Always tag the version of an image explicitly.
    

lintのルールが異なるのは標準が無ければまあそうだよね、なのでお好みに合わせてご利用ください、
なのですが、dockerfile-lintの方は参照先リンクが表示されるのでなぜ引っかかったのかわかって助かりますね…!
FROM ... AS ...がエラー扱いになる点がモヤっとしますが…

イメージのテスト

Dockerfileのlintをしたら(lintの結果を修正したり無視したりはケースバイケースですが)buildしてイメージを作るのですが、
最初に書いたように作ったイメージはテストしましょうね、ということで有名どころをいくつか試してみます。

Container Structure Tests

名前の通りコンテナイメージの構造のテストを行うためのツール、です。
できることとしては

  • コマンドが正しく実行できるか
  • ファイルが存在するか、しないか、パーミッションが正しいか
  • ファイルの内容が正しいか
  • Metadata(Env,Entrypoint,Cmd,Exposed Ports,Volumes,Workdir)が正しいか
  • ライセンス

となっており、後述するServerspec/dgossと被る部分もあるが単体では不足する、といった具合です。
イメージのtarballに対しても実行できますが、その場合はコマンドテストは実施できません。

ということで例です。Dockerfileはさきほどのものを使います。

  • config.yaml

    ---
    schemaVersion: '2.0.0'
    
    fileExistenceTests:
      - name: 'message'
        path: '/opt/message'
        shouldExist: true
        permissions: '-rwxr-xr-x'
    
    commandTests:
      - name: 'message'
        command: '/opt/message'
        args: []
        expectedOutput: ['Hello World!!']
        exitCode: 0
    
    fileContentTests:
      - name: 'message.txt'
        path: '/opt/message.txt'
        expectedContents: ['Hello World!!']
    
    metadataTest:
      entrypoint: ['./message']
      cmd: []
      workdir: ['/opt/']
    
    licenseTests:
      - debian: true
        files:
    
  • container-structure-test実行

    $ ./container-structure-test -test.v -image example-hello config.yaml
    Using driver docker
    === RUN   TestAll
    2018/03/14 13:02:02 Running tests for file config.yaml
    === RUN   TestAll/Command_Test:_message
    === RUN   TestAll/File_Existence_Test:_message
    === RUN   TestAll/File_Content_Test:_message.txt
    === RUN   TestAll/Metadata_Test
    === RUN   TestAll/License_Test_#0
    --- PASS: TestAll (90.92s)
        --- PASS: TestAll/Command_Test:_message (1.09s)
            docker_driver.go:85: stdout: Hello World!!
        --- PASS: TestAll/File_Existence_Test:_message (0.52s)
        --- PASS: TestAll/File_Content_Test:_message.txt (0.53s)
        --- PASS: TestAll/Metadata_Test (0.00s)
        --- PASS: TestAll/License_Test_#0 (88.77s)
            licenses.go:70: adduser
            licenses.go:70: apt
            licenses.go:70: base-files
            :
            (中略)
            :
            licenses.go:70: tzdata
            licenses.go:70: util-linux
            licenses.go:70: zlib1g
            structure_test.go:49: Total tests run: 5
    PASS
    

設定ファイルをYAMLで書けるのは見通しが良くて便利感ありますね!
(ちなみに拡張子がymlだとちゃんと認識してくれません)
あくまで構造のテストとしてとらえるとtarballへ対するテストのみ実施して、コマンドテストはServerspec/dgoss
に任せた方がすっきりするかもしれません。

Serverspec/goss(dgoss)

Serverspecとgossはコンテナイメージがどうこうというものではなく、サーバに対してテストを実行するための道具となります。
dgossはgossのバックエンドとしてDockerを使用するためのラッパーとなります。

できることは多々あるのですが

  • リスンポート
  • サービス
  • パッケージ
  • ユーザ、グループ
  • プロセス
  • ファイル
  • コマンド

等のテストが可能であり、Container Structure Testsと比較してOS寄りのテストが可能となります。
ということで下記の雑な例をテストしてみましょう。

  • Dockerfile

    FROM debian:9
    RUN apt-get update \
        && apt-get install -y --no-install-recommends \
          nginx \
          net-tools \
          procps \
        && apt-get clean
    COPY ./index.html /var/www/html/
    CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
    
  • index.html

    <html>
    <head></head>
    <body>
    Hello World!!
    </body>
    
  • goss.yaml

    package:
      nginx:
        installed: true
        versions:
          - 1.10.3-1+deb9u1
    process:
      nginx:
        running: true
    port:
      tcp:80:
        listening: true
    file:
      /usr/share/nginx/html/index.html:
        exists: true
        filetype: file
        contains:
          - "Hello World!!"
    
  • spec/example-nginx/example-nginx_spec.rb

    require 'spec_helper'
    
    set :docker_image, 'example-nginx'
    
    describe package('nginx') do
      it { should be_installed }
    end
    
    describe command('nginx -v') do
      its(:stderr) { should match /1.10.3/ }
    end
    
    describe process('nginx') do
      it { should be_enabled }
      it { should be_running }
    end
    
    describe port('80') do
      it { should be_listening }
    end
    
    describe file('/usr/share/nginx/html/index.html') do
      it { should exist }
      its(:content) { should match /Hello World!!/ }
    end
    
  • dgoss実行

    $ GOSS_PATH=./goss-linux-amd64 ./dgoss run --rm example-nginx
    INFO: Starting docker container
    INFO: Container ID: 6aaceea1
    INFO: Sleeping for 0.2
    INFO: Running Tests
    Process: nginx: running: matches expectation: [true]
    Port: tcp:80: listening: matches expectation: [true]
    File: /usr/share/nginx/html/index.html: exists: matches expectation: [true]
    File: /usr/share/nginx/html/index.html: filetype: matches expectation: ["file"]
    File: /usr/share/nginx/html/index.html: contains: matches expectation: [Hello World!!]
    Package: nginx: installed: matches expectation: [true]
    Package: nginx: version: matches expectation: [["1.10.3-1+deb9u1"]]
    
    
    Total Duration: 0.006s
    Count: 7, Failed: 0, Skipped: 0
    INFO: Deleting container
    
  • Serverspec実行

    $ bundle exec rspec
    
    Package "nginx"
      should be installed
    Command "nginx -v"
      stderr
        should match /1.10.3/
    
    Process "nginx"
      should be enabled
      should be running
    
    Port "80"
      should be listening
    
    File "/usr/share/nginx/html/index.html"
      should exist
      content
        should match /Hello World!!/
    
    Finished in 1.09 seconds (files took 0.36657 seconds to load)
    6 examples, 0 failures
    

ということでnginxが80/tcpで動いていてindex.htmlが「Hello World!!」を含むことがテストできました。dgossの方はnginxのバージョンまで
dgossはやはりyamlで書けてすっきりしているのですが、複数パッケージのインストール状況をテストするようなケース等では
Serverspecであればrubyなのでループで記述できたりするなど一長一短ですね…
(ちなみにgdossも拡張子がymlだとちゃんと認識してくれません)
また、Serverspecではprocessのためにpsコマンド、portのためにnetstatが必要となり、
dgossのテストケースに似せるためにあえて今回はprocps、net-toolsを導入しています。本質的に必要ないものを導入する必要があるという点は抵抗感がありますね。

infrataster

infratasterはサーバの振る舞いをテストするもので、Serverspec/gossがホワイトボックステストであることに対してブラックボックステストを行います。
例えば今回の例であれば前述と同じコンテナイメージに対して、

  • httpレスポンスが200で返ってくること
  • レスポンスヘッダのcontent-typeがtext/htmlであること
  • レスポンスボディに「Hello World!!」が含まれること

を等テストし、何のミドルウェアが稼働しているか、ファイルが存在するか、等は考慮しないテストとなります。
今回の例では使っていませんがCapybaraも使えます。便利ですね。

  • spec/infra_spec.rb

    require 'spec_helper.rb'
    require 'infrataster/rspec'
    require 'docker'
    
    # Dockerfileをビルドしてコンテナを作成する
    image = Docker::Image.build_from_dir('.')
    container = Docker::Container.create('Image' => image.id)
    container.start
    
    # テストスイートの最後にコンテナを削除する
    RSpec.configure do |config|
      config.after(:suite) do
        container.delete(:force => true)
      end
    end
    
    # コンテナが準備できるまで待つ
    begin
      sleep 1
    end while container.json['State']['Running'] == false
    
    # テスト対象のサーバ(コンテナ)の定義
    Infrataster::Server.define(:example_nginx) do |server|
      server.address = container.json['NetworkSettings']['IPAddress']
    end
    
    describe server(:example_nginx) do
      describe http('http://' + server(:example_nginx).server.address) do
        it 'responds as 200' do
          expect(response.status).to be 200
        end
        it 'responds as "text/html"' do
          expect(response.headers['content-type']).to eq('text/html')
        end
        it 'responds as "Hello World!!"' do
          expect(response.body).to include('Hello World!!')
        end
      end
    end
    
  • Infrataster実行

    $ bundle exec rspec
    
    server 'example_nginx'
      http 'http://172.17.0.4' with {:params=>{}, :method=>:get, :headers=>{}}
        responds as 200
        responds as "text/html;"
        responds as "Hello World!!"
    
    Finished in 0.7387 seconds (files took 3.07 seconds to load)
    3 examples, 0 failures
    

無事、コンテナの外側から見てhttpでindex.htmlがサーブされていることが確認できましたね。
この例では実行環境がDockerホストを想定して自前でコンテナの作成を行っていますが、実際の環境とは異なる(FWやProxyを経由していないなど)場合はテストのフェーズや目的によってコンテナの起動方法、テスト対象のURLや経路は考慮が必要となります。

まとめ

何をテストするためにどのツールを使うかは、作るものがどういうものであるかによって変わってきますが、
今回登場したものを組み合わせれば概ねコンテナイメージに関するlint/testはカバーできると思います。
これらをCIでまわせば健全なコンテナ生活が送れますね!!

ちなみにzuazo/dockerspecなるものがあり、これはServerspec/Infratasterをまるっとまとめてスッキリ書けるようです。READMEを見る感じDockerfileのメタデータのテストもできそうで便利感が滲み出ています。
infratasterのことを書いている辺りでこれを見つけました。
切ないですね。
頑張ります。