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

# この記事の内容

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

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

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

 * [LTTM](https://chrome.google.com/webstore/detail/lttm/jdidcgkdggndpodjbipodfefnpgjooeh?hl=ja)
 * [LGTMoon](https://qiita.com/yoshikyoto/items/4910b1a4bcefe7f5ab8c)
 * [LGTM.in](https://www.lgtm.in/)
 * [LGTM Editor](http://s.cfe.jp/lgtm/)
 * [LGTM Generator](http://lgtm-generator.herokuapp.com/)
 * [LGTM Hub](https://lgtm-hub.do7be.com/9)

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


# calgtm

## 何これ

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

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

## 例
このURLが
http://example.com/cat.jpg
この画像だとすると、
<img width="342" alt="sample_before.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/220908/bff8a0e2-3155-c97a-5ba1-3b6f1fb983e6.jpeg">

このURLは
~~http://calgtm.inabajun.work?img=http://example.com/cat.jpg~~
こうなります。
<img width="342" alt="sample_after.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/220908/058ecbce-3479-7ba5-c5c7-a3d4c8291c21.jpeg">

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


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

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

## システム構成

<img width="493" alt="constitution.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/db722d5a-4d15-bc57-fdf1-f2e3fc66aa71.png">

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

## コード
Python3.6です。初めてのPythonでしたが[Pillow](https://pillow.readthedocs.io/en/latest/)が便利そうだったのでPythonにしました。

```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
```


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

 * [PIL/Pillow チートシート](https://qiita.com/pashango2/items/145d858eff3c505c100a)

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

 * [Lambda統合でバイナリデータを返却する際レスポンスの形式に制約があること](https://qiita.com/inabajunmr/items/e49408ab356b6a63ea91#lambda%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89)
 * パラメータとして受け取った画像URLが複数のクエリパラメータを持っている場合に、画像URLと画像URLが持つクエリパラメータが分離してしまうため、組み立てる必要があったこと

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

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

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

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

## ビルド
こちらを参考にさせていただきました。
[【Python3.6】AWS Lambdaを再現するDocker Imageのdocker-lambdaを使ってみた](http://kiito.hatenablog.com/entry/2017/05/06/145424)

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

 * 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 とは
](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html)

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

### API Gateway

#### 何それ

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

[参考:Amazon API Gateway とは?
](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/welcome.html)

#### 設定

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

 * [Lambdaプロキシ統合とすること](https://qiita.com/inabajunmr/items/e49408ab356b6a63ea91#%E7%B5%B1%E5%90%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88)
 * [バイナリメディアタイプを指定すること](https://qiita.com/inabajunmr/items/e49408ab356b6a63ea91#%E3%83%90%E3%82%A4%E3%83%8A%E3%83%AA%E3%83%A1%E3%83%87%E3%82%A3%E3%82%A2%E3%82%BF%E3%82%A4%E3%83%97%E3%81%AE%E6%8C%87%E5%AE%9A)

の2点です。

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

こちらを色々と参考にさせていただきました。
[API Gatewayでサーバレスな画像リサイズAPIを作る](https://qiita.com/akitsukada/items/e6d8fe68c49973d1edf6)

### CloudFront

#### 何それ

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

[Amazon CloudFront ドキュメント
](https://aws.amazon.com/jp/documentation/cloudfront/)

#### CloudFrontを使った理由

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

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

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

#### 設定

<img width="534" alt="スクリーンショット 2018-04-03 0.45.47.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/0c4bf52a-2d76-91c3-d835-1660ca633832.png">

<img width="1087" alt="スクリーンショット 2018-04-03 0.45.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/b931e3bf-78f4-668d-583b-dbd8ed0273b2.png">

##### Origin Settings
Origin SettingsはOrigin Domain NameにAPI GatewayでデプロイしたエンドポイントのURLをコピペすると勝手に設定されます。あとはOrigin Protocol PolicyをHTTPS Onlyに指定しました。
<img width="672" alt="スクリーンショット 2018-04-03 0.57.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/09c197a7-5666-d6fa-547e-17d5c52775f1.png">

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

<img width="935" alt="スクリーンショット 2018-04-03 0.47.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/5ea9d54d-6c39-0630-04de-be5af41ec276.png">

##### Default Cache Behavior Settings

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

<img width="688" alt="スクリーンショット 2018-04-03 0.49.24.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/b7dedf38-0a4c-2a62-c2e9-7fa19576465e.png">

<img width="766" alt="スクリーンショット 2018-04-03 0.49.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/97a05ed8-196d-43af-9622-711af1fd3588.png">

##### Distributions Settings

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

<img width="752" alt="スクリーンショット 2018-04-03 0.50.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/be6ad42e-7005-00a7-95eb-a550d1b5c275.png">

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

<img width="990" alt="スクリーンショット 2018-04-03 0.50.57.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/bd6b8751-2f4e-5a3f-2586-b747bfc9ba9a.png">

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

<img width="984" alt="スクリーンショット 2018-04-03 0.51.09.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/49e12609-9747-3631-fe8a-71c9f95eb3e5.png">

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

<img width="974" alt="スクリーンショット 2018-04-03 0.51.32.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/8f8ca3e4-c11b-1ac5-a8a3-4684d8ed0b03.png">

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

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

<img width="1310" alt="スクリーンショット 2018-04-03 0.52.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/e7b793fa-3e14-c2d3-b2f5-847ba58a20d1.png">


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

<img width="836" alt="スクリーンショット 2018-04-03 1.28.49.png" src="https://qiita-image-store.s3.amazonaws.com/0/220908/8002bd38-36fd-7404-2f8f-01582d1b1f24.png">


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

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

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

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

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