NginxをReverseProxyにしてRailsアプリをデプロイしたらはまった


0. 導入

Railsでアプリケーションを作成しデプロイしました。しかし、Nginxの設定が原因でPOST Requestを行ったタイミングでエラーが発生してしまいました。どのようにエラーを解消したのかということをまとめます。

1. 概要

1.1. 構成

SakuraのVPSを借りてCentOS7系をインストールしています。
NginxをReverse Proxyとして動作させ、HTTPS通信を終端化し、リクエストをアップストリームサーバ(puma)に転送します。
pumaとRailsアプリはRack1を介してrequestとresponseをやりとりします。

1.2. 問題点

少し長目の記事なので最初にどこが問題であったのか書いておきます。
今回はNginxをReverseProxyとした際に、アップストリームサーバに必要なHTTPヘッダーを渡せていなかったことが問題でした。

2. エラー発生

2.1. 422エラーが返ってきた

ブログを作成し、デプロイも完了。早速ログインしブログを投稿しよう、と思った矢先…

なんかエラーが出てる…Status Code 422って何だ?

3. トラブルシュート

3.1. Status Code 422 とは?

MDN web docsを参照してみます。
422 Unprocessable Entity:

The HyperText Transfer Protocol (HTTP) の 422 Unprocessable Entity 応答状態コードは、サーバーが要求本文のコンテンツ型を理解でき、要求本文の構文が正しいものの、中に含まれている指示が処理できなかったことを表します。

わからない。「中に含まれている指示が処理できなかった」という表現がいささか抽象的です。

If you are the application owner check the logs for more information.

「アプリのオーナーなら詳細についてログを見てください」とのことなのでVPSにログインしRailsのログを確認してみます。

3.2. Logの確認

VPSにログインし下記のログを確認します。

/var/www/app_name/shared/log/production.log
(省略)
W, [*]  WARN -- : [*] HTTP Origin header (https://self-ref-penguin.com) didn't match request.base_url (http://self-ref-penguin.com)
I, [*]  INFO -- : [*] Completed 422 Unprocessable Entity in 2ms
F, [*] FATAL -- : [*]   
F, [*] FATAL -- : [*] ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):
F, [*] FATAL -- : [*]   
F, [*] FATAL -- : [*] actionpack (5.2.3) lib/action_controller/metal/request_forgery_protection.rb:211:in `handle_unverified_request'
(省略)

最初のWARNと最後のFATALに、具体的な情報がありそうに思えます。

W, [〜] WARN -- : [〜] HTTP Origin header (https://self-ref-penguin.com) didn't match request.base_url (http://self-ref-penguin.com)
F, [〜] FATAL -- : [〜] actionpack (5.2.3) lib/action_controller/metal/request_forgery_protection.rb:211:in `handle_unverified_request'

WARNでは「HTTPのオリジンヘッダーがrequest.base_urlにマッチしていない。」と言われています。FATALではrequest_forgery_protection.rbがエラーを出しています。まずはエラーを出しているメソッドを確認してゆきます。

3.3. request_forgery_protection.rbを見てみる

request_forgery_protection.rbを一部添付します。

rails/actionpack/lib/action_controller/metal/request_forgery_protection.rb
require "rack/session/abstract/id"
require "action_controller/metal/exceptions"
require "active_support/security_utils"
require "active_support/core_ext/string/strip"

module ActionController #:nodoc:
  class InvalidAuthenticityToken < ActionControllerError #:nodoc:
  end

  class InvalidCrossOriginRequest < ActionControllerError #:nodoc:
  end

  module RequestForgeryProtection
    extend ActiveSupport::Concern
(省略)
      def verify_authenticity_token # :doc:
        mark_for_same_origin_verification!

        if !verified_request?
          if logger && log_warning_on_csrf_failure
            if valid_request_origin?
              logger.warn "Can't verify CSRF token authenticity."
            else
              logger.warn "HTTP Origin header (#{request.origin}) didn't match request.base_url (#{request.base_url})"
            end
          end
          handle_unverified_request
        end
      end
(省略)
      def valid_request_origin? # :doc:
        if forgery_protection_origin_check
          # We accept blank origin headers because some user agents don't send it.
          raise InvalidAuthenticityToken, NULL_ORIGIN_MESSAGE if request.origin == "null"
          request.origin.nil? || request.origin == request.base_url
        else
          true
        end
      end
(省略)
  end
end

valid_request_origin?メソッドでrequest.origin == request.base_urlを比較しています。この比較がfalseを返すとverify_authenticity_tokenメソッドが上述のWARNメッセージを出すようです。
base_urlメソッドは、lib/action_dispatch/http/request.rbincludeしているRack::Request::Helpersに定義されています。

