Python(Go)でリバースプロキシを実装

リバースプロキシをライブラリに組み込む必要があったのでそのメモ。

proxy.py

github.com (accessed on 2021/12/10)

リバースプロキシなんて誰かが作ってるだろ、と探したらproxy.pyに行きついた。

高速・軽量・拡張性・安全性その他もろもろが売りらしい。

Pythonのプロキシライブラリは意外に少なくてwebsocketにも対応してるのがこれくらいだった。(選択理由)

使ってみると確かに拡張性が良く、Pluginを導入するだけで広告ブロックからリバースプロキシまで様々な機能を追加することができる。 継承して必要な処理だけオーバーライドすればカスタムプラグインも容易に作成できる。

結論からいうと、このライブラリは使用しなかった。

理由はまだ実装途中らしく、パッチを当てなければリバースプロキシとして使えないからだ。 軽くソースコードを書き換えてHTTPリクエストはハンドルできるようになったが、Websocketの方がうまく実装できずこのライブラリを使用することを諦めた。

期待はできそうなのでGitHubでスターは付けておいた。

Go

他に良さそうなライブラリがなかったので他の言語で実装することを考えた。

候補はいろいろあるが最初に挙がるのはやはりGois Godである。

JavaScriptPythonと同程度に遅いから使う必要が感じられないし、C/C++での実装は煩雑になりそうなのでNG。 Juliaはコミュニティが小さいので文献を探すのが大変で、Rもまあ似たような理由。

というわけでコミュニティがデカくて、高速なコンパイル言語Goから検索を開始したのである。

検索してびっくり仰天!なんとプロキシを標準ライブラリがサポートしているのである。

pkg.go.dev

しかも実装が非常に単純!

package main

import (
    "log"
    "net/url"
    "net/http"
    "net/http/httputil"
)

func main() {
    loc := "/go/is/god"
    proxyPass := "http://localhost:10000"
 
    url, err := url.Parse(proxyPass)
    if err != nil {
        log.Fatal(err)
    }
    http.Handle(loc, httputil.NewSingleHostReverseProxy(url))
    http.ListenAndServe()
}

これだけでNginx

location /go/is/god {
   proxy_pass http://localhost:10000
}

に相当する処理を実装出来て、必要なヘッダーもインジェクトしてくれる。もちろんWebsocket対応。

あとはこれをProgramableに書き換えて、こんな感じ。

package proxy

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"
)


type location struct {
    Loc string
    ProxyPass *url.URL
}


type ReverseProxy struct {
    locs []location
}


func NewReverseProxy() *ReverseProxy {
    locs := []location{}
    return &ReverseProxy{locs}
}


func (rp *ReverseProxy) AddLocation(loc, proxyPass string) error {
    target, err := url.Parse(proxyPass)
    if err != nil {
        return err
    }
    rp.locs = append(rp.locs, location{Loc: loc, ProxyPass: target})
    return nil
}


func (rp * ReverseProxy) ServeForever(host string, port int) {
 
    addr := fmt.Sprintf("%s:%d", host, port)
    mux := http.NewServeMux()
    for _, loc := range rp.locs {
        mux.Handle(loc.Loc, httputil.NewSingleHostReverseProxy(loc.ProxyPass))
    }
    srv := &http.Server{
        Addr:    addr,
        Handler: mux,
    }

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalln(err)
        }
    }()
}

Go is God

gopy

GoソースからCPythonバンディングソースを自動生成するプロジェクトである。

github.com

以前Cythonでやった”アレ”だ。

nnt339es.hatenablog.com

ライブラリを使用

go-extensionというライブラリを作成したので紹介する。 私の試行錯誤の結果をライブラリとしてまとめ、面倒な作業を行わずにビルド/配布を可能とする。

github.com

必要条件

Go1.16以上

インストール方法

pip install go-extension

使用方法

GoExtensionというクラスのインスタンスsetup()ext_modules引数に渡すだけ。

setup.py

from setuptools import setup
try:
    from go_extension import GoExtension
except ImportError:
    from pip._internal.cli import main
    main.main(['install','-U','go-extension'])
    from go_extension import GoExtension


