Nginxへの変更に伴うリバースプロキシのテストの改善

Nginxへの変更に伴うリバースプロキシのテストの改善

SREグループの菅原です。 クックパッドではブラウザ用Webサイトのリバースプロキシ用のWebサーバとして長らくApacheを使っていたのですが、最近、Nginxへと変更しました。

Nginxへの変更に当たって、構成管理の変更やテストの改善を行ったので、それらについて書きたいと思います。

リバースプロキシのリニューアルについて

まず、ブラウザ用Webサイトの基本的なサーバ構成は以下のようになります。

f:id:winebarrel:20180227123255p:plain

リバースプロキシはELB経由でリクエストを受けて、静的ファイルの配信やキャッシュサーバ・Appサーバへの振り分けを行います。

リバースプロキシとして利用されているApacheは、長年の改修により設定が煩雑なものとなっており、設定の追加や変更にコストがかかる状態になっていました。

また、Apacheの設定ファイルはItamaeでは管理されておらず、ItamaeのレシピがあるGitリポジトリとは別に、Apacheの設定ファイルだけを格納したGitリポジトリで管理され、Capistranoで設定を配布する方式になっていました。これは当初、サーバ全体の構成管理(当時はPuppet)の適用タイミングと、プロキシサーバの設定の変更タイミングが異なると考えてのことだったのですが、現状では単に管理が複雑になるだけでメリットがない状態になっていました。

この状況を踏まえ、リバースプロキシのOSなどのリニューアルをするタイミングで、より平易に設定を書くことができるNginxへ変更し、また、Nginxの設定ファイルについてはItamaeの管理下に置くことにしました。

既存のリバースプロキシのテストについて

以前の記事でも取り上げられたのですが、リバースプロキシの設定はInfratasterでテストが行われています。

InfratasterのテストにはDocker Composeを使用しており、以下のようなコンテナの構成になっていました。

f:id:winebarrel:20180227123314p:plain

前述の通り、Apacheの設定はItamaeとは別のGitリポジトリで管理されており、cookpad.comを含む主要なサービスの設定が同じリポジトリに含まれています。

[リバースプロキシリポジトリ]
├── cookpad/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_a/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_b/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
└── spec/
    ├── cookpad_spec.rb
    ├── other_service_a_spec.rb
    ├── other_service_b_spec.rb
    ├── docker/
    │   ├── apache/
    │   │   └── Dockerfile
    │   ├── backend
    │   │   └── Dockerfile
    │   └── taster/
    │       └── Dockerfile
    └── docker-compose.yml

テスト用のdocker-compose.ymlでは、cookpad.comを含むサービス毎のコンテナを、設定ファイルのディレクトリをマウントする形で起動し、InfratasterからRSpecを実行するようになっています。

リバースプロキシからダミーバックエンドにアクセスする場合は、Docker Composeのlink_local_ips設定を使ってダミーバックエンドのコンテナにIPアドレスを割り当て、リバースプロキシの/etc/hostsを書き換えることで、リバースプロキシからバックエンドへの問い合わせを、ダミーバックエンドに差し替えるようにしていました。*1

# docker-compose.yml
backend:
  networks:
    bridge:
      link_local_ips:
        - 169.254.100.100
        - 169.254.100.101
        - 169.254.100.102
        ...

 

# /etc/hosts
169.254.100.100 app-server-001
169.254.100.101 cache-server-001
169.254.100.102 ad-server-001
...

コンテナへのItamaeの適用

Itamae管理下に置かれたNginxの設定ファイルをテストするには、既存の方式のようにディレクトリをマウントするのではなく、コンテナに対してItamaeを適用する必要があります。

ItamaeレシピのGitリポジトリは、だいたい以下のようなファイル構成になっています。

[Itamaeリポジトリ]
├── ...
└── itamae/
    ├── function.rb
    ├── cookbooks/
    │   └── nginx/
    │       ├── default.rb
    │       ├── files/
    │       └── templates/
    └── roles/
        └── apne1_vpc-xxx/
            └── rproxy/
                ├── default.rb
                ├── td-agent.rb
                ├── files/
                └── templates/

