クラウドエンジニアのノート

情報技術系全般,自分用メモを公開してます。

Azure Web App + Flask + Github Actionsで認証ページ付きポートフォリオをデプロイする方法

はじめに

以前Firebaseでデプロイしたときをベースに書いてますので、先にこちらを見て下さい。 tmyoda.hatenablog.com

Azure クラウド サービス | Microsoft Azure にはとんでもない数のサービスがありますが 、 今回はポートフォリオを公開するために、WebのApp Serviceを使用しました。分類的にはPaaSですかね。

そしてなにより、1Gメモリ F1を選択すると無料で使えます。 また、学生でしたら、登録したときに1万円分のサブスククーポンを貰えます。

概要

  • AzureでWebページをデプロイ
  • AzureではBasic認証が使えないので、ログインページを用意(Flask)
  • 以前Firebaseでデプロイしたhtml,css,jsを使いまわしたいのでサブモジュールにて保持

参考にさせて頂いた記事

  • Azureの登録方法等 qiita.com

  • Flaskでログイン機能の実装 qiita.com

  • Bootstrap Login form

www.tutorialrepublic.com

Azure登録まで

参考記事に従ってAzureを登録します。 このとき、Flaskを使いたいのでランタイムスタックをPython3にします。

しばらくするとインスタンスが作成されます。

Flaskで認証ページを実装

Firebase Hostingでデプロイしたときは、Basic認証が使えたので、実質静的ページの公開と同じでした。

しかし、AzureはBasic認証が使えないようなので、Flaskでかんたんなログインを実装しようと考えました。 参考記事に従いながら実装していると‥

なんと、Flaskでhtmlを表示するにはjinja2のテンプレートに対応した記述が必要です。

しかし、サブモジュールでhtmlを保持しているため、書き換え等はしたくない、、、

かといってFlaskのstaticフォルダに格納したら直接が見られるが認証がかけられない、、、

そこで、

  • @app.route('/portfolio/')にGETが来たら、直接htmlをテキストとして読み出しResponse型で返す
  • その他のcss,jsへのアクセスが来たら、send_fileする

といった方針でひとまずやりたいことは実現しました。

解決策

from flask import Flask, request, Response, abort, render_template, redirect, url_for, send_file
from flask_login import LoginManager, login_user, logout_user, login_required, UserMixin
from collections import defaultdict
import os
import logging

app = Flask(__name__)
login_manager = LoginManager()
login_manager.init_app(app)
app.config['SECRET_KEY'] = なんらかしらのキーを設定

# logging setting
logging.basicConfig(level=logging.DEBUG)
#logging ex
# app.logger.info("Hello World %s", variable) 


# path setting
currnet_dir = os.path.dirname( os.path.abspath(__file__) )
static_dir = 'portfolio/functions/static/'


class User(UserMixin):
    def __init__(self, id, name, password):
        self.id = id
        self.name = name
        self.password = password

# ログイン用ユーザー作成
users = {
    1: User(1, "user01", "password"),
    2: User(2, "user02", "password")
}

# ユーザーチェックに使用する辞書作成
nested_dict = lambda: defaultdict(nested_dict)
user_check = nested_dict()
for i in users.values():
    user_check[i.name]["password"] = i.password
    user_check[i.name]["id"] = i.id

@login_manager.user_loader
def load_user(user_id):
    return users.get(int(user_id))


# css, js ,imgなどのコンポーネント呼び出し用
@app.route('/portfolio/<path:path>/<string:filename>', methods=["GET"])
@login_required
def portfolio_content(path, filename):
    return send_file(static_dir + path + "/" + filename)    

# index.html呼び出し 
# .htmlはこの方法じゃないとだめみたい
@app.route('/portfolio/')
@login_required
def portfolio():
    with open(currnet_dir + '/' + static_dir + 'index.html') as f:
        txt = f.read()
        return Response(txt)


