ASP.NET MVC Core2でステートレスな構成を作るための設定

こんにちは。beaglesoftの真鍋です。

ASP.NET Core で構築しているレポートシステムの開発が大分すすんで本番環境で稼働させられるところまで来ました。本番環境を構築して社内で利用してみるといろいろな課題画見えてきます。そんな中でちょっと直面した問題とその対応方法をまとめてみました。

利用している構成

  • dotnet core 2.1.301
  • Microsoft.AspNetCore.App 2.1.1

概要

ASP.NET MVC Coreで複数台構成の環境で運用するときに、CSRFトークンなど共通のキー情報により生成される情報はロードバランサーのSticky Sessionを利用する必要があります。

これはリクエストごとに生成されるキー情報がサーバーごとに異なるため、セッションを発行元と紐付ける必要があるためです。そのため、ロードバランサーがCookieから転送するサーバーを決定しセッションごとにサーバーを固定化します。

この構成の場合、ECSなどのステートフルな構成を前提としている運用ではコンテナのライフサイクルとリクエストのライフサイクルが一致しないためうまく運用ができません。

そのため、サーバー群で使用するキー情報を共有してリクエストごとに生成されるキー情報をどのサーバーでも同様に扱えるようにすることでステートレスな構成とすることができます。

ステートレスな構成とするためには、各サーバーが利用するキー情報を1カ所の共有ストレージに安全に保存する必要がありますが、その方法を実現するために提供されているライブラリがASP.NET Core Data Protectionとなります。

ASP.NET Core Data Protection | Microsoft Docs

また、ASP.NET Core antiforgeryについては下記の通りASP.NET Core Data Protectionを利用していることが明記されています。

ASP.NET Core implements antiforgery using ASP.NET Core Data Protection. The data protection stack must be configured to work in a server farm. See Configuring data protection for more information.

Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core | Microsoft Docs

設定方法

今回はRedisにキー情報を保存することにしました。具体的な設定方法は以下の通りとなります。

Key storage providers in ASP.NET Core | Microsoft Docs

CSRFを利用する場合に、トークン生成のキー情報を複数台構成のサーバーで共有するためには以下の設定を行います。

using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis.Extensions.Core;
using StackExchange.Redis.Extensions.Core.Configuration;
using StackExchange.Redis.Extensions.Newtonsoft;
using System;
using Microsoft.AspNetCore.DataProtection;
using StackExchange.Redis;

namespace net.girafa
{
    public partial class Startup
    {
        private void InitializeRedisCache(IServiceCollection services)
        {
            var redisHost = Configuration["RedisCache:host"];
            
            ...
            var redis = ConnectionMultiplexer.Connect(redisHost);
            services.AddDataProtection().PersistKeysToRedis(redis, "DataProtection-Keys");
        }
    }
}

このように設定することでRedisには以下の情報が生成されるようになります。(RedisCLIで確認しています。)

127.0.0.1:6379> keys DataProtection-Keys
1) "DataProtection-Keys"

127.0.0.1:6379> type DataProtection-Keys
list

127.0.0.1:6379> lrange DataProtection-Keys 0 1
1) "<key id=\"9b0ac354-...\" version=\"1\"><creationDate>2018-08-26T10:44:32.112617Z</creationDate><activationDate>2018-08-26T10:44:32.084794Z</activationDate><expirationDate>2018-11-24T10:44:32.084794Z</expirationDate><descriptor deserializerType=\"Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=2.1.1.0, Culture=neutral, PublicKeyToken=..\"><descriptor><encryption algorithm=\"AES_256_CBC\" /><validation algorithm=\"HMACSHA256\" /><masterKey p4:requiresEncryption=\"true\" xmlns:p4=\"http://schemas.asp.net/2015/03/dataProtection\"><!-- Warning: the key below is in an unencrypted form. --><value>0pp...5A==</value></masterKey></descriptor></descriptor></key>"

動作確認

動作確認はECS上でALBのStickeySessionをオフにしてから2台のコンテナを用意して実行しました。1台にリクエストが来ていることを確認してからそのコンテナを停止して処理を継続できることを確認します。

上記設定を行うことで実際に処理を継続できることを確認しました。

参考

以下のドキュメントを参考にしました。

asp.net core - How do I handle ValidateAntiForgeryToken across linux servers - Stack Overflow

ASP.NET Core Data Protection | Microsoft Docs

Key storage providers in ASP.NET Core | Microsoft Docs

Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core | Microsoft Docs

Effective C# 6.0/7.0

Effective C# 6.0/7.0

ユーザー初期画像生成APIをつくってみる(AWS Lambda編)

こんにちは。beaglesoftの真鍋です。

先日ユーザー初期画像生成APIを公開しました。

blog.beaglesoft.net

