BioErrorLog Tech Blog

試行錯誤の記録

DeletionPolicyの変更が反映されないときの対処法 | CDK/CloudFormation

CDKやCloudFormationにて、変更したDeletionPolicy (RemovalPolicy)がStackに反映されない時の対処法を残します。

はじめに

おはよう。@bioerrorlogです。

先日、CDKで作成したリソースのDeleteionPolicyを変更してdeployした際に、その変更が実環境のStackに反映されないことがありました。

その原因と対処法をメモします。

DeletionPolicyの変更が反映されないときの対処法

起こったこと

まず、起こったことをおさらいします。

例えば以下のように、S3を定義したシンプルなCDKコードがあるとします(Pythonを例とします)。

from aws_cdk import (
    aws_s3 as s3,
    core
)


class SampleS3Stack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_bucket = s3.Bucket(self, "TestBucket", bucket_name="deletion-policy-test-bucket")

app = core.App()
SampleS3Stack(app, "deletion-policy-test-stack")

app.synth()

上記のようにS3 BucketをCDK Constructのデフォルトで作成した場合、DeletionPolicyおよびUpdateReplacePolicyにはRetainが設定されます。

以下、生成されるCloudFormationテンプレート抜粋です。

Resources:
  TestBucket560B80BC:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: deletion-policy-test-bucket
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain

ここで、DeletionPolicy / UpdateReplacePolicyをDeleteに変更するには、以下のようにapply_removal_policy()を利用します。

from aws_cdk import (
    aws_s3 as s3,
    core
)


class SampleS3Stack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_bucket = s3.Bucket(self, "TestBucket", bucket_name="deletion-policy-test-bucket")
        
        # DeletionPolicy / UpdateReplacePolicyを指定
        s3_bucket.apply_removal_policy(core.RemovalPolicy.DESTROY) 

app = core.App()
SampleS3Stack(app, "deletion-policy-test-stack")

app.synth()

上記のようにapply_removal_policy()を適用することで、生成されるCloudFormationテンプレートやcdk diffの結果には変更が反映されます。

# cdk synth
Resources:
  TestBucket560B80BC:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: deletion-policy-test-bucket
    UpdateReplacePolicy: Delete
    DeletionPolicy: Delete
# cdk diff
Stack deletion-policy-test-stack
Resources
[~] AWS::S3::Bucket TestBucket TestBucket560B80BC 
 ├─ [~] DeletionPolicy
 │   ├─ [-] Retain
 │   └─ [+] Delete
 └─ [~] UpdateReplacePolicy
     ├─ [-] Retain
     └─ [+] Delete

しかし、上記変更後のCDKコードをデプロイしても、変更がStackに反映されませんでした。

以下のようにno changesと表示され、変更はデプロイされなかったのです。

# cdk deploy
deletion-policy-test-stack: deploying...
deletion-policy-test-stack: creating CloudFormation changeset...

 ✅  deletion-policy-test-stack (no changes)

原因

少し調べた結果、上記の振る舞いはCloudFormationの仕様であることが分かりました。

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

You can't update the CreationPolicy, DeletionPolicy. or UpdatePolicy attribute by itself. You can update them only when you include changes that add, modify, or delete resources.

CreationPolicy/DeletionPolicy/UpdatePolicyのみでは変更を更新できないため、他のリソース情報と一緒に更新する必要がある、とのことです。

仕様としてイケてないとは思いますが、DeletionPolicyが更新できないこと自体は仕様として想定通りということですね。

対処法

では、どのようにしてDeletionPolicyを更新すればよいのか。

それ単体では更新できないことが仕様である以上、他のリソース情報と一緒に更新する他なさそうです。

例えば、metadataやTagの変更を同時に行えば、リソースそのものにはあまり影響を与えずにDeletionPolicyを更新することが出来ます。

ドキュメントにも以下のように、metadataの更新を一緒に行うことが言及されています。

For example, you can add or modify a metadata attribute of a resource.


例えば、以下のようにmetadataを変更して再デプロイすればDeletionPolicyの変更が反映されます。

from aws_cdk import (
    aws_s3 as s3,
    core
)


class SampleS3Stack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_bucket = s3.Bucket(self, "TestBucket", bucket_name="deletion-policy-test-bucket")
        
        # DeletionPolicy / UpdateReplacePolicyを指定
        s3_bucket.apply_removal_policy(core.RemovalPolicy.DESTROY)
        
        # metadataを更新
        cfn_bucket = s3_bucket.node.default_child
        cfn_bucket.cfn_options.metadata = {
            "DeletionPolicyUpdated": "DESTROY"
        }

app = core.App()
SampleS3Stack(app, "deletion-policy-test-stack")

app.synth()

※Metadataの更新方法はドキュメントGitHub issueを参考にしています。 Metadataの上書き操作になるので注意が必要です(もともとあったmetadata aws:cdk:pathが上書きされる)。


また以下のようにTagを追加して再デプロイしても、DeletionPolicyの変更が反映されます。

from aws_cdk import (
    aws_s3 as s3,
    core
)


class SampleS3Stack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_bucket = s3.Bucket(self, "TestBucket", bucket_name="deletion-policy-test-bucket")
        
        # DeletionPolicy / UpdateReplacePolicyを指定
        s3_bucket.apply_removal_policy(core.RemovalPolicy.DESTROY)
        
        # Tagを追加
        core.Tags.of(s3_bucket).add("DeletionPolicyUpdated", "DESTROY")

app = core.App()
SampleS3Stack(app, "deletion-policy-test-stack")

app.synth()

おわりに

今回は、CloudFormation/CDKにてDeletionPolicyの変更が反映されないときの対処法を書きました。

そもそもDeletionPolicy単体では更新できないのがCloudFormationの仕様、というのにはなかなか驚きました。

なるべくリソースそのものに影響のないTagやmetadataの更新を一緒に行うことを対処法として書きましたが、他にもっと適切なやり方があれば教えて頂けると嬉しいです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

参考

[aws-events] `cdk diff` shows difference, but `cdk deploy` tells no changes · Issue #10219 · aws/aws-cdk · GitHub

Modifying a stack template - AWS CloudFormation

RemovalPolicy — AWS Cloud Development Kit 1.94.1 documentation

Escape hatches - AWS Cloud Development Kit (AWS CDK)

Identifiers - AWS Cloud Development Kit (AWS CDK)

Python: cfn_options.metadata cannot be mutated · Issue #6379 · aws/aws-cdk · GitHub

Add Support for adding Metadata to deal with 3rd party tools · Issue #8336 · aws/aws-cdk · GitHub

AWS CDKでTagを付与する

AWS CDKでTagを付与する方法の備忘録です。

はじめに

こんにちは、@bioerrorlogです。

CDKでリソースにTagを付与するとき、やり方を忘れてググり直すことがしばしばあります。

いちいち調べなくてもいいように、Tagの付与方法をメモします。

環境

今回は以下の環境で作業しました:
CDK version: 1.92.0
OS: Amazon Linux 2 (Cloud9)

CDKでTagを付与する

CDKでTagを付与するには、Tagsクラスを用いたやり方が推奨されています。

Tagging - AWS Cloud Development Kit (AWS CDK) v2

※他にはTagクラスを用いたやり方もありますが、非推奨となっています。

以下、Pythonを例にTag付与方法を見ていきます。

具体例

以下の形でTagを付与します。

Tags.of(SCOPE).add(key, value) 

SCOPEにはConstructやStackを指定し、tag, valueにはそれぞれタグのキーと値を指定します。

以下、具体例を挙げます。

Stack単位でTagを付与する

from aws_cdk import (
    aws_s3 as s3,
    core
)


class TagSampleStack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_01 = s3.Bucket(self, "TagTestBucket01", bucket_name="tag-test-bucket-001")
        s3_02 = s3.Bucket(self, "TagTestBucket02", bucket_name="tag-test-bucket-002")


app = core.App()
tag_sample_stack = TagSampleStack(app, "tag-sample")

# Stackにタグを付与する
core.Tags.of(tag_sample_stack).add("Project", "TagTest")

app.synth()

このようにStackに対してTagを付与すると、Stackに含まれるリソースすべてに対してTagが付与されます。

上記のケースですと、Stackに含まれるふたつのS3バケットtag-test-bucket-001tag-test-bucket-002の両方にProject: TagTestのTagが付与されます。

Construct単位でTagを付与する

from aws_cdk import (
    aws_s3 as s3,
    core
)