3.3. Rackを見てみる

request.rbを一部添付します。

rack/lib/rack/request.rb
require 'rack/utils'
require 'rack/media_type'

module Rack

  class Request
    class << self
      attr_accessor :ip_filter
    end
(省略)
    module Env
(省略)
      def get_header(name)
        @env[name]
      end
(省略)
    end

    module Helpers
(省略)
      DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 }

      HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME'
      HTTP_X_FORWARDED_PROTO  = 'HTTP_X_FORWARDED_PROTO'
(省略)
      def scheme
        if get_header(HTTPS) == 'on'
          'https'
        elsif get_header(HTTP_X_FORWARDED_SSL) == 'on'
          'https'
        elsif forwarded_scheme
          forwarded_scheme
        else
          get_header(RACK_URL_SCHEME)
        end
      end
(省略)
      def base_url
        url = "#{scheme}://#{host}"
        url = "#{url}:#{port}" if port != DEFAULT_PORTS[scheme]
        url
      end
(省略)
      def forwarded_scheme
        scheme_headers = [
          get_header(HTTP_X_FORWARDED_SCHEME),
          get_header(HTTP_X_FORWARDED_PROTO).to_s.split(',')[0]
        ]

        scheme_headers.each do |header|
          return header if ALLOWED_SCHEMES.include?(header)
        end

        nil
      end
    end
    include Env
    include Helpers
  end
end

base_urlメソッドを見つけました。
このメソッドの中でschemeメソッドが呼び出されています。get_headerenv2という変数が保持しているハッシュから、引数に指定されたKeyが持つValueを取得します。 get_headerの引数となるKeyにはHTTPヘッダ3が指定されているようなので、Nginxの設定でヘッダを付与してみることにします。

3.4. Nginxの設定を直す

/etc/nginx/conf.d/default.conf
upstream app-name {
    server unix:/var/www/app-name/shared/tmp/sockets/devcamp-portfolio-puma.sock fail_timeout=0;
}

server {

    if ($host = self-ref-penguin.com) {
        return 301 https://$host$request_uri;
    }
    listen 80;
    server_name self-ref-penguin.com;
    root /var/www/app-name/current/public;
}

server {
    listen       443 ssl http2 default_server;
    # listen       [::]:443 ssl http2 default_server;
    server_name self-ref-penguin.com;
    ssl_certificate ***;
    ssl_certificate_key ***;
    ssl_session_cache shared:SSL:1m;
    ssl_session_timeout  10m;
    ssl_ciphers ***;
    ssl_protocols ***;

    location ~ ^/assets/ {
      root /var/www/app-name/current/public;
    }

    try_files $uri/index.html $uri @app-name;

    location / {
        proxy_pass http://app-name;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        # この設定が抜けていました
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }
(省略)
}

X-Forwarded-Protoヘッダーの設定を追記し、「ユーザのリクエストが使用したHTTPスキームを指定する」ように修正。systemctl restart nginx.serviceを実行します。再度ページにアクセスしログインを実行。今度はログイン(POST)が成功したことを確認できました。

今回の構成ではNginxがReverse ProxyとなりHTTPSのアクセスを終端化していたのでした。今回のエラーの原因は下記の説明に要約されるかと思います。

アップストリームに伝える必要がある情報にクライアントのリクエスト情報があります。アップストリームサーバーへのリクエストはすべてプロキシを経由するため、そのままではクライアントの送信元アドレスや使用したプロトコルがわからなくなってしまいます。このため、クライアントのリクエスト情報をいくつかのヘッダを付与することでアップストリームへ伝えることができます。これらのヘッダは標準化されていませんが、Squid、Apache HTTPサーバなどでデファクトスタンダードとして使用されており、RubyのRackインタフェースもこれらのヘッダを解釈します。4

4. まとめ

エラーの原因についてはシステムの構成をしっかりと理解していれば、もっと簡単にあたりをつけることが可能であったと思います。


  1. Rackについては以下2つの記事によくまとまっています
    Rails on Rack
    What is Rack in Ruby? 

  2. EnvについてはStack over flowの質問を参照しました。「env is just a hash. Rack itself and various middlewares add values into it.」「envはハッシュで、Rackや様々なミドルウェアがこれにValueを加えていきます」 

  3. HTTPヘッダーの種類についてはMDN web docsを参照し、適切な値を探しました。 

  4. 久保達彦, 道井俊介(2016)『nginx 実践入門』p.139-140, 技術評論社.