LOLIPOP! レンタルサーバーで python cgi
ひょんなことでレガシー案件の相談に乗り、色々と調査したのでまとめ。
今回使ったのは lolipop の「ライト」プラン。 Mysql8 が使えて ssh 不可です。
ちなみに lolipop には「レンタルサーバー」と「マネージドクラウド」があります。「マネージドクラウド」の方が AWS に近いモダンな環境なのだと思われますが、まあ今回は一種の縛りプレイということで。。。
- ruby 2.6
- python 3.7
- PHP 8.3
が使えるようです。 ruby と python はサポートがすでに切れているバージョンなのでそれを考えれば PHP 一択なのですが、諸般の事情で python となりました。
パッケージ管理
python のパッケージ管理機能は流行り廃りがあり、最近だと rye & uv というのが良さそうです。
というわけで 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
を開くと動きます。
#!/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
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 を環境変数から取得するように拡張しておきます。
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
#!/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はこんな感じ。
#!/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
など工夫してみてもどうにもうまくいかない。
諦めて実行単位を分割し、 javascript で順次実行しつつ結果を出力するようにしました。これはまあ普通の Javascript っすねー。
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