class TagSampleStack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_01 = s3.Bucket(self, "TagTestBucket01", bucket_name="tag-test-bucket-001")
        s3_02 = s3.Bucket(self, "TagTestBucket02", bucket_name="tag-test-bucket-002")

        # ConstructにTagを付与する
        core.Tags.of(s3_01).add("Project", "TagTest")


app = core.App()
tag_sample_stack = TagSampleStack(app, "tag-sample")

app.synth()

このように特定のConstructに対してTagを付与すると、対象のConstructに対してのみTagが付与されます。

上記のケースでは、S3バケットtag-test-bucket-001にのみProject: TagTestのTagが付与されます。

補足: 非推奨のTag付与方法

以下のようにTagクラスを用いてもTagを付与することはできますが、このやり方は非推奨となっています。

Tag.add(SCOPE, key, value)


コード例:

from aws_cdk import (
    aws_s3 as s3,
    core
)


class TagSampleStack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        
        s3_01 = s3.Bucket(self, "TagTestBucket01", bucket_name="tag-test-bucket-001")
        s3_02 = s3.Bucket(self, "TagTestBucket02", bucket_name="tag-test-bucket-002")


app = core.App()
tag_sample_stack = TagSampleStack(app, "tag-sample")

# Tagクラスを用いてTagを付与
core.Tag.add(tag_sample_stack, "Project", "TagTest")

app.synth()

上記のコードを実行すると、以下のようにTag.addの使用は非推奨との警告が表示され、代わりTags.of(scope).add(k,v)の使用を推奨されます。

[Warning at /tag-sample] The API @aws-cdk/core.Tag.add(scope,k,v) is deprecated: Use "Tags.of(scope).add(k,v)" instead. This API will be removed in the next major release

おわりに

今回は、CDKでのTagの付与方法をメモしました。

Pythonを例にやり方を書きましたが、他の言語でも基本同じやり方でTagを付与することが出来ます。

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

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

参考

Tagging - AWS Cloud Development Kit (AWS CDK) v2

Tags — AWS Cloud Development Kit 1.170.1 documentation

Tag — AWS Cloud Development Kit 1.170.1 documentation

Lambdaのboto3バージョンを確認する | AWS SDK for Python

Lambdaのboto3/botocoreバージョンを確認する方法の備忘録です。

はじめに

こんにちは、@bioerrorlogです。

Lambdaからboto3を用いてAWS APIを叩くのは、よくあるケースかと思います。

私もよくLambda上でboto3を使いますが、先日Lambdaのboto3バージョンではカバーされていない機能を使用し、エラーになってしまったことがありました。 その際、Lambdaのboto3バージョンを確認する必要がありましたが、ぱっとやり方が思い浮かびませんでした。

そこで今回は、Lambdaのboto3バージョンを確認する方法をメモします。

Lambdaのboto3バージョンを確認する方法

大きく分けて、以下の二つの方法があります。

  • ドキュメントからLambdaに使われているboto3バージョンを確認する
  • Lambdaを実行してboto3バージョンを確認する

それぞれ見ていきます。

ドキュメントから確認する

まず一つ目のやり方は、ドキュメントからLambdaのboto3バージョンを確認することです。

以下のドキュメントに、Lambdaランタイム毎のboto3/botocoreバージョンが記載されています。

Lambda runtimes - AWS Lambda

ドキュメントにLambdaのboto3/botocoreバージョンが記載されている(オレンジ枠)

Lambdaのランタイムを確認して、上記ドキュメントからboto3バージョンを確認するのが一番手っ取り早い確認方法かもしれません。

しかし、ドキュメントがちゃんと最新化されているか、は注意する必要があります (特に、日本語ページはしばしば最新版が反映されていないので注意が必要です)

Lambda上から確認する

二つ目のやり方は、Lambdaを実行してboto3バージョンを確認することです。

boto3/botocoreのバージョンは、boto3.__version__botocore.__version__で取得することが出来ます。

よって、例えば以下のようにboto3.__version__botocore.__version__を出力させれば、boto3/botocoreのバージョンを確認できます。

import boto3
import botocore

def lambda_handler(event, context):
    
    print(f'boto3 version: {boto3.__version__}')
    print(f'botocore version: {botocore.__version__}')
# 出力結果
boto3 version: 1.16.31
botocore version: 1.19.31

ドキュメントでの確認とは違い、こちらのやり方では確実にLambdaで使用されているboto3のバージョンを調べることが出来ます。

おわりに

今回は、Lambdaのboto3バージョンを確認する方法を簡単にメモしました。

boto3の新しいリリース機能を使いたいときは、Lambdaのboto3バージョンが追い付いていない可能性があります。 そんな時は、さっとLambdaのboto3バージョンを確認できるとよいですね。

boto3には常日頃お世話になっているので、もっと詳しくなっていきたいところです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

Lambda runtimes - AWS Lambda

Troubleshoot Python (Boto 3) Lambda function runtime errors

python - Check Boto3 Version - Stack Overflow

“Failed to execute script XX” エラー対処: PyinstallerでPygameをexe化するときの注意点

PyinstallerでPygameスクリプトをexe化する際に発生した、以下のエラーに対処するためのチェックポイントを書き残します。

Failed to execute script XX

はじめに

こんにちは、@bioerrorlogです。

以前、Pygameで作ったゲームのスクリプトをPyinstallerを用いてexe化し、itch.ioで配布してみました。


その際、何度か悩まされたエラーがこちらです。

Failed to execute script XX

今回は、このエラーが発生する状況とその対処方法を書き残します。

環境

Windows10

各バージョン情報

Python 3.7.6
pygame 2.0.1
pyinstaller 4.2

“Failed to execute script XX” エラーの対処法

私がエラーに遭遇した状況からまとめると、以下の3つの注意点があります。

  1. Pygameスクリプト内のimportパッケージはインストールされているか
  2. ゲームアセットの依存関係は保たれているか
  3. Pygame終了時にsys.exit()しているか

以下、ひとつずつ説明します。

Pygameスクリプト内のimportパッケージはインストールされているか

まず一つ目、Pygameをexe化する際には、そのスクリプト内でimportされているパッケージがインストールされている必要があります。

例えばGitHubからコードをcloneし、そのままPyinstallerによるexe化を行った場合、ゲームの実行に必要なパッケージがインストールされていないままの可能性があります(私がそうでした)。

必要なパッケージがインストールされないまま作成されたexeファイルは、実行時にエラーが発生します。

対処法:

  • Pyinstallerによるexe化を行う前に、ゲームが正常に実行できるかを確認する
  • ゲームが実行できなかった場合、必要パッケージをインストールする

ゲームアセットの依存関係は保たれているか

作成されたexeファイルとゲームアセットファイルの間には、Pygameスクリプトとゲームアセットファイルの関係が保たれている必要があります。

例えば、以下のようなファイル構成のPygameプロジェクトがあったとします。

.
├── main.py # Pygameスクリプト
└── data # ゲームアセットフォルダ

main.pyから、dataフォルダは以下に配置された画像データ/音声データなどを参照しているような形です。

こちらをPyinstallerでexe化すると、次のように各ファイルが生成されます。

.
├── build
├── main.py
├── main.spec
├── data
├── __pycache__
└── dist
    └── main.exe # 実行ファイル

ここでmain.exeをそのまま実行しても、エラーを吐かれてしまいます。 アセットフォルダdatamain.exeと同階層にないため、アセットを読み込むことが出来ないからです。

よって、アセットフォルダdataを、main.exeと同階層にコピーする必要があります。

└── dist
    ├── data # コピーしてくる
    └── main.exe

対処法:

  • exeファイルとゲームアセットフォルダの関係を、ゲームアセットフォルダとPygameスクリプトの関係で保持する
  • 例) ゲームアセットフォルダをexeファイルの同階層にコピーする

Pygame終了時にsys.exit()しているか

ゲーム終了時には、sys.exit()を実行する必要があります。 sys.exit()が実行されない場合、exeファイルから実行したゲームを終了する際、エラーを吐かれてしまいます。

例えばゲーム終了の関数を用意する場合は、次のようにsys.exit()を実行するようにします。

import pygame
import sys

def quit_game():
    pygame.quit()
    sys.exit()

対処法:

  • ゲーム終了時の処理でsys.exit()を実行する

おわりに

以上、PyinstallerでPygameスクリプトをexe化する際に発生したエラーへの対処法を3つ書きました。

