Amazon Aurora Serverless v2が出たので使ってみた

以前の記事で、 Aurora Serverless を紹介しましたが、その v2 が登場していたので使用感を確かめてみました。
参考: Amazon Aurora Serverlessを使ってみる
Amazonからの発表: Amazon Aurora Serverless v2 の一般提供を開始
Amazon Aurora Serverless

かつてのAurora Serverless (要するにv1)には、ServerlessではないAuroraに比べてさまざまな制約がありましたが、v2ではそれらの制約がだいぶ改善されています。
個人的に期待しているというより嬉しいのは、グローバルデータベースが作れる点ですね。
これまでのAurora ServerlessはAWS内部からしかアクセスできなかったのでローカルからアクセスする場合はEC2などで踏み台作ることが必須でした。

注意点として、ACUあたりの料金が2倍になっています。ただし、スケーリング等がよりきめ細やかに行われるようになっているらしいので、同じ使い方した場合の請求金額がそのまま2倍になるようなことはないとも聞いています。これは実際に使ってみないとわからないですね。

クラスター接続編

さて、前置きが長くなって来たので使ってみましょう。手順は以下の通りです。

1. AWSのコンソールにログインし、RDSの管理画面に移動する。
2. [データベースの作成]をクリック
3. 標準作成を選択 (簡単作成でも良い)
4. エンジンのタイプ は [Amazon Aurora]を選択
5. エディション は [MySQLとの互換性を持つ Amazon Aurora]
6. MySQLのバージョンを選ぶ。
以前は、大量の選択肢の中からドキュメントで指定されているバージョンを自分で選んでおかないと以降の選択肢からサーバレスが消えていたのですが、今は「フィルターの表示」で、「Serverless v2 をサポートするバージョンを表示」として絞り込むことができます。(v1も同じフィルターが欲しいが無いようです。)
現時点では、「Aurora MySQL 3.02.0 (compatible with MySQL 8.0.23)」だけに絞られるのでこれを選びます。

7. テンプレートは個人利用の検証なので「開発/テスト」にしました。
8. DBクラスター識別子 は何か名前をつける。 (yutaro-aurora-serverless-v2-test)
9. マスターユーザー名 を指定 (デフォルトは admin)
10. パスワードの設定。 (自分はパスワードの自動作成にしました)
11. インスタンスの設定で、DBインスタンスクラスでサーバレスを選択。
12. キャパシティーの設定を変更。(0.5〜128で設定可能ですが、予算を抑えるため低めに。)
13. 接続のパブリックアクセスで「あり」を選択。
14. [データベースの作成]をクリック。

これで、DBの作成が始まります。
[認証情報の詳細を表示] から、 adminのパスワードを入手しておきましょう。

アクセスにはエンドポイントの名前も必要です。DBクラスター識別子とは違うので別途確認します。(DBの一覧から先ほど作ったクラスターを選択すると表示されます。)

あとは、セキュリティグループを設定して自分のPCからアクセスできるようにしたら準備完了です。

DBアクセス編

ローカル端末からPythonでアクセスしてみます。まだ空っぽのDBなので、とりあえずSHOW DATABASES文でも打ってみましょう。

import pymysql.cursors

con_args = {
    "host": "{エンドポイント名}",
    "port": 3306,
    "user": "{DBユーザー名}",
    "password": "{DBパスワード}",
    "charset": "utf8mb4",
    "cursorclass": pymysql.cursors.DictCursor,
}
# 通常は "database": "{データベース名}" も指定するが、まだ何も作ってないのでなし。

connection = pymysql.connect(**con_args)

with connection.cursor() as cursor:
    sql = "SHOW DATABASES"
    cursor.execute(sql)
    result = cursor.fetchall()
print(result)

# [{'Database': 'information_schema'}, {'Database': 'mysql'}, {'Database': 'performance_schema'}, {'Database': 'sys'}]

無事接続できたようです。

日本語対応編

ちなみに、 v1の頃は、日本語を使うために文字コードなどの設定が必要でした。
参考: Amazon RDS(Aurora)で日本語文字列や絵文字を使えるようにする

それがどうやら、v2では(というより、選択した互換MySQLのバージョンがそうだった可能性もありますが) 最初からutf8対応の設定のようです。

with connection.cursor() as cursor:
    sql = "SHOW VARIABLES LIKE 'char%'"
    cursor.execute(sql)
    result = cursor.fetchall()


for r in result:
    print(r)
"""
{'Variable_name': 'character_set_client', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_connection', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_database', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_filesystem', 'Value': 'binary'}
{'Variable_name': 'character_set_results', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_server', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_system', 'Value': 'utf8'}
{'Variable_name': 'character_sets_dir', 'Value': '/rdsdbbin/oscar-8.0.mysql_aurora.3.02.0.0.12759.0/share/charsets/'}
"""

未使用時の停止について

最後に、一番気になる未使用時の自動停止について。個人利用においては正直 Aurora Serverless を使う1番の理由は使わない時は止まって料金がかからない点です。

v1 の時は、 「数分間アイドル状態のままの場合コンピューティング性能を一時停止する」という設定があり、これを設定しておくと止まってくれていたのですが、 v2 ではこれに該当する設定を見つけられていません。(キャパシティーの設定にあるはずなのですが。)

ドキュメントにある、「Aurora Serverless v2 を使用すると、SaaS ベンダーは、プロビジョニングされた容量のコストを気にすることなく、個々の顧客ごとに Aurora データベースクラスターをプロビジョニングできます。データベースが使用されていないときは自動的にシャットダウンしてコストを節約し、変化するアプリケーション要件に合わせてデータベースの容量を即座に調整します。」の部分はどこで使うのでしょう。
v1 の時、デフォルトだと思って設定し忘れていたらDBが停止しておらず、普通に料金がかかったのでここが心配です。

もし、 v2 で自動的なシャットダウンが実装されていなかったら個人開発では引き続きv1を使わないといけないですね。一旦、放置して様子を見ようと思います。

(追記)
こちらに情報がありました。
Comparison of Aurora Serverless v2 and Aurora Serverless v1

