BioErrorLog Tech Blog

試行錯誤の記録

エラー対処: Object of type datetime is not JSON serializable | Python

Pythonのjsonモジュールを利用してjson変換する際に発生した、下記のエラーの対処法をまとめます。

"Object of type datetime is not JSON serializable"

はじめに

jsonモジュールを使うと、Python上でjsonを扱うことが出来ます。 しかし、json対応されていない形式は、そのままでは扱うことが出来ません。

今回、datetime形式が含まれたデータをjson変換しようとした際、以下のエラーに遭遇しました。

"Object of type datetime is not JSON serializable"

これに対する対処法を整理します。

背景

まずはエラーに遭遇した状況を説明します。

AWS のPython SDK: Boto3のdescribe_instances()を用いてEC2インスタンス情報をdict形式で取得し、それをjson.dumps()でjson変換しました。

import json
import boto3

ec2_client = boto3.client('ec2')
response = ec2_client.describe_instances()
    
json.dumps(response)

すると以下のエラーが吐かれます。

"errorMessage": "Object of type datetime is not JSON serializable",
"errorType": "TypeError",

json.dumps()の引数に渡したdictの中に、json変換できないdatetime形式が含まれている、というエラーです。

以下、このエラーに対する2つの対処法を紹介します。

対処法

defaultパラメータ

まず、json.dumps()がもつdefaultパラメータについて簡単に説明します。
(対処法は両方ともdefaultを使います)

defaultパラメータの説明をドキュメントから引用すると、

If specified, default should be a function that gets called for objects that can’t otherwise be serialized. It should return a JSON encodable version of the object or raise a TypeError. If not specified, TypeError is raised.

json変換できないオブジェクトに対して適用されるfunctionを指定できる、とのことです。

このdefaultに何かしらのfunctionを与えてあげることで、json変換できない形式を、json変換可能な形式にすることが出来ます。

対処法1:string変換

一つ目の簡単で雑な対処法としては、このdefaultパラメータにstrを与えることです。

json.dumps(response,default=str)

str()関数を与えることで、json変換できない形式のオブジェクトはすべてstringに変換されます。

かなり雑ですが、簡単にエラーを回避できます。

対処法2:カスタム変換

2つ目はもう少し丁寧な対処法です。

defaultに渡すfunctionを自分で定義します。

例えば以下のように、datetimeおよびdate形式のオブジェクトをisoformatに変換するjson_serial()を定義し、defaultに指定します:

from datetime import date, datetime

def json_serial(obj):

    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError (f'Type {obj} not serializable')

json.dumps(response,default=json_serial)

このやり方ならdatetimeだけでなく、他の形式についても同様に形式を指定して変換が可能です。

特に理由がなければ、こちらのやり方の方が良いでしょう。

おわりに

以上、json変換できないオブジェクトを扱う方法をまとめました。

分かってしまえば簡単ですが、分かるまでには色々とハマってしまいました。

同じ状況に陥ったどなたかの役に立てば幸いです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

json — JSON encoder and decoder — Python 3.11.4 documentation

python - How to overcome "datetime.datetime not JSON serializable"? - Stack Overflow

Working With JSON Data in Python – Real Python

特定のIAM RoleがアタッチされたEC2インスタンスを列挙する | AWS CLI

AWS CLIを用いて、特定のIAM RoleがアタッチされたEC2インスタンスを列挙する方法の備忘録です。


はじめに

おはよう。@bioerrorlogです。

あるEC2インスタンスにアタッチされたIAM Roleは、AWSマネジメントコンソールからも簡単に確認することが出来ます。

では逆に、特定のIAM Roleがアタッチされた全てのEC2インスタンスを列挙するにはどうすればいいか?

今回は、AWS CLIを用いてやる方法をメモします。


特定のIAM RoleがアタッチされたEC2を列挙する

やり方

まず結論から、以下のコマンドを実行します。

aws ec2 describe-instances \
    --filters Name=iam-instance-profile.arn,Values=<対象IAM Roleのインスタンスプロファイル> \
    --query 'Reservations[*].Instances[*].{Instance:InstanceId,Name:Tags[?Key==`Name`]|[0].Value}' \
    --output table


上記コマンドを実行すると、以下のように対象IAM RoleがアタッチされたEC2インスタンス情報を取得できます。

----------------------------------
|        DescribeInstances       |
+----------------------+---------+
|       Instance       |  Name   |
+----------------------+---------+
|  i-06f7c25f664d1e9ee |  Test1  |
|  i-06aacb6d35a8c4753 |  Test2  |
+----------------------+---------+


以下、上記コマンドを一つ一つ解説していきます。


解説

ec2 describe-instances

aws ec2 describe-instances

まずはこちら、AWS CLIの ec2 describe-instancesを利用しています。

このコマンドでは、特定のインスタンス、または条件で指定した複数のインスタンス情報が取得できます。


--filters

--filters Name=iam-instance-profile.arn,Values=<対象IAM Roleのインスタンスプロファイル>

続いて、フィルタ条件--filtersで、対象のインスタンスを抽出します。 対象IAM Roleのインスタンスプロファイルを指定することで、そのIAM RoleがアタッチされたEC2インスタンスをすべて取得することが出来ます。

"インスタンスプロファイル"とは、IAM Roleが格納される箱のようなもので、IAM RoleがEC2インスタンスにアタッチされる際の仲介者のような役割をもちます。

Using instance profiles - AWS Identity and Access Management

IAM Roleのインスタンスプロファイルは、マネジメントコンソールからも確認することが出来ます。

f:id:BioErrorLog:20201208231117p:plain
IAM Roleインスタンスプロファイルをマネジメントコンソールから確認する

ここで確認したIAM Roleのインスタンスプロファイルを、上記コマンドの<対象IAM Roleのインスタンスプロファイル>に置き換えます。

例)

--filters Name=iam-instance-profile.arn,Values=arn:aws:iam::123456789012:instance-profile/EC2TestRole


--query

--query 'Reservations[*].Instances[*].{Instance:InstanceId,Name:Tags[?Key==`Name`]|[0].Value}'

次は、--queryオプションで表示する情報を抽出しています。

仮に何も--queryに指定せず全ての情報を表示した場合、取得された情報の全体像のイメージは例えば以下のようになっています。

例)

{
    "Reservations": [
        {
        "Instances": [
                {
                    "AmiLaunchIndex": 0,
                    "ImageId": "ami-0abcdef1234567890",
                    "InstanceId": "i-1234567890abcdef0",
                    "InstanceType": "t2.micro",
                    "KeyName": "MyKeyPair",
                    "LaunchTime": "2018-05-10T08:05:20.000Z",
                    "Monitoring": {
                        "State": "disabled"
                    },
                    "Placement": {
                        "AvailabilityZone": "us-east-2a",
                        "GroupName": "",
                        "Tenancy": "default"
                    },
                    "PrivateDnsName": "ip-10-0-0-157.us-east-2.compute.internal",
                    "PrivateIpAddress": "10.0.0.157",
                    "ProductCodes": [],
                    "PublicDnsName": "",
                    "State": {
                        "Code": 0,
                        "Name": "pending"
                    },
                    "StateTransitionReason": "",
                    "SubnetId": "subnet-04a636d18e83cfacb",
                    "VpcId": "vpc-1234567890abcdef0",
                    "Architecture": "x86_64",
                    "BlockDeviceMappings": [],
                    "ClientToken": "",
                    "EbsOptimized": false,
                    "Hypervisor": "xen",
                    "NetworkInterfaces": [
                        {
                            "Attachment": {
                                "AttachTime": "2018-05-10T08:05:20.000Z",
                                "AttachmentId": "eni-attach-0e325c07e928a0405",
                                "DeleteOnTermination": true,
                                "DeviceIndex": 0,
                                "Status": "attaching"
                            },
                            "Description": "",
                            "Groups": [
                                {
                                    "GroupName": "MySecurityGroup",
                                    "GroupId": "sg-0598c7d356eba48d7"
                                }
                            ],
                            "Ipv6Addresses": [],
                            "MacAddress": "0a:ab:58:e0:67:e2",
                            "NetworkInterfaceId": "eni-0c0a29997760baee7",
                            "OwnerId": "123456789012",
                            "PrivateDnsName": "ip-10-0-0-157.us-east-2.compute.internal",
                            "PrivateIpAddress": "10.0.0.157"
                            "PrivateIpAddresses": [
                                {
                                    "Primary": true,
                                    "PrivateDnsName": "ip-10-0-0-157.us-east-2.compute.internal",
                                    "PrivateIpAddress": "10.0.0.157"
                                }
                            ],
                            "SourceDestCheck": true,
                            "Status": "in-use",
                            "SubnetId": "subnet-04a636d18e83cfacb",
                            "VpcId": "vpc-1234567890abcdef0",
                            "InterfaceType": "interface"
                        }
                    ],
                    "RootDeviceName": "/dev/xvda",
                    "RootDeviceType": "ebs",
                    "SecurityGroups": [
                        {
                            "GroupName": "MySecurityGroup",
                            "GroupId": "sg-0598c7d356eba48d7"
                        }
                    ],
                    "SourceDestCheck": true,
                    "StateReason": {
                        "Code": "pending",
                        "Message": "pending"
                    },
                    "Tags": [
                        {
                            "Value": "Test1", 
                            "Key": "Name"
                        }
                    ],
                    "VirtualizationType": "hvm",
                    "CpuOptions": {
                        "CoreCount": 1,
                        "ThreadsPerCore": 1
                    },
                    "CapacityReservationSpecification": {
                        "CapacityReservationPreference": "open"
                    },
                    "MetadataOptions": {
                        "State": "pending",
                        "HttpTokens": "optional",
                        "HttpPutResponseHopLimit": 1,
                        "HttpEndpoint": "enabled"
                    }
                }
            ],
            "OwnerId": "123456789012"
            "ReservationId": "r-02a3f596d91211712",
        },
        {
            # 以下instance分繰り返し
        }
    ]
}

ここから、欲しい情報を--queryで抽出します。 今回は、InstanceとしてInstanceIdを、Nameとしてインスタンス名を抽出しています。

'Reservations[*].Instances[*].{Instance:InstanceId,Name:Tags[?Key==`Name`]|[0].Value}'

EC2インスタンスのインスタンス名は、NameタグのValueとして格納されているので、Tags[?Key==Name]|[0].Valueとして値を抽出しています。

この状態での出力は、以下のようになります。

[
    [
        {
            "Instance": "i-06f7c25f664d1e9ee", 
            "Name": "Test1"
        }
    ], 
    [
        {
            "Instance": "i-06aacb6d35a8c4753", 
            "Name": "Test2"
        }
    ]
]


--output

最後は--outputオプションです。 このオプションはただ出力形式を指定するものなので、任意で指定してください。

--outputには、以下の3つが指定できます

  • json
  • text
  • table

それぞれ、出力は以下のようになります。

  • json
[
    [
        {
            "Instance": "i-06f7c25f664d1e9ee", 
            "Name": "Test1"
        }
    ], 
    [
        {
            "Instance": "i-06aacb6d35a8c4753", 
            "Name": "Test2"
        }
    ]
]


- text

i-06f7c25f664d1e9ee     Test1
i-06aacb6d35a8c4753     Test2


- table

----------------------------------
|        DescribeInstances       |
+----------------------+---------+
|       Instance       |  Name   |
+----------------------+---------+
|  i-06f7c25f664d1e9ee |  Test1  |
|  i-06aacb6d35a8c4753 |  Test2  |
+----------------------+---------+


任意で好きな出力形式を指定してください。


以上、コマンドの説明でした。

aws ec2 describe-instances \
    --filters Name=iam-instance-profile.arn,Values=<対象IAM Roleのインスタンスプロファイル> \
    --query 'Reservations[*].Instances[*].{Instance:InstanceId,Name:Tags[?Key==`Name`]|[0].Value}' \
    --output table


おわりに

今回は、特定のIAM RoleがアタッチされたEC2インスタンスをAWS CLIを用いて列挙する方法を記しました。

こうして調べるたびに、AWS CLIはとても強力だと感じます。

今後も使いこなしていきたいところです。


関連記事

AWS CLIのs3 cps3 syncの違いをまとめました。 www.bioerrorlog.work


S3バケットポリシーとIAMポリシーの関係を、同一アカウント・クロスアカウントそれぞれにおいて整理しました。 www.bioerrorlog.work


AWS Lambdaの中でAWS CLIを実行する方法を書きました。 www.bioerrorlog.work


参考

describe-instances — AWS CLI 1.18.211 Command Reference

Using instance profiles - AWS Identity and Access Management

Grobal parameters - AWS CLI

S3バケットポリシーとIAMポリシーの関係を整理する

S3バケットポリシーとIAMポリシーの関係を、同一アカウント・クロスアカウントそれぞれにおいて整理します。

はじめに

こんにちは、@bioerrorlogです。

S3に対するアクセス権限の制御方法としては、アクセス元のIAMポリシーとアクセス先のバケットポリシーのふたつが挙げられます。

S3に対するアクセス権限の制御

IAMポリシーとバケットポリシーのどちらで制御すればよいのか、はたまた両方で制御する必要があるのか、油断してると忘れそうになります。

今回は、このIAMポリシーとバケットポリシーでの制御方法の関係性について、同一アカウント内の場合とクロスアカウントの場合でそれぞれ整理します。

S3バケットポリシーとIAMポリシーの関係

結論