エラーメッセージもあまり詳しく書かれないので、単純な原因でもエラーの対処に結構時間がかかりました。

Pygameでのゲーム開発はマイナーとは思いますが、同じ境遇のどなたかの参考になれば幸いです。

[関連記事]
www.bioerrorlog.work

www.bioerrorlog.work

参考

Making an Executable from a Pygame Game (PyInstaller) - YouTube

python - "Failed to execute script myscript" when exiting pygame window without console open after converting to .exe with pyinstaller - Stack Overflow

Pygameのexeファイルを作成する | PyInstaller

PygameスクリプトをPyinstallerを用いてexe化する方法の備忘録です。

はじめに

おはよう。@bioerrorlogです。

Pygameで簡単なゲームを自作してみました。

初心者がPythonでゼロからゲームを作ってみた | デザインから実装まで - 生物系がゼロから始めるTech Blog

せっかく作ったのだからどこかでゲーム実行ファイルを配布しようと思い、itch.ioにゲームを登録しました。

ゲームを配布するには、ソースコードからゲーム実行ファイル(exeファイル)を作成する必要があります。

そこで今回は、PygameスクリプトをPyInstallerを用いてexe化するやり方を残します。

環境

Windows10で作業しました。

各バージョン情報は以下です。

Python 3.7.6
pygame 2.0.1
pyinstaller 4.2

PyInstaller でPygameのexeファイルを作成する

PyInstaller でPygameのexeファイルを作成するには、以下の手順を踏みます。

  1. PyInstallerのインストール
  2. exeファイルの作成
  3. ゲームアセット依存関係の解決

以下、ひとつひとつ説明します。

1. Pyinstallerのインストール

まずは、Pyinstallerをpipインストールします

pip install pyinstaller

終わったら、バージョンを確認してPyinstallerが正常にインストールされていることを確認します。

$ pyinstaller --version
4.2

無事バージョンが表示されれば、インストール完了です。

2. exeファイルの作成

続いて、Pygameプロジェクトのソースコードからexeファイルを作成します。

今回は、私のPygameプロジェクトを例にexe化してみます。

GitHub - bioerrorlog/CellForRest_Pygame: My first project - Clicker game in Python.

# Pygameプロジェクトを手元にclone
git clone https://github.com/bioerrorlog/CellForRest_Pygame.git
cd CellForRest_Pygame/game/

このPygameプロジェクトは、以下のようなフォルダ構成になっています。

.
├── CellForRest.py
└── data

CellForRest.pyがメインの実行ファイルで、dataフォルダの中に、画像などのゲームアセットファイルが格納されています。

これらを、以下のPyInstallerコマンドでexe化します。

pyinstaller CellForRest.py --onefile --noconsole

--onefileオプションと--noconsoleオプションは、必要に応じて付与してください。 それぞれ以下の処理が行われます。

  • --onefile
    関連ファイルを1つにまとめてexe化する

  • --noconsole
    exeファイル実行時にコンソールを非表示にする

上記コマンドを実行すると、もともとのディレクトリに以下のようなフォルダが生成されます。

.
├── build
├── CellForRest.py
├── CellForRest.spec
├── data
├── dist
└── __pycache__

生成されたフォルダの中のdistフォルダの中に、exeファイルが作成されています。

└── dist
    └── CellForRest.exe


[関連記事]
www.bioerrorlog.work

3. ゲームアセット依存関係の解決

最後に、exeファイルとゲームアセットフォルダの依存関係を解決します。

もともと、メインスクリプトCellForRest.pyと同階層にゲームアセットフォルダdataを配置していたので、生成したexeファイルでも同様の依存関係を保持する必要があります。

つまり今回の例だと、dataフォルダをdistフォルダ内にコピーします。

└── dist
    ├── data # コピーしてくる
    └── CellForRest.exe

これで、exeファイルCellForRest.exeを実行すれば、ゲームが起動するようになりました。

ゲームを配布するときは、このdistフォルダの中身(dataフォルダとCellForRest.exe)をzipする形になります。

おわりに

今回は、Pyinstallerを用いてPygameのexeファイルを作成する方法を書きました。

私の場合、exeファイルとゲームアセットフォルダの依存関係を保持する必要があることを知らなかったため、しばらくexeファイルが上手く実行できずに苦労してしまいました。

同じ境遇の誰かの参考になれば幸いです。

[関連記事]
www.bioerrorlog.work

www.bioerrorlog.work

参考

Making an Executable from a Pygame Game (PyInstaller) - YouTube

PyInstaller Manual — PyInstaller 4.2 documentation

Using PyInstaller — PyInstaller 4.2 documentation

pyinstaller · PyPI

S3 ListBucketsの実行に必要なIAM権限 | AWS

S3 ListBucketsの実行に必要なIAM PolicyのAction権限について整理します。

はじめに

おはよう。@bioerrorlogです。

S3のAPIを利用する際のIAM Policy設定にはしばしば悩まされます。 S3から提供されているAPIをそのままIAM Policyの"Action"として指定できないことが多いからです。

例えばS3のListBucketsを実行する際、IAM Policyの"Action"に"s3:ListBuckets"としてそのまま権限を指定することはできません。 IAM PolicyのActionテーブルには"s3:ListBuckets"は定義されていないからです。


[関連記事] IAM Policyの"Action"に指定できる権限の一覧はどこにあるのか


そこで今回は、S3のListBuckets実行に必要なIAM PolicyのAction権限について整理します。

検証方法

Lambdaからboto3のlist_buckets()を実行し、必要なIAM Policy権限を調べます。

f:id:BioErrorLog:20210123124952p:plain
LambdaからListBucketsを発行し、必要なIAM権限を調べる

Lambdaでは、以下のシンプルなコードを実行します:

import boto3

s3_client = boto3.client('s3')

def lambda_handler(event, context):
    
    response = s3_client.list_buckets()
    print(response)
  • Lambda runtime:
    Python 3.8

  • boto3 version:
    boto3-1.15.16 botocore-1.18.16

S3 ListBucketsの実行に必要なIAM権限

結論

まず結論から言うと、必要なAction権限は以下の2つです:

  • "s3:ListBucket"
  • "s3:ListAllMyBuckets"

IAM Policyドキュメントは以下のようになります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:ListAllMyBuckets"
            ],
            "Resource": "*"
        }
    ]
}


以降、ListBucketsの実行に必要なIAM権限を検証していきます。

検証

まず、S3への権限を何も付与せずにLambdaを実行すると、以下のエラーが出力されます。

[ERROR] ClientError: An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

当然ですが、AccessDeniedエラーが出ました。


次に、試しに"s3:ListBuckets"の権限を指定してみます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBuckets"
            ],
            "Resource": "*"
        }
    ]
}

こちらも当然、同様のAccessDeniedエラーが吐かれました。 "s3:ListBuckets"というActionは定義されていないからです。


次は、"s3:ListBucket"または"s3:ListAllMyBuckets"の権限をそれぞれ単体で付与して実行してみます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket"
            ],
            "Resource": "*"
        }
    ]
}

または

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets"
            ],
            "Resource": "*"
        }
    ]
}

しかし、どちらのケースも同様のAccessDeniedエラーが吐かれました。


そして、次のように"s3:ListBucket"と"s3:ListAllMyBuckets"の両方を許可してみます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:ListAllMyBuckets"
            ],
            "Resource": "*"
        }
    ]
}

これでようやく、エラーなくlist_buckets()を実行することが出来ました。

おわりに

今回は、S3 ListBucketsの実行に必要なIAM権限をメモしました。

S3から提供されているAPIはそのままIAM Policyの"Action"として指定できないことが多いので、よくIAM Policyの設定には苦労します。 今回はListBuckets APIについて書きましたが、他にも紛らわしい権限設定のAPIがあります。

必要なAction権限をすぐに調べられる仕組みがあればよいのですが、、
もしあれば是非教えて貰いたいです。

[関連記事]
www.bioerrorlog.work

www.bioerrorlog.work

参考

ListBuckets - Amazon Simple Storage Service

S3 — Boto3 Docs 1.17.93 documentation

Actions, resources, and condition keys for Amazon S3 - Service Authorization Reference

エラー対処: 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

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

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

エラー対処: RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII as encoding for the environment.

以下のエラーの対処法を記します。

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.


はじめに

おはよう。@bioerrorlogです。

先日、ある作業をしているときに、次のエラーに遭遇しました。

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

ClickというPythonパッケージ*1のロケールについてのエラーのようです。