ext = GoExtension(
    name='hello_go',
    packages=['github.com/hinihatetsu/go-extension-python/hello']
)

setup(
    ext_modules=[ext]
)

ビルドコマンド

python setup.py build_ext --inplace

ライブラリを使用しない

以下はライブラリ作成前の試行錯誤の過程である。

必要条件

Go1.15以上

  • pybindgen
  • goimports

ということで、依存ライブラリごとインストールする。

pip install pybindgen
go get golang.org/x/tools/cmd/goimports
go get go get github.com/go-python/gopy

ビルド

いろいろ研究した結果、

gopy build -name=go -no-make -rename -vm=python -output=<output> <pkg> 

が良さそうなビルドコマンドだと思われる。

  • -name=go : 無くてもよいが、名前をわかりやすくするため。
  • -no-make : Makefileが生成されなくなる。
  • rename : GoのパスカルケースをPythonのスネークケースに変換してくれる。
  • vm=python : Pythonの実行可能コマンド。python, python3など複数ある場合に有効。デフォルトがpython3
  • output=<output> : 出力先のディレクトリ。このディレクトリがそのままPythonパッケージになる。
  • <pkg> : Goのパッケージ名(複数可)。例 : github.com/hinihatetsu/hogehoge

例えば、次のようなパッケージを考える。

tree /f
│  go.mod
│  setup.py
│
├─go_pkg
│      main.go
│
└─py_pkg
        __init__.py

ルートがPythonのプロジェクトディレクトリであり、かつGoのプロジェクトディレクトリ。

go_pkgPythonのパッケージpy_pkg/goとしてビルドしたいならば、

gopy build -name=go -no-make -rename -vm=python -output=py_pkg/go github.com/hinihatetsu/root/go_pkg

となる。

配布する

これも試行錯誤した結果、最終的にソースからビルドするときはGoをインストールしてもらうことにした。 もちろん、ビルド済みのwheelも配布すれば実行環境にGoは必要ない。

試行錯誤の例

  • Dockerでビルドしてもらう → Dockerコンテナ内でも使えるsetup.pyが書けなくて没。
  • cソースから直接ビルドする → cgoはgccでビルドされるためWindows標準のmsvcではコンパイルできない。
  • MinGWやDockerを利用すればWindowsでもビルドできるが、その手間は。。。

setup.pyからビルドするためにbuildgo.pyというモジュールをルートに置いた。

要約すると、GoExtensionという拡張パッケージクラスのサブクラスを新しく作って、それのビルド方法をコマンドbuild_extに教えてやった。

# buildgo.py
from setuptools.extension import Extension
from setuptools.command.build_ext import build_ext as _build_ext
from setuptools import dep_util
from setuptools._distutils import spawn
from functools import reduce
from pathlib import Path
import os
import shutil

class GoExtension(Extension):
    name: str
    go_module_name: str
    pkgs: list[str]

    def __init__(self, 
        name: str, 
        go_module_name: str, 
        pkgs: list[str], 
        *args, **kwargs
    ) -> None:
        self.name = name
        self.go_module_name = go_module_name
        self.pkgs = pkgs
        super().__init__(name, [], *args, **kwargs)


class build_ext(_build_ext):
    def build_extension(self, ext: Extension) -> None:
        if isinstance(ext, GoExtension):
            install_build_tools()
            self.build_go(ext)
        else:
            super().build_extension(ext)


    def build_go(self, ext: GoExtension) -> None:
        assert isinstance(ext, GoExtension)
        ext_path = self.get_ext_fullpath(ext.name)
        get_src = lambda lst, pkg: lst + [file.as_posix() for file in Path(pkg).iterdir()]
        sources = reduce(get_src, ext.pkgs, [])
        dry_run = not (self.force or dep_util.newer_group(sources, ext_path, 'newer'))
        outdir = Path(ext_path).parent / ext.name.split(sep='.')[-1]
        os.environ['LD_LIBRATY_PATH'] = (os.environ.get('LD_LIBRATY_PATH', '') + ':' + os.curdir).strip(':')
        cmd = [
            'gopy',
            'build',
            '-name=go',
            '-no-make',
            '-rename',
            '-vm=python',
            f'-output={outdir}'
        ] + [ext.go_module_name+'/'+pkg for pkg in ext.pkgs]
        spawn.spawn(cmd, dry_run=int(dry_run))

