Nginx + Lua (Openresty) のキワモノ設定プラクティス

食べログ DevOps チームの @weakboson です。
この記事は Advent Calendar の9日目の投稿です。

Nginx とのどつきあいの果てに産まれたキワモノ設定プラクティスをお披露目します。

# アクセス元IPでログを分ける

```nginx:nginx.conf
http {
  geo $client_location {
    10.0.0.0/8      intra;
    172.16.0.0/12   intra;
    192.168.0.0/16  intra;
    10.255.241.0/24 monitor;
    default         customer;
  }
  map $client_location $intra_log {
    intra   1;
    default 0;
  }
  map $client_location $customer_log {
    customer 1;
    default  0;
  }
  map $client_location $monitor_log {
    monitor 1;
    default 0;
  }

  server {
    server_name example.com;
    access_log /var/log/nginx/intra/access.log    intra_format    if=$intra_log;
    access_log /var/log/nginx/monitor/access.log  minimum_format  if=$monitor_log;
    access_log /var/log/nginx/customer/access.log customer_format if=$customer_log;
  }
}
```

ストレージのコストがそんな高くもない昨今、こんなことせずにアクセスログは1箇所に富豪的に全部のせ1フォーマットで吐いて、集計するときに加工する方がよいと思います。(いきなりの全否定)

# proxy_pass したときの妙なデフォルト Host ヘッダを活用する

`proxy_set_header` ディレクティブを使わなくて済む。1行くらいお得。

```nginx:first-proxy.conf
http {
  upstream application {
    app1.example.com;
    app2.example.com;
  }
  upstream asset {
    app1.example.com;
    app2.example.com;
  }
  server {
    server_name example.com;
    location ~ \.(js|css)$ {
      proxy_pass http://asset;
    }
    location / {
      proxy_pass http://application;
    }
  }
}
```

```nginx:second-proxy.conf
  upstream rails {
    server unix:/application/current/tmp/sockets/rails.socket;
  }
  server {
    server_name application;
    location / {
      proxy_pass http://rails;
    }
  }
  server {
    server_name asset;
    root /application/current/public;
  }
```

実際どういうときこんな構成にするのよ？
アプリのコードをアプリケーションサーバだけにデプロイしたいときいいかもですね。Capistrano の rollback で巻き戻しができるかも。

# パスごとのアクセス制限をえいやーと実装

```nginx:nginx.conf
http {
  geo $customer {
    100.64.0.1/32 customer_a;
    100.64.0.2/32 customer_b;
    100.64.0.3/32 customer_c;
    default       not_customer;
  }
  server {
    server_name example.com;
    access_by_lua_file customer_acl.lua;
  }
}
```

```lua:customer_acl.lua
if not string.match(ngx.var.uri, "^/" .. ngx.var.customer .. "/") then
  return ngx.exit(ngx.HTTP_FORBIDDEN)
end
```

http://example.com/customer_a/ は 100.64.0.1 からのみアクセス可、http://example.com/customer_b/ は 100.64.0.2 からのみアクセス可……というアクセス制限が Lua 3行で実現できそうです。

# 外部ネットワークアクセスのときだけ `Server` ヘッダを消す

```nginx:nginx.conf
http {
  geo $server_visible {
    10.0.0.0/8      1;
    172.16.0.0/12   1;
    192.168.0.0/16  1;
    default         0;
  }
  header_filter_by_lua_file hide_server.lua;
}
```

```lua:hide_server.lua
if ngx.var.server_visible == "0" then
  ngx.headers['Server'] = nil
end
```

なんかこんなのばかりで飽きてきた…… Nginx を使っているコト自体伏せたいときにでも。他にも内部アクセスのときは拡張 HTTP ヘッダで詳細な情報を返して、外部アクセスのときは消しちゃうという使い方もできそうです。

わざわざ Lua を使わなくても `more_clear_headers` でもできそうですが、more_clear_headers は location 以外で条件分岐できません。こんな感じなら ok ですが、location が増えたとき大変なことに。

```nginx:nginx.conf
server {
  server_name example.com;
  location / {
    if ($server_visible) {
      more_clear_headers Server;
    }
  }
}
```