エラーメッセージには既に解決策が示されていますが、私の場合はそれでは解決せず、長い間右往左往してました。

今回は、このエラーの解決方法を書き残します。


作業環境

作業はUbuntu上のCloud9で行い、Pythonバージョンは以下の通りでした。

$ lsb_release -d
Description:    Ubuntu 18.04.4 LTS

$ python --version
Python 3.6.9


エラー対処

解決策1: export LC_ALL=C.UTF-8

まず最初に行うべき対処は、エラーメッセージに従い、以下のようにロケールを変更することです。

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

エラーメッセージで検索した時にでヒットするstack overflowでも専らこの解決策が議論されており、多くの状況でこの対処法が機能するようです。

なお、システムによってはUTF-8の表記に多少の差異があるようで、場合によっては次のようにUTF8と記述する必要があるようです。

export LC_ALL=C.UTF8
export LANG=C.UTF8


なお、どのようなロケール表記がサポートされているかは、次のコマンドで確認できます。

locale -a


しかし、私の場合は上記のいずれの対処法でも解決しませんでした。


解決策2: Python3.7以上を使う

そこで次の解決策は、Python3.7以上を使うことです。

エラーメッセージにも示されているClickのドキュメントページには、以下の記載があります。

In Python 3.7 and later you will no longer get a RuntimeError in many cases thanks to PEP 538 and PEP 540, which changed the default assumption in unconfigured environments.

Python3.7以上ではRuntimeErrorは起きないよ、とのことです。

私の場合はまさしく、Python3.7をインストールすることでエラーが解消しました。

Python3.7のインストール + Python3.7による仮想環境の構築は、以下のようにして実行します。

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

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

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


以上のように、Python3.7の環境を再構築することで、このエラーを解消することが出来ました。


おわりに

今回は、PythonパッケージClickのロケールエラーの解消方法を記しました。

エラーメッセージに示されている解決法が機能しなかったことで少し焦ってしまい、長い時間を無駄にしてしまいました。

困ったらまずドキュメントをよく読む、という基本の大切さを改めて身体に刻みます。

以上、同じ境遇のどなたかの参考になれば幸いです。


参考

Python 3 Support — Click Documentation (7.x)

*1:もう少し補足すると、Clickは“Command Line Interface Creation Kit”です。

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でStackをネストにする | NestedStack

AWS CDKでStackをネスト (入れ子) にしてデプロイする方法を記します。


はじめに

おはよう。@bioerrorlogです。

AWS CDKでStackをネスト(入れ子)にしようとしたところ、単純に入れ子にしたのでは上手くデプロイできないことに気が付きました。

Stackを入れ子にするにはひと工夫必要だったので、そのやり方の備忘録を残します。


なお、本記事で使用するコードは以下のGitHubにも置いてあります。
github.com


作業環境

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

CDKバージョン:

$ cdk --version
1.31.0 (build 8f3ac79)


言語はPythonを使用しました。
Pythonバージョン:

$ python --version
Python 3.6.10


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

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


CDKでStackをネストにする

問題: Stackが入れ子にできない

さて、今回の問題はStackが入れ子にできない、という点にあります。

例えば、以下のように単純に入れ子状のStackを作ったとします。

from aws_cdk import core
import aws_cdk.aws_s3 as s3


class ParentStack(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)
        
        ChildStack01(self, "ChildStack01")
        ChildStack02(self, "ChildStack02")
        
        
class ChildStack01(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)
        
        s3.Bucket(self, "ChildBucket01", bucket_name="child-bucket-01")
        
        
class ChildStack02(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        s3.Bucket(self, "ChildBucket02", bucket_name="child-bucket-02")


app = core.App()
ParentStack(app, "ParentStack")
app.synth()


このCDKコードを絵に描き表すと、次のような親子関係になっています。 f:id:BioErrorLog:20200408083931p:plain
ここで期待する動作は、親Stackである ParentStack をデプロイすることで、子Stackたちも同時にデプロイされる、というものです。

しかし、この状態で親Stackをデプロイしても、子StackならびにS3リソースはデプロイされませんでした。


まず、CDKコマンドから cdk synthcdk deploy を行うと、一見うまくいっているように見えます。

$ cdk synth
Successfully synthesized to /home/ec2-user/environment/cdk-test/cdk.out
Supply a stack id (ParentStack, ParentStackChildStack01796333D2, ParentStackChildStack0287B3CF12) to display its template.

$ cdk deploy ParentStack
ParentStack: deploying...
ParentStack: creating CloudFormation changeset...
 0/2 | 11:49:11 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata | CDKMetadata 
 0/2 | 11:49:13 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata | CDKMetadata Resource creation Initiated
 1/2 | 11:49:13 PM | CREATE_COMPLETE      | AWS::CDK::Metadata | CDKMetadata 
 2/2 | 11:49:15 PM | CREATE_COMPLETE      | AWS::CloudFormation::Stack | ParentStack 

   ParentStack


しかし、作成されたCloudFormation Stack ParentStack の中身を見てみると、リソースが何もデプロイされていないことが分かります。

f:id:BioErrorLog:20200408100042p:plain
CloudFormationコンソールから、ParentStackが作成されていることがわかる
f:id:BioErrorLog:20200408091043p:plain
デプロイされたリソースはメタデータのみで、それ以外は空っぽだった

このように、単純にStackを入れ子状にしても、期待する動作はしてくれないことが判明しました。


解決策: NestedStackを使う

この問題についての解決策は、思ったより単純でした。

子Stackに core.Stack を継承させる代わりに、aws_cloudformation NestedStack を継承させれば良いのです。

必要な変更は、以下の2点です。

  • import aws_cdk.aws_cloudformation as cfn を追加
  • 子Stackの継承を core.Stack から cfn.NestedStack に変更

※CDKのcloudformationパッケージをまだインストールしていない場合は、
pip install aws-cdk.aws-cloudformation でインストールします。

先ほどのCDKコードを書きなおすと、次のようになります。

from aws_cdk import core
import aws_cdk.aws_s3 as s3
import aws_cdk.aws_cloudformation as cfn # cloudformationのインポート


class ParentStack(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)
        
        ChildStack01(self, "ChildStack01")
        ChildStack02(self, "ChildStack02")
        
        
class ChildStack01(cfn.NestedStack): # NestedStackの継承

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)
        
        s3.Bucket(self, "ChildBucket01", bucket_name="child-bucket-01")
        
        
class ChildStack02(cfn.NestedStack): # NestedStackの継承

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        s3.Bucket(self, "ChildBucket02", bucket_name="child-bucket-02")


app = core.App()
ParentStack(app, "ParentStack")
app.synth()


それでは、書き直したCDKコードを cdk deploy でデプロイし、再びCloudFormationコンソールを確認します。

f:id:BioErrorLog:20200408100209p:plain

するとこのように、親Stackに加えて、子StackがNested Stackとしてデプロイされました。

これら二つの子Stackは、親Stackのリソースとしてデプロイされていることがわかります。

f:id:BioErrorLog:20200408123351p:plain
子Stackは、親Stackからデプロイされている

このようにして、CDK Stackを入れ子にしてデプロイすることに成功しました。

f:id:BioErrorLog:20200408083931p:plain


おわりに

今回は、CDK Stackを入れ子にする方法を記しました。

シンプルに解決できたので、この NestedStack はなかなかに有用だと感じました。

CDKのことを知れば知るほど、その便利さを噛みしめています。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work


参考

Nested stack support · Issue #239 · aws/aws-cdk · GitHub

NestedStack — AWS Cloud Development Kit 1.94.1 documentation

class NestedStack (construct) · AWS CDK

初心者がPythonでゼロからゲームを作ってみた | デザインから実装まで

Pythonでゲームを自作してみました。 ゲーム制作にあたって行った準備から、実装時にハマった注意点、出来上がったゲームのデザインまでを記録します。

はじめに

何かを作りたい

何かを作ってみたいと思いました。

ここ最近、初心者ながらコードの書き方がだんだんわかってきたので、さっそく何か新しいものを作りたくなったのです。

プログラミングで作るもの、として真っ先に思い付いたのが、ゲームでした。 私はそこまで大のゲーム好きという訳ではありませんが、ゲームは身近でイメージしやすく、作るのも楽しそうです。

そんなわけで、ゲームを自作することに決めました。


なぜPythonを選んだのか

このゲーム作りにPythonを選んだ理由は、Pythonがとても書きやすい言語だと思ったからです。