AWS LambdaでAPIを作ることが楽しかったので、ソースコードとその手順をまとめたいと思います。なお、Pythonについては初心者なので何かお気づきの点があれば指摘いただけるとうれしいです。

今回のゴール

今回作ろうと思うものは、アルファベット二文字を表示する画像を作成しAPI Gatewayのレスポンスとして返却する処理です。よくあるユーザーアイコンの初期画像のようなものですね。今回はAWS Lambdaへバックエンドの処理を作成するところまでまとめたいと思います。API Gatewayの設定は次回に行います。

f:id:beaglesoft:20180519092015p:plain

前提条件

今回実行した環境は以下の通りとなっています。

  • OS macOS Sierra 10.12.6
  • Python 3.6.5

雛形を作成する

Serverless Frameworkを利用して開発する雛形を用意したいと思います。Serverless FrameworkのインストールがまだのときにはServerless FrameworkがAWS Lambdaを使いやすくしてくれる - blog.beaglesoft.netを参考にインストールと概要を確認してください。

$ sls create -t aws-python3 -p user_image_creator-op
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/Users/ymanabe/projects/user_image_creator-op"
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v1.27.2
 -------'

Serverless: Successfully generated boilerplate for template: "aws-python3"

$ cd user_image_creator-op
$  user_image_creator-op ls -al
total 24
drwxr-xr-x   5 ymanabe  staff   170  5 16 13:36 .
drwxr-xr-x  46 ymanabe  staff  1564  5 16 13:36 ..
-rw-r--r--   1 ymanabe  staff   192  5 16 13:36 .gitignore
-rw-r--r--   1 ymanabe  staff   497  5 16 13:36 handler.py
-rw-r--r--   1 ymanabe  staff  2854  5 16 13:36 serverless.yml

プラグインを追加する

次にPythonのライブラリを管理するためserverless-python-requirements - npmをインストールします。

$ sls plugin install -n serverless-python-requirements
Serverless: Creating an empty package.json file in your service directory
Serverless: Installing plugin "serverless-python-requirements@latest" (this might take a few seconds...)
Serverless: Successfully installed "serverless-python-requirements@latest"

次にrequirements.txtを追加し、利用するPythonのライブラリを記述します。

numpy
Pillow

user_image_creator_ret_image/requirements.txt at master · beaglesoftjp/user_image_creator_ret_image · GitHub

以上で準備は完了です。

実装

今回は画像ファイルを作成してS3へ保存する処理を作成します。実装するソースコードは以下の通りとなります。

import base64
import json
import random
import urllib
import uuid

try:
    import unzip_requirements
except ImportError:
    pass

import numpy
from PIL import Image, ImageDraw, ImageFont

import logging

logging.basicConfig(
    format='%(asctime)s - %(threadName)s - %(module)s:%(funcName)s(%(lineno)d) - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

logger.info('Loading function')


def handle(event, context):
    logger.info("event:{event}".format(event=event))
    logger.info("context:{context}".format(context=context))

    body = event['queryStringParameters']
    s = body['s']

    if len(s) > 20:
        logger.error('Parameter error. Parameter s length is too long.')
        return response(400, {'message': "Parameter error. Parameter s length is too long."})

    display_str = urllib.parse.unquote(body['s'])
    logger.info(f"display_str:{display_str}")

    color_hash = get_color_hash()

    # サイズが150の正方形を生成する
    im = Image.new('RGB', (150, 150), color_hash['bg'])

    # テキストを中心に出力します
    draw_text_at_center(im, display_str, color_hash['font'])

    save_file_path = f"/tmp/{uuid.uuid4()}.png"
    im.save(save_file_path)

    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "image/png",
        },
        "body": base64.b64encode(open(save_file_path, 'rb').read()).decode('utf-8'),
        "isBase64Encoded": True}


def response(status_code, message):
    return {
        "isBase64Encoded": False,
        "statusCode": status_code,
        "body": json.dumps(message)
    }


# テキストを画像の中心に描画します
def draw_text_at_center(img, text, font_color):
    draw = ImageDraw.Draw(img)
    draw.font = ImageFont.truetype('GenJyuuGothicX-P-Bold.ttf', 48)
    img_size = numpy.array(img.size)
    txt_size = numpy.array(draw.font.getsize(text))
    pos = (img_size - txt_size) / 2

    draw.text(pos, text, font_color)


