KubernetesにdeployしたSinatraでPOSTするとSessionオブジェクトが消えたので調べてみた

# はじめに

結論としてはReverse Proxyの後段に配置するIngressはちゃんとTLS化しましょうという内容です。

Sinatraに限った話ではないですが、Sessionが維持できないことはよく遭遇する問題のひとつです。

Sessionが引き継げない問題は一般的には2つ以上のインスタンスを立ちあげて負荷分散している場合に発生しがちです。

例えばSessionの内容がメモリ上に保存されていて最初にSessionオブジェクトを生成したホストとは違うホストにアクセスしてSessionを見失ったり、共通化しなければいけないSessionID生成・利用時のキーがそれぞれのインスタンスで生成されていて共通化されていなかったりするなど、複数の原因が想定されます。

今回は少し特殊なケースだったのでメモを残しておくことにしました。

# 出現条件

* 開発環境では再現しない
* Formに設定したボタンを押下してPOSTメソッドを発火するとsession変数の内容が空になる
* GETメソッドでは再現しない

# 環境

* 開発環境: Ubuntu 22.04.4
* Ruby 3.2.2 (開発環境) and ruby:3.3-alpineコンテナ (本番環境)
* Rack関連のパッケージ
  * redis-rack 3.0.0
  * rack 3.0.9.1
  * rack-session 2.0.0
* 本番環境
  * Reverse Proxy: nginx
  * Kubernetes v1.27.7
  * Ingressを利用

# 原因分析

デバッグコードを追加して本番環境の片隅でテストした結果、次のことが分かりました。

1. SinatraでのSession関連のコードは全て rack-sessionの``rack/session/abstract/id.rb``に集中していること
1. POSTメソッドを受け付けた後の処理でだけ、load_for_write!メソッドが呼ばれている
1. load_for_write!メソッドを読んだ後、``@data``オブジェクトの内容が空になっている
1. ``@data``オブジェクトの内容が空なのに、``@loaded``はtrueのままなので、その後の処理で``@data``オブジェクトが更新されない

ここで``@data``オブジェクトはSessionの内容が保存されている実体です。

関連するコードは次のような内容です。

```ruby:rack/session/abstract.id.rb(rack-session-2.0.0)からの抜粋
      private

        def load_for_read!
          load! if !loaded? && exists?
        end

        def load_for_write!
          load! unless loaded?
        end

        def load!
          @id, session = @store.send(:load_session, @req)
          @data = stringify_keys(session)
          @loaded = true
        end
```

なのでworkaroundとしては、次のようにコードを修正することで解決します。

```ruby:修正後のコード
        def load_for_read!
          load! if (!loaded? && exists?) || @data.empty?
        end
```

あるいは``@data.clear``を読んだ後で、``@loaded``をfalseにすることでも対応できます。

```ruby:clearメソッド
        def clear
          load_for_write!
          @data.clear
          @loaded = false
        end
```

とりあえずプログラム自体はこれで一応は動作するようにはなります。

問題は根本原因です。

# load_for_write!は誰が呼び出したのか？

開発環境では同様にPOSTメソッドを呼び出しても``load_for_write!``が呼ばれることはありません。

この違いはどこからくるのか、これはprivateメソッドなので、id.rbの内部のメソッドを経由していることは間違いありません。

id.rb内のpublicメソッドを調べていくと、``clear()``メソッドを経由して問題のコードが呼ばれていることが分かります。

メソッド名からも消えて当然なのですが、なぜclear()を呼び出しているのか不明なので、``caller()``を埋め込んでみました。結果は次のような出力になります。

```text:rack-sessionのid.rbに埋め込んだclearメソッドに埋め込んだcallerの出力
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/base.rb:98:in `drop_session'
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/base.rb:57:in `react'
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/base.rb:51:in `call'
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/frame_options.rb:33:in `call'                                                          
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/null_logger.rb:13:in `call'
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/head.rb:15:in `call'
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/show_exceptions.rb:23:in `call'
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/base.rb:224:in `call'
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/base.rb:2115:in `call'
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/base.rb:1674:in `block in call'
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/base.rb:1890:in `synchronize'
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/base.rb:1674:in `call'
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/xss_header.rb:20:in `call'
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/frame_options.rb:33:in `call'
/app/lib/ruby/3.2.0/gems/rack-protection-4.0.0/lib/rack/protection/base.rb:53:in `call'
/app/lib/ruby/3.2.0/gems/rack-session-2.0.0/lib/rack/session/abstract/id.rb:288:in `context'
/app/lib/ruby/3.2.0/gems/rack-session-2.0.0/lib/rack/session/abstract/id.rb:279:in
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/tempfile_reaper.rb:20:in `call'
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/lint.rb:63:in `response'     
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/lint.rb:35:in `call'
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/show_exceptions.rb:27:in `call'    
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/common_logger.rb:43:in `call'   
/app/lib/ruby/3.2.0/gems/sinatra-4.0.0/lib/sinatra/base.rb:266:in `call'     
/app/lib/ruby/3.2.0/gems/rack-3.0.9.1/lib/rack/content_length.rb:20:in `call'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/configuration.rb:272:in `call'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/request.rb:100:in `block in handle_request'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/thread_pool.rb:378:in `with_force_shutdown'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/request.rb:99:in `handle_request'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/server.rb:464:in `process_client'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/server.rb:245:in `block in run'
/app/lib/ruby/3.2.0/gems/puma-6.4.2/lib/puma/thread_pool.rb:155:in `block in spawn_thread' 
```

というわけで原因はrack-protection-4.0.0のdrop_sessionが呼ばれた事でした。

rack-protectionによるdrop_sessionはsinatra-4.0.0のsinatra/base.rbの中でprotection!がtrueであれば必ず実行されるようになっています。