Pythonは文法がシンプルで、一つの実装にごちゃごちゃとコードを書く必要がありません。

例えばJavaとPythonを比べてみると、「ゲームウィンドウを表示する」という同じ目的でコードを書いても、必要なコード量が全然違います。

かわりにPythonは比較的処理が遅く、ゲーム制作用としては不人気のようです。 しかし、コードを簡単に書けるというのは、私のような初心者にはとても重要なことです。

また、Unityのようなゲームエンジンを使うことも考えましたが、今回は自分の手でゲームを作ることが目的だったので、不採用としました。

Pythonにはゲーム制作用のライブラリ"pygame"があります。 今回はこれを使って、ゲームを自作しました。


作業環境

Pythonバージョン情報 ↓

Python 3.7.2  
pygame 1.9.5

Windows10上で開発しました。

※Pythonのインストール方法
はじめてのPython | Windows環境構築 - Atom - BioErrorLog Tech Blog


ゲーム概要

こちらが、最終的なゲームの概要です。


ゲーム自体はitch.ioで配布しています。
もしよければ軽く触ってみてください:


ソースコードはGitHubに置いています。
ソースコードからゲームを実行するには、以下の手順をご参考ください。

# pygameをインストール
pip install pygame

# ソースコードをクローン
git clone https://github.com/bioerrorlog/CellForRest_Pygame.git

# ゲームを実行
cd CellForRest_Pygame/game/
python CellForRest.py


何を作ったのか、どのようにして作ったのか、どこにつまずいたのかを、順を追ってこの記事内で書き出していきます。

なお、私はプログラミングを始めて間もない初心者ですので、私の書いたコードが最善のやり方であることはまずないと思います。 書いてるときは常に、もう少しマシなやり方はないものかと苦悩してました。

かといって、悩み続けていてもゲームは完成しません。 初心者がとりあえず動くものを作ることを目的に書き上げたものだと思ってもらえれば幸いです。

それでは、ここまでの過程を一つ一つ振り返っていきます。


[関連記事] Devlog #1 ChatGPTを使ったゲームを作る実験をはじめる


Pythonでゲームを作る

Python / pygameの基本を知る

チュートリアルをやってみる

まずは、Pythonでのゲーム制作の基本を調べるところから始めました。

ゲームを作りたい、といきなり思い立っても、作り方が全く分かりません。 どうやってウィンドウを表示すればいいのか、動く画面はどうやって実装するのかなど、ゲーム作りの基本を一つも知らなかったのです。

そこでまずは、ネット上にあるチュートリアルを通して最低限の基本を身につけることにしました。 私は文章よりも動画で見る方が好きなので、Youtubeで「Python game dev」などで検索して、一番再生されてそうなsentdex氏のチュートリアル動画をやりました。

英語の動画になってしまいますが、ソースコードと書き方の手順がわかりやすく、あまり聞き取れなくても問題なく基本を追えるチュートリアルとなっていました。 また、ネットの情報は圧倒的に英語のものが多いので、早いうちに英語に慣れるという意味でもちょうどいいチュートリアルだったと思います。

チュートリアルを通して、

・ゲームウィンドウの表示
・画像の表示
・ゲームループの実装
・スタート画面 -> ゲーム画面/メニュー画面の流れ
・テキスト/ボタン表示の実装
・マウス/キーボードイベントの検知

など、基本となるゲーム実装のやり方を知ることができました。

私のように知識ゼロの状態からゲームを作ろうと思っている方は、どれでもいいのでチュートリアルをさらっとやることをお勧めします。 自分で一つのゲームを作ることに対して、勇気と希望を抱くことができます。


参考になったゲーム制作関連の情報

次に、制作を進める中で参考になった情報を紹介します。 早めに知っていればもっとスムーズに作れたなあと思ったので、ご参考までに。


1.初心者のためのpygameガイド

これは、pygame公式の初心者ガイドの日本語訳です。 初心者が知っておくべき13の心得が紹介されています。

私も後で説明しますが、処理スピードの話などは特に重要なポイントでしたので、早めに目を通しておくと良いかと思います。


2.Game Programming Patterns

ゲームプログラミングについてのデザインパターンが書かれた記事(英語)です。 日本語訳されたものは書籍として出版されていますが、英語のhtml版はネットで公開されています。

私には内容が高度に感じたのであまり真面目には読んでませんが、暇なときに読んでみると、たまに重要なヒントが得られました。 英語に慣れる練習にもなります。


3.redditのpygame板

redditは、海外の有名な掲示板です。 そこのpygame板では、毎日pygameユーザーによる議論が行われています。

もちろん、本当は日本語のpygameコミュニティーがあればよかったのですが、動いている日本のpygameコミュニティーを私は見つけられませんでした。 代わりにこのredditに入って、同じくゲームを作っている人を見てました。 モチベーションを高めるためにも、何かしらのコミュニティーを探すといいと思います。

振り返って特に参考になったと感じるのはこの3つです。 とはいえ、恐らくまだまだネット上にはたくさんの有益な情報が溢れていると思いますので、あくまで参考までにどうぞ。


[関連記事] Godot EngineでBoids Flockingシミュレーションを実装する | 人工生命


ゲームデザインを考える

デザインはどんどん変わっていった

ある程度ゲームのつくり方が理解できたら、つぎは自分がどんなゲームを作るのかを考えました。

とはいっても、作り始める前に考えていたデザインは、ほとんど跡形もなく変更してしまいました。 実装を進める中で、やっぱりデザインを変更しよう、別の方法で実装しよう、などとやってるうちに、当初作っていたものとは完全に別物になったわけです。

逆に今思えば、作り始める前にゲーム性を確定しようとするのは愚かだったと思います。 私はプログラミングの経験が浅く、どのような実装が難しいのかを把握しきれていません。 さらにはゲーム制作も初めてなので、どんなゲームにしたいのかも、現物を見ないとイメージできません。

なので、気楽にスケッチを描き始めるような感覚で、気軽に実装を進めることにしました。 まず書き始め、気に入らなければ躊躇なく変更し、頭の中のイメージに近づけていきます。

恐らくは個人が趣味で作るときのみに許される、自由で楽しい作り方です。 おかげで楽しくゲームを作り上げることができました。


最終的なゲームデザイン

さて、肝心の最終的なゲームの形ですが、クッキークリッカーのようなものに落ち着きました。

クッキークリッカーとは、Wikipediaの説明を借りると、

画面に現れるクッキーを1回クリックするごとに1枚(アイテムで1クリックあたりの枚数を増やすことができる)クッキーを焼くことができる。焼いたクッキーはクッキーの生産施設購入費用に充てることができ、次第に大量のクッキーが手に入るようになる仕組みをとっている。

つまり、クッキーを増やすだけのシンプルなゲームです。
ゲームを進めるにつれ指数的にクッキーが増えていく様子には、ある種のレベルアップ中毒のようなものがあり、私もえらくハマったのを覚えています。


今回出来上がったのは、3つのステージを行き来しながら、クッキーならぬ「Leaf」という値を増やしていくゲームです(Fig. 1)。

Fig. 1 ゲームの全体像
スタート画面からゲームをスタートし、「Cellステージ」「Treeステージ」「Caveステージ」をボタンで行き来しながらゲームを進めていきます。


Cellステージ(Fig. 2)、Treeステージ(Fig. 3)、Caveステージ(Fig. 4)には、それぞれ異なった役割があります。

Cellステージから順に、軽くゲーム画面を説明します。

Fig. 2 Cellステージ
Cellステージでは、手動クリックによる採取によって「Leaf」が獲得されます。

①: ステージ移動ボタン
  ->ほかのステージに移動する。
②: Leaf
  ->「Leaf」の残高が表示される。
③: Gate
  ->クリックで「Cell」を生成する。
  ->「Cell」の生成には「Leaf」を消費する。
④: Cell
  ->うねうね動き回る緑色のやつ
  ->ある程度動いた後に出芽して「Bud」になる。
  ->出芽する前に1回分裂して「Cell」を生む。
⑤: Bud
  ->「Cell」の成れ果て
  ->クリックで採取できる。
  ->採収すると「Leaf」が獲得される。


Fig. 3 Treeステージ
Treeステージにある「Tree」のレベルに応じて、「Leaf」が自動的に増えていきます。 その増加幅は「Human」の数に応じて倍増します。
※語呂の関係で、ステージ遷移ボタンでの名称は「Tree」ではなく「Forest」となっています。