# CORS ヘッダをきめ細やかにセット

[NGINX Cookbook by Derek DeJonghe](https://www.nginx.com/resources/library/complete-nginx-cookbook/) の 11.2 Allowing Cross-Origin Resource Sharing で紹介されていた CORS 実装の location if を使わない版です。

```nginx:nginx.conf
http {
  map $request_method $cors_method {
    OPTIONS 11;
    GET      1;
    POST     1;
    default  0;
  }
  map $uri $need_cors {
    ~\.(svg|eot|ttf|woff)$ 1;
  }
  server {
    server_name example.com;
    header_filter_by_lua_file add_cors_header.lua;
  }
}
```

```lua:add_cors_header.lua
local function add_cors_headers()
  local cors_method = ngx.var.cors_method
  if cors_method == "0" then
    return
  end

  if string.match(cors_method, "1") then
    ngx.header["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"
    -- アセット専用ドメインがあったり複数ドメインで共有してたりするなら
    -- Access-Control-Allow-Origin は server_name じゃだめかもしれない
    ngx.header["Access-Control-Allow-Origin"]  = ngx.var.server_name
    ngx.header["Access-Control-Allow-Headers"] = "DNT,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type"
  end

  if cors_method == "11" then
    ngx.header["Access-Control-Max-Age"] = "1728000"
    ngx.header["Content-Type"] = "text/plain; charset=UTF-8"
    ngx.header["Content-Length"] = "0"
    ngx.exit(ngx.HTTP_NO_CONTENT)
  end
end

if ngx.var.need_cors == "1" then
  add_cors_headers()
end
```

```shell:CORSヘッダ不要な場合
$ curl -I -X GET http://localhost/index.html
HTTP/1.1 200 OK
Server: openresty
Date: Thu, 06 Dec 2018 13:23:00 GMT
Content-Type: text/html
Content-Length: 53
Last-Modified: Thu, 06 Dec 2018 13:18:24 GMT
Connection: keep-alive
ETag: "5c0921a0-35"
Accept-Ranges: bytes
```

```shell:CORSヘッダが必要な場合
$ curl -I -X GET http://localhost/example-glyph.woff
HTTP/1.1 200 OK
Server: openresty
Date: Thu, 06 Dec 2018 13:23:55 GMT
Content-Type: application/x-font-woff
Content-Length: 43244
Last-Modified: Thu, 06 Dec 2018 13:16:04 GMT
Connection: keep-alive
ETag: "5c092114-a8ec"
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Origin: example.com
Access-Control-Allow-Headers: DNT,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type
Accept-Ranges: bytes
```

```shell:プリフライトリクエスト
$ curl -I -X OPTIONS http://localhost/example-glyph.woff
HTTP/1.1 204 No Content
Server: openresty
Date: Thu, 06 Dec 2018 13:21:30 GMT
Connection: keep-alive
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Origin: example.com
Access-Control-Allow-Headers: DNT,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type
Access-Control-Max-Age: 1728000
Content-Length: 0
```

# 投げやりな締め

Nginx は server や http といった大きな範囲に共通して適用したい分岐ルールというのが非常に実装しづらい Web サーバだと思います。 それは [geo](http://nginx.org/en/docs/http/ngx_http_geo_module.html) や [map](http://nginx.org/en/docs/http/ngx_http_map_module.html) という http コンテキストに書ける便利な変数設定ディレクティブはあっても、その変数を条件やパラメータにできるディレクティブが location にしか書けないことが多いからでないかと思います。

例えば「外部ネットワークアクセスのときだけ Server ヘッダを消す」を例に挙げると、http コンテキストにおいて判定はできているのに、条件付きで Server ヘッダを隠すディレクティブが location にしか記述できません。一方 Lua の Hook は http, server に設定できるため、上層で判定したことについて同じく上層で実施もできます。条件判定は宣言的に nginx.conf に記述して、その条件に従い何かを施行するのは Lua に記述するというのが nginx.conf　をメンテしやすく保つプラクティスの一つなのかもなーと思います。 

明日、21日目は @satoru_fukagawa による「iOSのUILabelのサイズそのままでHiragino SansのqyjpやÉの上下が見切れないようにする」です。