Stopping a cluster のところを見ると、v2は手動で停止しないといけなくて(You can manually stop and start the cluster by using the same cluster stop and start feature as provisioned clusters.)、v1は自動的に停止できる(The cluster pauses automatically after a timeout.)とありますね。これだとちょっと使えないので残念です。

タイムゾーンについて

もう一点1気になる問題があって、タイムゾーンの設定変更が反映されないんですよね。反映されるまでに時間がかかるのかな。DB クラスターのパラメータグループの time_zone を Asia/Tokyo に変えたのですが、 SELECT NOW() すると UTCの時刻が返って来ます。これも要調査です。

(追記)こちらはクラスターに設定した後、インスタンスを再起動したら反映されました。

Amazon Rekognitionで物体の検出

前回に引き続いて、Amazon Rekognitionの話です。
Rekognitionでは顔だけでなく、画像に写っている物体やシーンについてのラベル情報を得ることができます。
参考: オブジェクトおよびシーンの検出 – Amazon Rekognition

boto3で使う方法は、顔検出の時とよく似ていて、
detect_faces() の代わりに、 detect_labels() を呼び出すだけです。
参考: Rekognition — Boto3 Docs 1.17.95 documentation

サンプルとして、ドキュメントに掲載されている車の並んだ道でスケーボーやっている人の画像(ファイル名: skateboard.jpg)でやってみます。
顔検出の場合と同様にローカルの画像ファイルを読み込む方法と、S3にアップロードされた画像を使う方法があります。

ローカルのファイルを使う場合は次のようにします。


import boto3

with open("./skateboard.jpg", "rb") as f:
    img = f.read()

client = boto3.client('rekognition')
response = client.detect_labels(Image={'Bytes': img})

S3にアップロードしたデータを使う場合は次のようにします。


client = boto3.client('rekognition')
response = client.detect_labels(
    Image={
        'S3Object': {
            'Bucket': '{バケット名}',
            'Name': 'skateboard.jpg',
        }
    },
)

結果は辞書型で戻ってきます。
キー: Labels の値がメインの結果です。


response.keys()
# dict_keys(['Labels', 'LabelModelVersion', 'ResponseMetadata'])

Labels の値は、検出できたものの配列になっています。
試しに二つほど表示すると次のようになります。


import json

print(json.dumps(response["Labels"][4], indent=4))
"""
{
    "Name": "Person",
    "Confidence": 98.37577819824219,
    "Instances": [
        {
            "BoundingBox": {
                "Width": 0.1903613954782486,
                "Height": 0.27238351106643677,
                "Left": 0.43754446506500244,
                "Top": 0.3520295023918152
            },
            "Confidence": 98.37577819824219
        },
        {
            "BoundingBox": {
                "Width": 0.037608712911605835,
                "Height": 0.06765095144510269,
                "Left": 0.9162867665290833,
                "Top": 0.50001460313797
            },
            "Confidence": 86.00637817382812
        }
    ],
    "Parents": []
}
"""
print(json.dumps(response["Labels"][6], indent=4))
"""
{
    "Name": "Pedestrian",
    "Confidence": 97.18687438964844,
    "Instances": [],
    "Parents": [
        {
            "Name": "Person"
        }
    ]
}
"""

Name にラベル名が格納され、 Instances にそれが画像のどこに含まれていたが示されています。
Instances は空の配列のこともあります。要するに画像のどこかに写っているけど、場所は不明ということです。
位置が出力されるものとそうでないものにどんな規則性があるのかはいまいちわかりませんでした。
このほか、Parentsという属性があり、親概念になるラベル名が取得されます。

さて、検出されたラベル名と、 Instances の数、 Parentsの一覧を出力してみましょう。
結構色々検出されていますね。


for label in response["Labels"]:
    print(f'ラベル名: {label["Name"]}', f'インスタンス数: {len(label["Instances"])}')
    if len(label["Parents"]) > 0:
        print(f'親ラベル: {label["Parents"]}')


"""
ラベル名: Car インスタンス数: 14
親ラベル: [{'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Automobile インスタンス数: 0
親ラベル: [{'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Vehicle インスタンス数: 0
親ラベル: [{'Name': 'Transportation'}]
ラベル名: Transportation インスタンス数: 0
ラベル名: Person インスタンス数: 2
ラベル名: Human インスタンス数: 0
ラベル名: Pedestrian インスタンス数: 0
親ラベル: [{'Name': 'Person'}]
ラベル名: Skateboard インスタンス数: 1
親ラベル: [{'Name': 'Sport'}, {'Name': 'Person'}]
ラベル名: Sport インスタンス数: 0
親ラベル: [{'Name': 'Person'}]
ラベル名: Sports インスタンス数: 0
親ラベル: [{'Name': 'Person'}]
ラベル名: Road インスタンス数: 0
ラベル名: Wheel インスタンス数: 10
親ラベル: [{'Name': 'Machine'}]
ラベル名: Machine インスタンス数: 0
ラベル名: Path インスタンス数: 0
ラベル名: Downtown インスタンス数: 0
親ラベル: [{'Name': 'City'}, {'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: City インスタンス数: 0
親ラベル: [{'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Urban インスタンス数: 0
ラベル名: Building インスタンス数: 0
ラベル名: Town インスタンス数: 0
親ラベル: [{'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Tarmac インスタンス数: 0
ラベル名: Asphalt インスタンス数: 0
ラベル名: Parking Lot インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Parking インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Intersection インスタンス数: 0
親ラベル: [{'Name': 'Road'}]
ラベル名: Architecture インスタンス数: 0
親ラベル: [{'Name': 'Building'}]
ラベル名: Office Building インスタンス数: 0
親ラベル: [{'Name': 'Building'}]
ラベル名: Sidewalk インスタンス数: 0
親ラベル: [{'Name': 'Path'}]
ラベル名: Pavement インスタンス数: 0
親ラベル: [{'Name': 'Path'}]
ラベル名: Neighborhood インスタンス数: 0
親ラベル: [{'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Street インスタンス数: 0
親ラベル: [{'Name': 'City'}, {'Name': 'Road'}, {'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Coupe インスタンス数: 0
親ラベル: [{'Name': 'Sports Car'}, {'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Sports Car インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Sedan インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
"""

