python socketサーバーを作成して、マルチスレッドでcgiを利用する


はじめに

cgiとは

Common Gateway Interface(コモン・ゲートウェイ・インタフェース、CGI)は、ウェブサーバ上でユーザプログラムを動作させるための仕組み

wikipediaより。
例えばLinuxコマンドのようなそのOS上で実行可能なプログラム・コマンドをHTTPを介して利用しよう!というものです。

私の知っている範囲では、cgiとして、標準出力にHTTPレスポンスを出力すれば、出力内容をクライアントに送信してくれるという仕組みが多いです。
lighttpdのcgiもそんな仕組みで、STDOUTをがっつり読み込んで結果を送信します。

シングルスレッドなのでこれでいいですが、マルチスレッドでcgiを実現するにはどうしよう。STDOUTは1つなのでバッティングしちゃうんですよね。
せっかくFDイベント待ち受けの仕組みがあるのでそれは活かしたいし、どうせならC言語以外も使いたいな
スクリプト言語でよくあるsocketの仕組みを使って設計してみるかってのが今回の主題になります。

案1:コマンドサーバーを作って実行してもらおう!

ruby, python等のスクリプト言語で作るソケット通信例で、よく標準出力をソケットにリダイレクトする仕組みを見かけます。
そうか、HTTPサーバーはSTDOUTの代わりに別途作成したUnix Socketを待ち受けて、コマンド実行は別のサーバーに任せればいいか。
ということで設計。コマンドサーバーに実際のコマンドはお任せします。

サンプルもサクッと実装。備えの充実した言語、素晴らしいです。

CommandServiceというコマンドサーバー用クラスを作成。
HTTPサーバー⇒send_to_serverを使ってsend
コマンドサーバー⇒run_serverでrecv, 受信後コマンド実行, 応答送信という仕組みにしました。

ちなみにCommandServiceクラスにsend/recv両方導入したのは、コマンドサーバーが待ち受けるunix socketのファイルを隠ぺいしたかったから。
もちろんUnix socketの代わりにポートをあけて通信してもOKです。

2018/07/08 デリミタが","だとコマンド内で利用される可能性があったので、self._delimiter定義を追加しました。

CommandService.py
#for socket
import os, sys, socket
#for exec command
import subprocess

class CommandService:

    #constructor, set socket path
    def __init__(self):
        self._socket_path="/var/run/command_service.sock"
        self._delimiter="__ComSvc__"

    #for server
    def run_server(self):
        print("Start server")
        waitsock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
        try:
            waitsock.bind(self._socket_path)
            while True:
                req_cmd_byte = waitsock.recv(1024)
                if not req_cmd_byte: break
                self._exec_request(req_cmd_byte.decode('utf-8').split(self._delimiter))
        except socket.error as msg:
            print(msg)
        except:
            print("Execute")
        finally:
            waitsock.close()
            os.unlink(self._socket_path)
            print("Exit server")

    #parse request, call command and send response
    def _exec_request(self, req_cmd):
        command=req_cmd[0]
        sockpath=req_cmd[1]
        print("comamnd:" + command)
        print("socket:" + sockpath)
        callresp = self._call_cmd(command.split(" "))
        if len(callresp) != 0 :
            self._send(callresp.encode('utf-8'), sockpath)

    #call system command
    def _call_cmd(self, args):
        try:
            res_byte = subprocess.check_output(args)
            res=res_byte.decode('utf-8')
        except:
            print('Failed, command:', args )
            res=''
        return res

    #for client
    def send_to_server(self, command, response_sockpath):
        request=command + self._delimiter + response_sockpath
        self._send(request.encode('utf-8'), self._socket_path)

    def _send(self, request, sockpath):
        sendsock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
        try:
            sendsock.sendto(request, sockpath)
            #sendsock.sendto(b"test, command", self._socket_path)
        except socket.error as msg:
            print(msg)
        finally:
            sendsock.close()

軽く解説を入れると、サーバー側run_serverはこんな流れで動作しています。
1. socket.socketでソケット作成
2. bindで隠ぺいしているunix socketと紐づけ
3. recv
4. 受信データを_exec_requestでコマンド実行。実際にコマンド実行している箇所は_call_cmdsubprocess.check_outputで。["echo", "test"]みたいなコマンド引数の配列を入力としてコマンド実行します。
5. 結果を送信

注意点は以下です。
sendtoはbyteデータを扱うのでencode('utf-8')b"文字列"でbyte列に変換してあげること
recv, subprocess.check_outputは逆にbyte列で応答が返るのでdecodeしてあげること

後は以下のようにコマンドサーバーでrun_serverを実行する処理を書けばOK

CommandServer.py
from CommandService import CommandService
def main():
    command=CommandService()
    command.run_server()

if __name__ == '__main__':
    main()

試しにechoコマンドを投げる以下のようなCommandClientと、HTTPサーバー側の代わりに/tmp/resp_sockを待ち受けるサーバーをコピペで作成(省略)してテスト。環境内のpythonのバージョンはpython3.6です。

CommandClient.py
from CommandService import CommandService
def main():
    command=CommandService()
    command.send_to_server("echo -n test", "/tmp/resp_sock")

if __name__ == '__main__':
    main()

サーバーを起動してCommandClient.pyを実行すると、こんな感じに出力が出ます。
無事/tmp/resp_sock側にはecho -n testの出力結果が渡りました。

CommandServer.py側
# python3.6 CommandServer.py
Start server
comamnd:echo -n test
socket:/tmp/resp_sock
/tmp/resp_sock待ち受け側
# python3.6 TestService.py
Start server
test

2018/07/07 抜け漏れがあったので続きを記載
python 環境変数, stdinを制御してコマンドを実行する。

案2:forkしてコマンド実行すればよくね?

記事を書きつつ仕様を整理しながら思いました。これサーバーまで作る必要なくね?と。
…popenなりで直接_exec_requestの処理を呼べるようにすりゃいいじゃん。

まあ自分はforkがあまり好きじゃないので案1で行きますが。
いずれにせよ実現性が見えたのでC側は明日にしよう。満足したので寝ます

参考

socket参考:
PythonでUnixドメインソケットを使って通信する

リファレンス
https://docs.python.org/ja/3.6/library/socket.html#socket.socket.sendto

注意点の参考
対処方法 TypeError: a bytes-like object is required, not 'str'