まず簡潔に結論を言うと、以下のようになります。

  • 同一アカウント内アクセス:
    S3バケットポリシーかIAMポリシーのどちらかで許可が必要
  • クロスアカウントアクセス:
    S3バケットポリシーとIAMポリシーの両方で許可が必要
  • S3バケットポリシーかIAMポリシーのどちらかで拒否された場合は拒否される


以降、EC2からS3にGetObjectする場合をもとに検証していきます。


検証準備

EC2に付与するIAMポリシーとして、以下のものを用意します。

  • アクセス許可用
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::[ターゲットバケット名]/*"
        }
    ]
}


  • アクセス拒否用
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::[ターゲットバケット名]/*"
        }
    ]
}


バケットポリシーとしては、以下のものを用意します。

  • アクセス許可用
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::[アカウントid]:role/[EC2にアタッチしたRole名]"
            },
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::[バケット名]/*"
            ]
        }
    ]
}


  • アクセス拒否用
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": {
                "AWS": "arn:aws:iam::[アカウントid]:role/[EC2にアタッチしたRole名]"
            },
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::[バケット名]/*"
            ]
        }
    ]
}


EC2からGetObjectするには、以下のCLIを利用します。

aws s3api get-object --bucket [ターゲットバケット名] --key [オブジェクトキー] [ファイル保存名]


以上の条件で、各ケースにおけるアクセス可否を検証します。


同一アカウント内アクセス

同一アカウント内でのS3アクセス

まずは、同一アカウント内からのS3アクセスを検証します。

IAMポリシー バケットポシリー アクセス可否
- - Access Denied
Allow - Succeed
- Allow Succeed
Allow Allow Succeed
Deny Allow Access Denied
Allow Deny Access Denied

このように、同一アカウント内では

  • S3バケットポリシーかIAMポリシーのどちらかで許可されば許可される
  • S3バケットポリシーかIAMポリシーのどちらかで拒否された場合は拒否される

という挙動になります。


クロスアカウントアクセス

クロスアカウントでのS3アクセス

続いて、クロスアカウントでのS3アクセスを検証します。

IAMポリシー バケットポシリー アクセス可否
- - Access Denied
Allow - Access Denied
- Allow Access Denied
Allow Allow Succeed
Deny Allow Access Denied
Allow Deny Access Denied

このように、クロスアカウントでは

  • S3バケットポリシーかIAMポリシーの両方で許可されば許可される
  • S3バケットポリシーかIAMポリシーのどちらかで拒否された場合は拒否される

という挙動になります。


以上をまとめると、S3バケットポリシーとIAMポリシーの関係は以下のようになります(再掲)。

  • 同一アカウント内アクセス:
    S3バケットポリシーかIAMポリシーのどちらかで許可が必要
  • クロスアカウントアクセス:
    S3バケットポリシーとIAMポリシーの両方で許可が必要
  • S3バケットポリシーかIAMポリシーのどちらかで拒否された場合は拒否される


おわりに

今回は、S3バケットポリシーとIAMポリシーの関係を整理しました。

AWSを触っていると、IAM権限周りの正確な理解がとても重要と感じています。

今回取り上げた話題は比較的シンプルな話でしたが、一つ一つ整理していくことで、権限周りの自信を深めていきたいところです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

Amazon S3 がバケットオペレーションのリクエストを承認する仕組み - Amazon Simple Storage Service

S3 バケット内のオブジェクトへのクロスアカウントアクセスを許可する - AWSナレッジセンター

LambdaでAWS CLIを実行する | AWS Lambda Layer

Lambda Layerを利用して、Lambda上でAWS CLIを実行する方法をメモします。


はじめに

こんにちは、@bioerrorlogです。

Lambda上でAWS CLIを実行したいと思いました。 AWS CLIにはs3 syncコマンドのような、SDKには未実装の便利な機能があるためです。

しかし、Lambdaの実行環境にはAWS CLIはプリインストールされていないので、ひと工夫が必要になります。

カスタムランタイムコンテナサポート機能を利用する方法もありますが、今回はLambda Layerを利用してLambda上でAWS CLIを実行してみます。

LambdaでAWS CLIを実行する

以下の手順でやっていきます。

  1. Python仮想環境の作成
  2. 仮想環境にAWS CLIをインストール
  3. Python実行パスの変更
  4. AWS CLI関連パッケージをzipファイルにまとめる
  5. Lambda Layerの作成
  6. Lambda関数にLambda Layerをアタッチ
  7. Lambda関数でAWS CLIを実行

Python仮想環境の作成

任意の環境にAWS CLIをインストールするためのPython仮想環境を作成します。

ローカルやEC2等どこでも大丈夫ですが、今回はCloud9(Amazon Linux 2)を利用しました。

まず、Pythonがインストールされていることを確認します。

$ python3 --version
Python 3.7.9

$ pip3 --version
pip 9.0.3 from /usr/lib/python3.7/site-packages (python 3.7)


Pythonがインストールされていることが確認できたら、任意の場所で仮想環境を作成、activateします。

# 仮想環境用ディレクトリを作成
mkdir awscli-virtualenv

# 仮想環境を作成
python3 -m venv awscli-virtualenv

# 仮想環境を開始
cd awscli-virtualenv/bin/
source activate

仮想環境にAWS CLIをインストール

次は、仮想環境にAWS CLIをインストールします。 仮想環境がactivateされたまま、pipでインストールします。

# AWS CLIのインストール
pip install awscli


Python実行パスの変更

次は、Python実行パスを変更します。

AWS CLI実行ファイルawsの1行目にはPythonへの実行パスが指定されていますが、そのパスは現在作成している仮想環境に合わせたものになっています。 これをLambda用のパスに変更する必要があります。

$ cat aws
#!/home/ec2-user/environment/awscli-virtualenv/bin/python3
# Copyright 2012 Amazon.com, Inc. or its affiliates. All Rights Reserved.

# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at

#     http://aws.amazon.com/apache2.0/

# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
import sys
import os

if os.environ.get('LC_CTYPE', '') == 'UTF-8':
    os.environ['LC_CTYPE'] = 'en_US.UTF-8'
import awscli.clidriver


def main():
    return awscli.clidriver.main()


if __name__ == '__main__':
    sys.exit(main())

今回の私のケースだと、Python実行パスは#!/home/ec2-user/environment/awscli-virtualenv/bin/python3に指定されています。

これをLambdaで実行するために、Lambda実行環境に合わせた!/var/lang/bin/pythonに変更します。

sed -i "1s/.*/\#\!\/var\/lang\/bin\/python/" aws

これで、awsファイルの1行目が#!/var/lang/bin/pythonに変更されました。

$ cat aws
#!/var/lang/bin/python
# Copyright 2012 Amazon.com, Inc. or its affiliates. All Rights Reserved.

# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at

#     http://aws.amazon.com/apache2.0/

# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
import sys
import os

if os.environ.get('LC_CTYPE', '') == 'UTF-8':
    os.environ['LC_CTYPE'] = 'en_US.UTF-8'
import awscli.clidriver


def main():
    return awscli.clidriver.main()


if __name__ == '__main__':
    sys.exit(main())

AWS CLI関連パッケージをzipファイルにまとめる

では次に、インストールしたAWS CLI関連パッケージを、Lambda Layerにアップロードするためにzipにまとめます。

# 仮想環境を終了
deactivate

# Lambda Layerアップロード用のディレクトリを作成
cd ../..
mkdir awscli-lambda-layer
cd awscli-lambda-layer