def get_color_hash():
    color_list = [
        {'font': (255, 255, 255), 'bg': (248, 4, 6)},
        {'font': (255, 255, 255), 'bg': (204, 0, 10)},
        {'font': (255, 255, 255), 'bg': (229, 72, 0)},  # 緋色
        {'font': (255, 255, 255), 'bg': (39, 38, 114)},  # 藍色
        {'font': (255, 255, 255), 'bg': (57, 3, 124)},  # 紺藍
        {'font': (255, 255, 255), 'bg': (11, 43, 21)},  # セルリアンブルー
        {'font': (255, 255, 255), 'bg': (251, 231, 9)},  # 卵色
        {'font': (255, 255, 255), 'bg': (228, 176, 55)},  # ブロンド
        {'font': (255, 255, 255), 'bg': (228, 162, 11)},  # 山吹色
        {'font': (255, 255, 255), 'bg': (51, 96, 69)},  # 深緑
    ]

    r = random.randrange(10)
    return color_list[r]


if __name__ == '__main__':
    subscriber_id = uuid.uuid4()
    user_id = uuid.uuid4()

    # ObjectKeyは /{subscriber_id}/user_images/{user_id}.png に保存する
    event = {'resource': '/create', 'path': '/create', 'httpMethod': 'GET', 'headers': None,
             'queryStringParameters': {'s': 'BS'}, 'pathParameters': None, 'stageVariables': None,
             'requestContext': {'path': '/create', 'resourceId': 'mmgrks',
                                'stage': 'test-invoke-stage', 'requestId': 'ae0367fe-5904-11e8-a178-259f37ad7e5e',
                                'resourcePath': '/create', 'httpMethod': 'GET'},
             'body': None, 'isBase64Encoded': False}

    handle(event, None)

user_image_creator_ret_image/handler.py at master · beaglesoftjp/user_image_creator_ret_image · GitHub

ポイントは以下の2点です。

  1. フォントファイルについて
    フォントファイルをソースコードと同じディレクトリに保存してdraw_text_at_center内で設定します。
  2. パッケージのインポートについて
    PILやNumpyを利用するためserverless-python-requirements - npmの設定に沿ってインポート部分に記述を追加します。

それぞれまとめます。

フォントファイルについて

今回フォントファイルを利用しています。そのためフォントファイルが適切に配置されていないとエラーになります。適宜フォントをダウンロードしてソースコードと同じディレクトリに保存してください。

なお、今回フォントは源柔ゴシック (げんじゅうゴシック) | 自家製フォント工房を利用させていただきました。ありがとうございます。

jikasei.me

また、フォントを画像の中心に配置する処理はこちらのエントリーを参考にいたしました。こちらもありがとうございます。

d.hatena.ne.jp

パッケージのインポートについて

PILやNumpyを利用していますが、インポートは以下の通り記述する必要があります。

try:
    import unzip_requirements
except ImportError:
    pass

import numpy
from PIL import Image, ImageDraw, ImageFont

これはserverless-python-requirements - npmでAWS Lambdaのサイズ制限を回避するためサイズの大きいパッケージを圧縮して管理しているためです。

www.npmjs.com

この記述がないとnumpyやPILのインポートエラーになりますので注意してください。

ローカルで動作させてみる

早速ローカルで動作させてみます。今回は画像の作成先ディレクトリを/tmpとしているためWindowsユーザーの方はWSLなどを利用してLinux環境上で動作するようにしてください。

$ python handler.py

2018-05-19 09:24:01,507 - MainThread - handler:<module>(22) - INFO - Loading function
2018-05-19 09:24:01,507 - MainThread - handler:handle(26) - INFO - event:{'resource': '/create', 'path': '/create', 'httpMethod': 'GET', 'headers': None, 'queryStringParameters': {'s': 'BS'}, 'pathParameters': None, 'stageVariables': None, 'requestContext': {'path': '/create', 'resourceId': 'mmgrks', 'stage': 'test-invoke-stage', 'requestId': 'ae0367fe-5904-11e8-a178-259f37ad7e5e', 'resourcePath': '/create', 'httpMethod': 'GET'}, 'body': None, 'isBase64Encoded': False}
2018-05-19 09:24:01,507 - MainThread - handler:handle(27) - INFO - context:None
2018-05-19 09:24:01,507 - MainThread - handler:handle(37) - INFO - display_str:BS

これで/tmpに画像が作成されます。

$ ls -al /tmp | grep png

# macの場合はこちら
$ ls -al /private/tmp | grep png
-rw-r--r--   1 ymanabe  wheel   2291 May 19 09:24 6a5cae15-ccd5-4e76-bc26-e304545a2b0e.png

画像ファイルが正しく表示されればOKです。

デプロイ

最後にデプロイを行います。が、その前に追加したフォントをAWS Lambdaへアップロードするパッケージに含めるためserverless.ymlに追記を行います。

package:
  include:
    - GenJyuuGothicX-P-Bold.ttf

user_image_creator_ret_image/serverless.yml at master · beaglesoftjp/user_image_creator_ret_image · GitHub

何か追加で含めたいファイルや逆に除外したいファイルについては上記のようにserverless.ymlに追加することで対応できます。これは結構便利ですね。