結果が多すぎる場合は、 MaxLabels 引数で出力される結果の数を絞り込むこともできます。

さて、ラベル名の一覧だけみてもどのくらい正確なのかわかりにくいので、画像中に図示してみましょう。
インスタンス数が0のものは、図示できないので、インスタンスが返されたものだけ、ボックスを描いていきます。


# %%pycodestyle
import matplotlib.pyplot as plt
from matplotlib import patches
from skimage import io

# matplotlibの可視化用に画像の読み込み
img_array = io.imread("./skateboard.jpg")

# 画像の高さと幅の取得
image_h, image_w, _ = img_array.shape

fig = plt.figure(facecolor="w", figsize=(12, 12))
ax = fig.add_subplot(111)
# 元の画像を表示する
ax.imshow(img_array)

for label in response["Labels"]:
    for instance in label["Instances"]:
        left = instance["BoundingBox"]["Left"] * image_w
        top = instance["BoundingBox"]["Top"] * image_h
        width = instance["BoundingBox"]["Width"] * image_w
        height = instance["BoundingBox"]["Height"] * image_h
        patch = patches.Rectangle(
            xy=(left, top),
            width=width,
            height=height,
            fill=False,
            ec="c",
            linewidth=2
        )
        ax.add_patch(patch)
        ax.text(x=left, y=top, s=label["Name"], fontsize=15, c="b")

出力された結果がこちらです。

人や車などの位置がしっかり検出できていますね。

Amazon Rekognitionで顔検出

AWSの機械学習・画像認識サービスであるAmazon Rekognition を試してみたので記録を残しておきます。
今回はまず、画像中の人の顔を検出するタスクをやってみました。
また、例によってPython(boto3)を使っています。

使い方はめちゃくちゃ簡単で、boto3のクライアントAPIから、detect_faces()というメソッドを呼び出すだけでした。
ドキュメントはこちらです。
参照: Rekognition — Boto3 Docs 1.17.93 documentation

対象のデータはローカルのファイルをバイト列のデータとして渡す方法と、S3にアップロードしてそのバケット名とファイル名を渡す方法の2種類があります。
ドキュメントには、
The input image as base64-encoded bytes or an S3 object.
と書いてあるので、Base64エンコードしないといけないのかと思ったのですが、これはどうやらドキュメントの誤りです。
Base64エンコードして渡すと逆にエラーになりますので、バイト列で読み込む時はファイルを読み込んだバイトデータをそのまま渡してください。
(予想ですが、boto3のライブラリが内部処理で Base64エンコードしてくれてると思います。)
せっかくこのブログでもBase64エンコーディングの方法を紹介する記事を書いて準備していたのにいらなかったですね。

引数の渡し方は少し特殊で、名前付き引数に辞書型で渡す必要があります。
ローカルのファイルを使う場合は次のようにします。

ちなみに画像は、AWSのコンソールでサンプルとして表示される家族写真を使います。(ファイル名:family.jpg)


import boto3


# 画像データを読み込む。
with open("./family.jpg", "rb") as f:
    img = f.read()

client = boto3.client('rekognition')
response = client.detect_faces(Image={'Bytes': img})

S3にアップロードしたデータを使う場合は次のようにします。


client = boto3.client('rekognition')
response = client.detect_faces(
    Image={
        'S3Object': {
            'Bucket': '{バケット名}',
            'Name': 'family.jpg',
        }
    },
)

結果は、辞書型で帰ってきます。FaceDetailsというキーの中身が、メインの検出結果で、
ResponseMetadataのほうはRequestのIdや、HTTPレスポンスのステータスコード、処理を実行した時刻などのメタデータが入ってます。


print(response.keys())
# dict_keys(['FaceDetails', 'ResponseMetadata'])

response[“FaceDetails”] の中身は配列で、検出された顔一人分ごとに辞書型で検出された情報が入っています。
今回のサンプルでは3人検出されているのですが、全部表示すると長いので一人分お見せすると次のようになります。
(整形のためにjsonライブラリ使います)


import json
print(json.dumps(response["FaceDetails"][0], indent=4))
"""
{
    "BoundingBox": {
        "Width": 0.1937681883573532,
        "Height": 0.3873019516468048,
        "Left": 0.2916979193687439,
        "Top": 0.13570082187652588
    },
    "Landmarks": [
        {
            "Type": "eyeLeft",
            "X": 0.34084638953208923,
            "Y": 0.2765427529811859
        },
        {
            "Type": "eyeRight",
            "X": 0.4209189713001251,
            "Y": 0.31195494532585144
        },
        {
            "Type": "mouthLeft",
            "X": 0.32429391145706177,
            "Y": 0.40175312757492065
        },
        {
            "Type": "mouthRight",
            "X": 0.39117804169654846,
            "Y": 0.43135175108909607
        },
        {
            "Type": "nose",
            "X": 0.3650367856025696,
            "Y": 0.3684481084346771
        }
    ],
    "Pose": {
        "Roll": 17.15113067626953,
        "Yaw": -3.947751760482788,
        "Pitch": -1.8470479249954224
    },
    "Quality": {
        "Brightness": 62.19182586669922,
        "Sharpness": 78.64350128173828
    },
    "Confidence": 99.99921417236328
}
"""

BoundingBox の中にあるのが、顔が検出された位置です。顔を囲む長方形の情報が含まれています。
Landmarksの下に、目、鼻、口の両端の位置が含まれます。
Poseは顔の向きです。
Qualityは画像の明るさなどの情報で、Confidenceは境界ボックス内に顔が含まれている信頼度になります。
詳しくはこちら
参考: イメージ内の顔を検出する – Amazon Rekognition

顔やそのパーツの位置の座標が0〜1の範囲に収まっていることから分かる通り、これらは
画像の左上を(0%,0%)、右下を(100%, 100%)とした時の相対的な位置を示しています。

上のJSON型データを見てもどのくらい正確に検出できているかわからないと思うので可視化してみましょう。
使い慣れているので、Matplotlibでやってみました。
別途、skimageで画像をNumpy配列として読み取り、画像の幅と高さを取得しています。
そして、それをRekognitionで取得した相対的な位置と掛け合わせることで、絶対値での座標に変換しています。


