本ページでは、Google App Engine (GAE) でさまざまな機能を実現するにはどうすればよいか、どのような制約があるかを解説します。本ページは GAE チュートリアル はじめの一歩編 の続きです。GAE にデプロイ済の前提で進めますので、未読の方はまずはそちらから進んでください。
目次
GitHub リポジトリ
本ページのソースは https://github.com/68user/gcp-gae-python3-tutorial/tree/master/func_test にあります。
下記手順で GitHub からソースを取得し、GAE にデプロイすることができます。
# ソース取得
git clone https://github.com/68user/gcp-gae-python3-tutorial.git
# GCS サービス連携用サンプルプログラム置き場に移動
cd gcp-gae-python3-tutorial/func_test/
# デプロイ
gcloud app deploy
ただし、以下の文章においては GitHub から取得せず、手作業でファイルを更新して機能を追加していくこともできます。
ローカル実行
GAE + Flask のいろいろな機能を確認してみましょう。hello world は置いといて、新しく作業ディレクトリを作りましょう。
mkdir -p ~/work/func_test
cd ~/work/func_test
復習のため、hello world と同じところから始めます。下記ファイルを作成してください。
app.yaml
runtime: python37
requirements.txt
Flask==1.0.2
main.py
#!/usr/bin/python3
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
return 'This is function test!'
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8080, debug=True)
main.py だけ Hello World とは若干違います。違いは 1行目に shebang 行 (#!/usr/bin/python3) を追加したこと、return ‘Hello World!” の部分を return ‘This is function test!’ と変えたこと、 最後の2行に直接実行時のローカル実行のコードを追加したことです。
さらに、下記で main.py に実行権限を付与してください。
chmod +x ./main.py
今回はデプロイする前に、まずはローカル実行してみましょう。GAE へのデプロイは1分ほどかかりますので待つ時間がもったいないためです。ローカル実行は下記のようにします。
./main.py
ローカル実行すると下記のようになります。
* Serving Flask app "main" (lazy loading)
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:8080/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 437-187-304
これでポート8080で Webサーバが起動しました。なお、これは Flask の機能ですので、GAE は全く関与していません)。Ctrl-C で終了してしまうので、Ctrl-C する前にブラウザで動作確認しましょう。
ではどうやってブラウザで確認するか。この状態は localhost からしか接続できない状態になっているのですが、Cloud Shell ではよい感じにネットワークをつないでくれる機能があります。
Cloud Shell の右上にのアイコン群の右から2つ目をクリックすると、下記のようなプルダウンが出てきます。
「ポート上でプレビュー 8080」を選ぶとブラウザが起動して、下記のように Cloud Shell 上で動いている Flask に接続できます。
この状態は GAE は一切関与していないので GAE の動作確認にはなっていませんが、Python3 + Flask の動作確認が簡単に行えます。
なお、このページは他人からは見えません。GCP コンソールに、自分自身でログインした状態でないとこのページは見えません (GCP からログインした状態だと、認証を求められます)。
問題なければ、下記で GAE にデプロイし、http://xxxxx.appspot.com/ にアクセスして “This is function test!’ と表示されることを確認してください。
gcloud app deploy
ページを増やしてみよう
トップページだけではつまらないので、別ページを作成しましょう。function_test/main.py の下記部分を修正していきます。
@app.route('/')
def hello():
return 'This is function test!'
上記をまるごと削除し、下記に差し替えてください。
@app.route('/')
def do_main():
return """
<p>これは GAE Standard + Python3.7 の機能確認用ページです</p>
<ul>
<li><a href="/subpage">サブページへ</a></li>
</ul>
"""
@app.route('/subpage')
def do_subpage():
return 'サブページです'
do_main は、”/” にアクセスされたときに呼ばれ、サブページへのリンクを含む HTML を返すようにしました。
また、do_subpage という関数を追加しました。これは “/subpage” にアクセスされたときに呼ばれ、「サブページです」という文字列を表示します。
まずは下記でローカル実行し、ブラウザで確認してください (さきほど「 ポート上でプレビュー 8080」 を実行したのであれば、改めて「ポート上でプレビュー 8080」は実行しなくても大丈夫です。Cloud Shell にログインしなおしたり、前回実行から時間が経過している場合は再度行ってください)。
./main.py
問題なければ GAE に再度デプロイしましょう。
gcloud app deploy
トップページにアクセスすると、下記のように do_main の結果が出力されます。
「サブページへ」をクリックすると do_subpage の結果が出力されます。
ここではいきなり日本語を使いましたが、Python3 はデフォルトエンコーディングが UTF-8 ですし、Flask もデフォルトで Content-Type が “text/html; charset=UTF-8” というヘッダを付けてくれるので、これで動きます。もし文字化けしていたら、作成した main.py が UTF-8 になってるか確認してください。
Cloud Shell 上では file コマンドで確認するのがお手軽でしょう (とはいえ file コマンドの文字コード推測は大変怪しげな印象はありますが…) 。
% file main.py
main.py: Python script, UTF-8 Unicode text executable
GET パラメータを受け取るには
/getvar?text=xxxx で渡された文字列を受け取りたい場合は下記のようにします。
@app.route('/getvar')
def do_getvar():
from flask import request, escape
mytext = request.args.get('text')
return "text は [{}] です".format(escpae(mytext))
なお import で flask.request と flask.escape を使用しています。request は引数解析のため、escape は XSS 脆弱性防止のための HTML 実体参照化のために使用しています。
さらに下記のように do_main が返す HTML に、/getvar へのリンクを追加して動作確認しやすいようにしておきます。
@app.route('/')
def do_main():
return """
<p>これは GAE Standard + Python3.7 の機能確認用ページです</p>
<ul>
<li><a href="/subpage">サブページへ</a></li>
<li><a href="/getvar?text=hoge">GETパラメータ (text) 取得・表示</a>\</ul>
URLのパスからパラメータを取得するには
下記のように記述します。
@app.route('/pathvar/<pageId>/<pageNo>')
def do_pathvar(pageId=None, pageNo=None):
from flask import escape
return "pageId は [{}]、pageNo は [{}] です".format(escape(pageId), escape(pageNo))
Flask でテンプレートエンジン
HTML 文字列を format で作り上げていくのは結構大変ですし、escape 漏れで XSS 脆弱性が発生するのも怖いので、Flask のテンプレートエンジンを紹介しておきます。
通常の使い方は、hoge.html というテンプレートを作っておいて、下記のように render_template でデータを埋め込むというのが一般的です。
from flask import render_template
buf = render_template('hoge.html",
var1="this is var1", var2="this is var2")
ただし本ページでは別ファイルが増えると Python コードと HTML が別管理になり説明が面倒なので、下記のように render_template_string を使うことにします。
from flask import render_template_string
buf = render_template_string("""
var1 は {{ var1 }} です。
var2 は {{ var2 }} です。
""",
var1="this is var1", var2="this is var2")
静的ファイルの配置
PNG・jpeg などの画像ファイル、CSS・js ファイルなど、配置したファイルをそのままブラウザから見てほしいケースがあります。
ここでは画像ファイルを例にとります。
まずローカル (Cloud Shell 上) での静的ファイル置き場を決めます。ここでは www/ とします。次に適当な画像ファイルを www/ の下に配置します。
mkdir -p www/img
wget [適当なURL] -O www/sample.png
次に app.yaml に下記のような記述を追加します。
handlers:
- url: /img/(.*)
static_files: www/img/\1
upload: www/img/(.*)
上記の意味は、以下のとおりです。
- リクエストURL が /img/(.*) にマッチする場合、静的ファイルとして www/img/\1 を返す。
- url の (.*) にマッチした部分は、static_files で \1 として参照可能。
- つまり URL の /img/sample.png は、static_files では www/img/sample.png にマッピングされる。
ここまではなんとなくわかるかと思うのですが、では upload は何なのでしょうか。正解は下記です。
- upload に書いたファイルを GAE に静的ファイルとしてアップロードする
うーん、わかりづらい。どうやら、GAE にはファイル一式をアップロードはするものの、プログラムとして使う領域と、静的ファイルとして使う領域が異なるようで、upload に書いておかないと静的ファイル領域にアップロードされない模様です。
なお、上記は GAE の機能を利用した静的ファイル配置ですが、Flask でも静的ファイル配置が可能です。
Basic 認証
GAE として Basic 認証の機能はなさそうです (あるといいのにね)。よって Flask で Basic 認証を行う方法を紹介します。
まず、requirements.txt に下記を追加。
Flask_HTTPAuth==3.2.4
そして main.py を下記のようにします。
from flask import escape
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
users = {
'myuser': 'mypass',
'myuser2': 'mypass2'
}
@auth.get_password
def get_password(username):
if username in users:
return users.get(username)
return None
@app.route('/authpage')
@auth.login_required
def authpage():
return "{} でログイン中です".format(escape(auth.username()))
これだとすべてのメソッドに @auth.login_required を設定しないといけないので、下記のような共通設定としておく手もある。
@app.before_request
@requires_auth
def before_request():
pass
ただしこの場合、Flask 管理外のファイル、例えば画像などの静的ファイルには Basic 認証がかかっていないことに注意してください。
ステータスコードを指定したい (404, 503 など)
指定の URL にコンテンツがない場合に 404 を返したり、負荷が高い場合には 503 を返したりしたい場合がありますが、これは Flask で実現可能です。
@app.route('/error404')
def do_error404():
return ('404 です', 404)
@app.route('/error503')
def do_error503():
return ('503 です', 503)
環境変数を表示したい
環境変数は普通の Python プログラムと同様に os.environ から普通に取れます。下記は、設定されているすべての環境変数を表示するコードです。
@app.route('/printenv')
def do_printenv():
from flask import render_template_string
import os
return render_template_string("""
<ul>
{% for k,v in envs %}
<li>{{ k }}: {{ v }}</li>
{% endfor %}
</ul>
""", envs = sorted(os.environ.items()))
なお、GAE 上で動かした結果は以下のとおりです。秘匿すべき情報はないように見えますが、一応 ID っぽいものは xxxxx で隠しました。
DEBIAN_FRONTEND: noninteractive
GAE_APPLICATION: m~myprojectzxcv
GAE_DEPLOYMENT_ID: 417294xxxxxx
GAE_ENV: standard
GAE_INSTANCE: 00c61b117cdxxxx
GAE_MEMORY_MB: 128
GAE_RUNTIME: python37
GAE_SERVICE: default
GAE_VERSION: 20190406t181142
GOOGLE_CLOUD_PROJECT: myprojectzxcv
HOME: /root
LC_CTYPE: C.UTF-8
PATH: /env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PORT: 8081
PWD: /srv
PYTHONDONTWRITEBYTECODE: 1
PYTHONUNBUFFERED: 1
SERVER_SOFTWARE: gunicorn/19.9.0
VIRTUAL_ENV: /env
環境変数を追加したい
app.yaml に下記のように記述し、デプロイします。
env_variables:
MYENV1: "HOGE"
MYENV2: "FUGA"
その後 ↑で作った /printenv にアクセスすると、下記のように指定した環境変数が取得できています。
秘匿情報の登録
github 等で見えなくていいのであれば…
AWS や Twiter 等、外部連携をしたい場合、アクセスキー等が必要になることが多いでしょう。しかしソースファイルを GitHub 等で管理しており、なおかつパブリックなプロジェクトとして設定している場合、GitHub に上げた瞬間に大変なことになります。
そこで秘匿情報 (シークレット) を GitHub 等にアップしない方法を説明します。
まずは secret-info.yaml を生成し、秘密にしたい情報を記載します。
env_variables:
MY_SECRET: 'this is secret value'
app.yaml から secret-info.yaml を読み込むよう、include を追加します。
includes:
- secret-info.yaml
そして secret-info.yaml を git 管理から外しておきましょう。.gitignore に下記を追加します。
secret-info.yaml
これで秘匿情報が Github 等にアップされるのを防げます。防げますが、欠点が2つ。
- secret-info.yaml のソース管理をどうするのか。手で管理するのか。複数人で開発している場合、全員に手動配置をさせるのか。変更時は全員に差し替えを依頼するのか。
- secret-info.yaml は、App Engine 内にファイルとして配置されてしまう (/srv/secret-info.yaml として)。通常はそれでも問題ないが、もし開発したアプリケーションにパストラバーサルなどの脆弱性があって読み出されてしまう危険性があり、よろしくない。
ではどうするか、は現在勉強中。
プログラムからのファイル読み込み
Python プログラムとして普通にファイルを読むことができます。読み込むファイルの対象としては、gcloud app deploy でアップロードされたファイルが読めます。
main.py と同じディレクトリに read-sample.txt というファイルで適当な文字列を入れておいてください。
% echo "abc" > read-sample.txt
% echo "ほげほげ" >> read-sample.txt
App Engine 側ではカレントディレクトリにあるファイルを、普通に読み込みます。
@app.route('/fileread')
def do_fileread():
with open("./read-sample.txt") as f:
buf = f.read()
return escape(buf)
GAE にデプロイして https://xxxxx.appspot.com/fileread をブラウザで見ると、下記のように普通に読めているのがわかるかと思います。
また、/etc/hosts のようなシステムにすでにあるファイルも読み込めます。ただしファイルオーナーが root で、パーミッションが 400 (r——) になっている場合は普通に Permission Denied になります。
※static_files としたものは読めないのでは? (要確認)
コマンドを実行してみる
subprocess で普通に実行可能です。下記は /bin/ls と /bin/df を実行し、その結果を取得するサンプルです。
@app.route('/runcommand')
def do_runcommand():
from flask import render_template_string
import subprocess
cmd1 = "/bin/ls -l /bin hogehoge"
proc1 = subprocess.run(cmd1.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
rc1 = proc1.returncode
stdout1 = proc1.stdout.decode("utf8")
stderr1 = proc1.stderr.decode("utf8")
cmd2 = "/bin/df"
proc2 = subprocess.run(cmd2.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
rc2 = proc2.returncode
stdout2 = proc2.stdout.decode("utf8")
stderr2 = proc2.stderr.decode("utf8")
return render_template_string("""
<p>{{ cmd1 }}</p>
returncode: {{ rc1 }}<br>
stdout: <pre style="border: 1px solid black">{{ stdout1 }}</pre>
stderr: <pre style="border: 1px solid black">{{ stderr1 }}</pre>
<p>{{ cmd2 }}</p>
returncode: {{ rc2 }}<br>
stdout: <pre style="border: 1px solid black">{{ stdout2 }}</pre>
stderr: <pre style="border: 1px solid black">{{ stderr2 }}</pre>
""",
cmd1=cmd1, rc1=rc1, stdout1=stdout1, stderr1=stderr1,
cmd2=cmd2, rc2=rc2, stdout2=stdout2, stderr2=stderr2)
実行結果は下記のとおりです。
バイナリ持ち込みも、多分できます。
ファイルを生成したい
ファイルは生成できるのですが、生成したファイルをおけるのは /tmp のみです。/tmp 以外は Read-Only でマウントされているため、配置できません (root 権限は得られないのでどうにもならない)。
ちなみに Python2 ではできない (1st gen のため)。
外部ネットワークへの接続
GAE + Python3 環境では、外部へのネットワーク接続は普通に Python コードとして書けば大丈夫です。下記は https://www.yahoo.co.jp に HTTPS でアクセスし、コンテンツを取得するサンプルコードです。
Python3 には requests という便利なモジュールがあるので、それを使っています。デフォルトモジュールなので requirements.txt に書く必要はありません。
@app.route('/ext_requests')
def do_ext_requests():
import requests
from flask import render_template_string
url = 'https://www.yahoo.co.jp/'
res = requests.get(url)
return render_template_string("""
<p>url: {{ url }}</p>
<p>status_code: {{ status_code }}</p>
<p>===== response header ======</p>
<ul>
{% for k,v in headers.items() %}
<li>{{ k }}: {{ v }}</li>
{% endfor %}
</ul>
<p>===== response body ======</p>
<pre>{{ body }}</pre>
""",
url=res.url,
status_code = res.status_code,
headers = res.headers,
body=res.text
)
さらに、下記のようなソケットを直接使った接続方法でも可能です。
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('www.yahoo.co.jp', 80))
s.sendall(b'GET / HTTP/1.0\r\n')
s.sendall(b'Host: www.yahoo.co.jp\r\n')
s.sendall(b'\r\n')
data = s.recv(1024)
print(data)
「そりゃまぁそうだよね当然でしょ」とお思いの方もいらっしゃるとは思いますが、GAE スタンダード 1st gen、つまり GAE Python 2.7 環境では自由な外部接続はできませんでした。Google によってカスタマイズされた httplib, urllib などを経由しないと取得できなかったのです。
※といいつつ GAE +Py2.7 環境で本当に動かないかは未確認。そのうち確認します。
クライアントの IP アドレスを確認したい
GAE 側でクライアント (ブラウザ) の IP アドレスを取得する方法です。IP アドレス制限をアプリケーション側で判断したいこともあると思います。
普通の flask であれば flask.request.remote_addr で取得できます。GAE でも取得はできるのですが、直接の接続先は nginx になっているようで、常に 127.0.0.1 が返ってきてしまいます。
かわりにリクエストヘッダを見ると、下記のような情報を得られます。
[IPv4 接続の場合]
X-Forwarded-For: 100.101.102.103, 169.254.1.1
X-Appengine-User-Ip: 100.101.102.103
Forwarded: for="100.101.102.103";proto=https
[IPv6 接続の場合]
Forwarded: for="2001:db8:1:3f::231";proto=https
X-Appengine-User-Ip: 2001:db8:0:3f::231
X-Forwarded-For: 2001:db8:1:3f::231, 169.254.1.1
どの値を使うかですが、X-Appengine-User-Ip がよいでしょう。ドキュメントにも書いてあります。
X-Forwarded-For には GAE 内部用と思われるリンクローカルアドレス 169.254.1.1 が必ず付きますし、X-Forwarded-For の先頭を取得すればよいようにも見えますが、「クライアント → クライアント側内部 Proxy → GAE」という接続形態の場合、先頭にはクライアント PC などの内部 IP アドレスが入りそうですので、使わない方が無難に見えます。
確認用コードは下記のとおりです。
@app.route('/printipaddr')
def do_printipaddr():
from flask import request
from flask import render_template_string
h = request.headers
return render_template_string("""
IPアドレスは [{{remote_addr}}]<br>
Forwarded は [{{forwarded}}]<br>
X-Appengine-User-Ip は [{{user_ip}}]<br>
X-Forwarded-For は [{{forwarded_for}}]<br>
""",
remote_addr = request.remote_addr,
forwarded = h.get('Forwarded'),
user_ip = h.get('X-Appengine-User-Ip'),
forwarded_for = h.get('X-Forwarded-For'))
ログ
GAE のログは、ログファイルとしては残りません。Stackdriver Logging というサービスに送信され、Stackdriver Logging の画面上で確認することができます。
Cloud Console > App Engine > サービス で表示される行の右端に「ツール▼」というプルダウンがあります。それを開くと下図のように「ログ」がありますのでクリックしてください。
すると Stackdriver Logging 画面に遷移します。
いろいろ便利な機能があるのですが、下記に主要なものをあげておきます。
- 画面下の「新しいログを読み込む」で、直近で追加されたログが表示されます。
- 画面上の「▶」マークを押すと、tail -f 的なことができます。
- 画面上のテキストボックスで文字列による絞り込みができます。
- 画面上の「過去1時間」は、過去24時間・過去7日間などと変更できます。
アプリログは下記…でやってみたが、py3.7では出ない?
import logging
logging.getLogger().setLevel(logging.DEBUG)
logging.info('hoge')
logging.debug('fuga')
まとめ
「GAE は便利なんだけどクセが強い! ソースをそのまま持っていっても動かないのでかなりカスタマイズが必要」という声を聞いた方もいるかもしれません。GAE 1st gen では確かにそうだったと思いますが、2nd gen になって IaaS で普通にできていたことが GAE でもできるようになりましたので、ぜひ使ってみてください。
次は GAE チュートリアル GCP サービス連携編 にて、BigQuery や Cloud Storage を利用してみます。
TODO
メール送信、タイムアウト、Stackdriver Debug、ログ監視/通知 (StackdrtverLogging->pubsub->slackとか)
gcloud app deploy で毎回 y/n 聞いてくるのをなんとかしてほしい
→ -q か –quite オプションをつける。