Docker と infrataster で nginx の振る舞いをテストする

こんにちは。吉川 ( @rrreeeyyy ) です。今期オススメのアニメはリゼロです。

Nginx は設定ファイルの記述力も高い、大変便利な Web サーバです。 便利な反面、設定ファイルの複雑化や、設定に依っては意図しない挙動を引き起こしてしまうこともあります。 そこで本稿では docker 並びに infrataster を使用し、 Nginx の挙動をテストすることによって、安全に Nginx の設定を記述する方法について紹介します。

テスト対象の Nginx の仕様

今回は例として、次のような仕様の Nginx のテストについて考えます。

  • ネットワーク帯は 10.0.0.0/16 を使用している
  • Nginx の前段として L7 ロードバランサが存在している
    • L7 ロードバランサが https を終端している
  • Nginx 自体は 80 番ポートと 8080 番ポートにて待ち受けている
    • 80 番ポートは通常のアプリケーション用になっている
    • 8080 番ポートは Nginx の stub_status を閲覧する用途になっている
  • http でアクセスがあった場合は https に 301 リダイレクトを行う
  • Nginx とアプリケーションは Unix domain socket を使用して通信している
  • 接続元 IP アドレスのブラックリストが存在している
    • ブラックリストの IP アドレスからアクセスがされた場合 403 を返す
  • メンテナンスモードが存在している
    • メンテナンス時には /maintenance.html に 302 リダイレクトを行う
    • 特定の IP アドレスからアクセスされた場合はメンテナンス時でも通常通り表示出来るようにしている
    • /healthcheck というパスに関してはメンテナンス時でも通常通りプロキシするようにしている

一般的に存在しそうな、少し複雑な Nginx の設定、というところでしょうか。

難しい所として、L7 ロードバランサがあるため、X-Forwarded-For ヘッダや、 X-Forwarded-Proto のようなヘッダに注意して設定を行う必要がある点が挙げられます。

また、実際に本番環境に設定を適用した段階の動作確認に関しても、 特定 IP アドレスのそれぞれから行う必要があるのも難しい点かと思います。

例えば、ブラックリストの IP アドレス一覧は Nginx の geo ディレクティブを使い次のように書けます。 ここでは、同一ネットワークの上流に L7 バランサがいると仮定しているため、proxy ディレクティブにて、 同一ネットワークから来たアクセスの場合、X-Forwarded-For を参照させるようにしています。 (参考: Module ngx_http_geo_module)

http {
    :
    geo $blacklist {
        default 0;
        proxy 10.0.0.0/16;

        192.0.2.1/32 1;
    }
    :
}

今回は、Docker のネットワーク機能など使用し、 擬似的に上記のネットワーク・アプリケーション構造を再現することにより、 このように特定ネットワーク等に依存する設定ファイルのテストを行えるようにしていきます。

ソースコード一覧

最初に、これから紹介するソースコードの全体は、 Github の rrreeeyyy/nginx-behavior-test-example にて公開しています。こちらをあわせて参照しながら読み進めて行くと分かりやすいかと思います。

docker-compose

まず、振る舞いをテストするにあたって、実際に必要なコンポーネントは以下の 3 つだと考えます。

  • L7 ロードバランサ
  • Nginx (テスト対象)
  • アプリケーション

これらを、Docker のネットワーク機能で組み合わせる事を考えます。 各コンポーネントの詳細は後述しますが、docker-compose.yml は次のようになります。

version: '2'

networks:
  front:
    ipam:
      config:
        - subnet: 10.0.0.0/16

volumes:
  socket:
    driver: local

services:
  haproxy:
    build: haproxy/
    container_name: docker-haproxy-t
    networks:
      - front
    ports:
      - "8888:80"
  nginx:
    build: nginx/
    container_name: docker-nginx-t
    networks:
      - front
    expose:
      - "80"
    ports:
      - "8080:8080"
    volumes:
      - socket:/var/run/unicorn/
  app:
    build: app/
    container_name: docker-app-t
    networks:
      - front
    volumes:
      - socket:/var/run/unicorn/

まず、Compose のファイルフォーマットは v2 です。そのため Compose 1.7 以上をお使いください。

networks ディレクティブで仮想ネットワークを作成します。 ここでは front という名前のネットワークを作成しています。 この subnet を 10.0.0.0/16 とし、実際の本番環境のネットワーク構造と合わせることで、 Nginx の proxyset_real_ip_from などの設定を変更すること無く、 実際の設定ファイルをそのまま使用してテストを行う事を可能にしています。

また、nginx とアプリケーションは、Unix domain socket で通信するという前提になっているので、 ソケットを配置する共有ボリュームを volumes ディレクティブで作成し、 Nginx とアプリケーションの両方のコンテナにて、 /var/run/unicorn としてマウントしておきます。 もしソケットではなく TCP で通信する場合でも、expose を上手く使用すれば同等の事を実現可能です。

フロントのロードバランサは、ホストの 8888 をコンテナの 80 に対応付けるようにしています。

また、Nginx 自体は 80 をコンテナネットワーク内で通信できるようにし、 8080 はホストの 8080 に対応付けるようにしています。

続いて、各コンポーネントの説明をしていきます。

ロードバランサのモック

今回は L7 ロードバランサとして、haproxy を使用します。