import matplotlib.pyplot as plt
from matplotlib import patches
from skimage import io


# matplotlib表示用に画像を配列で読み込み
img_array = io.imread("./family.jpg")
# 画像の高さと横幅を取得
image_h, image_w, _ = img_array.shape

fig = plt.figure(facecolor="w", figsize=(12, 12))
ax = fig.add_subplot(111)
# 元の画像を表示する
ax.imshow(img_array)

for face_detail in response["FaceDetails"]:
    # 検出された顔の位置を取得し、座標に変換する
    left = face_detail["BoundingBox"]["Left"] * image_w
    top = face_detail["BoundingBox"]["Top"] * image_h
    width = face_detail["BoundingBox"]["Width"] * image_w
    height = face_detail["BoundingBox"]["Height"] * image_h
    # 取得した座標の位置に長方形を描写する
    patch = patches.Rectangle(
        xy=(left, top),
        width=width,
        height=height,
        fill=False,
        ec="w",
        linewidth=2,
    )
    ax.add_patch(patch)

    # 目、鼻、口の両端に点をプロット
    ax.scatter(
        [landmark["X"] * image_w for landmark in face_detail["Landmarks"]],
        [landmark["Y"] * image_h for landmark in face_detail["Landmarks"]],
        c="w",
        s=3
    )

このコードで出力されるのが次の画像です。

3人分の顔が精度良く検出できていることがわかりますね。

さて、detect_faces()ですが、実はもう一つ引数を持っています。
それが、Attributes です。
Attributes=[“DEFAULT”] (こちらがデフォルト)
または、
Attributes=[“ALL”]
と指定します。 []も必須です。


response = client.detect_faces(
    Image={'Bytes': img},
    Attributes=["ALL"]
)

のように、 [“ALL”]を指定すると、取得できる情報が一気に増えます。
表情(笑顔かどうか)や、メガネやサングラスの有無、髭の有無や口が開いているかどうか、
大まかな年齢の推定なども行ってくれます。
また、目や口の位置情報はより詳細になり、輪郭や眉毛などに関する位置も取得されます。

結果がものすごく大きくなるのでこの記事には載せませんが、ぜひ一度試してみてください。

EC2(Amazon Linux 2)に日本語フォント(IPAフォント)をインストールする

以前、MacにインストールしたやつのEC2版です。
参考: MacにIPAフォントをインストールする

Macではフォントファイルをダウンロードしてきて自分で配置する必要がありましたが、
EC2では、yumでインストールできます。

yumでインストール可能なものの一覧の中に、IPAフォントが含まれていることを見ておきましょう。


$ yum list | grep ipa- | grep fonts
ipa-gothic-fonts.noarch                003.03-5.amzn2                amzn2-core
ipa-mincho-fonts.noarch                003.03-5.amzn2                amzn2-core
ipa-pgothic-fonts.noarch               003.03-5.amzn2                amzn2-core
ipa-pmincho-fonts.noarch               003.03-5.amzn2                amzn2-core

一つ入れれば十分なのですが、せっかくなので4つともインストールしておきましょう。


sudo yum install ipa-gothic-fonts ipa-mincho-fonts ipa-pgothic-fonts ipa-pmincho-fonts

インストールしたフォントは、 /usr/share/fonts という場所に置かれるようです。


$ cd /usr/share/fonts
$ ls
dejavu  ipa-gothic  ipa-mincho  ipa-pgothic  ipa-pmincho

Linux では fc-list というコマンドで利用可能なフォントが一覧取得できるのですが、その中にも登場します。


$ fc-list | grep ipa
/usr/share/fonts/ipa-gothic/ipag.ttf: IPAGothic:style=Regular
/usr/share/fonts/ipa-mincho/ipam.ttf: IPAMincho:style=Regular
/usr/share/fonts/ipa-pmincho/ipamp.ttf: IPAPMincho:style=Regular
/usr/share/fonts/ipa-pgothic/ipagp.ttf: IPAPGothic:style=Regular

あとはこれを jupyter & matplotlib で使うにはMacの場合と同じように設定すればOKです。

参考: matplotlibのデフォルトのフォントを変更する

font.family :
に指定する文字列は、IPAGothic/IPAPGothic/IPAMincho/IPAPMincho
のいずれかです。

boto3でS3のバケットの操作

前回の記事でboto3を使ったファイル操作について紹介したので、ついでにバケットの操作についても紹介しておこうという記事です。
前回同様、公式ドキュメントはこちらです。
S3 — Boto3 Docs 1.17.84 documentation

バケット名の一覧取得

存在するバケットの一覧は以下のコードで取得できます。


import boto3


s3 = boto3.resource("s3")
for bucket in s3.buckets.all():
    print(bucket.name)

"""
blog-work-sample1
blog-work-sample2
  ~ 以下略 ~
"""

バケットの作成

新規バケットの作成は以下のコードで実行できます。
引数は必ず名前付き引数で渡す必要があります。また、CreateBucketConfigurationは省略できないようです。


s3.create_bucket(
    Bucket="blog-work-sample3",
    CreateBucketConfiguration={
        'LocationConstraint': 'ap-northeast-1'
    }
)
"""
s3.Bucket(name='blog-work-sample3')
"""

バケット名の名前空間は全てのユーザーで共有されており、しかも全てのリージョンでも共有されています。
そのため、誰かが既に作っているバケット名を指定しているとエラーになるので注意が必要です。
以下のようなエラーが出ます。


try:
    s3.create_bucket(
        Bucket="sample",
        CreateBucketConfiguration={
            'LocationConstraint': 'ap-northeast-1'
        }
    )
except Exception as e:
    print(e)
"""
An error occurred (BucketAlreadyExists) when calling the CreateBucket operation:
The requested bucket name is not available. The bucket namespace is shared by all users of the system.
Please select a different name and try again.
"""

バケットの削除

中身が空のバケットであれば、単純にdelete()メソッドを呼び出すだけで消えます。
ただし、中にオブジェクトがある場合はエラーになるので、先にそれらを消しておく必要があります。


bucket = s3.Bucket("blog-work-sample3")
bucket.delete()