もう少し詳しくみていくと、Rack::Protection::HttpOrigin に起因していることが分かりました。

このためHTTPメソッドを使ってアプリケーションにアクセスし、:permitted_origins にマッチしないホストを経由している場合にはclearメソッドが呼ばれることになります。

# Cookieのsecure属性を有効にする

:permitted_originsを設定することでも問題は解決しますが、そもそもTLS化していないことによるポリシーの適用によって現象が発生していることが分かりました。

またIngressでTLSを有効化していないことによりX-Forwarded-Proto等がhttpsではなくhttpとなるため、RackがCookieにSecure属性を付与してくれません。

Djangoで作成しているアプリケーションはIngressがTLS化されていなくてもsecure属性を付与できて動作しているのですが、Rackは単純にsecure属性をsession-cookieに付与することは難しそうです。

Ingress側で強制的にX-FORWARDED-PROTOにhttpsを指定しようとしたのですが、うまく動作せず他の懸念点もあったことからTLS化したところ、期待どおりの動作をしました。

手を抜かないできちんとIngressまでTLS化をするべきということなのだなと思いました。

# IngressのTLS化について

簡単にIngressのTLS化についてまとめておきます。

フロントエンドのreverse proxyにはnginxを利用しています。ここまではTLS化しているので、同じ証明書(tls.crt)と秘密鍵(tls.key)を使って簡単に背後のIngressをTLS化することができます。

* [K8s公式ガイド - Ingress](https://kubernetes.io/ja/docs/concepts/services-networking/ingress/)

ただlet's encryptを利用していると証明書の有効期限が短いため、自動化も可能だと思いますが手作業だと頻繁に変更作業が発生して実用的な解決策ではないかもしれません。

## Makefile

次のような内容のMakefileを準備しました。

```makefile:Makefile
.PHONY: all
all:
        @echo usage; please see the Makefile

.PHONY: setup-sec
setup-sec:
        sudo kubectl -n ingress-nginx create secret tls kubecamp-tls --cert=./conf/tls.crt --key=./conf/tls.key

.PHONY: delete-sec
delete-sec:
        sudo kubectl -n kubecamp delete secret kubecamp-sec
```

## tls.crt and tls.key files

```bash:
$ ls -l conf
total 8
-rw-rw-r-- 1 ubuntu ubuntu 2553 Feb 26 15:29 tls.crt
-rw-rw-r-- 1 ubuntu ubuntu 1704 Feb 26 15:29 tls.key
```

ファイルの名前は任意です。Secretオブジェクトの中で"tls.crt"と"tls.key"をキー名とする内容に変換されます。

またtls.crtからは中間認証局の証明書は含めていません。

## Ingressオブジェクトの変更

公式ガイドの記述どおりに設定します。この時に指定するhost名はfrontendのreverse proxyが認識するvirtualhost名と同じです。

host名を設定しないとエラーになるのでまず既存の全てのIngressオブジェクトを変更し、host:行を追加します。

```yaml:svc-gitlab.yamlの例
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: gitlab
  labels:
    group: ingress-nginx
  namespace: ingress-nginx
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "512m"
spec:
 ingressClassName: nginx
 rules:
 - host: kubecamp.example.com
   http:
     paths:
     - path: /gitlab
       pathType: Prefix
       backend:
         service:
           name: gitlab-svc
           port:
             number: 80
```

.spec.tlsの設定は、その設定だけを入れているIngress(name: default-tls)を作成しています。既存の設定に.spec.rules[*].hostだけを設定しています。

```yaml:svc-default-tls.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: default-tls
  labels:
    group: ingress-nginx
  namespace: ingress-nginx
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - kubecamp.example.com
    secretName: kubecamp-tls
  rules:
  - host: kubecamp.example.com
```

## Ingress Controllerでの443ポートの有効化

Ingress Controller自体は443ポートの接続を受け付けるので、適当なServiceオブジェクトを作成します。

今回は作成していた80ポート用のServiceに443ポートを追加しました。

```yaml:01.svc-ingress.yaml
apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-lb
  namespace: ingress-nginx
spec:
  type: LoadBalancer
  loadBalancerIP: "192.168.1.100"
  ports:
  - name: http
    port: 80
    targetPort: http
  - name: https
    port: 443
    targetPort: https
  selector:
    app.kubernetes.io/name: ingress-nginx
```

各サービスのPORTSでは80版しか開放されていませんが、443ポートにアクセスすればgrafanaなどに適切にルーティングされるようになります。

```bash:kubectl -n ingress-nginx get ingressの出力
NAME           CLASS   HOSTS                   ADDRESS                                                       PORTS     AGE
gitbucket      nginx   kubecamp.example.com   192.168.100.51,192.168.100.52,192.168.100.53,192.168.100.54   80        22h
gitlab         nginx   kubecamp.example.com   192.168.100.51,192.168.100.52,192.168.100.53,192.168.100.54   80        23h
grafana        nginx   kubecamp.example.com   192.168.100.51,192.168.100.52,192.168.100.53,192.168.100.54   80        23h
default-tls    nginx   kubecamp.example.com   192.168.100.51,192.168.100.52,192.168.100.53,192.168.100.54   80, 443   701d

```

# さいごに

結果としてIngressをTLS化する機会になったので良かったのかなと思います。

Sinatraの挙動は素直で便利なので好んで使っています。

Session周りのちゃんとしているところは良い点だとは思うのですが、良い意味でいい加減だったDjangoに慣れていたのでまさかという感じでした。

懸案だった挙動の問題が一つ解決したので、他のK8sクラスターのIngressもTLS化して使っていく予定です。