これをCapistranoを使って、適用対象のサーバ上でitamae local function.rb roles/apne1_vpc-xxx/default.rbというようなコマンドを実行して、レシピを適用します。

function.rbはItamaeのヘルパーが定義されており

module RecipeHelper
  def include_role(name)
    include_role_or_cookbook(name, "role")
  end

  def include_cookbook(name)
    include_role_or_cookbook(name, "cookbook")
  end

  def include_role_or_cookbook(name, type)
    Pathname.new(File.dirname(@recipe.path)).ascend do |dir|
      names = name.split("::")
      names << "default" if names.length == 1

      if type == "cookbook"
        recipe_file = dir.join("cookbooks", *names)
      else
        recipe_file = dir.join(*names)
      end
      if recipe_file.exist?
        include_recipe(recipe_file.to_s)
        return
      end
    end

    raise "#{name} is not found."
  end
end

Itamae::Recipe::EvalContext.send(:include, RecipeHelper)

Itamaeのレシピ内で

include_cookbook 'nginx'
include_recipe 'rproxy::td-agent'

と書くことにより、roles/cookbooks/配下のレシピを読み込めるようにしていました。

これらのItamaeのレシピをコンテナに適用する場合、以下の問題点があります。

  1. Nodeオブジェクトに含まれるサーバのメタ情報(例: node[:ec2])が、コンテナ適用時には含まれない
  2. Nginx以外の不要なレシピ(例: zabbix-agentなど)が適用されてしまう

1の問題については、Nodeクラスを書き換えることによって回避しました。

以下のコードをItamae適用時に読み込ませることで、レシピからEC2のメタ情報などを参照する場合に、未定義のときはnilを返すのではなく、ダミー値(key)が返るようにして、レシピの適用が失敗しないようにしました。

module FakeNode
  module Value
    def [](key)
      key.to_s
    end
    alias :fetch :[]
  end

  def [](key)
    value = super

    if value.nil? && !self.mash.has_key?(key)
      case key
      when :http_proxy
        nil
      when :rspec
        true
      else
        key.to_s.tap do |v|
          v.extend(Value)
        end
      end
    else
      value
    end
  end
end

Itamae::Node.prepend FakeNode

また、2の問題については、SKIP_RECIPESという環境変数を定義して、そこに含まれるレシピはItamaeでは適用しないようにヘルパーを修正しました。

module RecipeHelper
  SKIP_RECIPES = ENV.fetch('SKIP_RECIPES', '').split(',')

  def include_role_or_cookbook(name, type)
    return if SKIP_RECIPES.include?(name)

上記の修正などにより、既存のItamaeレシピに大きな修正をすることなく、サーバ同様にコンテナにもItamaeを適用できるようになりました。

DockerfileでのItamaeを適用する箇所は以下のようなコードになります。

ENV SKIP_RECIPES haproxy,td-agent,zabbix-agent

RUN cd /infra2 && \
    itamae local \
      spec/itamae/fake_node.rb \
      itamae/functions.rb \
      itamae/roles/apne_vpc-xxx/rproxy/default.rb

テストの改善

既存のリバースプロキシのテストをItamaeリポジトリに移行するに当たって、以下の点を改善するようにしました。

  1. 複数のサービスを同じdocker-compose.ymlで定義するのをやめて、環境構築の時間を短縮し、テストの相互依存をなくす
  2. link_local_ips/etc/hostsを使った経路の差し替えをやめ、エンドポイントのホスト名そのままでダミーサーバにアクセスできるようにする
  3. 実際のサーバ構成を再現するようにコンテナを構成して、SSL TerminationやIPの偽装などの処理をリバースプロキシのコンテナに持ち込まない

最終的にテストまわりのコンテナ構成・ファイル構成は以下のようになりました。

f:id:winebarrel:20180227123328p:plain

[itamae/spec]
├── backend/
│   ├── Dockerfile
│   └── files/
│       ├── backend.rb
│       └── init.sh
├── internal_service_proxy/
│   ├── Dockerfile
│   └── files/etc/nginx/conf.d/default.conf.tmpl
├── itamae/
│   └── fake_node.rb
├── rproxy/
│   ├── Dockerfile
│   ├── docker-compose.yml
│   ├── files/
│   │   ├── etc/haproxy/haproxy.cfg
│   │   └── init.sh
│   └── spec/
│       ├── cookpad_spec.rb
│       └── spec_helper.rb
├── ssl/
│   ├── cookpad.com.crt
│   ├── cookpad.com.key
│   └── root-ca.crt
└── taster/
    └── Dockerfile