boto3でS3のファイル操作

boto3を使ってAWS S3のファイルを操作する方法をあれこれまとめておきます。
公式ドキュメントはこちらです。
S3 — Boto3 Docs 1.17.84 documentation
この記事ではリソースAPIの方を使います。

準備

とりあえず、この記事のために次の名前でS3にバケットを作っておきました。
– blog-work-sample1
– blog-work-sample2

さらに、 blog-work-sample1 の方には、
– sample-folder1
というフォルダーを掘っておきます。

また、サンプルとしてアップロードするファイルが必要なのでローカルに作っておきます。


!echo Hello S3! > samplefile.txt

ファイルのアップロード

まずはファイルのアップロードです。
アップロードしたいバケットを、 s3.Bucket(“バケット名”) で取得し、
upload_file(“アップロードしたいファイルのパス”, “アップロード先のファイルのパス”)でアップロードできます。
次のサンプルコードではアップロード時にファイル名を変更していいますがもちろんローカルのファイルと同じままのファイル名でも大丈夫です。


import boto3


s3 = boto3.resource("s3")
bucket = s3.Bucket("blog-work-sample1")
# バケットの直下に samplefile1.txt という名前でアップロードする場合
bucket.upload_file("samplefile.txt", "samplefile1.txt")
# フォルダ配下にアップロードする場合
bucket.upload_file("samplefile.txt", "sample-folder1/samplefile2.txt")

S3内でのファイルのコピー

ファイルのコピーには、 copy()というメソッドを使うのですが少しクセのある使い方をします。
どういうことかというと
{コピー先のバケットオブジェクト}.copy({コピー元の情報を辞書型で指定}, “コピー先のパス”)
という使い方をするのです。

まず、同じバケット内でコピーしてみます。


bucket = s3.Bucket("blog-work-sample1")  # コピー先のバケット

# 元のファイルを辞書型で指定
copy_source = {
    'Bucket': 'blog-work-sample1',
    'Key': 'samplefile1.txt'
}
bucket.copy(copy_source, "samplefile3.txt")

そして、別のバケットにコピーする時はこうです。copyメソッドの引数ではなく、元のバケットオブジェクトの取得が変わっているのがポイントです。


bucket = s3.Bucket("blog-work-sample2")  # コピー先のバケット

# 元のファイルを辞書型で指定
copy_source = {
    'Bucket': 'blog-work-sample1',
    'Key': 'samplefile1.txt'
}
bucket.copy(copy_source, "samplefile4.txt")

バケット内のオブジェクトの一覧を取得

バケット内のオブジェクトの一覧を取得するには、
bucketが持っている、objectsというプロパティの、all()メソッドでイテレーターとして取得します。
取得したオブジェクトのkey(S3において、ファイルパスやフォルダパスに相当する概念)を表示するコードが次です。


bucket = s3.Bucket("blog-work-sample1")
for obj in bucket.objects.all():
    print(obj.key)

"""
sample-folder1/
sample-folder1/samplefile2.txt
samplefile1.txt
samplefile3.txt
"""

フォルダとファイルを分けて表示したい場合は、Keyの末尾が/で終わっているかどうかで区別するしかないようです。
そもそもS3においてはフォルダという概念が存在せず、全てKey(パスのような概念)と値(ファイルの中身に相当する概念)で管理されているためこうなっているようです。
ただし、AWSの管理コンソールでは気を利かせてくれて、フォルダっぽく表示してくれています。

ファイルのダウンロード

ファイルのダウンロードには、 download_file(“ダウンロードしたいファイルのキー”, “ローカルに保存するパス”)
というメソッドを使います。

したのコードで、 blog-work-sample1/samplefile3.txt がダウンロードされ、
ローカルに、samplefile5.txt という名前で保存されます。


bucket = s3.Bucket("blog-work-sample1")
bucket.download_file("samplefile3.txt", "samplefile5.txt")

ファイルの削除

最後にファイルの削除です。
これには、delete_objectsというメソッドを使います。
一見、{バケットオブジェクト}.delete_objects{“消したいファイルのキー”} で消せそうな気がしますが、なぜかこれはかなり特殊な形の引数で渡す必要があります。

blog-work-sample1/samplefile1.txt
を消したい場合の使い方は以下の通りです。
必ず名前付き引数(Delete=)で、サンプルのような辞書を渡す必要があります。


bucket = s3.Bucket("blog-work-sample1")
bucket.delete_objects(Delete={"Objects": [{"Key": "samplefile1.txt"}]})

以上で、S3へのファイルのアップロード、コピー、リストアップ、ダウンロード、削除ができるようになりました。

boto3のclient API とresource APIについて

boto3を使ったソースコードを読んでいると、
boto3.client(“サービス名”) と使っているものと、boto3.resource(“サービス名”) と使っているものがあり、
自分でも無意識に使い分けていたことに気づいたのでこれらの違いについて調べてみました。

ドキュメントを探すと、次のページに書いてありました。
参照: AWS SDK for Python | AWS

Boto3 には、2 つの異なるレベルの API があります。クライアント(「低レベル」)API では、下層の HTTP API 操作との 1 対 1 のマッピングが提供されます。 リソース API では、明示的なネットワーク呼び出しが表示されず、属性にアクセスしアクションを実行するためのリソースオブジェクトとコレクションが提供されます。

ちょっとわかりにくいですね。実際に動かしてみた違いから考えると、
クライアント API(boto3.clientの方)は、AWSの各リソースが持っているREST APIと1対1に対応した単純なPythonラッパーのようです。
それに対して、リソースAPI(boto3.resourceの方)はAWSのリソースをオブジェクト指向のプログラムで操作できるようにしたもののようです。

実際に動かしてみましょう。
EC2のインスタンスのインスタンスIDとインスタンスタイプ、現在の動作状況を一覧取得するプログラムをそれぞれ書いてみます。

まず、クライアントAPIの方です。
インスタンスの一覧は、describe_instances()で取得できます。
まず、単純に結果を表示すると、結果が辞書型で得られていることが確認できます。 (すごく長いので省略しています)


import boto3


ec2_client = boto3.client("ec2")
result = ec2_client.describe_instances()
print(result)