①: ステージ移動ボタン
  ->ほかのステージに移動する。
②: Leaf
  ->「Leaf」の残高が表示される。
③: Tree
  ->レベルに応じて「Leaf」を自動加算する。
  ->レベルに応じて大きくなる。
  ->レベルアップには「Leaf」を消費する。
④: House
  ->クリックで「Human」を生成する。
  ->「Human」の生成には「Leaf」を消費する。
⑤: Human
  ->数に応じて「Tree」の効果が倍増する。
  ->地面を動き回る。


Fig. 4 Caveステージ
Caveステージにある「BlueGem」のレベルに応じて、Cellステージでの「Leaf」獲得量が倍増します。

①: ステージ移動ボタン
  ->ほかのステージに移動する。
②: Leaf
  ->「Leaf」の残高が表示される。
③: BlueGem
  ->上下にゆらゆら揺れる。
  ->レベルに応じてCellステージでの「Leaf」獲得量が倍増する。
  ->レベルアップには「Leaf」を消費する。
  ->レベルに応じて周りの青いやつが増える。

今回出来上がったゲームは以上のような感じです。


[関連記事] 人工生命をつくりたい - 思うところとアプローチのメモ | ALife


実装する

それではここから、ソースコードについての注意点を書き出していきます。 どのようなことに苦労したのか、将来の自分に向けてメモを残すつもりでやっていきます。

ソースコードはGitHubに置いてあります。

ソースコードの全体像

まずは、ソースコード全体の構造(Fig. 5)をまとめます。

Fig. 5 ソースコード全体像
gameInit()メソッドによってスタート画面を表示し、スタート画面でStartボタンが押されるとgameLoop()メソッドが開始します。gameLoop()メソッドの中ではゲームループがまわり続け、ゲームが進行していきます。

ゲームループの中では、3つのステージそれぞれに対応するLayerManagerクラスのオブジェクトが動いています。LayerManagerクラスは、それぞれのステージで登場するキャラクターのオブジェクトを生成・保持・削除します。

button()メソッドやtextDisplay()メソッドなど、ゲームを通して使用するメソッドはクラス外で宣言し、各クラスが使用するようにしました。

全体像はざっとこんな感じです。 ここからは部分部分について、注意が必要だったところのメモを残していきます。


ゲームループ

ゲームループとは、ゲームを静止画ではなく動画として表現するための仕組みです。

一つのwhileループ内でゲーム処理と描画処理を実行し、ループを繰り返します。

今回私は次のように実装しました。
以下gameLoop()メソッド部分のみの切り抜きです。

# Game loop: event.get() -> update() -> draw() -> display.update() -> clear()
# Close button: Quit game
# m button: Menu
# Left click: Inform each game layer via setMouseEventUp()
def gameLoop():
    global intro
    intro = False
    game_exit = False

    cellLayerManager = CellLayerManager()
    treeLayerManager = TreeLayerManager()
    caveLayerManager = CaveLayerManager()

    while not game_exit:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                gameQuit()
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_m:
                    menu()
            elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
                # cellLayerManeger uses pygame.mouse.get_pressed()
                treeLayerManager.setMouseEventUp(True)
                caveLayerManager.setMouseEventUp(True)

        cellLayerManager.update()
        treeLayerManager.update()
        caveLayerManager.update()

        if cell_layer == True:
            cellLayerManager.draw()
        elif tree_layer == True:
            treeLayerManager.draw()
        elif cave_layer == True:
            caveLayerManager.draw()

        pygame.display.update()
        clock.tick(30)

        cellLayerManager.clear()
        treeLayerManager.clear()
        caveLayerManager.clear()

流れとしては、各ステージに対応したLayerManagerクラスのオブジェクトをそれぞれ一つずつ生成してからループに入り、

1.イベントの取得: pygame.event.get()
2.更新処理: update()
3.描画処理: draw()
4.ディスプレイ更新: pygame.display.update()
5.更新後処理: clear()

これをループで繰り返しました。

イベントの取得では、キーボードやマウスのボタンイベントを取得します。 ここについては少し注意が必要でしたので、別途後述したいと思います。

update()をループごとに必ず一度呼び出されるメソッドにして、各ステージの数値更新が主に行わせました。 一方draw()は3つあるステージのうち表示中のステージのみで実行されるようにしました。

pygameによるディスプレイの更新では、clock.tick()でfpsを指定できます。 fpsとはframes per secondのことで、一秒間に何回画面を更新するかの単位です。
今回私は30fpsにしました。 ゲームが進んで処理オブジェクトが増えても、30fpsを下ることはあまりなかったからです。 逆にこれを60fpsとかに設定してしまうと、処理オブジェクトが増えていったときに処理の低下が目立ってしまいます。

更新後処理clear()も定義して、ディスプレイの更新後に値を初期化するときなどに使いました。


3つのLayerManagerクラスに以上のupdate()メソッド、draw()メソッド、 clear()メソッドを必ず持たせて、それらをゲームループでフレームごとに順番に呼び出させるという訳です。

以上が、今回作ったゲームループでした。


クリックイベントが検出されない原因と対処

開発途中に、なぜかマウスのクリックが検出されないという事態にハマりました。 左クリックを検出するコードを書いたのに、2回に1回ほどしか認識されなかったのです。 この原因と対処法について、メモを残します。

まず、マウスやキーボードの入力を検出するには、二つの方法があります。
初心者のためのpygameガイドには、次のように書いてあります。

最初の方法は、入力デバイスの状態を直接チェックすることだ。 これは、たとえば pygame.mouse.get_pos() や pygame.key.get_pressed() なんかを呼ぶことで実現できる。 これは その関数を呼んだ時点での 入力デバイスの状態を 教えてくれるだろう。

このようなpygame.mouse.get_pressed()などを使うやり方は、クリックをしている間ずっと検出し続けるので、検出漏れはありません。 しかし一方で、一度のクリックで何回分もクリックが検出されてしまうので、場合によっては使い勝手がよくありません。

例えば、ボタン押下でレベルアップを行う処理を行う場合、1回だけレベルアップしたつもりが、何回も(限界まで)レベルアップが行われてしまう、ということになります。


1回のクリックで1回のクリック検出を行いたいときは、二つ目の方法で実装する必要があります。

2番目の方法は SDL のイベントキューを使うことだ。 このキューはイベントのリスト -- イベントが 検出されると、リストに追加される -- になっている。 そしてこれらのイベントは取り出されるとリストから消える。

こちらはpygame.event.get()を利用する方法です。 これを使うときに注意しなければいけないのは、pygame.event.get()を呼び出すたびに"イベントが取り出されてリストから消える"ということです。

つまり、複数の場所でpygame.event.get()を書いてしまい、一つのゲームループの中で複数回pygame.event.get()を呼び出してしまうと、検出漏れが起きてしまうということです。

そこで、今回私は次のようにゲームループの中で1回pygame.event.get()を呼び出し、その結果を各LayerManagerに渡すというやり方をしました。

def gameLoop():
・
・中略
・
    while not game_exit:
        for event in pygame.event.get():
            if event.type == pygame.MOUSEBUTTONUP and event.button == 1:
                # cellLayerManeger uses pygame.mouse.get_pressed()
                treeLayerManager.setMouseEventUp(True)
                caveLayerManager.setMouseEventUp(True)

for event in pygame.event.get():とすることで、各マウスクリックやキーボードのボタン押下状態を一つずつ取り出して検証することができます。 今回の場合だと、event.type == pygame.MOUSEBUTTONUPでマウスボタンがUPしたことを検出し、event.button == 1でそれが左クリックであることを認識しています。

それを各LayerManagerに作成したsetMouseEventUp()メソッドにTrueとして渡しているのはとてもダサいですが、動くのでまあ良しとしました。

同様のやり方でキーボード押下や閉じるボタンの処理を記述することができます。 詳しくはソースコードや、pygameのドキュメントをご参考ください。


画像ファイルの読み込み

画像ファイルの読み込みにも、すこし注意が必要でした。

当初、私は単純に次のようにして画像を読み込んでいました。

img = pygame.image.load('img.png')

しかし、これはあまり上手いやり方ではありません。 convert()を使っていないからです。

初心者のためのpygameガイドにも、次のようにあります。

最初に surface.convert() の説明を読んだとき、 ぼくはそれがそんなに気にかけるほどのものだとは思わなかった。 「自分は png しか使わないから、ぼくが扱うものはすべて 同一の形式になるはずだ、だから convert() なんか必要ない」 -- でもこれは激しく間違っていることがわかった。