# AWS CLI関連パッケージをコピー
cp ../awscli-virtualenv/bin/aws .
cp -r ../awscli-virtualenv/lib/python3.7/site-packages/* .     

ここでコピーしたAWS CLI関連パッケージの構成は以下です。

# コピーしたファイルを確認
$ ls
aws                         dateutil                   pip-20.1.1.dist-info             rsa                          six.py
awscli                      docutils                   pkg_resources                    rsa-4.5.dist-info            urllib3
awscli-1.18.176.dist-info   docutils-0.15.2.dist-info  pyasn1                           s3transfer                   urllib3-1.25.11.dist-info
botocore                    easy_install.py            pyasn1-0.4.8.dist-info           s3transfer-0.3.3.dist-info   yaml
botocore-1.19.16.dist-info  jmespath                   __pycache__                      setuptools                   _yaml.cpython-37m-x86_64-linux-gnu.so
colorama                    jmespath-0.10.0.dist-info  python_dateutil-2.8.1.dist-info  setuptools-47.1.0.dist-info
colorama-0.4.3.dist-info    pip                        PyYAML-5.3.1-py3.7.egg-info      six-1.15.0.dist-info

ここまできたら、あとはzipにまとめます。

# AWS CLI関連パッケージをzipにまとめる。
zip -r ../awscli-lambda-layer.zip *

Lambda Layerの作成

次は、作成したzipファイルからLambda Layerを作成します。

Lambda Layerの作成にはaws cliのpublish-layer-versionコマンドを使用します。 今回はPythonランタイムでLambdaを作成するので、--compatible-runtimesにはPython3.6Python3.7Python3.8を指定しています。

# Lambda Layerの作成
cd ..
aws lambda publish-layer-version --layer-name aws-cli --description "for aws cli" \
    --zip-file fileb://awscli-lambda-layer.zip --compatible-runtimes python3.6 python3.7 python3.8

これでLambda Layerが作成されました。

マネジメントコンソール上で見に行くと、次のようにLambda Layerが作成されていることが確認できます。

作成されたLambda Layerが確認できる

Lambda関数にLambda Layerをアタッチ

次は、Lambda関数にLambda Layerをアタッチします。

これは、以下の手順でコンソールから簡単にアタッチすることが出来ます。

LambdaコンソールからLayerをアタッチ: その1

LambdaコンソールからLayerをアタッチ: その2

これで、Lambda Layerがアタッチされました。

Lambda関数でAWS CLIを実行

それではいよいよ、LambdaでAWS CLIを実行します。

今回は、以下のPythonコードを実行します。

import subprocess
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def run_command(command):
    command_list = command.split(' ')

    try:
        logger.info("Running shell command: \"{}\"".format(command))
        result = subprocess.run(command_list, stdout=subprocess.PIPE);
        logger.info("Command output:\n---\n{}\n---".format(result.stdout.decode('UTF-8')))
    except Exception as e:
        logger.error("Exception: {}".format(e))
        return False

    return True

def lambda_handler(event, context):
    run_command('/opt/aws --version')

※コードはこちらを参考にしています。

そのまま実行すると3秒のタイムアウトに掛かってしまうので、タイムアウト時間は適度に伸ばしておく必要があります。

CLIの実行には、Pythonのsubprocess.run()を使用しています。

AWS CLIコマンドは、awsの代わりに/opt/awsに続く形で指定して実行します。 Lambda LayerとしてアタッチしたAWS CLIの実行ファイルawsが、/opt/awsに格納されているためです。

上記のコードを実行した結果、以下のようにAWS CLIのバージョンが表示されれば成功です。

---
aws-cli/1.18.176 Python/3.8.5 Linux/4.14.193-110.317.amzn2.x86_64 exec-env/AWS_Lambda_python3.8 botocore/1.19.16

---

同様に、他のAWS CLIコマンドも実行することが出来ます。

おわりに

今回はLambda Layerを使って、Lambda上でAWS CLIを実行する方法を書きました。

s3 sync等、AWS CLIの便利な高レベルコマンドを手軽に使いたいときなど、役に立つかもしれません。

カスタムランタイムやコンテナサポート機能を使う手もあるので、そちらも試してみたいものです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

参考

How to use AWS CLI within a Lambda function (aws s3 sync from Lambda) :: Ilya Bezdelev

amazon web services - Call aws-cli from AWS Lambda - Stack Overflow

S3既存オブジェクトに一括でbucket-owner-full-controlを付与する | AWS CLI

S3バケットの既存の全オブジェクトに--acl bucket-owner-full-controlを一括付与するやり方の備忘録です。


はじめに

おはよう。@bioerrorlogです。

クロスアカウントのS3間でオブジェクトをコピーするとき、--acl bucket-owner-full-controlを付与せずにそのままアップロードしてしまうと、アップロード先のアカウント側からアクセスできなくなることが知られています


f:id:BioErrorLog:20201102184648p:plain
--acl bucket-owner-full-controlを付与せずにクロスアカウントS3アップロードすると、アップロード先のアカウントからアクセスできない

この問題を事前に回避するには、オブジェクトコピー時に--acl bucket-owner-full-controllを付与したり、コピー先のS3バケットでObject Ownershipを設定したりする方法が挙げられます。

一方、オブジェクトコピー後にこの問題を解消するには、put-object-aclコマンドを用いて後からbucket-owner-full-controlを付与する方法があります。

aws s3api put-object-acl --bucket destination_awsexamplebucket --key keyname --acl bucket-owner-full-control

※コマンドはオブジェクトコピー元アカウントの権限で実行する必要があります。

しかし、このコマンドはS3オブジェクト1つに対して実行するものであり、特定ディレクトリやバケット全体を指定することはできません。 そうしたい場合には、ちょっとした工夫が必要になります。

今回はそのような、特定ディレクトリやバケット全体を指定して--acl bucket-owner-full-controlを一括付与するやり方を記します。


既存オブジェクトにbucket-owner-full-controllを一括付与する

結論として、以下のコマンドを実行します。

aws s3 ls s3://target-bucket/ --recursive | awk '{print $4}' | xargs -I KEY aws s3api put-object-acl --bucket target-bucket --key "KEY" --acl bucket-owner-full-control

(コマンドはこちらを参考/改変しています)


※追記: 上記コマンドは、オブジェクトキーに日本語が入っていたり、スペースが入っていたりすると上手く機能しません。 代わりに以下のコマンドの方がお勧めです。

aws s3api list-objects-v2 --bucket target-bucket | jq .Contents[].Key | xargs -I KEY aws s3api put-object-acl --bucket target-bucket --key "KEY" --acl bucket-owner-full-control


これは3つのコマンドをパイプで繋げたものであり、指定したS3バケット/ディレクトリ以下にあるオブジェクト全てにbucket-owner-full-controlを付与しています。

以下、上記コマンドを旧バージョンのものと追記バージョンのものとで、それぞれひとつひとつ見ていきます。


追記バージョンコマンドの解説

aws s3api list-objects-v2 --bucket target-bucket | jq .Contents[].Key | xargs -I KEY aws s3api put-object-acl --bucket target-bucket --key "KEY" --acl bucket-owner-full-control

まず、こちら追記バージョンコマンドの解説をします。


aws s3api list-objects-v2 --bucket target-bucket

まずはこちらlist-objects-v2コマンドにより、以下のようにバケットのオブジェクトリストを取得します。

$ aws s3api list-objects-v2 --bucket target-bucket
{
    "Contents": [
        {
            "LastModified": "2020-11-02T12:45:17.000Z", 
            "ETag": "\"47bce5c74f589f4867dbd57e9ca9f808\"", 
            "StorageClass": "STANDARD", 
            "Key": "aaa.txt", 
            "Size": 3
        }, 
        {
            "LastModified": "2020-11-02T12:45:17.000Z", 
            "ETag": "\"585cc54d19a29743e94948bf6d3a4592\"", 
            "StorageClass": "STANDARD", 
            "Key": "bbb.txt", 
            "Size": 12
        }, 
        {
            "LastModified": "2020-11-02T12:45:17.000Z", 
            "ETag": "\"9df62e693988eb4e1e1444ece0578579\"", 
            "StorageClass": "STANDARD", 
            "Key": "ccc.txt", 
            "Size": 3
        }, 
        {
            "LastModified": "2020-11-02T12:45:17.000Z", 
            "ETag": "\"47bce5c74f589f4867dbd57e9ca9f808\"", 
            "StorageClass": "STANDARD", 
            "Key": "ddd.txt", 
            "Size": 3
        }, 
        {
            "LastModified": "2020-11-02T12:45:17.000Z", 
            "ETag": "\"585cc54d19a29743e94948bf6d3a4592\"", 
            "StorageClass": "STANDARD", 
            "Key": "eee.txt", 
            "Size": 12
        }, 
        {
            "LastModified": "2020-11-02T12:45:17.000Z", 
            "ETag": "\"9df62e693988eb4e1e1444ece0578579\"", 
            "StorageClass": "STANDARD", 
            "Key": "fff.txt", 
            "Size": 3
        }
    ]
}

なお、取得範囲を特定パスに絞りたいときは、--prefixオプションを使用します。


次に、上記実行結果をjqコマンドで処理します。

jq .Contents[].Key

jqは、jsonから値を抽出できるコマンドです。 上記コマンドにより、オブジェクトリストのうちオブジェクトキーを抽出します。

$ aws s3api list-objects-v2 --bucket target-bucket-001 | jq .Contents[].Key                                                                                                                                             
"aaa.txt"
"bbb.txt"
"ccc.txt"
"ddd.txt"
"eee.txt"
"fff.txt"

これでちょうど、オブジェクトキーが出力される形になります。


最後にこの出力を受け取って、以下のコマンドを実行します。

xargs -I KEY aws s3api put-object-acl --bucket target-bucket --key "KEY" --acl bucket-owner-full-control

xargsコマンドは、標準入力からインプットを受けてコマンドを実行するものです。

-Iオプションに指定した変数(今回の例ではKEY)を置き換え文字とし、標準入力から受け取ったオブジェクトキー名を後続コマンドaws s3api put-object-acl--keyオプションに当てています。


以上この3段階のコマンドを経て、指定したディレクトリ以下にある全オブジェクトに--acl bucket-owner-full-controlを付与さることができます。


旧バージョンコマンドの解説

aws s3 ls s3://target-bucket/ --recursive | awk '{print $4}' | xargs -I KEY aws s3api put-object-acl --bucket target-bucket --key "KEY" --acl bucket-owner-full-control

こちら旧バージョンコマンドの解説も残しておきます。

前述の通り、こちらはオブジェクトキーに日本語が入っていたり、スペースが入っていたりすると上手く機能しないので注意が必要です。

aws s3 ls s3://target-bucket/ --recursive

まずはこのaws s3 lsコマンドで、指定したS3バケット/ディレクトリ以下にある全オブジェクトを取得します。

このコマンド単体の実行結果は以下のようになります。

$ aws s3 ls s3://target-bucket-001/ --recursive
2020-11-02 12:45:17          3 aaa.txt
2020-11-02 12:45:17         12 bbb.txt
2020-11-02 12:45:17          3 ccc.txt
2020-11-02 12:45:17          3 ddd.txt
2020-11-02 12:45:17         12 eee.txt
2020-11-02 12:45:17          3 fff.txt


次に、上記実行結果がawkコマンドに渡されます。

awk '{print $4}'

awkコマンドは、テキストデータをパターンで処理できるコマンドです。 '{print $4}'を指定することで、4行目を出力させています。

ここまでを合わせると、出力は以下のようになります。

$ aws s3 ls s3://target-bucket-001/ --recursive | awk '{print $4}'
aaa.txt
bbb.txt
ccc.txt
ddd.txt
eee.txt
fff.txt

ちょうどオブジェクトキーが出力される形になります。


最後にこの出力を受け取って、以下のコマンドを実行します。

xargs -I KEY aws s3api put-object-acl --bucket target-bucket --key "KEY" --acl bucket-owner-full-control

xargsコマンドは、標準入力からインプットを受けてコマンドを実行するものです。

-Iオプションに指定した変数(今回の例ではKEY)を置き換え文字とし、標準入力から受け取ったオブジェクトキー名を後続コマンドaws s3api put-object-acl--keyオプションに当てています。


以上この3段階のコマンドを経て、指定したディレクトリ以下にある全オブジェクトに--acl bucket-owner-full-controlを付与さることができます。


おわりに

S3バケットの既存オブジェクトに、--acl bucket-owner-full-controlを一括付与するやり方の備忘録を書きました。

今回はbucket-owner-full-controlを取り上げましたが、ほかのACL権限設定についても同様のやり方で対処することが出来ます。

また、もちろんBoto3などSDKを用いても同様の処理を書くことができます。

が、一行で済んでしまうこの手軽さは、コマンド良いところだと改めて感じます。


関連記事

S3バケットポリシーとIAMポリシーの関係を、同一アカウント・クロスアカウントそれぞれにおいて整理しました。 www.bioerrorlog.work


AWS CLIのs3 cps3 syncの違いをまとめました。 www.bioerrorlog.work


Boto3でAssumeRoleするやり方を書きました。 www.bioerrorlog.work


参考

Resolve 403 errors from S3 objects uploaded by other accounts

Require access to S3 objects uploaded from another AWS account

Bucket policy examples - Amazon Simple Storage Service

Amazon S3 Object Ownership is available to enable bucket owners to automatically assume ownership of objects uploaded to their buckets

Controlling ownership of uploaded objects using S3 Object Ownership - Amazon Simple Storage Service

Boto3でAssumeRoleする | AWS SDK for Python

Boto3でAssumeRoleするやり方をまとめます。

はじめに

こんにちは、@bioerrorlogです。

Boto3を利用したPythonスクリプトで、AssumeRoleしたい状況はよくあります。

その度にAssumeRoleの実装方法を調べなおしているので、今回はその方法をまとめます。

Boto3でAssumeRoleする

AssumeRole関数の実装

さっそくAssumeRoleするためのPython関数を実装してみます。

import boto3

def assume_role(aws_account_number: str, role_name: str) -> boto3.Session:
    """
    Assumes the provided role in the target account and returns Session.
    Args:
        - aws_account_number: AWS Account Number
        - role_name: Role to assume in target account
    Returns:
        AssumeRole Session.
    """
    try:
        sts_client = boto3.client('sts')

        # Get the current partition
        partition = sts_client.get_caller_identity()['Arn'].split(":")[1]

        response = sts_client.assume_role(
            RoleArn=f'arn:{partition}:iam::{aws_account_number}:role/{role_name}',
            RoleSessionName=f'SessionFor{role_name}In{aws_account_number}'
        )

        # Storing STS credentials
        session = boto3.Session(
            aws_access_key_id=response['Credentials']['AccessKeyId'],
            aws_secret_access_key=response['Credentials']['SecretAccessKey'],
            aws_session_token=response['Credentials']['SessionToken']
        )
    except Exception as e:
        raise ValueError(f'Error in AssumeRole process: {e}')
      
    print(f'Assumed session for {role_name} in {aws_account_number}.')

    return session

※実装はaws-samplesにあるこちらのスクリプトを参考/改変しています。

このコードの大まかな流れは以下です:

  1. AssumeRoleしたいRole名とアカウントidを引数に受け取る
  2. STSのBoto3クライアントを作成する
  3. AssumeRoleする
  4. AssumeRoleしたcredentialsを設定したSessionを返却する

この関数から返却されたSessionを利用すれば、AssumeRole先の権限でアクションが行える、という仕組みです。

それでは上記AssumeRole関数の簡単な使用例を見ていきます。

使用例

上記関数assume_role()を用いて、AssumeRole先の権限でs3バケットリストを取得するAPIを叩いてみます。

# 必要情報の格納
role_name = 'AssumeRoleTest' # AssumeRoleしたいRole名
target_account_id = '123456789012' # AssumeRole先のアカウントid

# AssumeRole先のSessionからs3のboto3クライアントを取得
session = assume_role(target_account_id,role_name)
s3_client = session.client('s3')

# APIの実行
buckets = s3_client.list_buckets()
print(buckets)

assume_role()で取得したsessionから、各種boto3クライアントを発行する、というのが主な使い方です。

上の例ではs3クライアントを発行していますが、ほかのサービスを使うときも同様に対象サービスのクライアントが発行できます。

client = session.client('[対象サービス名]')

各サービスのboto3クライアント発行方法の詳細は、直接ドキュメントに当たるのがおすすめです:
Boto3 documentation — Boto3 Docs 1.24.75 documentation


以上、AssumeRoleの簡単な使用方法でした。

おわりに

以上、Boto3でAssumeRoleする方法を書きました。

最近は、AssumeRoleの利用を前提としたきめ細かいRole設計も普通になってきたかと思います。 マルチアカウントで運用しているような大きなプロジェクト等では、AssumeRoleの活用は必須になるでしょう。

どなたかの参考になれば幸いです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

GitHub - aws-samples/amazon-detective-multiaccount-scripts: interact with Amazon Detective in multiple accounts and regions

Boto3 documentation — Boto3 Docs 1.24.75 documentation

AssumeRole - AWS Security Token Service

aws s3 cpとsyncの違い | AWS CLI

AWS CLIのs3 cps3 syncの違いをまとめます。

はじめに

こんにちは、@bioerrorlogです。

あるS3バケットのオブジェクトを別のS3バケットにコピーしようと思ったとき、2つのコマンドが頭に浮かびました。

s3 cps3 syncです。

雰囲気で分かったつもりになっていましたが、自分はまだ両者の違いを明確に説明できない気がしました。

今回は、この2つのコマンドの違いを調べ、整理していきます。

cpとsyncの違い

先に一言で両者の最も大きな違いを言うならば、

cpファイルをコピーするコマンド
syncディレクトリの更新差分をコピーする(同期する)コマンド

と言えるでしょう。

Descriptionを比較する

ではまず、両者のCLIドキュメントのDescriptionを比較します。

cp — AWS CLI 2.7.22 Command Reference
Description:

Copies a local file or S3 object to another location locally or in S3.


sync — AWS CLI 2.7.22 Command Reference
Description:

Syncs directories and S3 prefixes. Recursively copies new and updated files from the source directory to the destination. Only creates folders in the destination if they contain one or more files.

cpは単純に"オブジェクトのコピー"をするコマンドであるのに対し、syncでは"新規作成または更新されたファイル"について、"指定ディレクトリ以下を再帰的に"コピーする、ということが分かります ("ひとつ以上のファイルが含まれるフォルダのみを作成する"という挙動特性もあるようです)。

これらcpsyncの説明の違いは、ファイルを"コピー"するのか、ディレクトリを"同期(更新差分をコピー)"するのか、という違いとして直感的にも頷けるものでした。

コマンドオプションを比較する

次は、コマンドの実際の使い方を比較します。
具体的には、コマンドに用意されているオプションの差異を調べてみます。

cpsyncのコマンドオプションは大半が同じものですが、いくつか異なるものがあります。

cpコマンドとsyncコマンドのオプション比較 (灰色: cpとsyncの両方にあるオプション、オレンジ: それぞれのコマンドで独自のオプション)

まず、syncコマンドに特有のオプションとしては、以下のものがあります。

  • [--size-only]
  • [--exact-timestamps]
  • [--delete]

[--size-only]オプションは、sync元のファイルが更新されたか否か(=データコピーを実行するか否か)、をファイルサイズのみで判定することを指定するオプションのようです。

Makes the size of each key the only criteria used to decide whether to sync from source to destination.


[--exact-timestamps]オプションは、sync元のファイルが更新されたか否かを、タイムスタンプの一致まで見るオプションのようです。

When syncing from S3 to local, same-sized items will be ignored only when the timestamps match exactly. The default behavior is to ignore same-sized items unless the local version is newer than the S3 version.


[--delete]オプションは、sync先にはあるけれどsync元にはないファイルを削除するオプションです。

Files that exist in the destination but not in the source are deleted during sync.


上記3つのsync特有のオプションはいずれも、ディレクトリの更新差分をコピーする、というsyncコマンドの機能に深く関与するものと言えるでしょう。


次は、cpコマンド特有のオプションを見ていきます。

  • [--expected-size ]
  • [--recursive]

[--expected-size <value>]は、S3へのアップロードサイズが50GBを超える場合に、マルチパートアップロードをエラーなく実行するために必要なオプションのようです。

This argument specifies the expected size of a stream in terms of bytes. Note that this argument is needed only when a stream is being uploaded to s3 and the size is larger than 50GB. Failure to include this argument under these conditions may result in a failed upload due to too many parts in upload.


[--recursive]は、指定したディレクトリ下の全てのファイルを再帰的にコピーするオプションです。

Command is performed on all files or objects under the specified directory or prefix.


cpコマンドはsyncオプションと違い、ファイルのコピーをデフォルト挙動としているため、ディレクトリ下を再帰的にコピーしたい場合は[--recursive]オプションを使うことになります。

これはcpsyncの挙動の違いを表す、象徴的なオプションと言えるでしょう。

コマンドの挙動を比較する

最後に、実際にcpコマンドとsyncコマンドを叩いて挙動を確認することで、両者の違いを実感したいと思います。

syncとcpを用いたS3バケット間ファイルコピー

まずは単純に、S3バケット間の複数ファイルコピーを行います。

syncでこれを行うには、以下のシンプルなコマンドを発行します。

$ aws s3 sync s3://cp-sync-source s3://sync-destination                                                                                                   
copy: s3://cp-sync-source/aaa.txt to s3://sync-destination/aaa.txt
copy: s3://cp-sync-source/bbb.txt to s3://sync-destination/bbb.txt


一方、cpでディレクトリ単位のコピーを行うには、前述の通り--recursiveオプションを付与する必要があります。

$ aws s3 cp s3://cp-sync-source s3://cp-destination --recursive
copy: s3://cp-sync-source/bbb.txt to s3://cp-destination/bbb.txt
copy: s3://cp-sync-source/aaa.txt to s3://cp-destination/aaa.txt


では次に、ディレクトリ更新差分に対する両者の挙動の違いを見ていきます。

ディレクトリ更新後のsyncとcpの違い(赤: 更新ファイル)

コピー元のS3バケットcp-sync-sourceのうち、bbb.txtを更新し、ccc.txtファイルを新規追加しました。

この状態で再びsyncを行うと、

$ aws s3 sync s3://cp-sync-source s3://sync-destination
copy: s3://cp-sync-source/bbb.txt to s3://sync-destination/bbb.txt
copy: s3://cp-sync-source/ccc.txt to s3://sync-destination/ccc.txt

コピーが実行されるのはbbb.txtccc.txtのみで、aaa.txtはコピーされません。

一方cpを実行すると、

$ aws s3 cp s3://cp-sync-source s3://cp-destination --recursive
copy: s3://cp-sync-source/bbb.txt to s3://cp-destination/bbb.txt  
copy: s3://cp-sync-source/aaa.txt to s3://cp-destination/aaa.txt  
copy: s3://cp-sync-source/ccc.txt to s3://cp-destination/ccc.txt  

ディレクトリの更新差分とは関係なく、全ファイルが再びコピーされます。


このように、

cpファイルをコピーするコマンド
syncディレクトリの更新差分をコピーする(同期する)コマンド

という違いが見て取れました。

おわりに

今回は、AWS CLIコマンドのcpsyncの違いを調べました。

大まかな挙動の違いは、文字通り"コピー"なのか"同期"なのか、というのと直感的に近しいものでした。

が、各オプションを比較してみると、同じものと違うものとでそれぞれのコマンドの想定用法が見えてきて勉強になりました。

分かった気になっていても、今一度調べてみると多くの発見があって面白いものです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

cp — AWS CLI 2.7.22 Command Reference

sync — AWS CLI 2.7.22 Command Reference

Uploading objects - Amazon Simple Storage Service

s3 streaming upload allocates 5-10x more mem than the multipart chunk size (oom risk) and fails to saturate net tx (inefficient) · Issue #2390 · aws/aws-cli · GitHub

CloudWatch EventsでS3への特定オブジェクトのアップロードを検知する

S3への特定オブジェクトのアップロードをトリガーに、CloudWatch Eventsを起動するやり方の備忘録を残します。


はじめに

おはよう。@bioerrorlogです。

S3への特定オブジェクトのアップロードをトリガーにCloudWatch Eventsを起動するやり方について、細かいところで理解が曖昧なところがありました。

今回はこの手順について、ステップ毎に細かく確認していきます。

f:id:BioErrorLog:20201017193156p:plain
S3への特定オブジェクトのアップロードをトリガーに、CloudWatch Eventsを起動する

CloudWatch Eventsのイベントソースを調べる

まず、CloudWatch Eventsがサポートしているイベントソースを、ドキュメントから調べます。

CloudWatch Events Event Examples From Supported Services - Amazon CloudWatch Events

これを見るに、S3イベントはCloudWatch Eventsの直接的なイベントソースとしてサポートされていません。 よって、CloudTrailを介したCloudWatch Eventsルールを作成することになります。

Events Delivered Via CloudTrail - Amazon CloudWatch Events

また、S3側の設定としてeventを通知する仕組みも用意されていますが、ここでサポートされているevent通知先はSNS/SQS/Lambdaの3つであり、CloudWatch Eventsは直接のサポートはありません。

Configuring Amazon S3 event notifications - Amazon Simple Storage Service


以上から、S3トリガーでCloudWatch Eventsを起動するには、CloudTrailを介したやり方がふさわしそうです。


S3トリガーで起動するCloudWatch Eventsを作成する

それでは、S3トリガーで起動するCloudWatch Eventsを作成していきます。 手順としては、CodePipelineのドキュメントに記載されていたものを参考にします。

Create a CloudWatch Events rule for an Amazon S3 source (console) - AWS CodePipeline


CloudTrail証跡を作成する

CloudTrailを経由してCloudWatch Eventsを起動する流れになるので、CloudTrailのTrail (証跡)を作成していない場合は以下の手順で作成します。

  1. AWS CloudTrail コンソールを開きます。
  2. ナビゲーションペインで、[Trails] を選択します。
  3. [Create Trail (証跡の作成)] を選択します。[Trail name] に、証跡の名前を入力します。
  4. [Apply trail to all regions (すべてのリージョンで証跡を適用)] では [No] を選択します。
  5. [Data events (データイベント)] で [S3] が選択されていることを確認します。フォルダ内のすべてのオブジェクトのデータイベントをログ記録するために、Amazon S3 バケットおよびオブジェクトプレフィックス (フォルダ名) を指定します。証跡ごとに、最大 250 個の Amazon S3 オブジェクトを追加できます。
  6. [Read/Write events (読み取り/書き込みイベント)] で [なし] を選択します。
  7. [Write (書き込み)] を選択します。証跡では、指定したバケットとプレフィックスの Amazon S3 オブジェクトレベルの API アクティビティ (GetObject や PutObject など) が記録されます。
  8. [保存場所] でログファイルを保存するために使用するバケットを作成あるいは指定します。デフォルトでは、Amazon S3 バケットとオブジェクトはプライベートです。リソース所有者 (バケットを作成した AWS アカウント) のみが、バケットとそのオブジェクトにアクセスできます。バケットには、バケット内のオブジェクトにアクセスできる AWS CloudTrail 権限を付与するリソースポリシーがある必要があります。
  9. 証跡が適切であることを確認したら、[作成] を選択します。

これで下準備はOKです。


CloudWatch Eventsルールを作成する

次はCloudWatch Eventsを作成します。

CloudWatchコンソールのサイドバーから イベント>ルール を開き、ルールの作成 をクリックし、CloudWatch Eventsルールを作成します。

今回は以下のようにして、S3バケットcloudwatch-events-trigger-testに対するPutObjectを検知するようにしました。

f:id:BioErrorLog:20201017175155p:plain
特定のS3バケットへのPutObjectを検知するように設定する

しかしこれだけでは、CloudWatch Eventsのトリガー元をS3バケットに絞ることが出来ても、オブジェクトレベルでは絞れていません。 オブジェクトレベルで絞るには、イベントパターンを編集する必要があります。

以下のように、イベントパターンのrequestParameterskeyを追加して指定することで、オブジェクトを指定することが出来ます。

{
  "source": [
    "aws.s3"
  ],
  "detail-type": [
    "AWS API Call via CloudTrail"
  ],
  "detail": {
    "eventSource": [
      "s3.amazonaws.com"
    ],
    "eventName": [
      "PutObject"
    ],
    "requestParameters": {
      "bucketName": [
        "cloudwatch-events-trigger-test"
      ],
      "key": [
        "target/object"
      ]
    }
  }
}

上の例では、S3バケットcloudwatch-events-trigger-testに、オブジェクトtarget/objectPutObjectされたときに、このCloudWatch Eventsが起動することになります。

ここまでイベントソースを指定したら、今度はCloudWatch Eventsのターゲットを指定します。 今回は検証としてCloudWatch Evetnsが起動したことが分かればよいので、CloudWatch ロググループをターゲットとして指定しました。

CloudWatch Eventsルールの作成は以上です。


CloudWatch Eventsの動作確認

それでは、S3に該当オブジェクトをアップロードして、CloudWatch Eventsが起動されることを確認します。 今回は、ターゲットとしたCloudWatchロググループを確認して、CloudWatch Eventsが起動されたかどうかを見ました。

まず、S3バケットcloudwatch-events-trigger-testに、オブジェクトtarget/objectをアップロードします。 CloudWatch Eventsが1回起動されることが予想されます。

f:id:BioErrorLog:20201017185210p:plain
S3バケット`cloudwatch-events-trigger-test`に、オブジェクト`target/object`をアップロード

想定通り、CloudWatch Eventsが起動されました。


つぎは、S3バケットcloudwatch-events-trigger-testに、オブジェクトtarget/object-dummyをアップロードします。 CloudWatch Eventsが起動されない(= ログは1つのまま)ことが予想されます。

f:id:BioErrorLog:20201017185420p:plain
S3バケット`cloudwatch-events-trigger-test`に、オブジェクト`target/object-dummy`をアップロード

想定通り、CloudWatch Eventsは起動されませんでした。

以上、S3への特定オブジェクトのアップロードをトリガーに、CloudWatch Eventsを起動する方法を見ました。


補足: ワイルドカードの使用

補足として、オブジェクト名の指定にワイルドカードが使用できるのか、を調べました。

CloudWatch Eventsルールの定義としてkeyの値にワイルドカードを使用し、特定パス下にオブジェクトが配置された際にCloudWatch Eventsを起動することが出来るか、が気になったためです。

CloudWatch Eventsルールのイベントパターンを編集し、keyの値をtarget/*とします。

{
  "source": [
    "aws.s3"
  ],
  "detail-type": [
    "AWS API Call via CloudTrail"
  ],
  "detail": {
    "eventSource": [
      "s3.amazonaws.com"
    ],
    "eventName": [
      "PutObject"
    ],
    "requestParameters": {
      "bucketName": [
        "cloudwatch-events-trigger-test"
      ],
      "key": [
        "target/*"
      ]
    }
  }
}

これで、target/パス下に任意のオブジェクトが配置されたときにCloudWatch Eventsが呼び出されるか、を調べました。

結果、CloudWatch Eventsは呼び出されませんでした。

keyの値にワイルドカードを使用することは、今のところはできないようです。

ドキュメントをあたってみても、登録されていないフィールドには "": "" ワイルドカードが補完される、という記述はあるものの、値の指定にワイルドカードを使用することについては記載が見当たりませんでした。

Event Patterns in CloudWatch Events - Amazon CloudWatch Events


おわりに

今回は、CloudWatch EventsでS3への特定オブジェクトのアップロードを検知する方法を書きました。

CloudWatch Eventsの特性上、できるんだろうなとは思っていました。 が、いざ手を動かしてやってみると、CloudTrail証跡を介在して起動していたり、ワイルドカードが使用できなかったりと、意外と理解の抜けていた部分が判明しました。

なお、最近ではCloudWatch EventsよりもEventBridgeの使用が推奨されている雰囲気を感じます。 いずれはEventBridgeの理解も深めていきたいと思っています。


関連記事

AWS CLIのs3 cpとs3 syncの違いをまとめました。 www.bioerrorlog.work


S3バケットポリシーとIAMポリシーの関係を、同一アカウント・クロスアカウントそれぞれにおいて整理しました。 www.bioerrorlog.work


参考

What Is Amazon CloudWatch? - Amazon CloudWatch

CloudWatch Events Event Examples From Supported Services - Amazon CloudWatch Events

Configuring Amazon S3 event notifications - Amazon Simple Storage Service

Event Patterns in CloudWatch Events - Amazon CloudWatch Events

Amplify & GraphQLでのデータモデル設計事例集

AWS Amplify & GraphQLでのデータモデル (スキーマ) 設計例をまとめます。


はじめに

こんにちは、@bioerrorlogです。

最近、AWS Amplifyに注目してします。
Amplifyはフルスタックなサーバレスアプリを素早く作ることが出来るプラットフォームで、プロダクト開発の生産性を高めることが出来ます。

AmplifyプロジェクトのAPIを GraphQL (AppSync)で構築するときには、データモデルをスキーマschema.graphqlに定義する流れになります。 このGraphQLのスキーマ設計はリレーショナルデータベースでのデータモデル設計とはやり方が異なるため、最初は慣れが必要です。

今回は、Amplifyのdeveloper advocateであるNader Dabit氏による以下の記事を参考に、GraphQL & Amplifyでのスキーマ設計例を学んでいきます。

GraphQL Recipes (V2) - Building APIs with GraphQL Transform - DEV Community 👩‍💻👨‍💻

以下の順にスキーマ例を見ていきます。

  1. Todoアプリ
  2. イベントアプリ
  3. チャットアプリ
  4. Eコマースアプリ
  5. WhatsAppクローン
  6. Redditクローン
  7. マルチユーザーチャットアプリ
  8. インスタグラムクローン
  9. カンファレンスアプリ


スキーマ設計例

Todoアプリ

type Todo @model {
  id: ID!
  name: String!
  description: String
}

Todoアプリにおけるユーザーの行動:

  • 全TodoをList取得できる
  • TodoをCreate/Update/Deleteできる

その実現に必要な要件:

  • Todo type
  • データベースの作成
  • GraphQL mutations(create/update/delete)の定義
  • GraphQL queries(listTodos)の定義
  • 各処理のためのGraphQL resolversの作成


まずはシンプルなTodoアプリの例です。 上記のスキーマから、GraphQL API(AppSync)やデータベース(DynamoDB)の作成、GraphQL resolversの作成、GraphQL queries/mutations/subscriptionの定義がデプロイできます。


イベントアプリ

type Event @model
  @key(name: "itemType", fields: ["itemType", "time"], queryField: "eventsByDate")
  @auth(rules: [
    { allow: groups, groups: ["Admin"] },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
    id: ID!
    name: String!
    description: String
    time: String!
    itemType: String!
    comments: [Comment] @connection #optional comments field
}

# Optional Comment type
type Comment @model
  @auth(rules: [
    { allow: owner, ownerField: "author" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  message: String!
  author: String
}

イベントアプリにおけるユーザーの行動:

  • 日付順でイベントをList取得できる
  • 個々のイベントを確認できる
  • イベントに付けられたコメントを確認できる
  • サインインしたユーザーはコメントを作成できる
  • 管理者はイベントを作成できる
  • 管理者はイベントを更新/削除できる


directives(@key, @auth, @connection )を活用して、上記の要求を満たすスキーマが設計されています。

@keyによる定義によって、日付順のイベントList取得を可能にしています。
@keyの使い方はこちら

@connectionを用いたデータモデル間関係の定義によって、イベントに付けられたコメントの確認を可能にしています。
@connection の使い方はこちら

@authを用いた権限設定によって、ユーザー毎に可能な操作を制限しています。
@auth の使い方はこちら


イベントを作成するときは、以下のようなcreate mutationを発行します。

mutation createEvent {
  createEvent(input: {
    name: "Rap battle of the ages"
    description: "You don't want to miss this!"
    time: "2018-07-13T16:00:00Z"
    itemType: "Event"
  }) {
    id 
    name 
    description 
    time
  } 
}

itemTypeに一定の値"Event"を格納しておくことで、以下のように日付順にソートしたイベントのListが取得出来ます。

query listEvents {
  eventsByDate(itemType: "Event") {
    items {
      id
      name
      description
      time
    }
  }
}


イベントに紐づいたコメントを作成するには、以下のようなcreate mutationを発行します。

mutation createComment {
  createComment(input: {
    eventCommentsId: "7f0d82f5-b57e-4417-b515-ce04475675a2"
    message:"Amazing!"
  }) {
    id
    message
  }
}

eventCommentsIdを指定することで、コメントを紐づけるイベントを指定することが出来ます。


チャットアプリ

ここではシンプルなチャットアプリについて書きます。 より現実的なチャットアプリについては、後述のマルチユーザーチャットアプリの章にて記述します。

type Conversation @model {
  id: ID!
  name: String
  messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"])
  createdAt: String
  updatedAt: String
}

type Message
  @model(subscriptions: null, queries: null)
  @key(name: "messagesByConversationId", fields: ["conversationId"]) {
  id: ID!
  conversationId: ID!
  content: String!
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
}

チャットアプリにおけるユーザーの行動:

  • 会話を作成する
  • 会話でメッセージを送る
  • 全ての会話とメッセージを閲覧する
  • 新規メッセージと会話をリアルタイムに確認(Subscribe)する


会話の作成、会話に対するメッセージの作成、全ての会話とメッセージの取得のためには、以下のquery / mutationを発行します。

# 会話の作成
mutation createConversation {
  createConversation(input: {
    name: "my first conversation"
  }) {
    name
    id
  }
}

# 会話に対するメッセージの作成
mutation createMessage {
  createMessage(input: {
    conversationId: "your-conversation-id"
    content: "Hello world"
  }) {
    id
    content
  }
}

# 全ての会話とメッセージの取得
query listConversations {
  listConversations {
    items {
      name
      messages {
        items {
          content
        }
      }
    }
  }
}


Eコマースアプリ

type Customer @model(subscriptions: null)
  @auth(rules: [
    { allow: owner },
    { allow: groups, groups: ["Admin"] }
  ]) {
  id: ID!
  name: String!
  email: String!
  address: String
  orders: [Order] @connection(keyName: "byCustomerId", fields: ["id"])
}

type Product @model(subscriptions: null)
  @auth(rules: [
    { allow: groups, groups: ["Admin"] },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  name: String!
  description: String
  price: Float!
  image: String
}

type LineItem @model(subscriptions: null)
  @key(name: "byOrderId", fields: ["orderId"])
  @auth(rules: [
   { allow: owner },
   { allow: groups, groups: ["Admin"] }
  ]) {
  id: ID!
  orderId: ID!
  productId: ID!
  qty: Int
  order: Order @connection(fields: ["orderId"])
  product: Product @connection(fields: ["productId"])
  description: String
  price: Float
  total: Float
}

type Order @model(subscriptions: null)
  @key(name: "byCustomerId", fields: ["customerId", "createdAt"], queryField: "ordersByCustomerId")
  @auth(rules: [
   { allow: owner },
   { allow: groups, groups: ["Admin"] }
  ]) {
  id: ID!
  customerId: ID!
  total: Float
  subtotal: Float
  tax: Float
  createdAt: String!
  customer: Customer @connection(fields: ["customerId"])
  lineItems: [LineItem] @connection(keyName: "byOrderId", fields: ["id"])
}

Eコマースアプリにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーは商品を閲覧できる
  • ユーザーは注文を作成できる
  • ユーザーは購入商品 (line item) を注文に追加できる
  • ユーザーは自身のアカウントとそれに紐づいた注文・商品を閲覧できる
  • 管理者は商品/注文/ユーザーを作成/更新/削除できる
  • 管理者は商品/注文/ユーザーを取得できる
  • 管理者はユーザーに紐づいた注文を取得できる


各操作には、以下のGraphQL mutations / queriesを発行します。

mutation createProduct {
  createProduct(input: {
    name: "Yeezys"
    description: "Best shoes ever"
    price: 200.00
  }) {
    id
    name
    description
    price
  }
}

mutation createCustomer {
  createCustomer(input: {
    name: "John Doe"
    email: "johndoe@myemail.com"
    address: "555 Hwy 88"
  }) {
    id
    email
    name
    address
  }
}

mutation createOrder {
  createOrder(input: {
    subtotal: 250.00
    total: 275.00
    tax: 25.00
    customerId: "some-customer-id"
  }) {
    id
    subtotal
    tax
    total
    customer {
      name
    }
  }
}

mutation createLineItem {
  createLineItem(input: {
    qty: 1
    productId: "some-product-id"
    orderId: "some-order-id"
    price: 250.00
    total: 250.00
  }) {
    id
    qty
  }
}

query getCustomer {
  getCustomer(id: "some-customer-id") {
    id
    name
    address
    orders {
      items {
        id
        lineItems {
          items {
            description
            price
            total
            qty
            product {
              id
              name
              description
            }
          }
        }
      }
    }
  }
}

query ordersByCustomerId {
  ordersByCustomerId(
    customerId: "some-customer-id"
  ) {
    items {
      id
      lineItems {
        items {
          id
          price
          total
        }
      }
      total
      subtotal
      tax
    }
  }
}

query listOrders {
  listOrders {
    items {
      id
      total
      subtotal
      tax
      lineItems {
        items {
          id
          price
          product {
            id
            price
            description
          }
        }
      }
    }
  }
}


WhatsAppクローン

type User
  @key(fields: ["userId"])
  @model(subscriptions: null)
  @auth(rules: [
    { allow: owner, ownerField: "userId" }
  ]) {
  userId: ID!
  avatar: String
  conversations: [ConvoLink] @connection(keyName: "conversationsByUserId", fields: ["userId"])
  messages: [Message] @connection(keyName: "messagesByUserId", fields: ["userId"])
  createdAt: String
  updatedAt: String
}

type Conversation
  @model(subscriptions: null)
  @auth(rules: [{ allow: owner, ownerField: "members" }]) {
  id: ID!
  messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"])
  associated: [ConvoLink] @connection(keyName: "convoLinksByConversationId", fields: ["id"])
  members: [String!]!
  createdAt: String
  updatedAt: String
}

type Message
  @key(name: "messagesByConversationId", fields: ["conversationId"])
  @key(name: "messagesByUserId", fields: ["userId"])
  @model(subscriptions: null, queries: null) {
  id: ID!
  userId: ID!
  conversationId: ID!
  author: User @connection(fields: ["userId"])
  content: String!
  image: String
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type ConvoLink
  @key(name: "convoLinksByConversationId", fields: ["conversationId"])
  @key(name: "conversationsByUserId", fields: ["userId"])
  @model(
    mutations: { create: "createConvoLink", update: "updateConvoLink" }
    queries: null
    subscriptions: null
  ) {
  id: ID!
  userId: ID!
  conversationId: ID!
  user: User @connection(fields: ["userId"])
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type Subscription {
  onCreateConvoLink(userId: ID): ConvoLink
    @aws_subscribe(mutations: ["createConvoLink"])
  onCreateMessage(conversationId: ID): Message
    @aws_subscribe(mutations: ["createMessage"])
}

WhatsAppクローンにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーはプロフィールのアバター画像を更新できる
  • ユーザーは会話を作成できる
  • ユーザーは会話でメッセージを送れる


Redditクローン

type User @model(subscriptions: null)
  @key(fields: ["userId"])
  @auth(rules: [
    { allow: owner, ownerField: "userId" }
  ]) {
  userId: ID!
  posts: [Post] @connection(keyName: "postByUser", fields: ["userId"])
  createdAt: String
  updatedAt: String
}

type Post @model
  @key(name: "postByUser", fields: ["authorId", "createdAt"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  authorId: ID!
  author: User @connection(fields: ["authorId"])
  postContent: String
  postImage: String
  comments: [Comment] @connection(keyName: "commentsByPostId", fields: ["id"])
  votes: [PostVote] @connection(keyName: "votesByPostId", fields: ["id"])
  createdAt: String
  voteCount: Int
}

type Comment @model
  @key(name: "commentsByPostId", fields: ["postId"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  authorId: ID!
  postId: ID!
  text: String!
  author: User @connection(fields: ["authorId"])
  votes: [CommentVote] @connection(keyName: "votesByCommentId", fields: ["id"])
  post: Post @connection(fields: ["postId"])
  voteCount: Int
}

type PostVote @model
  @auth(rules: [
    { allow: owner, ownerField: "userId"},
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "votesByPostId", fields: ["postId"]) {
  id: ID!
  postId: ID!
  userId: ID!
  post: Post @connection(fields: ["postId"])
  createdAt: String!
  vote: VoteType
}

type CommentVote @model
  @auth(rules: [
    { allow: owner, ownerField: "userId"},
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "votesByCommentId", fields: ["commentId"]) {
  id: ID!
  userId: ID!
  commentId: ID!
  comment: Comment @connection(fields: ["commentId"])
  createdAt: String!
  vote: VoteType
}

input VoteInput {
  type: VoteType!
  id: ID!
}

enum VoteType {
  up
  down
}

Redditクローンにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーはポスト(テキストor画像)を作成できる
  • ユーザーはポストにコメントできる
  • ユーザーはポストをVoteできる
  • ユーザーはコメントをVoteできる


ここで、カスタムresolverを実装してVoteのIdpostIduserIdの組み合わせにすることで、ユーザーが一つのポストに複数回Voteするのを防ぐことが出来ます。

カスタムresolverの追加実装例は以下です。

#set($itemId = "$context.identity.username#$context.args.postId")
$util.qr($context.args.input.put("id", $util.defaultIfNull($ctx.args.input.id, $itemId)))

そしてVoteの上書きを回避するための以下のコードがあれば、削除orコメントアウトします。

#set( $condition = {
  "expression": "attribute_not_exists(#id)",
  "expressionNames": {
      "#id": "id"
  }
} )


また、Voteカウントを算出したければ、DynamoDBトリガーのLambda関数を作成し、カスタムロジックを実装する必要があります。
Lambda実装例はこちら


マルチユーザーチャットアプリ

type User
  @key(fields: ["userId"])
  @model(subscriptions: null)
  @auth(rules: [
    { allow: owner, ownerField: "userId" }
  ]) {
  userId: ID!
  conversations: [ConvoLink] @connection(keyName: "conversationsByUserId", fields: ["userId"])
  messages: [Message] @connection(keyName: "messagesByUserId", fields: ["userId"])
  createdAt: String
  updatedAt: String
}

type Conversation
  @model(subscriptions: null)
  @auth(rules: [{ allow: owner, ownerField: "members" }]) {
  id: ID!
  messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"])
  associated: [ConvoLink] @connection(keyName: "convoLinksByConversationId", fields: ["id"])
  members: [String!]!
  createdAt: String
  updatedAt: String
}

type Message
  @key(name: "messagesByConversationId", fields: ["conversationId"])
  @key(name: "messagesByUserId", fields: ["userId"])
  @model(subscriptions: null, queries: null) {
  id: ID!
  userId: ID!
  conversationId: ID!
  author: User @connection(fields: ["userId"])
  content: String!
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type ConvoLink
  @key(name: "convoLinksByConversationId", fields: ["conversationId"])
  @key(name: "conversationsByUserId", fields: ["userId"])
  @model(
    mutations: { create: "createConvoLink", update: "updateConvoLink" }
    queries: null
    subscriptions: null
  ) {
  id: ID!
  userId: ID!
  conversationId: ID!
  user: User @connection(fields: ["userId"])
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type Subscription {
  onCreateConvoLink(userId: ID): ConvoLink
    @aws_subscribe(mutations: ["createConvoLink"])
  onCreateMessage(conversationId: ID): Message
    @aws_subscribe(mutations: ["createMessage"])
}

マルチユーザーチャットアプリにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーは会話を作成できる
  • ユーザーは会話でメッセージを送れる
  • ユーザーは会話のリストを閲覧できる
  • ユーザーはほかのユーザーとの会話を作成できる


インスタグラムクローン

type User @model(subscriptions: null)
  @key(fields: ["userId"])
  @auth(rules: [
    { allow: owner, ownerField: "userId" },
    { allow: private, operations: [read] }
    ]) {
  userId: ID!
  posts: [Post] @connection(keyName: "postsByUserId", fields: ["userId"])
  createdAt: String
  updatedAt: String
  following: [Following] @connection(keyName: "followingByUserId", fields: ["userId"])
}

type Post @model
  @key(name: "postsByUserId", fields: ["authorId"])
  @auth(rules: [
    { allow: owner ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  authorId: ID!
  content: String!
  postImage: String
  author: User @connection(fields: ["authorId"])
  comments: [Comment] @connection(keyName: "commentsByPostId", fields: ["id"])
  likes: [PostLike] @connection(keyName: "postLikesByPostId", fields: ["id"])
}

type Comment @model
  @key(name: "commentsByPostId", fields: ["postId"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  postId: ID!
  authorId: ID!
  text: String!
  likes: [CommentLike] @connection(keyName: "commentLikesByCommentId", fields: ["id"])
  author: User @connection(fields: ["authorId"])
  post: Post @connection(fields: ["postId"])
}

type PostLike @model
  @auth(rules: [
    { allow: owner, ownerField: "userId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "postLikesByPostId", fields: ["postId"])
  @key(name: "postLikesByUser", fields: ["userId", "createdAt"], queryField: "likesByUser") {
  id: ID!
  postId: ID!
  userId: ID!
  user: User @connection(fields: ["userId"])
  post: Post @connection(fields: ["postId"])
  createdAt: String!
}

type CommentLike @model
  @auth(rules: [
    { allow: owner, ownerField: "userId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "commentLikesByCommentId", fields: ["commentId"])
  @key(name: "commentLikesByUser", fields: ["userId", "createdAt"], queryField: "likesByUser") {
  id: ID!
  userId: ID!
  postId: ID!
  commentId: ID!
  user: User @connection(fields: ["userId"])
  post: Post @connection(fields: ["postId"])
  createdAt: String!
}

type Following @model
  @auth(rules: [
    { allow: owner, ownerField: "followerId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "followingByUserId", fields: ["followerId"]) {
  id: ID
  followerId: ID!
  followingId: ID!
  follower: User @connection(fields: ["followerId"])
  following: User @connection(fields: ["followingId"])
  createdAt: String!
}

インスタグラムクローンにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーはポストを作成できる
  • ユーザーはポストにコメントを付けれる
  • ユーザーは他のユーザーをフォロー/フォロー解除できる
  • ユーザーはポスト/コメントに"いいね"を付けれる

"いいね"機能の実装については、Redditクローンと同様、カスタムresolverを実装する必要があります。
詳しくはRedditクローンの章を参照。


カンファレンスアプリ

type Talk @model
  @auth(rules: [
    { allow: groups, groups: ["Admin"] },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  name: String!
  speakerName: String!
  speakerBio: String!
  time: String
  timeStamp: String
  date: String
  location: String
  summary: String!
  twitter: String
  github: String
  speakerAvatar: String
  comments: [Comment] @connection(keyName: "commentsByTalkId", fields: ["id"])
}

type Comment @model
  @key(name: "commentsByTalkId", fields: ["talkId"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
{
  id: ID!
  talkId: ID!
  talk: Talk @connection(fields: ["talkId"])
  message: String
  createdAt: String
  authorId: ID!
  deviceId: ID
}

type Report @model
  @auth(rules: [
    { allow: owner, operations: [create, update, delete] },
    { allow: groups, groups: ["Admin"] }
  ])
  {
  id: ID!
  commentId: ID!
  comment: String!
  talkTitle: String!
  deviceId: ID
}

type ModelCommentConnection {
  items: [Comment]
  nextToken: String
}

type Query {
  listCommentsByTalkId(talkId: ID!): ModelCommentConnection
}

type Subscription {
  onCreateCommentWithId(talkId: ID!): Comment
        @aws_subscribe(mutations: ["createComment"])
}

カンファレンスアプリにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーは講演のリストを閲覧できる
  • ユーザーは個々の講演を閲覧できる
  • ユーザーは講演にコメントを付けれる
  • (オプション)ユーザーはコメントを報告できる

なお、追加で定義しているsubscriptiononCreateCommentWithIdによって、閲覧中の講演に対するコメントのみをsubscribeすることが出来ます。

React Nativeで構築したカンファレンスアプリの例はこちら


おわりに

以上、各アプリを題材にしたGraphQLスキーマの設計例を見てきました。

GraphQLに馴染みのないままスキーマを設計するのは難しい作業です。 今回は、習うより慣れよ、の精神で大量のスキーマ設計を読み込みました。

個人的には大いに勉強になりましたが、これがどなたかの役にも立てれば幸いです。


関連記事

www.bioerrorlog.work

www.bioerrorlog.work


参考

GraphQL Recipes (V2) - Building APIs with GraphQL Transform - DEV Community 👩‍💻👨‍💻

API (GraphQL) - Directives | Amplify Docs

dynamodb-triggers/counter.js at master · dabit3/dynamodb-triggers · GitHub

GitHub - full-stack-serverless/conference-app-in-a-box: Full stack & cross platform app customizable & themeable for any event or conference.


Amplify & AppSyncで時系列データを日付範囲でList取得する

AWS Amplify (AppSync)で、時系列データをある日付の範囲でList取得する方法を記します。


はじめに

おはよう。@bioerrorlogです。

例えば以下のようなGraphQL schemaでデータモデルTimeStampが定義されてるとします。

type TimeStamp @model 
@key(fields: ["id", "createdAt"]){
  id: ID!
  createdAt: AWSDateTime!
}

このようなモデルをcreatedAtに対して一定の日付範囲で取得するやり方を備忘録に残します。


Amplify & AppSyncで時系列データを日付範囲でList取得する

ケース1: 全List取得に対する日付範囲での絞りこみ

全List取得に対してある一定の日付範囲で絞りこむには、以下のようにfilterを用いたQueryが使えます:

query MyQuery {
  listTimeStamps(filter: {createdAt: {between: ["2020-09-14T00:00:00.000Z", "2020-09-16T00:00:00.000Z"]}}) {
    items {
      id
      createdAt
    }
    nextToken
  }
}

上記Queryに対しては、以下のようにcreatedAtが絞られたListデータが取得できます:

{
  "data": {
    "listTimeStamps": {
      "items": [
        {
          "id": "0001",
          "createdAt": "2020-09-14T22:12:30.461Z"
        },
        {
          "id": "0001",
          "createdAt": "2020-09-15T22:11:30.461Z"
        },
        {
          "id": "0002",
          "createdAt": "2020-09-14T22:10:30.461Z"
        },
        {
          "id": "0002",
          "createdAt": "2020-09-15T22:10:30.461Z"
        }
      ],
      "nextToken": null
    }
  }
}


[関連記事] Amplify & GraphQLでのデータモデル設計事例集


ケース2: Partition keyを指定した上での日付範囲の絞りこみ

特定のPartition key (今回はid) を指定した上で日付範囲を絞ったデータを取得するには、以下のようなQueryが使えます:

query MyQuery {
  listTimeStamps(id: "0001", createdAt: {between: ["2020-09-14T00:00:00.000Z", "2020-09-16T00:00:00.000Z"]}) {
    items {
      id
      createdAt
    }
    nextToken
  }
}

上記Queryに対して、以下のようにidcreatedAtが絞られたListデータが取得できます:

{
  "data": {
    "listTimeStamps": {
      "items": [
        {
          "id": "0001",
          "createdAt": "2020-09-14T22:12:30.461Z"
        },
        {
          "id": "0001",
          "createdAt": "2020-09-15T22:11:30.461Z"
        }
      ],
      "nextToken": null
    }
  }
}


[関連記事] Amplifyプロジェクトのgitリポジトリを公開するときの注意点


使用データ

今回使用した全データは以下です:

{
  "data": {
    "listTimeStamps": {
      "items": [
        {
          "id": "0001",
          "createdAt": "2020-09-13T22:12:30.461Z"
        },
        {
          "id": "0001",
          "createdAt": "2020-09-14T22:12:30.461Z"
        },
        {
          "id": "0001",
          "createdAt": "2020-09-15T22:11:30.461Z"
        },
        {
          "id": "0001",
          "createdAt": "2020-09-16T11:11:11.111Z"
        },
        {
          "id": "0002",
          "createdAt": "2020-09-13T21:12:30.461Z"
        },
        {
          "id": "0002",
          "createdAt": "2020-09-14T22:10:30.461Z"
        },
        {
          "id": "0002",
          "createdAt": "2020-09-15T22:10:30.461Z"
        },
        {
          "id": "0002",
          "createdAt": "2020-09-16T10:11:11.111Z"
        }
      ],
      "nextToken": null
    }
  }
}


おわりに

以上、Amplify & AppSyncで時系列データを日付範囲でList取得する方法を記しました。

背景として、日付範囲を絞って取得するにはElasticSearchを噛ませる必要があるのか?などと色々迷った経緯があります。 結果としてElasticSearchを利用しなくとも日付範囲を絞った取得が出来たので、今回のメモを書くに至りました。

ただ、処理コスト面やfilterを使う場合と使わない場合の違いなど、まだ理解していない部分はたくさんあります。

引き続き分からないところは調べていく所存です。


参考

Data Modeling in Depth with GraphQL & AWS Amplify - 17 Data Access Patterns - DEV Community

Amplify Framework Documentation

elasticsearch - AppSync - query for all items created within a date range? - Stack Overflow

Amplifyプロジェクトのgitリポジトリを公開するときの注意点

AWS Amplifyプロジェクトのgitリポジトリをパブリックにするときのセキュリティ上の注意点についての備忘録です。

一言で言うと、team-provider-info.jsonをgit管理から外すべし、となります。


はじめに

おはよう。@bioerrorlogです。

最近、Amplifyをよく使います。

主にReact + Amplifyの組み合わせで開発していますが、APIの作成やCognito連携、Hosting環境やCI/CDパイプライン立ち上げなどが極めて容易にできるため、とても有用なサービスだと感じています。

ここでふと、AmplifyプロジェクトをGitHubなどで公開しようと思ったとき、リポジトリをそのままpushしていいものだろうかと不安になりました。

もともとAmplifyによって.gitignoreが追記され、いくつかのファイルが除外されてはいますが、全世界にgitを公開する場合には、それだけでは不十分なのではないか?ということです。

今回は、Amplifyリポジトリを公開する際にgitから除外すべきファイルを調べました。


作業環境

Amplify CLIバージョン

$ amplify --version
4.21.0


Amplifyプロジェクト環境: Javascript / React

$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project amplify
? Enter a name for the environment dev
? Choose your default editor: None
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start


Amplifyプロジェクトのgitリポジトリを公開するときの注意点

Amplifyの.gitignoreファイル

まず、Amplifyによって生成される.gitignoreを確認します。
ここに記載されたファイルはgit管理されないので、機密な情報が含まれていても問題ありません。

# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*

#amplify
amplify/\#current-cloud-backend
amplify/.config/local-*
amplify/mock-data
amplify/backend/amplify-meta.json
amplify/backend/awscloudformation
build/
dist/
node_modules/
aws-exports.js
awsconfiguration.json
amplifyconfiguration.json
amplify-build-config.json
amplify-gradle-config.json
amplifytools.xcconfig

amplify-meta.jsonなど、アカウント情報が記載されたファイルがgit管理から排除されているのが確認できます。


git管理されるAmplify関連ファイル

次に、git管理に含まれるファイルを以下の手順で確認してみます。

  1. amplify add authamplify add apiでAPIとAuthを作成
  2. amplify pushでamplifyバックエンドを構築
  3. これら処理によってgit管理に追加されたファイルを確認

結果、次のファイルがgitに追加されました。

new file:   .graphqlconfig.yml
new file:   amplify/.config/project-config.json
new file:   amplify/backend/api/amplify/parameters.json
new file:   amplify/backend/api/amplify/schema.graphql
new file:   amplify/backend/api/amplify/stacks/CustomResources.json
new file:   amplify/backend/api/amplify/transform.conf.json
new file:   amplify/backend/auth/amplifyc38b6d9a/amplifyc38b6d9a-cloudformation-template.yml
new file:   amplify/backend/auth/amplifyc38b6d9a/parameters.json
new file:   amplify/backend/backend-config.json
new file:   amplify/team-provider-info.json
new file:   src/graphql/mutations.js
new file:   src/graphql/queries.js
new file:   src/graphql/schema.json
new file:   src/graphql/subscriptions.js

これらファイルを上から確認していきます。

  • .graphqlconfig.yml
    GraphQLのスキーマファイルのパスなどを記載

  • amplify/.config/project-config.json
    プロジェクト名やビルドコマンドなどを記載

  • amplify/backend/api/amplify/parameters.json
    API設定や認証用Cognito情報などを記載

  • amplify/backend/api/amplify/schema.graphql
    GraphQLスキーマを定義

  • amplify/backend/api/amplify/stacks/CustomResources.json
    CloudFormationテンプレートファイル

  • amplify/backend/api/amplify/transform.conf.json
    何かの設定が記載:
    { "Version": 5, "ElasticsearchWarning": true }

  • amplify/backend/auth/amplifyc38b6d9a/amplifyc38b6d9a-cloudformation-template.yml
    CloudFormationテンプレートファイル

  • amplify/backend/auth/amplifyc38b6d9a/parameters.json
    Auth用の各設定が記載

  • amplify/backend/backend-config.json
    作成したバックエンドカテゴリ情報が記載

  • amplify/team-provider-info.json
    各リソースのARNなどが記載

  • src/graphql/*
    GraphQLのquery/mutation/subscription定義

ここで、amplify/team-provider-info.jsonにリソースのARNなどが記載されている点には注意が必要でしょう。 Secret Keyなどが直接書かれているわけではありませんが、AWS Account IdやAmplifyAppIdなどが記載されています。

gitを公開する際は、amplify/team-provider-info.jsonは、除いた方が良いでしょう。

結論: team-provider-info.jsonはgit管理から外すべし

以上、ここまで見てきたように、全世界にgitを公開する場合にはteam-provider-info.jsonを外すべきかと思います。

Issueでの議論でも、Amplifyの中の人が次のように発言しています。

The purpose of this file is basically sharing it within your team-members on the same project and want to update/use the same AWS Infrastructure tied to an environment. If you're open-sourcing your project you can totally get rid of this file (or make it a part of .gitignore).

意訳すると、次のような感じでしょうか。
「このファイル(team-provider-info.json)の基本的な役割は、プロジェクトのチームメンバーとバックエンド環境を連携させることです。 もしプロジェクトをオープンソース化するなら、このファイルは削除するか.gitignoreに含めるといいでしょう。」

この先Amplifyプロジェクトを公開するときは、このことを留意したいと思います。


関連記事

www.bioerrorlog.work

www.bioerrorlog.work


参考

Security best practices for Amplify app in a public repo · Issue #1779 · aws-amplify/amplify-cli · GitHub

Amplify Framework Documentation

AWS CDK をアップデートする | Cloud9での注意点

AWS CDKのバージョンアップ方法を記します。
また、Cloud9でCDKをバージョンアップする際の注意点についても記述します。

はじめに

CDKは非常に活発に開発されています。

CDKのリリースを見るに、だいたい一週間ぐらいの間隔で新バージョンがリリースされているようです(下図)。

CDK バージョン リリース日
v1.35.0 2020-04-23
v1.34.0 2020-04-21
v1.33.0 2020-04-17
v1.32.0 2020-04-10
v1.31.0 2020-03-24
v1.30.0 2020-03-18

CDKをアップデートするとき、特にCloud9で作業する場合に、少し躓くポイントがありました。

今回は、それらCDKアップデート方法をメモします。

作業環境

Ubuntu上で動作確認を行いました。

$ lsb_release -d
Description:    Ubuntu 18.04.4 LTS

CDK をアップデートする

一般的なCDKアップデート方法

npm install -g aws-cdkとしてしてインストールされた一般的なケース*1では、CDKのアップデート方法は単純です。

普通にnpmパッケージをアップデートするように、npm updateコマンドでCDKパッケージaws-cdkがアップデートできます。

sudo npm update -g aws-cdk


CDKバージョンを確認すると、現時点での最新バージョン(v1.35.0)にアップデートされていることが確認できます。

$ cdk --version
1.32.2 (build e19e206)

$ sudo npm update -g aws-cdk

$ cdk --version
1.35.0 (build e0810c8)

Cloud9でのCDKアップデート方法

Cloud9の場合、少し注意が必要です。
結論から言うと、CDKパッケージとしてaws-cdkではなくcdkを指定することでCDKのアップデートができます。

npm update -g cdk


どういうことかというと、Cloud9でプリインストールされているのはaws-cdkパッケージ*2ではなくcdkパッケージ*3である、ということのようです。


普通にnpm install -g aws-cdkでCDKをインストールした場合、次のようにaws-cdkパッケージはあってもcdkパッケージはありません。

$ npm ls -g aws-cdk
/home/ubuntu/.nvm/versions/node/v10.20.1/lib
└── aws-cdk@1.35.0 

$ npm ls -g cdk
/home/ubuntu/.nvm/versions/node/v10.20.1/lib
└── (empty)


一方、Cloud9でaws-cdkパッケージとcdkパッケージを調べると、次のようになります。

$ npm ls -g cdk
/home/ubuntu/.nvm/versions/node/v10.20.1/lib
└── cdk@1.35.0 

$ npm ls -g aws-cdk  
/home/ubuntu/.nvm/versions/node/v10.20.1/lib
└─┬ cdk@1.35.0
  └── aws-cdk@1.35.0 


つまり、Cloud9でプリインストールされているのはcdkパッケージであり、aws-cdkはその一部に過ぎない、ということのようです。

よって、
npm update -g aws-cdkは上手くいかず、
npm update -g cdkなら上手くいく、という訳です。

おわりに

今回は、CDKのアップデート方法を記しました。

Cloud9では何故かアップデートできない、というところが始まりでしたが、aws-cdkではなくcdkパッケージがインストールされていた、というのはなかなか面白い不思議ポイントでした。

なぜcdkパッケージの方がプリインストールされているのか、aws-cdkパッケージとcdkパッケージの違いは何なのか、など色々と気になるところがあります。

折を見て調べていきたいです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

Releases · aws/aws-cdk · GitHub

Troubleshooting common AWS CDK issues - AWS Cloud Development Kit (AWS CDK) v2

*1:ドキュメントでは、このインストール方法が推奨されています。

*2:https://www.npmjs.com/package/aws-cdk

*3:https://www.npmjs.com/package/cdk

AWS ChaliceアプリケーションをCDKでデプロイする | cdk-chalice

ChaliceアプリケーションをCDKでデプロイする方法を記します。


はじめに

おはよう。@bioerrorlogです。

先日、AWS ChaliceをCDKでデプロイする方法を紹介する記事がAWSの公式ブログにポストされました

私はまだChaliceを触ったことがありませんでしたので、この記事を参考にChaliceアプリケーションをCDKでデプロイしてみます。


なお、本記事のコードはこちらにまとめています。

github.com


AWS Chaliceとは

f:id:BioErrorLog:20200207073536p:plain
画像はAWS Chalice GitHubより引用

Chaliceは、サーバレスアプリケーションのフレームワークです。

Chaliceを使用することで、API GatewayとLambdaを使用するアプリをすぐにデプロイすることができます。

以下、Chaliceについて参考になりそうな資料:


作業環境

作業はUbuntu上のCloud9で行いました。

各バージョンは以下の通りです。

$ lsb_release -d
Description:    Ubuntu 18.04.4 LTS

$ python --version
Python 3.7.5
# 後述のように、Python3.6以下だとエラーが発生するので注意

$ cdk --version
1.31.0 (build 8f3ac79)

$ chalice --version
chalice 1.13.1, python 3.7.5, linux 4.15.0-1065-aws

$ pip show boto3
Name: boto3
Version: 1.12.39


実行手順

前準備: Python3.7のインストール

まず、前準備としてPython3.7をインストールします。

Python3.6以前のバージョンだと、cdk synth時にcdk-chaliceパッケージで以下のようなエラーが発生してしまうためです*1

RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII as encoding for the environment. Consult https://click.palletsprojects.com/python3/ for mitigation steps.

This system supports the C.UTF-8 locale which is recommended. You might be able to resolve your issue by exporting the following environment variables:

    export LC_ALL=C.UTF-8
    export LANG=C.UTF-8

そしてこちら、単純にエラーメッセージに示される解決策 export LC_ALL=C.UTF-8export LANG=C.UTF-8 を実行しても問題は解決しませんでした。


このエラーは、Python3.7をインストールすることで解消します。

以下、Python3.7のインストールと、それに伴い必要となるパッケージをインストールする手順です。

# Python3.7 インストール
sudo apt update
sudo apt install python3.7

# python3.7-venvのインストール
sudo apt install python3.7-venv


前準備は以上です。


Chaliceプロジェクト作成

それではまず、以下のコマンドでChaliceプロジェクトを作成していきます。

# 前準備: CDKのインストール
npm install -g aws-cdk

# ワークスペースの作成
mkdir users-service
cd users-service

# Python仮想環境の作成とアクティブ化
python3.7 -m venv .venv # Python3.7で実行する
source .venv/bin/activate

# Chaliceインストールとプロジェクトの作成
pip install chalice
chalice new-project web-api
cd web-api


作成したChaliceプロジェクトのディレクトリ構造は以下の通りです。

$ tree -a
web-api
├── app.py
├── .chalice
│   └── config.json
├── .gitignore
└── requirements.txt

1 directory, 4 files


あとは、とりあえず必要なツールをrequirements.txtに放り込んでインストールすれば、プロジェクトの準備はおしまいです。

echo "boto3" > requirements.txt
echo "chalice" >> requirements.txt
pip install -r requirements.txt


Chalice appコード

つぎは、Chaliceのコードを書いていきます。
Chaliceプロジェクトの中に作成されたapp.pyに、以下のPythonコードを書き込みます。

import os
import boto3
from chalice import Chalice

# アプリケーションオブジェクト作成
app = Chalice(app_name='web-api')

# DynamoDBの取得
dynamodb = boto3.resource('dynamodb')
dynamodb_table = dynamodb.Table(os.environ['DYNAMODB_TABLE_NAME'])


# 以下、エンドポイントの設定
# @app.routeに渡した情報がAPI Gatewayに設定される
@app.route('/users', methods=['POST'])
def create_user():
    user = app.current_request.json_body
    dynamodb_table.put_item(Item=user)
    return user

@app.route('/users/{username}', methods=['GET'])
def get_user(username):
    response = dynamodb_table.get_item(Key={'username': username})
    return response['Item']

@app.route('/users/{username}', methods=['DELETE'])
def delete_user(username):
    dynamodb_table.delete_item(Key={'username': username})


DynamoDBテーブルへcreate, get, deleteするAPIが定義されました。
デコレータ@app.routeに渡した情報が、API Gatewayに設定されるという仕組みです。


次は、このChaliceアプリケーションで利用されるAWSリソースをCDKでデプロイします。


CDKプロジェクトの作成

まずはCDKプロジェクトを作成し、下準備を済ませます。

# ワークスペースの作成
cd ..
mkdir infra
cd infra

# CDKプロジェクトの作成
cdk init --language python --generate-only

# デフォルトのinfraファイルは削除して、後から新しく作成する
rm -rf infra
# Construct libraryを作るわけではないのでsetup.pyは不要
rm setup.py

# 必要なツールをrequirements.txtに放り込んでインストール
echo "aws-cdk.aws-dynamodb" > requirements.txt
echo "aws-cdk.core" >> requirements.txt
echo "cdk-chalice==0.6.0" >> requirements.txt # 現時点でのlatest version: 0.7.0ではインストールに失敗した
pip install -r requirements.txt

# 新規のstacksパッケージを作成
mkdir stacks
touch stacks/__init__.py
touch stacks/web_api.py


これで環境の下準備が整ったので、CDKコードを書いていきます。


CDKコード

まずは、先ほど作成したstacks/web_api.pyにCDK Stackを定義していきます。

import os

from aws_cdk import (
    aws_dynamodb as dynamodb,
    aws_iam as iam,
    core as cdk
)
from cdk_chalice import Chalice


class WebApi(cdk.Stack):

    def __init__(self, scope: cdk.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)
        
        # DynamoDBの作成
        partition_key = dynamodb.Attribute(name='username',
                                           type=dynamodb.AttributeType.STRING)
        self.dynamodb_table = dynamodb.Table(
            self, 'UsersTable', partition_key=partition_key,
            removal_policy=cdk.RemovalPolicy.DESTROY)
        cdk.CfnOutput(self, 'UsersTableName', value=self.dynamodb_table.table_name)


        # LambdaがDynamoDBにアクセスするためのIAMロールを作成
        lambda_service_principal = iam.ServicePrincipal('lambda.amazonaws.com')
        self.api_handler_iam_role = iam.Role(self, 'ApiHandlerLambdaRole',
                                             assumed_by=lambda_service_principal)
        self.dynamodb_table.grant_read_write_data(self.api_handler_iam_role)


        # web_api_source_dirはChaliceアプリケーションソースコードへのパス
        # ソースコードはChaliceによってパッケージングされ、
        # SAMテンプレートの作成とLambdaデプロイのためのZIP化が行われる
        web_api_source_dir = os.path.join(os.path.dirname(__file__), os.pardir,
                                          os.pardir, 'web-api')
        chalice_stage_config = self._create_chalice_stage_config()
        
        # Chaliceの作成
        self.chalice = Chalice(
            self, 'WebApi', source_dir=web_api_source_dir,
            stage_config=chalice_stage_config)

    # Chalice Stagesを定義
    def _create_chalice_stage_config(self):
        chalice_stage_config = {
            'api_gateway_stage': 'v1',
            'lambda_functions': {
                'api_handler': {
                    'manage_iam_role': False,
                    'iam_role_arn': self.api_handler_iam_role.role_arn,
                    'environment_variables': {
                        'DYNAMODB_TABLE_NAME': self.dynamodb_table.table_name
                    },
                    'lambda_memory_size': 128,
                    'lambda_timeout': 10
                }
            }
        }

        return chalice_stage_config


これで、DynamoDBとChaliceアプリケーションを作成するCDK Stack WebApiが定義されました。

次は、CDK StackをもとにCDKアプリケーションをinfra/app.pyに定義します。

import os
from aws_cdk import core as cdk
from stacks.web_api import WebApi

app = cdk.App()

# Stackの環境を定義
# 'CDK_DEFAULT_ACCOUNT'と'CDK_DEFAULT_REGION'からは現在のデフォルトが読み込まれる
dev_env = cdk.Environment(
    account=os.environ['CDK_DEFAULT_ACCOUNT'],
    region=os.environ['CDK_DEFAULT_REGION'])

# 本番環境はアカウントidとregionを指定
prod_eu_west_1_env = cdk.Environment(account='123456789012', region='eu-west-1')
prod_us_east_1_env = cdk.Environment(account='123456789012', region='us-east-1')

# Stackを定義
WebApi(app, 'WebApiDev', env=dev_env)
WebApi(app, 'WebApiProdEuWest1', env=prod_eu_west_1_env)
WebApi(app, 'WebApiProdUsEast1', env=prod_us_east_1_env)

app.synth()

これでCDKコードは完成です。

あとは、このCDKコードをデプロイしていきます。


CDKデプロイ

さっそく、CDKをデプロイしていきます。

# CDKテンプレート生成
cdk synth
# CDKデプロイ
cdk deploy WebApiDev

デプロイが成功すると、次のようなOutputsが返ってきます。

Outputs:
WebApiDev.UsersTableName = WebApiDev-UsersTable9725E9C8-1HVYW061T3POO
WebApiDev.APIHandlerArn = arn:aws:lambda:ap-northeast-1:123456789012:function:WebApiDev-APIHandler-10LR6FLOB1SGV
WebApiDev.APIHandlerName = WebApiDev-APIHandler-10LR6FLOB1SGV
WebApiDev.RestAPIId = 0oexyzw39c
WebApiDev.EndpointURL = https://0oexyzw39c.execute-api.ap-northeast-1.amazonaws.com/v1/


では、Outputsに表示されたEndpointURL + /usersに対してPOSTし、動作を確認してみます。

curl \
    -H "Content-Type: application/json" \
    -X POST \
    -d '{"username":"bioerrorlog", "email":"bioerrorlog@example.com"}' \
    https://0oexyzw39c.execute-api.ap-northeast-1.amazonaws.com/v1/users


次のようにレスポンスが帰ってくれば、アプリケーションのデプロイは成功です。

{"username":"bioerrorlog","email":"bioerrorlog@example.com"}


DynamoDBにも、ちゃんとデータが格納されていることが確認できました。

f:id:BioErrorLog:20200412182449p:plain


リソースの削除

作成したリソースを削除するのは、ごく簡単です。

以下のコマンドを実行します。

cdk destroy WebApiDev

成功すれば、今回CDKで作成したリソースが削除されます。


おわりに

今回は、CDKを用いてChaliceアプリケーションをデプロイしました。

Python3.6以下ではうまく機能しないなど躓きどころはありましたが、使いこなせればサーバレスアプリケーションの作成にかかる手間が削減できそうです。

とはいうものの、私はまだSAMやServerless Frameworkなどメジャーなサーバレスフレームワークすらまだよく知らないので、そちらの方も手を出していこうと思っています。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

Deploying AWS Chalice application using AWS Cloud Development Kit | AWS Developer Blog

GitHub - alexpulver/aws-cdk-sam-chalice: Example project for working with AWS CDK, AWS SAM and AWS Chalice

GitHub - aws/chalice: Python Serverless Microframework for AWS

GitHub - alexpulver/cdk-chalice: AWS CDK construct for AWS Chalice

*1:このエラーについては、別記事にもまとめています。

AWS CDK 基本コマンドまとめ

よく使うCDKの基本コマンドをまとめます。


はじめに

おはよう。@bioerrorlogです。

AWS CDKは、TypeScriptやPythonなどの言語でAWSインフラリソースを定義・デプロイできるフレームワークです。

CDKコードからCloudFormationテンプレートが生成され、AWSにリソースがデプロイされる、という仕組みになっています。

CDKは、YAMLで直接記述するCloudFormationテンプレートより書きやすく、またCloudFormationテンプレートより抽象度が高い(記述量が少なくて済む)ため、個人的にはよく好んで使っています。


CDKには、プロジェクトを作成する、リソースをデプロイ/削除するなど、いくつかのコマンドがあります。

毎回コマンドを忘れて調べなおしている自分がいるので、今回は基本的なCDKコマンドを備忘録としてまとめることにしました。


環境

今回の作業環境は以下の通りです。

CDKバージョン:

$ cdk --version
1.31.0 (build 8f3ac79)


Amazon LiunxのCloud9で確認作業を行いました。

$ cat /etc/system-release
Amazon Linux AMI release 2018.03


CDKコマンドまとめ

一般的な作業の流れに沿って、必要となるコマンドをまとめていきます。


CDKバージョン確認

CDKのバージョンを確認します

cdk --version


CDKインストール

CDKがインストールされていない場合、以下のnpmコマンドでインストールします。

npm install -g aws-cdk


CDKプロジェクト作成

CDKのプロジェクトを作成します。

主な例(pythonの場合)は次のようになります。

  • シンプルなテンプレートからCDKプロジェクトを作りたい場合:
cdk init app --language=python


  • サンプルコードを含んだCDKプロジェクトを作りたい場合:
cdk init sample-app --language=python


  • 仮想環境やgitファイルの作成なしに、まっさらなCDKプロジェクトを作りたい場合:
cdk init --generate-only --language=python


もう少し詳しく説明すると、次のようになります。

cdk init [template] --language=[python|typescript|csharp|fsharp|java|javascript]

[template]部分には app, sample-app, lib のどれかをオプションで入力し、
[language]部分には python typescript などの言語をひとつ入力します。


templateの app, lib, sample-app それぞれの特徴は、cdk init --listで確認できます。

$ cdk init --list
Available templates:
* app: Template for a CDK Application
   └─ cdk init app --language=[csharp|fsharp|java|javascript|python|typescript]
* lib: Template for a CDK Construct Library
   └─ cdk init lib --language=typescript
* sample-app: Example CDK Application with some constructs
   └─ cdk init sample-app --language=[csharp|fsharp|java|javascript|python|typescript]

app:素の状態のCDKテンプレート

sample-app:Stackのサンプルやtestフォルダがあらかじめ作成されたCDKテンプレート

lib:CDK Construct Library用のテンプレート

となっています。


そして、仮想環境やgitの作成なしにまっさらな状態からCDKプロジェクトを作りたい場合は、テンプレートを指定せずに --generate-only を指定します。


CDKコマンド実行前の下準備

CDKコマンドとは直接関係ありませんが、CDKプロジェクト内で作業を始める前に済ませておく必要のあるコマンドをメモします。

※Pythonの場合を例として書きます。

  • 仮想環境起動

cdk init時に作成されたPython仮想環境.envを起動します。

source .env/bin/activate


  • 必要パッケージのインストール

requirement.txtに書かれたPythonパッケージをインストールします。

pip install -r requirements.txt 


CDK Stack一覧表示

CDKコードに含まれたCDK Stackの一覧を表示します。

cdk ls


CloudFormationテンプレートの生成

CDKコードに含まれたCDK Stackから、CloudFormationテンプレートを生成・表示します。

cdk synth


特定Stackを指定して、CloudFormationテンプレートを生成・表示することもできます。

cdk synth [Stack名]

※Stack名はcdk lsで確認できます。


CDKデプロイ前準備:bootstrap

CDKで定義したAWSリソースをデプロイする前に、次のコマンドでCDKデプロイ環境を構築します。

cdk bootstrap

cdk bootstrapすると、CDKToolkitというCloudFormation Stackが作成され、S3バケットcdktoolkit-stagingbucket-[random]が作成されます。

CDKToolkitStackは、デプロイ先のリージョンに作成されている必要があります。


CDKデプロイ

CDKコードで定義したAWSリソースをデプロイします。

cdk deploy [Stack名]


CDKコード内にStackが一つしかない場合は、Stack名を指定しなくともデプロイできます。

cdk deploy


CDK Stackの差分を表示

コードを変更した際に、CDK Stackリソースの差分を表示します。

cdk diff [Stack名]


Stack名を指定しないと、全Stackの差分が表示されます。

cdk diff


CDKリソース削除

CDKデプロイされたリソースを削除します。

cdk destroy [Stack名]


CDKコード内に Stackが一つしかない場合は、Stack名を指定せずにリソースを削除できます。

cdk destroy


おわりに

今回は、CDKの基本コマンドをまとめました。

CDKはCloudFormationテンプレートより可読性が高く、テストコードも書けるなど一般言語ならではのメリットも享受できます。

これからもどんどん利用していきたいです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

参考

AWS CDK Intro Workshop :: AWS Cloud Development Kit (AWS CDK) Workshop

What is the AWS CDK? - AWS Cloud Development Kit (AWS CDK)

Cloud9エラー: An error occurred (ExpiredToken) when calling the XXX operation: The provided token has expired.

AWS Cloud9のエラー

An error occurred (ExpiredToken) when calling the XXX operation: The provided token has expired.

の対処法を記します。

はじめに

Cloud9を使っていた際に、次のようなエラーに出会いました。

$ aws s3 ls
An error occurred (ExpiredToken) when calling the ListBuckets operation: The provided token has expired.

上の例はaws cliでListBucketsしようとしたときに表示されたものですが、それ以外のいかなるAWSサービスへもアクセスできませんでした。

(この時、IAMユーザーにはAdministratorAccessが付与されているのにも関わらず、です。)

このエラーへの対処法を備忘録として残します。

# 作業環境
$ cat /etc/system-release
Amazon Linux AMI release 2018.03

エラー対処

解決策: Cloud9環境のOwnerにログインしてもらう

このエラーが起きたのは、(Ownerユーザーではなく)Cloud9環境に招待されたIAMユーザーで作業しているときではありませんでしたか?

この解決策は単純で、Cloud9環境のOwnerに、Cloud9にログインしてもらう、です。

Cloud9アクセス権限の仕組み

この問題の背景にある仕組みを少し説明します。

まず、Cloud9でのアクセス権限は少し複雑で、
IAMユーザーの権限 + temporary credentials(一時認証情報)
で決まります。

以下、ドキュメント抜粋です。

  1. AWS Cloud9 checks to see if the calling AWS entity (for example, the IAM user) has permissions to take the requested action for the requested resource in AWS. If the permission doesn't exist or is explicitly denied, the request fails.

  2. AWS Cloud9 checks AWS managed temporary credentials to see if its permissions allow the requested action for the requested resource in AWS. If the permission doesn't exist or is explicitly denied, the request fails. For a list of permissions that AWS managed temporary credentials support, see Actions Supported by AWS Managed Temporary Credentials.

  3. If both the AWS entity and AWS managed temporary credentials allow the requested action for the requested resource, the request succeeds.

  4. If either the AWS entity or AWS managed temporary credentials explicitly deny (or fail to explicitly allow) the requested action for the requested resource, the request fails. This means that even if the calling AWS entity has the correct permissions, the request will fail if AWS Cloud9 doesn't also explicitly allow it. Likewise, if AWS Cloud9 allows a specific action to be taken for a specific resource, the request will fail if the AWS entity doesn't also explicitly allow it.

つまり、Cloud9からAWSサービスへのアクセスが要求された際、次のように権限がチェックされます。

  1. Cloud9を操作するAWSエンティティ(IAMユーザーなど)がその権限を持っているか
  2. Cloud9に付与された一時認証情報がその権限を持っているか
  3. これらチェックが両方通ったときのみ、アクセスを許可する

ここでCloud9環境に招待されたユーザーにとってネックとなるのは、一時認証情報の方です。

一時認証情報は、その名の通り一時的に付与されるアクセス権限です。

大抵の操作に対するアクセスが許可されていますが、一定時間後(デフォルトで12時間)にその認証は切れてしまいます。

Cloud9環境のOwner(そのCloud9の作成者)が作業している間は5分毎に一時認証情報が更新されますが、招待者が単独で作業する場合は、Ownerログアウトの12時間後で一時認証情報が無効になってしまうことになります。

よって、再びOwnerにCloud9を開いてもらうことで一時認証情報が更新され、AWSサービスにアクセスできるようになる、ということです。

おわりに

Cloud9に招待されたユーザーで陥りがちなエラーの対処法を書き記しました。

はじめてこのエラーに遭遇した時は、Cloud9の認証機構の不思議に感動したものでした。

同じ状況のどなたかの役に立てば幸いです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

AWS Cloud9

Troubleshooting AWS Cloud9 - AWS Cloud9