続いてデプロイを行います。デプロイはsls deployで実行できます。今回はproductionモードでデプロイしたいと思いますのでs- prodをつけます。

$ sls deploy -v -s prod

...
Serverless: Stack update finished...
...

Serverless: Stack update finished...と出れば正常に終了しています。早速AWS コンソールでLambda関数ができていることを確認してください。

f:id:beaglesoft:20180519094507p:plain

API Gatewayの設定

ここまででAWS Lambdaをデプロイすることができました。このあと実際にAPIからレスポンスを返却するためにAPI Gatewayの設定を行い今回作成したLambda関数と連携することになります。この設定は次のエントリーでまとめたいと思います。

Amazon Web Servicesを使ったサーバーレスアプリケーション開発ガイド

Amazon Web Servicesを使ったサーバーレスアプリケーション開発ガイド

ユーザーの初期画像に便利なAPIを作りました

こんにちは。beaglesoftの真鍋です。

いま構築しているシステムで欲しい機能だけれどもなかったから作ってみたAPIにユーザー名に該当する文字列を設定すると特定のサイズの画像を生成するAPIを作りました。今回のAPIは引数に設定された文字を埋め込んで適当な背景の画像を作成します。

f:id:beaglesoft:20180519094745p:plain

Auth0とかどこかのOAuthとかを利用しているなら特に自分で用意する必要はないのですが、組み込みで認証を自分で用意しているとちょっと欲しいけど作るには面倒だなぁというものではないでしょうか。(私はASP.NET Securityを利用しています。)

利用するには

以下の通り実行すれば画像を取得できます。(Windowsの方はWSLなどでcurlを利用できるようにして実行してみてください。)

$ curl "https://api.camello.info/uc-ri/img?s=BS" -H 'x-api-key:gR8u8SlQj71huEjxn1RmF9PmsbVyrDhf4gzVDURC' -o ~/sample.png

f:id:beaglesoft:20180519092015p:plain

日本語もURLエンコードすると取得できます。

$ curl "https://api.camello.info/uc-ri/img?s=%E7%9C%9F%E9%8D%8B" -H 'x-api-key:gR8u8SlQj71huEjxn1RmF9PmsbVyrDhf4gzVDURC' -v -o ~/sample.png

f:id:beaglesoft:20180519094745p:plain

パラメーター

パラメーターは以下の通りとなります。注意点として、日本語はURLエンコードするようにしてください。

引数名 必須 データ型 内容 備考
s String 設定する文字 2文字程度のアルファベットまたは日本語を想定しています。

APIキー

APIGatewayを利用して公開していますが、APIキーは以下の通りとなります。

gR8u8SlQj71huEjxn1RmF9PmsbVyrDhf4gzVDURC

このキーは利用者全体で共有していますが、月間リクエストが10,000件まで利用可能としています。

画像サイズなど

画像サイズは今のところ150x150に固定しています。また、フォントはipag.ttfを利用して48ポイント固定です。背景色は以下の通り10種類作成してその中からランダムに利用しています。

def get_color_hash():
    color_list = [
        {'font': (255, 255, 255), 'bg': (248, 4, 6)},
        {'font': (255, 255, 255), 'bg': (204, 0, 10)},
        {'font': (255, 255, 255), 'bg': (229, 72, 0)},  # 緋色
        {'font': (255, 255, 255), 'bg': (39, 38, 114)},  # 藍色
        {'font': (255, 255, 255), 'bg': (57, 3, 124)},  # 紺藍
        {'font': (255, 255, 255), 'bg': (11, 43, 21)},  # セルリアンブルー
        {'font': (255, 255, 255), 'bg': (251, 231, 9)},  # 卵色
        {'font': (255, 255, 255), 'bg': (228, 176, 55)},  # ブロンド
        {'font': (255, 255, 255), 'bg': (228, 162, 11)},  # 山吹色
        {'font': (255, 255, 255), 'bg': (51, 96, 69)},  # 深緑
    ]

    r = random.randrange(10)
    print(f"r:{r}")
    return color_list[r]

システムの構成

このAPIはAWS LambdaでPythonを利用してバックエンドを作成し、API GatewayでWebAPIとして公開しています。Serverless Fraemworkを利用してます。

最後に

Serverlessというか、マイクロサービスというか、そういうものは仕組みが整っていると対象とする用途に強い言語で開発ができることはとても素晴らしいのではと思います。最初はとっつきづらい感じがあっても慣れれば便利なので是非試してみて楽しんでもらえればと思います。

追記

画像のフォントを変更しました。こちらを利用させていただきました。ありがとうございます。

jikasei.me

追記2

このAPIのソースコードや構成をまとめました!

blog.beaglesoft.net