またdocker-compose.ymlは以下のようになりました。

version: '3'
services:
  backend:
    build:
      context: ../backend
    networks:
      - spec-network
  internal-service:
    environment:
      SERVERS: cache-server-001:backend
    build:
      context: ../internal_service_proxy
    depends_on:
      - backend
    networks:
      spec-network:
        aliases:
          - cache-server-001
  rproxy:
    build:
      context: ../..
      dockerfile: spec/rproxy/Dockerfile
    depends_on:
      - backend
      - internal-service
    networks:
      - spec-network
  elb:
    environment:
      SERVERS: cookpad.com:rproxy
    build:
      context: ../ssl_termination_proxy
    volumes:
      - ../ssl:/ssl
    depends_on:
      - rproxy
    networks:
      spec-network:
        aliases:
          - cookpad.com
  taster:
    build:
      context: ../..
      dockerfile: spec/taster/Dockerfile
    volumes:
      - ./spec:/spec
    working_dir: /spec
    depends_on:
      - elb
    networks:
      - spec-network
networks:
  spec-network:

elbコンテナ(ssl_termination_proxy/

elbコンテナは、SSL Terminationを行うコンテナで、ELBの役割を担うコンテナです。 Docker Composeのaliasesを利用して、実際と同様のホスト名でコンテナにアクセスできるようにしています。

コンテナで動くNginxの設定には、以下のようにテンプレートを用意して*2

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  listen 443 ssl;
  server_name {{ $server_name }};
  ssl_certificate /ssl/{{ $server_name }}.crt;
  ssl_certificate_key /ssl/{{ $server_name }}.key;

  underscores_in_headers on;

  location / {
    # set external network ip address
    set $custom_x_forwarded_for "93.184.216.34";

    if ($http_x_test_client_ip != "") {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Forwarded-Host $host:$server_port;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $custom_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

環境変数SERVERSの値(ホスト名:バックエンド,...)で各サービスのserverproxy_passを定義し、また、ホスト名毎に、あらかじめ用意しておいたSSL証明書を読み込むようにして、httpsでもアクセスできるようにしています。 (ルート証明書はtasterコンテナに含めています)

また、クライアントからのリクエストにX-Test-Client-IPというヘッダをつけることで、任意のIPからリバースプロキシにリクエストが投げられたように見せかけるようにもしています。

この機能のため、ngx_http_realip_moduleを読み込む箇所のItamaeレシピでは、テスト用コンテナへの適用時にset_real_ip_fromのレンジを広げるようにしています。

real_ip_header X-Forwarded-For;
set_real_ip_from xx.xx.xx.xx/xx;
<%- if node[:rspec] -%>
set_real_ip_from 0.0.0.0/0;
<%- end -%>
real_ip_recursive on;

rproxyコンテナ(rproxy/

rproxyコンテナは、テスト対象となるリバースプロキシのコンテナです。前述のItamaeレシピの適用を行ったNginxサーバが動作します。 cookpad.com用のdocker-compose.ymlはこのディレクトリに置いて、cookpad.com関連以外のコンテナの定義が含まれないようにしました。

rproxyコンテナのNginxの設定はItamaeレシピで適用されるため、実際のサーバと同じですが、HAProxyの設定だけはItamaeレシピのものを使わずに、テスト専用に用意しています。

listen www
  bind :8080
  mode http
  balance roundrobin
  http-request set-header X-Via-Haproxy 'localhost:www:8080'
  server app-server-001 backend:80 check inter 5s fall 3

この設定により、localhost:8080へのアクセスはbackendコンテナに投げられるようになります。

internal-serviceコンテナ(internal_service_proxy

internal-serviceコンテナは、HAProxyを経由しないキャッシュサーバへのリクエストを受け付けるコンテナです。基本的にはelbコンテナと同じような構成で、SSL Terminationの機能が除かれています。

Nginxの設定は以下のようなテンプレートで、elbコンテナと同様に環境変数 SERVERSserverproxy_passを定義しています。

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  server_name {{ $server_name }};

  location / {
    set $custom_x_forwarded_for $proxy_add_x_forwarded_for;

    if ($http_x_test_client_ip) {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Dest {{ $server_name }};
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

また、aliases設定を使って、プロダクションサーバのホスト名そのままで、internal-serviceコンテナにアクセスできるようにしています

backendコンテナ(backend/

backendコンテナは、各種サーバを偽装するダミーサーバで、Webrickで書いています。

#!/usr/bin/env ruby
require 'webrick'
require 'json'
require 'mime/types'

URI_ATTRS = %w(
  scheme
  userinfo
  host
  port
  registry
  path
  opaque
  query
  fragment
)

server = WEBrick::HTTPServer.new(Port: 80)
trap(:INT){ server.shutdown }

server.mount_proc('/') do |req, res|
  uri = req.request_uri

  res.body = JSON.pretty_generate({
    request_method: req.request_method,
    uri: URI_ATTRS.map {|k| [k, uri.send(k)] }.to_h,
    header: req.header,
    body: req.body,
  })

  res.keep_alive = false

  mime_type = MIME::Types.type_for(uri.path).first
  res.content_type = mime_type.to_s if mime_type

  x_test_set_cookie = req['x-test-set-cookie']
  res['set-cookie'] = x_test_set_cookie if x_test_set_cookie

  x_test_status = req['x-test-status']
  res.status = x_test_status.to_i if x_test_status
end

server.start

基本的にリクエストの情報をJSONで返すだけのサーバですが、X-Test-...というリクエストヘッダが来た場合に、任意のCookieやステータスコードを返せるようにして、異常系などのテストパターンに対応しています。

tasterコンテナ(taster/

tasterコンテナは、Infratasterを実行するコンテナです。 Infratasterと、rproxy/配下のspecを含むようにしています。

RSpec

リバースプロキシをテストするためのspecファイルは以下のようになります。

# spec_helper.rb
require 'infrataster/rspec'

%w(
  cookpad.com
  xxx.cookpad.com
).each do |server|
  Infrataster::Server.define(server, server)

  RSpec.shared_examples "#{server} normal response" do
    it 'returns 200' do
      expect(response.status).to eq(200)
    end

    it "accesses #{server}" do
      expect(request_uri.fetch('host')).to eq(server)
    end
  end
end

 

# cookpad_spec.rb
describe server('cookpad.com') do
  let(:body_as_json) { JSON.parse(response.body) }
  let(:request_uri) { body_as_json.fetch('uri') }

  describe 'normal' do
    describe http('https://cookpad.com') do
      it_behaves_like 'cookpad.com normal response'
      it_behaves_like 'https'

      it "doesn't cache" do
        expect(response.headers).not_to have_key('cache-control')
      end
    end
  end

  describe 'error pages' do
    describe http('https://cookpad.com/error', headers: {'X-Test-Status' => 500}) do
      it "return front 500 page" do
        expect(response.status).to eq(500)
        expect(response.body.strip).to eq("fw_errors/500.html")
      end
    end
  end
end

aliases設定を使ったことで、コンテナのIPなどを意識することなくテストを記述することができます。

RSpecはdocker-composeをつかって以下のように実行します。

docker-compose run taster rspec -I. -r spec_helper .

まとめ

今回の作業により、煩雑だった設定ファイルの見通しがよくなり、設定の追加などが大分楽になりました。 また、テストまわりの改善をしたことで、テスト環境の構築に時間がかかったり、原因不明でテストがコケるようなことがなくなり、テストに付随するyak shavingも減らせました。 以前の「設定を変更する→テストがめんどくさい→テストをサボる/設定の追加を諦める」というような負のスパイラルをうまく断ち切れた気がします。

ちょっと実行したテストの結果がFFFFFFFFFFFFFFF...になっていると、精神に大変なダメージを受けるので、未来の自分のメンタルヘルスを保つために、今後も改善を続けていきたいところです。

*1:link_local_ipsはCompose file version 3でサポートされなくなりました

*2:テンプレートを使うためにEntrykitを利用しています