class GoNotFoundError(Exception):
    pass


def install_build_tools():
    if not shutil.which('go'):
        raise GoNotFoundError('Go1.15 or above is required to build this package.')
    if not shutil.which('gopy'):
        print('installing gopy...')
        cmd = ['go', 'get', 'github.com/go-python/gopy']
        spawn.spawn(cmd)
    if not shutil.which('goimports'):
        print('installing goimports...')
        cmd = ['go', 'get', 'golang.org/x/tools/cmd/goimports']
        spawn.spawn(cmd)
    spawn.spawn(['python', '-m', 'pip', 'install', '-Uq', 'pybindgen'])

そしてsetup.pyを次のように書く。

from setuptools import setup
import sys
sys.path.append('.')
from buildgo import GoExtension, build_ext

go_pkg = GoExtension(
    name='py_pkg.go',
    go_module_name='github.com/hinihatetsu/root',
    pkgs=['go_pkg']
)

setup.py(
    ext_modules=[go_pkg],
    cmdclass={'build_ext': build_ext}
)

最後にMANIFEST.inをルートディレクトリに置いてGo関連のファイルをパッケージに含める。

include go_pkg/*
include go.mod
include go.sum

とりあえずこれでGithubからはpipでインストールできて、

pip install git+http://github.com/hinihatetsu/hogehoge

Goがあればビルドしてくれる。

ただ何故かPyPIからインストールしようとすると、Goパッケージだと認識されなくてビルドに失敗する。 今のところPyPIのwheelとGithubからのインストールがあれば良いかなということで原因究明はToDo送りとなった。#ToDo

もう一つ確認している問題はpython setup.py build_ext -iでインプレイスビルドができないので、インプレイスが必要な開発者用のMakefileを別途作っておく必要がある。#ToDo

Dockerでビルドする例

Dockerコンテナとして利用する際にマルチステージビルドする例を示す。

FROM golang:1.17-buster as go

FROM python:3.9-buster as builder

COPY --from=go /usr/local/go /go

WORKDIR /go
ENV GOROOT /go
ENV GOPATH /gohome
ENV PATH $PATH:$GOROOT/bin:$GOPATH/bin
RUN go get golang.org/x/tools/cmd/goimports \
 && go get github.com/go-python/gopy

RUN pip install -U pip \
 && pip install git+https://github.com/hinihat/remoteweb


FROM python:3.9-slim-buster as runner
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages

余談

このsetup.pyに組み込む部分も汎用性が高そうだからライブラリとして仕上げたいよね。作成した

最近ライブラリのリリース作業ばかりやっているが、README・Docstringの整備だとかテストコードの拡充だとかドキュメントの生成(Sphinx)・公開(GitPages)だとか拡張パッケージのビルドだとかCIへの登録だとかPyPIへのアップロードだとか、Pythonコード書くよりもしんどいことが多くて今週は毎日睡眠3時間とかで昨日爆睡して多少回復した。

これらの作業ってすべてのライブラリで必要なのに、ライブラリによって設定とか異なるから自動化できないのがキツイ。

GitとかDockerとかCIでかなり開発環境は良くなったんだろうけど、それでもコーディング、テスト、デプロイのフローを作るのは結構きつくて個人の限界に挑戦しているって感じ。

正直このようなStableなものにする作業はクソだと感じているので絶対にプログラマになることはないな。

テストコードは絶対に必要で、アップデートする際に既存の仕様を壊すことなく実装できるし、問題が発生しても基底のライブラリのテストがしっかりしてあれば表面のコードを調べるだけになってトラブルシューティングが楽になる。(というのを実体験した。)

まとめ

ということで数日間を生贄にGoソースからPythonパッケージを生成・配布する手段を習得した。

Goは通信関係の実装が得意であるため、今後も積極的に活用していきたいと考えている。

GoでPythonを拡張できるんだったらもうCythonいらなくね?