画像URLを指定するとLGTM画像に変換するサービスをAWS Lambdaで作ってデプロイするまで

この記事の内容

この記事には以下の内容が含まれています。

  • 作ったアプリの仕様の説明
  • 画像にLGTM文字列を合成するAWS Lambdaのコード
  • CloudFront->API Gateway->Lambda構成での設定
  • お名前.comで取得したドメインをCloudFrontに適用する際に、ACMで証明書を取得する流れ

LGTM画像関連のサービス

LGTM画像を生成したり探したりするサービスは色々あります。

日々お世話になっていますが、私も簡単なものを作ってみました。

calgtm

何これ

GUIなしで、クエリパラメータに画像URLを指定するとLGTM画像にして返却するだけのサービスです。

以下のURLの${img_url}に画像URLを指定すると、LGTM画像となって返却されます。
http://calgtm.inabajun.work/?img=${img_url}

このURLが
http://example.com/cat.jpg
この画像だとすると、
sample_before.jpg

このURLは
http://calgtm.inabajun.work/img=http://example.com/cat.jpg
こうなります。
sample_after.jpg

  • 今の所予定はないですが、理由なく突然サービスを停止する可能性があります。
  • なので停止したら困る環境で利用する場合は別の環境にデプロイして使ってもらえればと思います。
  • 変な挙動があったらGitHubのIssueか何かで教えてもらえるとありがたいです。
    • GitHub
    • 巨大な画像を指定するとダウンロード中にタイムアウトして、例外用のLGTM画像を返却します。
    • その他本サービスからアクセスできない画像URLを指定した場合も例外用のLGTM画像を返却します。

仕組みとデプロイについて

以下はこのサービスの構成とコードとデプロイの設定です。

システム構成

constitution.png

全部AWSです。CloudFront->API Gateway->Lambdaです。
calgtmはCloudFront API Gateway Lambda GTMの略です。構成を変えても名前を変えるつもりはあんまりないです。あんまり考えてないです。

コード

Python3.6です。初めてのPythonでしたがPillowが便利そうだったのでPythonにしました。

import requests
from PIL import Image, ImageFont, ImageDraw
import urllib.request
import io
import base64

def lambda_handler(event, context):
    img_url = event["queryStringParameters"]['img']

    try:
        img_binary = get_img(assemble_query_parameter(event["queryStringParameters"], img_url))
        lgtm_img_binary = lgtm(img_binary)
        return generate_response(200, {"Content-Type": "image/jpeg"}, True, base64.b64encode(lgtm_img_binary).decode('utf-8'))
    except BaseException as err:
        # when occur error, return default error lgtm image.
        img_binary = io.BytesIO(open("error_image.jpg", "rb").read())
        img = Image.open(img_binary)
        output = io.BytesIO()
        img.save(output, format='JPEG')
        return generate_response(200, {"Content-Type": "image/jpeg"}, True, base64.b64encode(output.getvalue()).decode('utf-8'))

def generate_response(status_code, headers, is_base_64_encoded, body):
    response = {
       "statusCode": status_code,
       "headers": headers,
       "isBase64Encoded": is_base_64_encoded,
        "body" : body
    }
    return response

# when img parameter's url has multiple query parameters, this can not be interpreted part of url.
# so must assemble img to original image url
def assemble_query_parameter(query_parameters, url):
    for k, v in query_parameters.items():
        if k != "img":
            url = url + "&" + k + "=" + v

    return url