# ログインパス
@app.route('/', methods=["GET", "POST"])
def login():
    if(request.method == "POST"):
        # ユーザーチェック
        if(request.form["username"] in user_check and request.form["password"] == user_check[request.form["username"]]["password"]):
            # ユーザーが存在した場合はログイン
            login_user(users.get(user_check[request.form["username"]]["id"]))
            return redirect(url_for('portfolio'))
        else:
            return abort(401)
    else:
        return render_template("login.html")

# ログアウトパス
@app.route('/logout/')
@login_required
def logout():
    logout_user()
    return Response('''
    logout success!<br />
    <a href="/login/">login</a>
    ''')

if __name__ == '__main__':
    app.run(threaded=True)

pass,idベタ書きしてますが、本当はDBに格納してパスワードもハッシュ化しないといけませんね。 まあ最悪全世界に晒されても良い内容なので、今回はこの程度で良いでしょう。

/portfolio に来るアクセスはindex.htmlを返して、 /portfolio/img/, css/ とかはsend_fileで返却します。

フォルダ階層

.
├── Pipfile
├── Pipfile.lock
├── app.py
├── portfolio
│   └── functions
│       └── static
│           ├── css
│           ├── img
│           ├── index.html
│           ├── js
│           └── vendor
├── requirements.txt
└── templates
    └── login.html

この工夫点として、portfolioフォルダは別リポジトリのサブモジュールです。 なので、どっちかでポートフォリオをアップデートしたら、もう片方がアップデートされます。

また、 staticフォルダを作成していない点もポイントです。(staticは外から見える)

そして、Pythonは、*.py、requirements.txt、または runtime.txtが最初に呼び出されるらしいので、 名前をちゃんと変えておきましょう。

デプロイ

ここまできたらいよいよです。今回はGithub Actionsでのデプロイが目標ですので、Azure portalのデプロイメントから、 Githubを選択して、Actionsを選択します。(要認証)

すると勝手にworkflowが作成されるので、次々と進むと勝手にCI/CDが走ってデプロイ完了です。 非常にかんたんですね!

注意点として、再起動しないと認識されないので注意です。

サブモジュール関連のトラブル

サブモジュール使ってない人はパスして下さい。

サブモジュールがpullされておらず、htmlがNot Foundになりました。 その解決方法として、追記したworkflow/ymlを晒します。

    steps:
    - uses: actions/checkout@master
      with:
        submodules: true
        token: ${{ secrets.PORTFOLIO_ACCESS_TOKEN }}
    
    - name: Sparse-Checkout
      run: |
        echo /functions/static >> .git/modules/portfolio/info/sparse-checkout
        cd portfolio
        git config core.sparsecheckout true
        git read-tree -mu HEAD
        git checkout master
        cd ..

まず、プライベートリポジトリでしたので、アクセストークンを取得して設定する必要があります。 以下を参考に作成して、サクッと設定しましょう。見ての通り、token: ${{ secrets.PORTFOLIO_ACCESS_TOKEN }}で埋め込めます。

help.github.com

また、私の場合はhtmlに関係するファイルのみ欲しかったので、sparse-checkoutしています。 一括でうまくやってくれるworkflowファイルを見つけられなかったので、runでベタ書きしました。

とりあえず、これで上手く動いてくれているので良かったです。 (すぐデプロイするつもりがなんだかんだ一日かかっちゃいました‥)

注意点

  • 再起動しないとデプロイが反映されない
  • privateリポジトリをサブモジュールで持つなら、Access Tokenが必要
  • FlaskはStatic以下は常に晒されている
  • Secretkey pass, idなどはちゃんとDB管理

追記

Azureは適当にhtmlファイル置くだけでも表示されるらしいので、なんかのWebサーバが裏で動いているんですかね、わかりません。

そしてなんと、Azure Static Web Appというより気軽なサービスが最近追加されたらしいです‥ こっち使ったほうが早かったですね