💾

LOLIPOP! レンタルサーバーで python cgi

2025/01/17に公開

ひょんなことでレガシー案件の相談に乗り、色々と調査したのでまとめ。
今回使ったのは lolipop の「ライト」プラン。 Mysql8 が使えて ssh 不可です。
ちなみに lolipop には「レンタルサーバー」と「マネージドクラウド」があります。「マネージドクラウド」の方が AWS に近いモダンな環境なのだと思われますが、まあ今回は一種の縛りプレイということで。。。

https://lolipop.jp/pricing/
https://lolipop.jp/manual/hp/cgi/

  • ruby 2.6
  • python 3.7
  • PHP 8.3

が使えるようです。 ruby と python はサポートがすでに切れているバージョンなのでそれを考えれば PHP 一択なのですが、諸般の事情で python となりました。

パッケージ管理

python のパッケージ管理機能は流行り廃りがあり、最近だと rye & uv というのが良さそうです。
https://rye.astral.sh
https://pypi.org/project/uv/
が、 uv の minimum requirements が python >= 3.8 だったので今回の案件では断念。
というわけで pip でなんとかします。

pip はインストール済みだった

lolipop レンタルサーバーには pip が入ってない、というネット情報がちらほらありますが、 2024年時点で cgi 経由で実行可能であるのを確認済みです。

ssh なくても cgi でどうにでもなる

cgi は web 界のロストテクノロジーです。40歳以下の方は全く存在を知らないかもしれませんが、基本のアーキテクチャは簡単です。

  • http サーバ (通常は Apache) が http request を受け取る
  • request path に対応した cgi script を起動する。その際、 http request を STDIN に接続する
  • 起動した python script からの STDOUT を http response として返す

http request の数だけ cgi プロセスを立ち上げるので大規模なアクセスが来た際のリソース消費が激しく、廃れていきました。

python cgi script はこんな感じです。これを lolipop rental server に FTP でアップして実行権限 o+x を付与し、ブラウザから /setup.cgi を開くと動きます。

setup.cgi
#!/usr/local/bin/python3.7
import os
import sys
import subprocess

def run(command):
    result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    print(result.stdout)
    if result.stderr:
        print("Error: " + result.stderr)

if os.environ.get('REQUEST_METHOD') != 'POST':
  # ブラウザからURLを叩いていきなり実行されるとブラウザの再起動時などに勝手に
  # 実行されて不便なので、 まずは Run ボタンを表示する。
  # http の仕様通り、最初に response header を print し、
  # 空行を挟んで body を print する
  print("Content-Type: text/html")
  print()
  print("<html><body><form method='post'><button>Run</button></form></body></html>")
  sys.exit()

# 以下が実行の本体。try ブロック内を書き換えていろんな実験ができる
# text/plain にしてエラー時のログをそのまま読みやすくしておくと便利。
print("Content-Type: text/plain")
print()
try:
  run("python3 -m pip install --user --upgrade pip")
  run("python3 -m pip install --user python-dotenv")
  run("python3 -m pip install --user openpyxl")
  run("python3 -m pip install --user mysql-connector-python")
  # print("DB_HOST=" + os.environ.get('DB_HOST'))
except:
  import traceback
  traceback.print_exc(file=sys.stdout)
print('---done---')

ローカルで開発、テストし、コマンド一発でデプロイしたいよね

環境がレガシーでも開発様式までレガシーにする必要はありません。ローカルで自動テストを実行しながら開発を進め、それをコマンド一発で lolipop サーバへアップロードできるようにします。
アップロードには FTP を使うのですが、 python3.7 の FTP は utf8 に対応していないのでパッチを当てます。 (日本語ファイル名を扱わないなら対応不要かも?)

ftp.py
ftp.py
import ftplib
import os

from dotenv import load_dotenv

load_dotenv()


# python3.7 の FTP は encoding を指定できないので、独自の FTP クラスを作成
class CustomFTP(ftplib.FTP_TLS):
    def __init__(
        self,
        host="",
        user="",
        passwd="",
        acct="",
        timeout=None,
        source_address=None,
        encoding="utf8",
    ):
        self.encoding = encoding
        super().__init__(host, user, passwd, acct, timeout, source_address)

    def putcmd(self, line):
        line = line + "\r\n"
        self.sock.sendall(line.encode(self.encoding))

    def retrlines(self, cmd, callback=None):
        if callback is None:
            callback = print
        self.sendcmd("TYPE A")
        conn = self.transfercmd(cmd)
        fp = conn.makefile("r", encoding=self.encoding)
        while True:
            try:
                line = fp.readline()
                if not line:
                    break
                callback(line.rstrip("\r\n"))
            except UnicodeDecodeError as e:
                print(f"Error decoding line: {e}")  # Handle or log the error as needed
        fp.close()
        conn.close()
        return self.voidresp()

