Nginxへの変更に伴うリバースプロキシのテストの改善
SREグループの菅原です。 クックパッドではブラウザ用Webサイトのリバースプロキシ用のWebサーバとして長らくApacheを使っていたのですが、最近、Nginxへと変更しました。
Nginxへの変更に当たって、構成管理の変更やテストの改善を行ったので、それらについて書きたいと思います。
リバースプロキシのリニューアルについて
まず、ブラウザ用Webサイトの基本的なサーバ構成は以下のようになります。
リバースプロキシはELB経由でリクエストを受けて、静的ファイルの配信やキャッシュサーバ・Appサーバへの振り分けを行います。
リバースプロキシとして利用されているApacheは、長年の改修により設定が煩雑なものとなっており、設定の追加や変更にコストがかかる状態になっていました。
また、Apacheの設定ファイルはItamaeでは管理されておらず、ItamaeのレシピがあるGitリポジトリとは別に、Apacheの設定ファイルだけを格納したGitリポジトリで管理され、Capistranoで設定を配布する方式になっていました。これは当初、サーバ全体の構成管理(当時はPuppet)の適用タイミングと、プロキシサーバの設定の変更タイミングが異なると考えてのことだったのですが、現状では単に管理が複雑になるだけでメリットがない状態になっていました。
この状況を踏まえ、リバースプロキシのOSなどのリニューアルをするタイミングで、より平易に設定を書くことができるNginxへ変更し、また、Nginxの設定ファイルについてはItamaeの管理下に置くことにしました。
既存のリバースプロキシのテストについて
以前の記事でも取り上げられたのですが、リバースプロキシの設定はInfratasterでテストが行われています。
InfratasterのテストにはDocker Composeを使用しており、以下のようなコンテナの構成になっていました。
前述の通り、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のレシピをコンテナに適用する場合、以下の問題点があります。
- Nodeオブジェクトに含まれるサーバのメタ情報(例:
node[:ec2]
)が、コンテナ適用時には含まれない - 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リポジトリに移行するに当たって、以下の点を改善するようにしました。
- 複数のサービスを同じdocker-compose.ymlで定義するのをやめて、環境構築の時間を短縮し、テストの相互依存をなくす
link_local_ips
と/etc/hosts
を使った経路の差し替えをやめ、エンドポイントのホスト名そのままでダミーサーバにアクセスできるようにする- 実際のサーバ構成を再現するようにコンテナを構成して、SSL TerminationやIPの偽装などの処理をリバースプロキシのコンテナに持ち込まない
最終的にテストまわりのコンテナ構成・ファイル構成は以下のようになりました。
[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
の値(ホスト名:バックエンド,...
)で各サービスのserver
とproxy_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コンテナと同様に環境変数 SERVERS
でserver
とproxy_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...になっていると、精神に大変なダメージを受けるので、未来の自分のメンタルヘルスを保つために、今後も改善を続けていきたいところです。