Chienomi

CGIエコーサーバー(生CGI)とRack/CGI

プログラミング::web

もはや使うことも少なくなったCGI。

現代ではほとんどの場合HTTPを理解するアプリケーションサーバーか、もしくは(特にPHPの場合は)バイナリプロトコルであるFastCGIを利用するため、CGIを使うことは本当に減った。

しかし、CGIの仕組みは非常に単純であり、コードが書きやすい。 そのため、簡単なプログラムを動かしたい場合は現代でも利用価値はある。

一方で「レガシーな技術」とみなされているために、有効なアクセスが難しい部分がある。 例えば、リクエストボディがクエリストリングならばCGIライブラリがよきにはからってくれるが、リクエストボディがJSONという、現代ではごくごくごくごく普通のことがなかなか取り扱えなかったりする。

恐らく最も現代的なCGIライブラリは、RubyのRack/CGIハンドラだろう。 ライブラリはrackup、クラスはRackup::Handler::CGIである。

だがこれはこれで、なんだか不器用な動作をする。

CGIの基本的な仕組み

ウェブサーバーはCGIアプリケーションを普通にプロセスとして起動する。

Apacheの場合は対象プログラムを普通に実行する。 つまり、対象プログラムは実行可能である必要がある。

LighttpdやHiawathaは拡張子に対応したハンドラに対象プログラムのパスを渡す。 対象ファイルは実行可能である必要はないが、スクリプトファイルではない場合はひと工夫必要になる。

このとき、環境変数として各種パラメータをセットした上でプロセスをspawnする。 プロセスの標準入力はリクエストボディにつながっている。 また、プロセスの標準出力はHTTPレスポンスになる。

CGIプログラムの出力はHTTPヘッダではなくCGIヘッダと呼ばれるものになる。 特徴的な要素として、Statusというヘッダがあり、このヘッダがウェブサーバーによってHTTPレスポンスステータスに変換される。

以下はBashによるハローワールドCGIである。

#!/bin/bash

cat <<EOF
Status: 200 OK
Content-Type: text/plain

Hello, world.
EOF

リクエストボディを読む

リクエストボディは標準入力から読むことができる。 しかし、EOFはこないため、単純なSTDIN.readのようなことはできない。

リクエストボディの長さは$CONTENT_LENGTH環境変数に格納されている。 ただ、これはクライアント側のContent-Lengthヘッダに従うため、正しいことが保証されているわけではない。

また、リクエストボディの長さとContent-Lengthの値が等しい必要はない。 これは「受け取ってほしいデータはこの長さです」と言っているだけで、それ以上のデータを送ることが許されている。

Content-Lengthに送信データが満たない場合、サーバーはデータが送られてくるのを待つことになる。 問題はあるが、ありえないことではない。

Content-Lengthを送ってこないというケースもある。 これも違反ではない。ありえることだ。

Content-Lengthを送ってこないことを許容できない場合、411 Length Requiredというステータスを返すことができる。特にチャンクで送信してくることを許容できないケース、というのがある。 このような場合の扱いは難しく、生CGIを使う状況では考えたくない。 Content-Lengthがなければ滅多に使わない411ステータスコードを返しておく、というのが簡単な話だろう。相手がそれに合わせてどうこうしてくれることは期待できないが、拒否理由の明示にはなる。

生CGIでのエコーサーバー

以上を踏まえて次のようなエコーサーバーが書ける。

一応、ウェブサーバーがContent-Typeとヘッダターミネーターを必須にしている場合に備えて少し余計なものを入れてある。

#!/usr/bin/ruby

def main
  meth = ENV["REQUEST_METHOD"]
  content_length = ENV["CONTENT_LENGTH"].to_i

  # POSTメソッドだけを受け入れる
  unless meth == "POST"
    puts <<-EOF
Status: 405 Method Not Allowed
Content-Type: text/plain

    EOF
    return
  end
  
  # Content Lengthがないリクエストを拒否する
  if !content_length || content_length.zero?
    puts <<-EOF
Status: 411 Length Required
Content-Type: text/plain

    EOF
    return
  end
  
  # 標準入力から $CONTENT_LENGTH 分だけ読み込む
  content = STDIN.read(content_length)
  
  # 応答を返す
  puts <<-EOF
Status: 200 OK
Content-Type: text/plain

#{content}
  EOF
end

main

explainのために最小限で書いているが、ウェブブラウザからリクエストされるのであればOPTIONメソッドに対応する必要がある場合がある。

また、この処理ではContent-Lengthが実際のデータよりも大きい場合に発生するタイムアウトはウェブサーバーが処理してくれることを期待しており、タイムアウト処理を入れていない。

だが、ウェブブラウザからアクセスされるようなスクリプトは生CGI取り扱いをしないほうが良い。

Rack/CGI

RubyのウェブアプリケーションインターフェイスライブラリであるRackは、Rubyでウェブアプリケーションを開発する場合のデファクトスタンダードとなる選択肢である。

そして、RackのCGIハンドラというのが存在し、CGIとして起動されるスクリプトでもRackを使うことができる。 ただし、Rack/CGIは割とCGIの事情に左右されるため、単にハンドラを変更するだけでCGI以外へ移植する、というのはできない場合が多い。

次に示すのはRack/CGIでただただ204 No Contentを返すだけのRackアプリケーションである。 これは、ハンドラを書き換えれば他のハンドラでも動作する。

require 'rack'
require 'rackup'

class MyApp
  def call env
    res = Rack::Response.new
    req = Rack::Request.new env
    res.status = 204

    res.finish
  end
end