さらに、 FTP の host, user, pass を環境変数から取得するように拡張しておきます。

ftp.py
class LolipopFTP(CustomFTP):
    def __init__(self):
        super().__init__(
            os.environ["FTP_HOST"], os.environ["FTP_USER"], os.environ["FTP_PASS"]
        )

これを使って deploy コマンドを実装します。アップロードしたのち、拡張子に応じて chmod しておきましょう。実行可能にするのは cgi だけで、そこから呼ばれる py ファイルは 600 です。

deploy.py
deploy.py
#!/usr/bin/env python3
from .lib.ftp import LolipopFTP

import os

ftp_root = "/v1"

# see https://lolipop.jp/manual/hp/cgi/ # 設定するパーミッションの値
with LolipopFTP() as ftp:
    print(ftp.prot_p())
    for root, dirs, files in os.walk("lolipop"):
        if root.endswith("/__pycache__") or root.endswith("/pages"):
            continue
        # print(f"# root: {root}, dirs: {dirs}, files: {files}")
        parts = ftp_root.split("/") + root.split(os.sep)[1:]
        ftp_dir = "/".join(parts)
        ftp_parent = "/".join(parts[:-1]) or "/"
        if ftp_dir not in ftp.nlst(ftp_parent):
            print(ftp.mkd(ftp_dir))
            print(ftp.sendcmd("SITE CHMOD 705 " + ftp_dir))
        for file in files:
            # if file != "prepare.cgi":
            #     continue
            local_path = os.path.join(root, file)
            ftp_path = "/".join([ftp_dir, file])
            print(f"# Uploading {local_path} to {ftp_path}")
            with open(local_path, "rb") as fp:
                # use storbinary if file is binary
                ftp.storlines("STOR " + ftp_path, fp)
            if file == ".htaccess":
                print(ftp.sendcmd("SITE CHMOD 604 " + ftp_path))
            if file.endswith(".html"):
                print(ftp.sendcmd("SITE CHMOD 604 " + ftp_path))
            if file.endswith(".cgi"):
                print(ftp.sendcmd("SITE CHMOD 700 " + ftp_path))
            if file.endswith(".py") or file == "requirements.txt":
                print(ftp.sendcmd("SITE CHMOD 600 " + ftp_path))

print(f"Done. open https://kuboon.main.jp{ftp_root}/mycgi/prepare.cgi and click Run")

black v23.3.0 を使う

v23.7.0 で python3.7 対応が drop されるので、その直前の v23.3.0 をインストールします。

pip install git+https://github.com/psf/black@23.3.0

ローカル開発の進め方

ローカルで CGI を起動するのはクソ面倒なので、実装の本体は普通に python で書き、薄いラッパー CGI ファイルを用意してサーバからは CGI 経由で実行するようにします。
ラッパーCGIはこんな感じ。

search.cgi
#!/usr/local/bin/python3.7
import os
import sys

from dotenv import load_dotenv; load_dotenv()

parent_dir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)) + "/..")
sys.path.append(parent_dir)
from mycgi.search import main

try:
  response_body = main(os.environ.get('QUERY_STRING'))
except:
  import traceback
  print("Content-Type: text/html\n\n<pre>")
  traceback.print_exc(file=sys.stdout)

print("Content-Type: application/json")
print()
print(response_body)

sys.path.append は lolipop cgi から python module を import するためのテクです。試行錯誤してようやくここに辿り着きました。他のサーバでは同じように動かないかも。

os.environ.get('QUERY_STRING') は CGI のルールです。 http request に関する様々な情報が環境変数経由で入手できます。実行に失敗したら stack trace を表示し、成功したら json を返すようにしてあります。

実装の本体は mycgi.search モジュールの main 関数にあり、これは普通の python ファイルなので慣れ親しんだ方法で実装、テストを進められます。

CGI の実行中の経過を見たい

サーバによっては CGI からの標準出力をリアルタイムに http response としてストリーム的に返してくれる場合もあるのですが、今回使用している lolipop サーバでは fork など工夫してみてもどうにもうまくいかない。
https://dothiko.hatenablog.com/entry/2015/01/17/163747

諦めて実行単位を分割し、 javascript で順次実行しつつ結果を出力するようにしました。これはまあ普通の Javascript っすねー。

run.js
const resultDiv = document.getElementById('result');
function addResult(text) {
  resultDiv.insertAdjacentText('beforeend', text + '\n');
}
await fetch('prepare.cgi?task=1').then(x=>x.text()).then(addResult)
await fetch('prepare.cgi?task=2').then(x=>x.text()).then(addResult)
await fetch('prepare.cgi?task=3').then(x=>x.text()).then(addResult)

Discussion