はじめに
結論としては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を利用
原因分析
デバッグコードを追加して本番環境の片隅でテストした結果、次のことが分かりました。
- SinatraでのSession関連のコードは全て rack-sessionの
rack/session/abstract/id.rb
に集中していること - POSTメソッドを受け付けた後の処理でだけ、load_for_write!メソッドが呼ばれている
- load_for_write!メソッドを読んだ後、
@data
オブジェクトの内容が空になっている -
@data
オブジェクトの内容が空なのに、@loaded
はtrueのままなので、その後の処理で@data
オブジェクトが更新されない
ここで@data
オブジェクトはSessionの内容が保存されている実体です。
関連するコードは次のような内容です。
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としては、次のようにコードを修正することで解決します。
def load_for_read!
load! if (!loaded? && exists?) || @data.empty?
end
あるいは@data.clear
を読んだ後で、@loaded
をfalseにすることでも対応できます。
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()
を埋め込んでみました。結果は次のような出力になります。
/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化することができます。
ただlet's encryptを利用していると証明書の有効期限が短いため、自動化も可能だと思いますが手作業だと頻繁に変更作業が発生して実用的な解決策ではないかもしれません。
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
$ 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:行を追加します。
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だけを設定しています。
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ポートを追加しました。
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などに適切にルーティングされるようになります。
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化して使っていく予定です。