# 以下出力
{
    'Reservations': [
        {
            'Groups': [],
            'Instances': [
                {
                    'AmiLaunchIndex': 0,
                    'ImageId': 'ami-da9e2cbc',
                    'InstanceId': '{1つ目のインスタンスID}',
                    'InstanceType': 't1.micro',
                    'KeyName': '{キーファイルの名前}',
                    'LaunchTime': datetime.datetime(2021, 3, 20, 5, 35, 47, tzinfo=tzutc()),
                    'Monitoring': {'State': 'enabled'},
                    'Placement': {'AvailabilityZone': 'ap-northeast-1a',
                    'GroupName': '',
                    'Tenancy': 'default'
                },
    # 中略
    'ResponseMetadata': {
        'RequestId': '1b6c171d-d199-46c0-b2a6-7037fcfda28b',
        'HTTPStatusCode': 200,
        'HTTPHeaders': {
            'x-amzn-requestid': '1b6c171d-d199-46c0-b2a6-7037fcfda28b',
            'cache-control': 'no-cache, no-store',
            'strict-transport-security': 'max-age=31536000; includeSubDomains',
            'content-type': 'text/xml;charset=UTF-8',
            'transfer-encoding': 'chunked',
            'vary': 'accept-encoding',
            'date': 'Sun, 23 May 2021 08:21:51 GMT',
            'server': 'AmazonEC2'
        },
        'RetryAttempts': 0
    }
}

この巨大な辞書ファイルの中から必要な情報を取り出します。


for r in result["Reservations"]:
    print(r["Instances"][0]["InstanceId"])
    print(r["Instances"][0]["InstanceType"])
    print(r["Instances"][0]["State"])
    print(" - ・"*10)
"""
{1つ目のインスタンスID}
t1.micro
{'Code': 80, 'Name': 'stopped'}
 - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・
{2つ目のインスタンスID}
t2.nano
{'Code': 16, 'Name': 'running'}
 - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・
"""

上のコードを見ていただければわかる通り、単純な辞書の巨大な塊なのでちょっと情報が取り出しにくいのがわかると思います。

一方で、リソースAPIではどうでしょうか。
こちらはインスタンスの一覧は、ec2_resource.instances.all()でそれらのイテレーターが取得できます。
そして、インスタンスIDやインスタンスタイプ、状態などは属性として取得できます。
実際に先程のクライアントAPIと同様の情報を取得してみましょう。


ec2_resource = boto3.resource("ec2")
for instance in ec2_resource.instances.all():
    print(instance.instance_id)
    print(instance.instance_type)
    print(instance.public_ip_address)
    print(instance.state)
    print(" - ・"*10)
"""
{1つ目のインスタンスID}
t1.micro
None
{'Code': 80, 'Name': 'stopped'}
 - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・
{2つ目のインスタンスID}
t2.nano
54.199.43.209
{'Code': 16, 'Name': 'running'}
 - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・ - ・
"""

得られる結果は同じですが、コードがずいぶんわかりやすいのが実感していただけると思います。

さて、結論としてどっちを使えばいいのか、という話ですが、
実現したい操作がリソースAPIでできるのであればリソースAPIを使えばいいのではないかなと思いました。

ただ、提供されているREST APIと1対1に対応しているクライアントAPIと違って、
リソースAPIは全ての操作が実現できると保証されているものではありません。

たとえば、翻訳サービスである、Translateなどは、クライアントAPIしか存在せず、リソースAPIで使おうとするとエラーになります。


try:
    boto3.resource("translate")
except Exception as e:
    print(e)
"""
The 'translate' resource does not exist.
The available resources are:
   - cloudformation
   - cloudwatch
   - dynamodb
   - ec2
   - glacier
   - iam
   - opsworks
   - s3
   - sns
   - sqs

Consider using a boto3.client('translate') instead of a resource for 'translate'
"""

boto3.client(‘translate’) 使えって言われてますね。
このように、リソースAPIが未対応の時は、諦めてクライアントAPIを使いましょう。

サービスとして動かしているjupyter notebookに環境変数を設定する

jupyterをサービスとして動かしていると、 .bash_profile で設定した環境変数を読み込んでくれなかったので、その対応のメモです。

ちなみに、今回設定したい環境変数はAWSのデフォルトリージョンで、値としては、
AWS_DEFAULT_REGION=ap-northeast-1
です。
方法としては、Unit ファイルに直接書き込んで設定する方法と、
環境変数をまとめた設定ファイルを作成し、そのファイルパスをUnitファイルに指定する方法があります。

直接書き込む場合は、 Unitファイルの [Service] のセクションに、
Environment=”環境変数名=値”
で指定します。
複数指定したい場合は、
Environment=”環境変数名1=値1″
Environment=”環境変数名2=値2″
と2行に分けて書くか、
Environment=”環境変数名1=値1″ “環境変数名2=値2”
のように空白で区切って指定すれば良いようです (値にスペースがない場合はダブルクオーテーションは省略可能)
ドキュメントはここ。
systemd.exec Environment=

ちょっと試してみましょう。


sudo vim /etc/systemd/system/jupyter.service
ファイル中にEnvironment= の2行を追加

[Unit]
Description=Jupyter Notebook

[Service]
ExecStart=/home/ec2-user/.pyenv/shims/jupyter notebook
Restart=always
User=ec2-user
Group=ec2-user
Environment="VAR1=word1 word2"
Environment=VAR2=word3 "VAR3=$word 5 6"

[Install]
WantedBy=multi-user.target

# 上記ファイルを保存
# サービスを再起動
sudo systemctl daemon-reload
sudo systemctl restart jupyter

jupyter notebookで認識できているか確認します。
!をつけるとOSコマンドが実行できるのでそれを使います。


!set | grep VAR
# 以下出力
BASH_EXECUTION_STRING='set | grep VAR'
VAR1='word1 word2'
VAR2=word3
VAR3='$word 5 6'

設定されていますね。

ということで、今回の要件だけ考えれば
Environment=AWS_DEFAULT_REGION=ap-northeast-1
と直書きしてしまって良さそうです。

ただ、将来的に設定したい環境変数が増えていくことも考えられますので、その時Unitファイルが煩雑にならないように、
今の段階でもう一個の方法の、環境変数の設定ファイルを作る方法を使うことにしました。

ドキュメントはこちらです。
systemd.exec EnvironmentFile=

環境変数が入力されたファイル自体は、
/etc/sysconfig/サービス名
に作成するのがお作法らしいです。
そして、作成したファイルのパスを EnvironmentFile= に指定します。


$ sudo vim /etc/sysconfig/jupyter

# 以下の内容を記入して保存
AWS_DEFAULT_REGION=ap-northeast-1

$ sudo vim /etc/systemd/system/jupyter.service
# EnvironmentFile= の行を追加

[Unit]
Description=Jupyter Notebook

[Service]
EnvironmentFile=/etc/sysconfig/jupyter
ExecStart=/home/ec2-user/.pyenv/shims/jupyter notebook
Restart=always
User=ec2-user
Group=ec2-user

[Install]
WantedBy=multi-user.target

# 上記ファイルを保存
# サービスを再起動
sudo systemctl daemon-reload
sudo systemctl restart jupyter

設定されたことを確認してみましょう。


!set | grep VAR
# 以下出力
AWS_DEFAULT_REGION=ap-northeast-1
BASH_EXECUTION_STRING='set | grep AWS'

ちゃんと設定されましたね。
これで boto3 を使うときに、
region_name=”ap-northeast-1
をいちいち指定しなくて良くなりました。

DBのエンドポイントや接続情報など環境変数に入れておきたい内容はこの調子で、
/etc/sysconfig/jupyter
に突っ込んでいきましょう。

EC2にIAMロールを設定する

EC2に構築したJupyterサーバーでboto3を使うとき、ローカルのMacbookと同じようにIAMユーザーのアクセスキーを、
環境変数 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY に設定して使うものだと思っていたのですが、
実はEC2にメタデータとしてIAMロールを設定できることを知ったのでそのメモです。

boto3のドキュメントを見ると、認証情報として以下の8種類が使える(上のものほど優先される)ことがわかります。
参考: Configuring credentials

1. boto.client()メソッドにパラメーターとして渡された認証情報
2. セッションオブジェクトを作成するときににパラメータとして渡された認証情報
3. 環境変数
4. 共有認証情報ファイル(~/.aws/credentials)
5. AWS設定ファイル(~/.aws /config)
6. ロールの引き受けの提供
7. Boto2設定ファイル(/etc/boto.cfg と 〜/.boto)
8. IAMロールが設定されているAmazonEC2インスタンスでは、インスタンスメタデータサービス

(6. とかよく意味がわからない。。。)

この中の、8. のものを使ってみようという話です。

設定したら設定できたことを確認したいので、とりあえず以下の記事で紹介した翻訳サービスでも使ってみましょう。
参考: Amazon Translate を試してみた

まず、何も設定していないと、認証情報がないって趣旨のエラーが出ることを確認しておきます。


import boto3

text = """
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
"""

client = boto3.client("translate", region_name="ap-northeast-1")

result = client.translate_text(
    Text=text,
    SourceLanguageCode="ja",
    TargetLanguageCode="en",
)

# NoCredentialsError: Unable to locate credentials

ちなみに、 region_name=”ap-northeast-1″ を指定しないと、
NoRegionError: You must specify a region.
が出ます。

さて、本題の IAMの設定に移ります。
まずはEC2に付与するIAMロールを作成します。IAMユーザーではないので注意が必要です。
この辺、正確に理解できてるわけではないのですが、人に権限を付与するのがIAMユーザーで、AWSのリソースに権限を付与するのがIAMロールのようです。(超雑な説明)

1. AWSの管理コンソールのIAMのページの、左ペインでロールを選択します。
https://console.aws.amazon.com/iam/home#/roles
2. ロールの作成、をクリックします。
3. 信頼されたエンティティの種類を選択で、AWSサービスを選択します。 (他にも選択肢があるってことは、AWSサービス以外にもロールを付与できるはずなのですが使ったことがありません。)
4. ユースケースの選択から EC2 を選択します。
5. 次のステップ:アクセス権限 ボタンをクリックします。
6. Attach アクセス権限ポリシー で必要な権限を選択します。 (今回の例では、 TranslateFullAccess を選びます。)
7. 次のステップ:タグ をクリック。
8. 次のステップ:確認 をクリック。
9. ロール名と説明を入力 (例: ec2-jupyter)。
10. ロールの作成 をクリック。

これでロールができるので、 EC2に付与します。
1. EC2の管理画面に移動し、付与したいインスタンスを選択。
2. アクションのセキュリティにある、IAMロールを変更を選択。
3. IAM ロール に先ほど作ったIAMロールを選択。
4. 保存をクリック。

これで、 EC2にIAMロールが付与され、boto3が動くようになりました。
試します。


import boto3

text = """
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
"""

client = boto3.client("translate", region_name="ap-northeast-1")

result = client.translate_text(
    Text=text,
    SourceLanguageCode="ja",
    TargetLanguageCode="en",
)

print(result["TranslatedText"])
"""
Melos got furious.
He determined that he must exclude the king of wicked violence.
"""

将来的に、 Translate 以外のサービスも使いたくなったら、
ロールの管理画面から今回作ったIAMロールにポリシーを追加でアタッチしていけば使うことができます。

Amazon RDS(Aurora)で日本語文字列や絵文字を使えるようにする

RDSのデフォルトの設定のクラスタで、データベースとテーブルを特に文字コードを設定せずに作成した場合、
VARCHAR(文字列)型の列を作ってもそこに日本語などのマルチバイト文字を格納することはできません。

カラムか、テーブルか、データベース単位で設定してもいいのですが、パラメーターグループを使って設定する方法を紹介します。
Aurora(正確にはそれが互換性を持っているMySQL)では、文字コード(Character Set)と照合順序(Collation)をそれぞれ独立した設定として持っています。
照合順序というのはソート順やアルファベットの大文字小文字を同一視するかどうかといったルールのセットです。

文字コードは日本語を使いたいのであればUTF8を使えるように設定する必要がありますが、
歴史的な経緯により、MySQLのUTF8には、utf8とutf8mb4の2種類があります。
utf8には3バイトまでの文字しか含まれておらず、絵文字等の4バイト文字も使えるようにするためにはutf8mb4を設定する必要があります。

参考: 10.1.10.7 utf8mb4 文字セット (4 バイトの UTF-8 Unicode エンコーディング)

さて、とりあえず何も設定しなかった場合の設定を見ておきましょう。


MySQL [(none)]> SHOW VARIABLES LIKE 'char%';
+--------------------------+-------------------------------------------------------------------------+
| Variable_name            | Value                                                                   |
+--------------------------+-------------------------------------------------------------------------+
| character_set_client     | utf8                                                                    |
| character_set_connection | utf8                                                                    |
| character_set_database   | latin1                                                                  |
| character_set_filesystem | binary                                                                  |
| character_set_results    | utf8                                                                    |
| character_set_server     | latin1                                                                  |
| character_set_system     | utf8                                                                    |
| character_sets_dir       | /rdsdbbin/oscar-5.7.serverless_mysql_aurora.2.08.3.38.0/share/charsets/ |
+--------------------------+-------------------------------------------------------------------------+
8 rows in set (0.01 sec)

MySQL [(none)]> SHOW VARIABLES LIKE 'coll%';
+----------------------+-------------------+
| Variable_name        | Value             |
+----------------------+-------------------+
| collation_connection | utf8_general_ci   |
| collation_database   | latin1_swedish_ci |
| collation_server     | latin1_swedish_ci |
+----------------------+-------------------+
3 rows in set (0.01 sec)

character_set_client, character_set_connection, character_set_results
はデフォルトでは utf8 になっていますが、 character_set_database と、 character_set_server が、latin1 になっています。
絵文字含む日本語文字を使うためにはこれら5つをutf8mb4に設定する必要があります。

ここからがややこしいことなのですが、RDSのパラメーターグループではこの5つをそれぞれ設定できるようになっているのに、
character_set_client, character_set_connection, character_set_results にはそれが反映されません。
そして、 character_set_database には character_set_server に設定した値が入ります。
反映されない3つは、クライアント側で設定する必要があるようです。

また、collation の方も、 collation_connection には設定した値が反映されず、
collation_server に設定した値が、collation_server と collation_database に反映されます。

これを踏まえて設定していきます。まず、RDSのパラメーターグループには次のふたつを設定します。

character_set_server: utf8mb4
collation_database: utf8mb4_bin

この段階で、設定は次のようになります。


MySQL [(none)]> SHOW VARIABLES LIKE 'char%';
+--------------------------+-------------------------------------------------------------------------+
| Variable_name            | Value                                                                   |
+--------------------------+-------------------------------------------------------------------------+
| character_set_client     | utf8                                                                    |
| character_set_connection | utf8                                                                    |
| character_set_database   | utf8mb4                                                                 |
| character_set_filesystem | binary                                                                  |
| character_set_results    | utf8                                                                    |
| character_set_server     | utf8mb4                                                                 |
| character_set_system     | utf8                                                                    |
| character_sets_dir       | /rdsdbbin/oscar-5.7.serverless_mysql_aurora.2.08.3.38.0/share/charsets/ |
+--------------------------+-------------------------------------------------------------------------+
8 rows in set (0.01 sec)

MySQL [(none)]> SHOW VARIABLES LIKE 'coll%';
+----------------------+-----------------+
| Variable_name        | Value           |
+----------------------+-----------------+
| collation_connection | utf8_general_ci |
| collation_database   | utf8mb4_bin     |
| collation_server     | utf8mb4_bin     |
+----------------------+-----------------+
3 rows in set (0.00 sec)

次にクライアント側の設定です。
僕は、Amazon Linux 2 で標準のRDSになった、MariaDBを利用しています。
設定ファイルは、 /etc/my.cnf なのですが、
その中を見ると、 !includedir /etc/my.cnf.d となっていて、他のファイルを読み込んでいます。

/etc/my.cnf.d/client.cnf と言うファイルの中に、 [client]と言うセクションがあるのでそこに設定します。


$ sudo vim /etc/my.cnf.d/client.cnf

[client]
# 以下の1行を追加
default-character-set = utf8mb4

MySQL [(none)]> SHOW VARIABLES LIKE 'char%';
+--------------------------+-------------------------------------------------------------------------+
| Variable_name            | Value                                                                   |
+--------------------------+-------------------------------------------------------------------------+
| character_set_client     | utf8mb4                                                                 |
| character_set_connection | utf8mb4                                                                 |
| character_set_database   | utf8mb4                                                                 |
| character_set_filesystem | binary                                                                  |
| character_set_results    | utf8mb4                                                                 |
| character_set_server     | utf8mb4                                                                 |
| character_set_system     | utf8                                                                    |
| character_sets_dir       | /rdsdbbin/oscar-5.7.serverless_mysql_aurora.2.08.3.38.0/share/charsets/ |
+--------------------------+-------------------------------------------------------------------------+
8 rows in set (0.00 sec)

MySQL [(none)]> SHOW VARIABLES LIKE 'coll%';
+----------------------+--------------------+
| Variable_name        | Value              |
+----------------------+--------------------+
| collation_connection | utf8mb4_general_ci |
| collation_database   | utf8mb4_bin        |
| collation_server     | utf8mb4_bin        |
+----------------------+--------------------+
3 rows in set (0.01 sec)

これで、ほぼ設定できました。
実用上これで困ることなく使うことができると思います。

後1点, collation_connection の設定が utf8mb4_bin にならずに、 utf8mb4_general_ci になってしまうのですが、
どなたか collation_connection の設定を utf8mb4_bin にする方法をご存知の方がいらしたら教えていただけないでしょうか。

そのセッションに限った設定であれば、
SET collation_connection = utf8mb4_bin;
で設定できるのですが、接続を切ると元に戻ってしまいます。

また、
SET GLOBAL collation_connection = utf8mb4_bin;
は RDS では実行できないようです。

先述の通り、パラメーターグループで設定しても反映されません。
あまり困ることもないのですが、ここだけ設定がズレていて気持ち悪いので可能であればutf8mb4_binに揃えたいです。