def lgtm(img_binary, fillcolor="white", shadowcolor="black"):
    img = Image.open(img_binary)

    # convert to jpg
    img.convert("RGB")
    # compress for too big image
    img.thumbnail((1024, 1024), Image.ANTIALIAS)
    width, height = img.size

    # adjust font size
    font_size = width / 2 if width <= height else height / 2

    draw = ImageDraw.Draw(img)

    # calculate text size
    font_name = "LiberationSans-Bold.ttf"
    font = ImageFont.truetype(font_name, int(font_size))
    text = "LGTM!"
    text_w, text_h = draw.textsize(text, font)

    # resize font size until text does not run off image.
    while text_w > width or text_h > height:
        font_size = font_size - 1
        font = ImageFont.truetype(font_name, int(font_size))
        text_w, text_h = draw.textsize(text, font)

    # adjust place of text
    x, y = (width - text_w)/2, (height - text_h)/2

    # draw border
    draw.text((x, y-1), text, font=font, fill=shadowcolor)
    draw.text((x, y+1), text, font=font, fill=shadowcolor)
    draw.text((x+1, y-1), text, font=font, fill=shadowcolor)
    draw.text((x+1, y), text, font=font, fill=shadowcolor)
    draw.text((x+1, y+1), text, font=font, fill=shadowcolor)
    draw.text((x-1, y+1), text, font=font, fill=shadowcolor)
    draw.text((x-1, y), text, font=font, fill=shadowcolor)
    draw.text((x-1, y-1), text, font=font, fill=shadowcolor)

    # draw main text
    draw.text((x, y), text, font=font, fill=fillcolor)

    # discarding the alpha channel. JPEGs can't represent an alpha channel.
    if img.mode in ('RGBA', 'LA'):
        background = Image.new(img.mode[:-1], img.size, "white")
        background.paste(img, img.split()[-1])
        img = background

    output = io.BytesIO()
    img.save(output, format='JPEG')
    return output.getvalue()

def get_img(url):
    req = urllib.request.Request(url)
    image_read = urllib.request.urlopen(req, timeout=3).read()
    img_binary = io.BytesIO(image_read)
    return img_binary

こちらを特に参考にさせていただきました。

Pillowが使いやすかったので特筆すべき部分はあんまりないですが、ポイントは

くらいでしょうか。
後者は例えば、画像URLとして https://example.com/?key1=value1&key2=value2 を渡すと、クエリパラメータが&で分断されるため、eventオブジェクトが

{
  "queryStringParameters":{
    "img" : "https://example.com/?key1=value1",
    "key2" : "value2"
  }
}

となってしまうため、assemble_query_parameterで無理やりURLを組み立て直しているところです。

はっきり言って微妙だなと思いつつも、URLを入力する側に何かさせるのは嫌だったのでこうしました。今後拡張して何かパラメータを渡したくなった時どうすればいいのかが悩みどころです。

ビルド

こちらを参考にさせていただきました。
【Python3.6】AWS Lambdaを再現するDocker Imageのdocker-lambdaを使ってみた

というかそのままですが、

  • AWS Lambdaで外部モジュールを利用する場合、zipにしてアップロードする必要がある
  • PillowのビルドをローカルではなくLambdaの実行環境で行う必要がある

ため、Lambda関数を作成した場所にDockerfileを置いて以下でイメージを作成し、

docker build -t mylambda .

以降は

docker run -v "$PWD":/var/task mylambda

で、dockerを起動し、環境内でrequirements.txtに記載されたモジュールを取得してLambda関数と共にzipにしています。

デプロイ

Lambda

何それ

ざっくり言ってしまえば、サーバを維持せずにデプロイした関数を動かせるサービスです。

参考:AWS Lambda とは

設定

特別なことはしていません。ランタイムをPython 3.6にして、上記ビルドコマンドで作成されたzipファイルをアップロードしました。

API Gateway

何それ

APIのエンドポイントを作成できるサービスです。バックエンドにAWSの各サービスや、HTTPのエンドポイントを指定できます。入力と出力のデータをAPI Gatewayだけである程度変換できたりします。あとはエンドポイントにいく前のバリデーションとか権限のチェックとか。

参考:Amazon API Gateway とは?

設定

そこそこはまりました。ポイントは、

の2点です。

詳しくは別の記事にまとめました。
API Gateway の Lambda プロキシ統合でバイナリデータをレスポンスする(Python3.6)