haproxy の設定ファイルとして、次のようなものを用意しました。

global
  log 127.0.0.1 local0
  log 127.0.0.1 local1 notice
  chroot /
  user root
  group root

defaults
  log global
  mode http
  option httplog
  option dontlognull
  timeout connect 5000ms
  timeout client 50000ms
  timeout server 50000ms

frontend http-in
  bind *:80
  default_backend nginx

backend nginx
  server nginx nginx:80

80 番ポートで待ち受け、nginx コンテナの 80 番にプロキシするだけの設定ファイルです。

実際の本番環境で HAProxy のような L7 ロードバランサを使用する際には、 X-Forwarded-ForX-Forwarded-Proto 等を正しく設定し、後段のサーバに渡す必要があります。

今回は、あえて設定しないことにより、上記のようなヘッダをクライアントが任意に設定出来るようになり、 様々なシチュエーションを考慮したテストを記述することが可能となります。

アプリケーションのモック

アプリケーションに関しては、実際にプロキシを行うアプリケーションが Docker で用意できればベストですが、 なかなかそうなっていない事もあると思うので、Sinatra で次のようなアプリを用意しました。

require 'json'
require 'sinatra'

class App < Sinatra::Base
  set :protection, :except => [:ip_spoofing]
  get '/healthcheck' do
    'success'
  end
  get '/*' do
    env.to_json
  end
end

これは、/healthcheck にアクセスが来た時には success という文字列を返し、 その他のパスにアクセスが来た際には env を JSON にして返すという単純なアプリケーションです。

前述のとおり、実際にプロキシを行うアプリケーションが用意できるのがベストですが、 今回はプロキシの振る舞いにのみ興味があり、コンテンツの内容までをテスト対象としていないため、 このような擬似的なアプリケーションで十分であると判断しました。

env を返している理由は、プロキシサーバから渡ってきた env を、 アプリケーション内で使用するパターンが存在する可能性があるため、 アプリケーションにどのような env が渡っているかもテスト出来るようにしたいという狙いがあるためです。

また、今回は前述のとおり意図的に ip_spoofing が可能なようにしているので、 Rack::Protection::IPSpoofing を無効にしています。

振る舞いのテスト

さて、実際のテスト自体は spec 配下に入っています。 spec_helper.rb に関しては rspec --init で生成されたものに、 Infrataster 用の設定を追加しただけとなります。追加部は以下のとおりです。

require 'uri'
require 'infrataster/rspec'

Infrataster::Server.define(
  :nginx,
  URI.parse(ENV['DOCKER_HOST']).host,
)

ここで、サーバの IP を DOCKER_HOSThost 部にしています。 例えば tcp://192.168.99.100:2376 であれば 192.168.99.100 です。 もし DOCKER_HOST が Unix domain socket の場合は、ここは localhost 等にしてやれば良いはずです。

テスト本体である spec/www.example.com_spec.rb は以下のとおりです。

describe server(:nginx) do
  describe http(
    'http://haproxy:8888/',
    headers: {
      'Host' => 'www.example.com',
      'X-Forwarded-Proto' => 'https',
    }
  ) do
    it '/ responds with 200' do
      expect(response.status).to eq(200)
    end
  end

  describe http(
    'http://haproxy:8888/',
    headers: {
      'Host' => 'www.example.com',
      'X-Forwarded-Proto' => 'https',
      'X-Forwarded-For' => '192.0.2.1',
    }
  ) do
    it '/ responds with 403 when access from blacklisted IP address' do
      expect(response.status).to eq(403)
      expect(response.body).to include('Forbidden')
    end
  end
end

前述のとおり、X-Forwarded-ProtoX-Forwarded-For は、 テスト内で任意に決定することが可能なため、様々なシチュエーションを想定したテストを書くことが出来ます。 上記の例では、ブラックリストに登録された IP からアクセスが来たら本当に 403 を返すかどうかもテスト出来ています。

最初に紹介した仕様を満たすかどうかをテストしたものは、 前述のとおり、公開している Github リポジトリの spec 配下に全て入っているため、 よろしければ参考にしてみてください。

ちなみに、全てのテストの実行結果は以下のようになります。

infrataster-result.png

まとめ

Docker によるネットワークの再現、ロードバランサ、アプリケーションのモックにより、 Nginx の設定ファイルの振る舞いをテストする方法について紹介しました。

このテストは、Nginx の設定をパースしたりしてテストしているわけではなく、振る舞い自体をテストしているため、 nginScriptlua-nginx-module, ngx_mruby 等にて記述された設定ファイルにも適用可能な手法だと考えています。

また、今回は Infrataster を使用しましたが、実際にはただ HTTP アクセスをしているのみなので、 例えば curl $(docker-machine ip):8888 -H 'Host: www.example.com' などすれば、 curl でのデバッグも出来ますし、Perlの標準モジュール Test::More と HTTP::Tiny だけでテストコードを書く 事も恐らく可能です。

このように、ミドルウェアの設定ファイルの振る舞いに対するテストを書くことで、 安全かつ積極的な設定変更が行えるようになると考えています。


ハートビーツでは、より安心してサービスを任せられる MSP となるべく、 テスト手法自体や設定ファイルのテストに興味があるエンジニアを 募集 しています。