私もまったく同じ状況でした。

convert()の機能は、pygameドキュメントによると、

何も引数を定しなかった場合は、SurfaceはディスプレイのSurfaceと同じピクセル形式になります。この方法を行うと、常に使用ディスプレイ環境での画像描写に最も高速化された形式となります。画像描写処理を行う前には、Surface.convert()命令でピクセル形式を最適な状態に変更するとよいでしょう。

あまりよく分かりませんが、「ピクセル形式」をディスプレイと同じに変えることで、画像描写処理を高速化してくれるそうです。

convert()を適用するには、次のようにして画像を読み込みます。

img = pygame.image.load('img.png').convert()

末尾に.convert()をつけるだけです。 ちなみに、png画像などで透明度を保持する必要がある場合は.convert_alpha()を使います。

私の場合、背景もpng画像で表示していたため、このconvert()/convert_alpha()を適用することで画像描写処理が劇的に改善しました。 fpsで言えば、大体3倍以上の違いが出ました。

画像の読み込み時にはconvert()を忘れないことが大切です。


キャラクターの作画と描画

キャラクターの作画と描画処理についても、少し書いておきます。

まず、絵描きツールは、次の二つを使いました。

Inkscape
無料のベクターグラフィックツール。
ペイントツールでフリーハンドで書くよりも、Inkscapeで図形や線・曲線を組み合わせて描いた方が絵心の無さがごまかせるので、こちらをメインに使っていました。

FireAlpaca
無料のペイントツール。
こちらでキャラクターそのものを書くことは少なかったですが、レイヤーエフェクトをかけるのがとても簡単なので時折使っていました。

基本的にはInkscapeでキャラクターを描き、FireAlpacaでエフェクトをかける、ということをしていました。

エフェクトをかける、というと大げさですが、画像全体の色味を少し変えるという程度の話です。 マウスオーバー時にキャラクターが少し明るくなるような仕様にしたかったため、各キャラクターに色違いの画像を用意しました(Fig. 6)。

Fig. 6 マウスオーバー時に表示する画像を変える

マウスオーバー時に表示画像を入れ替える描画処理は、CellTreeなどのキャラクタークラスのdraw()メソッドに、次のようにして実装しました。

def draw(self):
    mouse = pygame.mouse.get_pos()
    if self.x+self.width > mouse[0] > self.x and self.y+self.height > mouse[1] > self.y:
        game_display.blit(self.act_img, (self.x, self.y))
    else:
        game_display.blit(self.inact_img, (self.x, self.y))

mouse = pygame.mouse.get_pos()でマウス位置を取得し、X方向mouse[0]とY方向mouse[1]が画像位置内に収まっていればマウスオーバー用の画像act_imgを、そうでなければ元の画像inact_imgblit()で表示させるのです。

このキャラクタークラスのdraw()メソッドはLayerManagerクラスのdraw()メソッドが呼び出し、そして今度はゲームループがLayerManagerクラスのdraw()メソッドを呼び出すことで、画面にキャラクターが描画されるというやり方をとりました。

反省点

今回のこのプロジェクトの目的は、とりあえず何かを作ってみる、ということでした。 なので、出来上がったものにはたくさんの反省点があります。

まず、ゲームバランスの調整をほとんどやってないことです。 作ることが目的だったために、誰かに遊ばれることを想定していません。 なので、ゲームの肝となるゲームバランス調整には、ほとんど時間を割いていません。

加えて、このゲームにはセーブ機能がありません。 このような放置系ゲームにおいては致命的でしょう。 pygameでセーブ機能を実装するのは少し厄介そうだったので、断念しました。 志が湧いてきたら、挑戦してみるのも面白いかもしれません。

コードについては見返すたびに反省点が浮かびますが、まずはソースコードを一つのファイルに詰め込んだのはよくなかったと思います。 参考にしたゲーム制作チュートリアルがオブジェクト指向でなかったため、そのまま中途半端な感じになってしまいました。 各クラス間の依存関係を薄くして、それぞれファイルを分けた方が見やすいですし、再利用もしやすいでしょう。

また、今回はクラス継承という機能を利用しませんでした。 似たようなクラスがあるにも関わらず、それぞれが特に親クラスを持っていないため、共通項が取れずにコードが読みずらくなっています。

次また何か作るときは、改善していきたいです。


おわりに

今回は、Pythonでゲームを自作してみました。

プログラミング練習のようなものではなく、自分の頭で考えて作った初めてのものになります。 おかげでいろいろと面白い経験ができました。

思えば結構な時間がかかりました。
朝にコーヒーを飲みながらのコーディングは、ささやかな楽しみでもありました。

今回の記事はかなり長いものになってしまいました。
やりたいことは他にもたくさんあります。
この辺でこのゲームに区切りをつけ、次に行きたいと思います。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

YouTube

初心者のための pygame ガイド

A Newbie Guide to pygame — pygame v2.5.0 documentation

Table of Contents · Game Programming Patterns

Reddit - Dive into anything

クッキークリッカー - Wikipedia

event - Pygameドキュメント 日本語訳

surface - Pygameドキュメント 日本語訳

Draw Freely | Inkscape

フリー ペイントツール(Mac/Windows 両対応)FireAlpaca[ファイア アルパカ]

実験 | 画像をtxtファイルから3Dグラフで可視化したい | Python

txtファイルとして出力した画像ファイルを、Pythonを用いて3Dプロットで可視化してみます。


はじめに

こんにちは、@bioerrorlogです。

先日、画像ファイルというものは所詮数値に過ぎないと改めて実感する出来事がありました。 画像解析ソフトFiji1で画像解析をしていると、面白いものを見つけたのです。 名前をつけて保存"Save as"の欄に、"Text Image"というファイル形式がありました。

画像ファイルの保存形式としてはJPEGやPNGなどを使っていましたが、"Text Image"形式とはどういうことでしょうか。 試してみると、0から255までの数値で各ピクセルが表現されたtxtファイルが出力されました(Fig. 1)。

Fig. 1 画像ファイルをtxtファイルとして出力。右のtxtデータは一部抜粋したもの。

ひとつ思いついたのは、このtxtファイルをもとにして、3Dグラフを描画したいということです。 画像ファイルが数値の連なりに過ぎない、ということを実感できるいい経験になると思います。

それではPythonを使ってやっていきます。

作業環境

Pythonバージョン:

$ python3 --version
Python 3.6.7


Ubuntu18.04.1 LTS を
Windows10の上に、VMwareによって構築した仮想環境で起動しています。
www.bioerrorlog.work


画像をtxtファイルから3Dグラフで可視化する

txtファイルの読み込み | pd.read_csv()

まずは、Fijiで出力したtxtファイル"peterpan_syndrome.txt"をpandasのread_csv()で読み込んで、行数と列数を出力してみます。 正しく読み込めていれば、画像のピクセル数"796 800"が表示されるはずです。

import pandas as pd

img_txt = pd.read_csv('peterpan_syndrome.txt',sep='\t',header=None)

row_count = img_txt.shape[0]
col_count = img_txt.shape[1]
print(col_count,row_count)
796 800#出力

予想通りの出力が得られ、正しく読み込めたのがわかります。

ちなみに、txtファイルだからといってread_table()で読み込もうとすると、

FutureWarning: read_table is deprecated, use read_csv instead, passing sep='\t'.
  img_txt = pd.read_table('peterpan_syndrome.txt',header=None)

read_table()は良くないからread_csv()でsep='\t'を使いなさいと怒られてしまいました。


XYの用意 | np.arange() / np.meshgrid()

私が描画しようとしている3Dグラフは、X軸とY軸がそれぞれ画像のwidthとHeightに相当し、Z軸が格子状にtxtファイルの値を示すものです。

そこでX軸とY軸を得るために、xyに1間隔でそれぞれのピクセル数を上限とした配列を与え、そこから格子列XYを生成します。

import numpy as np

x = np.arange(0, col_count, 1)
y = np.arange(0, row_count, 1)

X, Y = np.meshgrid(x, y)

これで、必要なXYを規定できました。

ここからは、3Dグラフにプロットしていきます。


3Dグラフプロット | mplot3d

ModuleNotFoundError: No module named 'tkinter'

しかし、3dグラフの描画に必要なモジュールのひとつmatplotlibをインポートをした際に、エラーを吐かれてしまいました。