Rackup::Handler::CGI.run MyApp.new

普通のRackアプリケーションなら、次の要領でエコーサーバーが書ける。

require 'rack'
require 'rackup'

class MyApp
  def call env
    res = Rack::Response.new
    req = Rack::Request.new env
    body = req.body.read
    res.status = 200
    res.write body

    res.finish
  end
end

Rackup::Handler::CGI.run MyApp.new

実は、ApacheやLighttpdはCGIアプリケーションに渡すSTDINをEOFつきになるように渡すため、これで動作する。 しかし、Hiawathaはストリームをそのまま渡すため、タイムアウトしてしまう。

これは、よくやるJSON.load(req.body.read)JSON.load env["rack_input"].readでも同じことが言える。 というのも、このreq.bodyは単なるIO::STDINであり、生CGIと同じ問題を抱えているのだ。

これについて、ApacheやLighttpdのほうが自然な挙動に見えるが、Hiawathaのほうが仕様に忠実な挙動である。 これに対応するためには、生CGIと同じような処理が必要になり、一気に汚くなる。

require 'rack'
require 'rackup'

class MyApp
  def call env
    res = Rack::Response.new
    req = Rack::Request.new env
    body = req.body.read(ENV["CONTENT_LENGTH"].to_i)
    res.status = 200
    res.write body

    res.finish
  end
end

Rackup::Handler::CGI.run MyApp.new

bodyがない場合や、メソッドのことを考えていないのも問題だ。 これらの考慮を入れると、生CGIとあまり変わらない話になり、Rackの嬉しさはほとんど残らない。

本来、こういう問題はRackup::Handler::CGIが面倒を見るべきで、それでこそ適切な抽象化なのだが、このハンドラはそこまで丁寧に作られていないので、かなり微妙な結果になる。

ちなみに、Rackup::Handler::CGIを使う場合、HiawathaではRack::Request#paramsも動作しない。 つまり、EOFがない入力に関しては全くカバーされていない。

Rack/CGIは本当に嬉しいか?

例えば当座はレンタルサーバーの(というか、Rackが動かせるというところまで話を限定するとほぼほぼConoHa WINGの)CGIで動作させるが、将来的にはVPSでアプリケーションサーバーに以降したい、などと考える場合、Rack/CGIを使うというのも悪い手ではない。

また、現在のプラットフォームがレンタルサーバーではなく、あくまで静的ファイルが主体で、一部簡単なアプリケーションがある程度の構成であるためにLighttpdをウェブサーバーに採用している、というような場合も、将来的な移行を考えてRack/CGIで書いておくことは、CGIライブラリを使うよりは建設的だと見ることができる。

単に簡単なプログラムをCGIとして動作させたい場合は、Rack/CGIにこだわる意味はないだろう。 CGIライブラリでもいいし、生CGIでもいい。 CGIライブラリの存在価値はほとんどクエリストリングの解析にあるため、JSONで渡されるのならばCGIライブラリを使う必要もない。

ただ間違いなく言えるのは、HiawathaのCGIはとてもきつい、ということだ。 ちなみに、RubyのCGIライブラリはHiawathaの環境でもちゃんと動く。

だが、アプリケーションを簡単に動作させるためにLighttpdもしくはHiawathaでCGIを利用し、リバースプロキシとしてNginxを採用する、というのは、ものすごく価値に乏しい。

というのも、生Rackはそこまで便利でもなく、Sinatraが圧倒的に書きやすい、という利点がある。 生RackではなくSinatraで書くことで得られる省エネ効果は、Systemdユニットを1つ書いてNginxにパスを1本生やす手間よりもずっと大きい可能性が高い。

つまり、ハローワールドならこんな感じで書いて

#!/bin/ruby

require 'sinatra'

set , ["thin"]
set , "127.0.10.2"
set , 8001

get "/" do
  "Hello?"
end

こんなユニット作って

[Unit]
Description=Sinatra 8001

[Service]
Type=simple
ExecStart=/usr/local/bin/sinatra8001.rb
Restart=always

[Install]
WantedBy=multi-user.target

systemctl enable --now sinatra8001.serviceとかして、あとはNginxに

location /fooapp {
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

  proxy_pass http://127.0.10.2:8001;
}

とかしてリロードしたら出来上がりである。

これだけならSinatraのほうが手間に思えるが、ここから機能を色々足していく場合、比較にならないほどSinatraで書くほうが楽。

小さいのをたくさん書くとlocalhost(=127.0.0.1, ::1)のポート枯渇が……と気になるかもしれないが、localhostのポート競合を気にせずサーバーを起動するで述べたように127.0.0.2とかしておけば問題は解決である。

このように、VPS等でウェブサーバーを運用している諸兄諸姉におかれては、「Rack/CGI、ひいてはCGIを使うことで楽になる要素はほとんどない」だと思って良い。 もちろん、言語が違えばライブラリが違ってくるので、事情は変わってくるかもしれないが、Rubyの場合はSinatraのアドバンテージが大きすぎる。

あと、アプリケーションサーバー方式を取れば簡単にNode(Express等)で書けるという選択肢の広がりも見逃せない。

ちなみに、私はConoHa WINGから突貫でVultr (VPS)に移行させたため、WINGで動かしていたCGIの移植作業を行う余裕がなく、現在も一部はCGIで動作させている。 が、もし今新規に書くならCGIを採用することはないだろう。

とはいえ、本当に簡単な目的(ほとんどの場合はWebhookの受け口)に使いたいことがあるため、CGIが動く環境がほしいことはある。だから、Lighttpdをそれ用に動かしておくのも、悪くはないと思うが。