こちらを色々と参考にさせていただきました。
API Gatewayでサーバレスな画像リサイズAPIを作る

CloudFront

何それ

CDNです。バックエンドのレスポンスをキャッシュできます。あとバックエンドに渡すヘッダを設定したりできます。

Amazon CloudFront ドキュメント

CloudFrontを使った理由

API Gatewayの設定ができた時点でサービスとしては動くのですが、

  • API Gatewayに指定したバイナリメディアタイプに一致するAcceptヘッダを指定してURLにアクセスしないと、返却されるデータがデコードされない(テキストで返ってきてしまう)
  • 単純にキャッシュしたい。同じ画像に対してなんどもLambdaをキックしたくない。

ため、CloudFront経由でアクセスさせるようにしました。

設定

スクリーンショット 2018-04-03 0.45.47.png

スクリーンショット 2018-04-03 0.45.12.png

Origin Settings

Origin SettingsはOrigin Domain NameにAPI GatewayでデプロイしたエンドポイントのURLをコピペすると勝手に設定されます。あとはOrigin Protocol PolicyをHTTPS Onlyに指定しました。
スクリーンショット 2018-04-03 0.57.10.png

Origin Custom Headerは上記の理由のため以下を指定します。

スクリーンショット 2018-04-03 0.47.41.png

Default Cache Behavior Settings

頻繁にデータを更新する必要もない気がするので、デフォルトのTTLは長めに設定しました。

スクリーンショット 2018-04-03 0.49.24.png

スクリーンショット 2018-04-03 0.49.41.png

Distributions Settings

取得したドメインを使用する場合、Alternate Domain Namesにドメインを指定します。Custom SSL Certificateを選択するとRequest or Import a Certificate with ACMボタンが現れるので、そちらをクリックします。今回はACMで証明書を発行しています。

スクリーンショット 2018-04-03 0.50.37.png

クリックすると以下の画面が表示されるので、ドメインを入力します。

スクリーンショット 2018-04-03 0.50.57.png

検証方法を選択します。今回はDNSの検証を選択しました。

スクリーンショット 2018-04-03 0.51.09.png

DNS設定をファイルにエクスポートをクリックすると、設定値がダウンロードできるので、設定値に合わせてDNSにCNAMEレコードを追加します。

スクリーンショット 2018-04-03 0.51.32.png

今回はお名前.comで取得したドメインを使ったので、お名前.comのコンソールからDNSにレコードを設定しました。

即座には完了しないようだったので一晩置いたところ、朝には発行済みとなっていました。

スクリーンショット 2018-04-03 0.52.27.png

発行が完了すると、以下に発行した証明書を指定できます。

スクリーンショット 2018-04-03 1.28.49.png

これでCloudFrontの配信を開始しある程度待つと、指定したドメインでデプロイしたサービスにアクセスできるようになりました。

デプロイ作業は以上となります。

今後やりたいこと

  • インフラをコード化してコマンド一発でデプロイしたい
    • SAMとか?ServerlessFrameworkとか?
      • ACMもいけるのかな・・・
  • 多少パラメータを追加したい
    • LGTM文字の色を変えるとか
  • 単体テスト書きたい

デプロイした後で思ったこと

  • 当初はFBにアップした写真とかをサクッとLGTM画像にして遊びたいという意図で作った
  • 完成してちょっと触ってたらGitHubに貼るときにFBから画像のURLコピペしてこのサービスのURLとくっつけて云々やるのだるくね?って思ってしまった
  • ブックマークレットとかChrome拡張とかで画像URLを入力するとこのサービスのURLと合体してmarkdownにして云々やってくれるやつがあるとまだマシかもしれない
    • でもやっぱりLTTMの快適さには勝てないと思った
  • LTTM的なサービスのバックエンドとして使われる道はあるかもしれないと思った
    • 例えばLTTMでGitHubにコピペされるURLにこのサービスのURLをくっつけるだけでLGTM画像(LGTMという文字が入っている画像の意)に変換できる