from matplotlib import pyplot as plt
~
ModuleNotFoundError: No module named 'tkinter'

'tkinter'モジュールがない、と怒られてしまった訳です。 しかしここで、'tkinter'モジュールをインストールしようとしても、上手くいきませんでした。

解決策を探すと、同じ問題に当たった人を見つけました。

stackoverflow.com

これによると、'python3-tk'をインストールすればいいようです。

$ sudo apt install python3-tk

私もこれで、上記のエラーが解消されました。


mplot3dで3Dグラフを描画する

それでは、ついに3Dグラフを描画します。

3Dグラフの描画には、mplot3 Toolkitという便利なライブラリがあるようです。

https://matplotlib.org/tutorials/toolkits/mplot3d.html#sphx-glr-tutorials-toolkits-mplot3d-pymatplotlib.org

このmplot3 Toolkitドキュメントにしたがって、3Dグラフの描画処理を書いていきます。 今回は、きれいなグラフになりそうなFilled contour plots "contourf()"を用いて描画しました。

from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

fig = plt.figure()
ax = Axes3D(fig)

ax.contourf(X,Y,img_txt)

plt.show()

これで、上手くいくはずです。 ここまでのコードをすべてまとめます。

import pandas as pd
from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np

img_txt = pd.read_csv('peterpan_syndrome.txt',sep='\t',header=None)
row_count = img_txt.shape[0]
col_count = img_txt.shape[1]

x = np.arange(0,col_count,1)
y = np.arange(0,row_count,1)

X, Y = np.meshgrid(x, y)

fig = plt.figure()
ax = Axes3D(fig)

ax.contourf(X,Y,img_txt)

plt.show()

これを実行すると、じつに美しい3Dプロットが得られました(Fig. 2)。

お見事です。

Fig. 2 画像のtxtファイルから得られた3Dプロット


おわりに

今回は、画像をtxtファイルで出力し、それをもとにPythonで3Dプロットを行いました(Fig. 3)。

Fig. 3 png画像からtxtファイル、そして3Dグラフへ変遷した

このように、私のような経験の浅いものでも簡単にプロットを行えるのが、Pythonのいいところでしょう。 今回使用したmplot3dも、公式のチュートリアルドキュメントが大変充実していました。

ところで、今回使ったFilled contour plots以外にも、mplot3には多くの描画形式がありました。 また、カラーなどの多くのオプションもあり、まだまだそれらも使いこなせていません。

Pythonでのデータ可視化というのも、いろいろ遊んで慣れていきたいものです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work


  1. Fiji: ImageJ, with "Batteries Included" - 生物系では広く利用されているオープンソースの画像解析ソフトです。

Ubuntuでpip/pip3がインストールできないときの対処法 | Python

Ubuntuでは、Pythonのpip/pip3がプリインストールされていませんでした。
これらのインストールに予想外にもつまずいたので、記録を残します。

はじめに

こんにちは、@bioerrorlogです。

Pythonにパッケージをインストールするときには、pipコマンドが便利です。 しかし、Ubuntuにはデフォルトでpipがインストールされていません。

試しにpandasをpipインストールしてみると、

$ pip install pandas

Command 'pip' not found, but can be installed with:

sudo apt install python-pip

エラーが表示され、代わりにpipのインストール手順が示されます。

しかし、この提示されたインストール手順も、そのままではうまく機能しませんでした。

試行錯誤の結果pip / pip3のインストールに成功したので、その記録を残します。

作業環境

Ubuntu18.04.1 LTS を
Windows10の上に、VMwareによって構築した仮想環境で起動しています。
www.bioerrorlog.work

Ubuntuでpip/pip3をインストールする

pipとpip3は違う

まず、前提としてpipとpip3の違いについて確認させてください。

pip3はPython3を指定したpipコマンド、pipはPython2/3のどちらにも設定できるメインのpipコマンド、という整理のようです。

Pythonの「pip」と「pip3」は何が違う?

お恥ずかしい話、当初pipコマンドはすべてpipで、pip3はおまけのようなものだと思っていました。

しかし、UbuntuのようにPython2とPython3が両方インストールされている場合には、この違いが無視できません。 デフォルトではpipと打てばPython2の、pip3と打てばPython3のpipとして機能します。

逆に、Python2かPython3のどちらか片方しかインストールしていない場合は、pipと打てば該当のPythonが指定されます。

まさに、Pythonプログラムを実行する時のpython/python3コマンドと同じでしょう。 Pyhon3で実行するときはpython3を用い、単にpythonで実行すれば、Python2で実行されます。

今回は、pipとpip3をそれぞれ別々にインストールしていきます。

apt installが機能しない
: pip× / pip3×

それでは、pipとpip3をインストールしてみます。

上述のように、pipを使おうとすると次のインストール手順が提示されます。

pip:sudo apt install python-pip

$ pip install pandas

Command 'pip' not found, but can be installed with:

sudo apt install python-pip


pip3:sudo apt install python3-pip

$ pip3 install pandas

Command 'pip3' not found, but can be installed with:

sudo apt install python3-pip


しかし、このどちらもエラーを吐かれてしまい上手くいきません。 とりあえず別の方法を探します。

"get-pip.py"からpipをインストールする
: pip○ / pip3×

しばらく解決方法を探していると、pipをインストールする他の方法を見つけました。

How to Install Pip on Ubuntu 16.04 LTS - Liquid Web

これによると、"get-pip.py"をあるURLから取得・実行すればいいようです。

URLから"get-pip.py"ファイルを取得するにはcurlコマンドを使います。 デフォルトではインストールされていないので、aptでインストールします。

$ sudo apt install curl

インストールが終わったら、"get-pip.py"ファイルをcurlで取得します。

$ curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py"

これで、"get-pip.py"ファイルが取得できました。

この"get-pip.py"を、pythonコマンドで実行します。

$ sudo python get-pip.py

これで"get-pip.py"が実行され、pipがインストールされました。

pipのインストールを--versionで確認すると、

$ pip --version
pip 19.0.3 from /usr/local/lib/python2.7/dist-packages/pip (python 2.7)

確かにpipがインストールされています。


次は、pip3のインストールを試みます。 "get-pip.py"のコードの中身を少し読んでみるとPython2とPython3の両方の環境に対応していたため、pipと同じ要領で"get-pip.py"をpython3で実行してみます。

$ sudo python3 get-pip.py 
~
ModuleNotFoundError: No module named 'distutils.util'

しかしこのやり方ではエラーを吐かれてしまい、なかなか解消することが出来ませんでした。

apt updateしてからapt installする
: pip○ / pip3○

解決策を探して長いこと右往左往していましたが、実に単純な操作で解決しました。 apt installする前にアップデートしてみたら、上手くいったのです。

アップデートは次のふたつの手順で行いました。

$ sudo apt update
~
$ sudo apt upgrade
~

アップデート終了後に再びpip3をインストールしてみると、

$ sudo apt install python3-pip

見事、今度はエラーなくインストールに成功しました。

pip3がちゃんとインストールされたかを--versionで確認すると、

$ pip3 --version
pip 9.0.1 from /usr/lib/python3/dist-packages (python 3.6)

確かにpip3がインストールされていることが分かります。

結論:アップデートしてからapt installすべし

まず行うべきはアップデートです。

$ sudo apt update
$ sudo apt upgrade

ただし、このupdate/upgradeの両方が必要なのか、updateだけで大丈夫なのか、私にはいまいち理解できていません1


その後、pipとpip3をそれぞれapt installします。

$ sudo apt install python-pip
$ sudo apt install python3-pip


上手くいかなければ、"get-pip.py"からpipとpip3をそれぞれインストールします。

$ curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py"
$ sudo python get-pip.py
$ sudo python3 get-pip.py

おわりに

今回は、Ubuntuでpip/pip3をインストールときの試行錯誤を記録しました。

蓋を開ければ解決方法は単純で、インストール前にアップデートしておくというものです。 これはpip/pip3のインストールのみならず、他の多くのパッケージでも適応できる教訓だと思うので、肝に銘じておきます。

ところで、updateとupgradeは具体的にどのような操作を行っているのでしょうか。 漠然とイメージが掴めるばかりに、実際の処理を私は理解できていません。

知らないことが増えていくのは、楽しいことです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

参考

Pythonの「pip」と「pip3」は何が違う?

How to Install Pip on Ubuntu 16.04 LTS - Liquid Web


  1. 多くのネット上の情報で助言されているのは、updateのみでした。