AWS CDKで、cdk deployしたら「Unable to resolve AWS account to use」エラー

AWS CDKを使って cdk deploy したところ

$ cdk deploy
Unable to resolve AWS account to use. It must be either configured when you define your CDK or through the environment

というエラーが発生したため、対応した時のメモ。

 

環境

 

原因と対応

CDK CLIdoctor コマンドを使って確認したところ

$ cdk doctor
ℹ️ CDK Version: 1.9.0 (build 30f158a)
ℹ️ AWS environment variables:
  - AWS_SECRET_ACCESS_KEY = <redacted>
  - AWS_REGION = us-east-1
  - AWS_ACCESS_KEY_ID = AK<redacted>
  - AWS_S3_GATEWAY = http://http://s3.amazonaws.com
ℹ️ No CDK environment variables

と、自分のAWS CLIのconfigとは異なる値(AWS_REGION)や、設定していない値(AWS_S3_GATEWAY)が出てきました。

 
何か環境変数を指定したかなと思い確認したところ、

$ export -p
...
declare -x AWS_ACCESS_KEY_ID="AK***"
declare -x AWS_REGION="us-east-1"
declare -x AWS_S3_GATEWAY="http://http://s3.amazonaws.com"
declare -x AWS_SECRET_ACCESS_KEY="***"

と、別のところで使った時の環境変数を消し忘れているようでした。

 
そのため、 cdk doctor に出てきた環境変数を消しました。

$ unset AWS_S3_GATEWAY
$ unset AWS_REGION
$ unset AWS_SECRET_ACCESS_KEY
$ unset AWS_ACCESS_KEY_ID

 
再度実行したところ、問題なくデプロイできました。

$ cdk deploy
step-functions: deploying...
step-functions: creating CloudFormation changeset...
 0/2 | 8:12:22 PM | UPDATE_IN_PROGRESS   | AWS::CDK::Metadata               | CDKMetadata 

 ✅  step-functions

AWS CDK + Pythonで、ネストした AWS StepFunctions のワークフローを作ってみた

今年の7月にAWS CDK (Cloud Development Kit) がGAとなりました。
AWS クラウド開発キット (CDK) – TypeScript と Python 用がご利用可能に | Amazon Web Services ブログ

APIリファレンスも公開されているため、これでPythonを使ってAWSのリソースを作成することができるようになりました。
API Reference · AWS CDK

 
ただ、ドキュメントではTypeScriptの書き方がメインであり、Pythonでどう書くのかイマイチ分かりませんでした。

そこで、AWS CDK + Pythonで、ネストした AWS StepFunctions のワークフローを作ってみることにしました。

 
なお、記事の末尾にもあるように、ソースコードは公開しています。この記事はそのソースコードの解説です。

 

目次

 

環境

 
今回作成するStepFunctionsのワークフローです。

メインのStateMachineを実行します。サブのStateMachineを3パラレルで起動します。

f:id:thinkAmi:20190929163040p:plain:w300

 
サブの方は、各タスクにエラーハンドリングが付いています。

f:id:thinkAmi:20190929163143p:plain:w250

 
各StateMachineの機能です。

  • メイン
    • LambdaからS3へファイルを保存
    • サブのStateMachineを3パラレルで起動
  • サブ
    • Lambdaを実行

 

環境構築

Getting Startedに従い、環境を構築します。
Getting Started With the AWS CDK - AWS Cloud Development Kit (AWS CDK)

AWS CDKのCLIをインストールします。

$ npm install -g aws-cdk

$ cdk --version
1.9.0 (build 30f158a)

$ mkdir step_functions

$ cd step_functions/

 

cdk initPythonを使ったCDK環境を作成します。

$ cdk init --language python
Applying project template app for python
Executing Creating virtualenv...


# ちなみに、ディレクトリの中に何かファイルがあるとエラー
$ cdk init --language python
`cdk init` cannot be run in a non-empty directory!

 
Pythonの仮想環境が準備されるため、activate後にインストールします。

$ source .env/bin/activate

$ pip install -r requirements.txt

 
次にAWS CDKで使うリソースに対するモジュールを追加でインストールします。

どのモジュールが必要なのかは、APIリファレンスのトップに記載されています。*1

 
まずは、StepFunctionまわりで必要なモジュールをインストールします。

$ pip install aws_cdk.aws_stepfunctions

$ pip install aws_cdk.aws_stepfunctions_tasks

 
次にLambdaまわり。

$ pip install aws_cdk.aws_lambda

 
S3バケットにファイルを保存するため、S3のモジュールも必要です。

$ pip install aws_cdk.aws_s3

 
あとは、S3バケットにファイルを保存するために、IAMロールも必要になります。

$ pip install aws_cdk.aws_iam

 

作成順について

AWS CDKでは、下位のリソースから順に作成します。

今回は

  1. S3
  2. IAM Managed Policy
  3. IAM Role
  4. Lambda
  5. サブのStatemMachine (Step Functions)
  6. メインのStatemMachine (Step Functions)

の順で作成します。

 
Pythonでは、 core.Stack を継承したクラスの __init__() にて、作成したいリソースのオブジェクトを生成します。

class StepFunctionsStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        # S3バケットを生成
        self.bucket = self.create_s3_bucket()
        # 管理ポリシーを生成
        self.managed_policy = self.create_managed_policy()
        ...

 

S3バケットの作成

ドキュメントに従い、S3バケットを新規作成します。
class Bucket (construct) · AWS CDK

 
ドキュメントはTypeScript形式で書かれています。

new Bucket(scope: Construct, id: string, props?: Bucket<wbr>Props)

Pythonに読み替えた時の書き方です。

def create_s3_bucket(self):
    return Bucket(
        self,  # scope
        'S3 Bucket',  # id
        bucket_name=f'sfn-bucket-by-aws-cdk',  # propsのbucketName
    )

Pythonで読み替える時のポイントは以下です。

  • scopeは self で読み替え
  • 引数名は、snake_caseで読み替え
  • props にて指定可能なキーと値は、ドキュメントの Construct Props に記載

 
ちなみに、もし既存のS3バケットを利用したい場合は

  • fromBucketArn()
  • fromBucketAttributes()
  • fromBucketName()

などの静的メソッドを使います。

これにより、既存のS3バケットのオブジェクトが取得できるので、他のリソースでの指定が可能になります。

 

IAM Managed Policy (管理ポリシー)の作成

今回は、S3バケットにアクセスする管理ポリシーを作成します*2
class ManagedPolicy (construct) · AWS CDK

 
Managed Policyを作成するには、

  1. PolicyStatement
  2. ManagedPolicy

の順でオブジェクトを作成します。

 

PolicyStatementの作成

ドキュメントに従い作成します。
class PolicyStatement · AWS CDK

なお、resourcesでは、上記で作成したBucketのARNを参照する必要があります。

ドキュメントにあるように、Bucketオブジェクトのプロパティ bucket_arn でARNを参照します。
https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-s3.Bucket.html#properties

def create_managed_policy(self):
    statement = PolicyStatement(
        effect=Effect.ALLOW,
        actions=[
            "s3:PutObject",
        ],
        resources=[
            f'{self.bucket.bucket_arn}/*',
        ]
    )

 

ManagedPolicyの作成

こちらもドキュメント通りです。
class ManagedPolicy (construct) · AWS CDK

return ManagedPolicy(
    self,
    'Managed Policy',
    managed_policy_name='sfn_lambda_policy',
    statements=[statement],
)

 

IAMロールを作成

次に、LambdaからS3バケットへアクセスするためのIAMロールを作成します。

まずは、 ServicePrincipal オブジェクトでLambdaを指定します。
class ServicePrincipal · AWS CDK

def create_role(self):
    service_principal = ServicePrincipal('lambda.amazonaws.com')

 
次に、 Role オブジェクトを作成します。
class Role (construct) · AWS CDK

ポイントは以下です。

  • assumed_by に、ServicePrincipalオブジェクトを指定
  • managed_policies に、作成したManaged Policyを指定
return Role(
    self,
    'Role',
    assumed_by=service_principal,
    role_name='sfn_lambda_role',
    managed_policies=[self.managed_policy],
)

 

Lambdaの作成

今回は4つのLambdaを作成します。

Lambda名 用途
sfn_first_lambda S3にファイルを保存
sfn_second_lambda サブStateMachineの1番目のLambda
sfn_third_lambda サブStateMachineの2番目のLambda
sfn_error_lambda sfn_second_lambda・sfn_third_lambdaでエラーが起きた時に実行されるLambda

 
なお、CDKで使うLambda本体は、どこかのディレクトリに入れておけばOKです。

CDKのFunctionオブジェクトを生成する際に、そのディレクトリパスを指定することで、CDKがzip化・アップロードまで面倒を見てくれます。

 

S3にファイルを保存するLambdaを作成
Lambda本体

lambda_function/first/lambda_function.py として作成します。

ポイントは以下です。

  • boto3でS3へアップロードする
  • NumPyを使って値を取得する
  • 環境変数 BUCKET_NAME で、保存先のS3バケット名を受け取る
  • Lambdaのパラメータとして message が渡されてくる
    • StateMachineのInputより渡されることを想定
  • 戻り地は、 bodymessage を持つdict
import os
import boto3
from numpy.random import rand


def lambda_handler(event, context):
    body = f'{event["message"]} \n value: {rand()}'
    client = boto3.client('s3')
    client.put_object(
        Bucket=os.environ['BUCKET_NAME'],
        Key='sfn_first.txt',
        Body=body,
    )

    return {
        'body': body,
        'message': event['message'],
    }

 

CDKでLambdaリソース(Function)を作成

まずは、Lambda本体のあるファイルパスを使って、AssetCodeオブジェクトを生成します。
class AssetCode · AWS CDK

function_path = str(self.lambda_path_base.joinpath('first'))
code = AssetCode(function_path)

 
次にNumPyを用意します。

Lambdaでは、NumPyのようによく使われるモジュールは Lambda Layers として用意されていますので、今回もこちらを利用します。
新機能 – AWS Lambda :あらゆるプログラム言語への対応と一般的なコンポーネントの共有 | Amazon Web Services ブログ

 
LayerVersionオブジェクトにて、既存のLambda Layersを扱えます。
class LayerVersion (construct) · AWS CDK

今回はARNを指定してNumPyのLayerを取得するため、静的メソッド from_layer_version_arn() を使います。

scipy_layer = LayerVersion.from_layer_version_arn(
    self, f'sfn_scipy_layer_for_first', AWS_SCIPY_ARN)

なお、Lambda LayersのARNについては、AWSアカウントごとに異なるようです。

そのため、一度、Lambda Consoleにて、該当LayerのARNを確認する必要があります。

 
最後に、FunctionオブジェクトでLambdaリソースを生成します。
class Function (construct) · AWS CDK

return Function(
    self,
    f'id_first',
    # Lambda本体のソースコードがあるディレクトリを指定
    code=code,
    # Lambda本体のハンドラ名を指定
    handler='lambda_function.lambda_handler',
    # ランタイムの指定
    runtime=Runtime.PYTHON_3_7,
    # 環境変数の設定
    environment={'BUCKET_NAME': self.bucket.bucket_name},
    function_name='sfn_first_lambda',
    layers=[scipy_layer],
    memory_size=128,
    role=self.role,
    timeout=core.Duration.seconds(10),
)

 
ちなみに、Layerを自分で作成したい場合は以下のようにします。

Codeオブジェクトのリファレンスは以下です。
class Code · AWS CDK

LayerVersion(
    self,
    'layer_id',
    code=Code.from_asset('your_zip_filepath'),
    compatible_runtimes=[Runtime.PYTHON_3_7],
    layer_version_name='layer_version_name',
)

 

残りのLambda
2番目のLambda本体

エラーハンドリングしたいため、パラレル番号が偶数の場合はエラーとしています。

また、 result_path で結果をわかりやすく表示したいため、戻り値も絞っています。

def lambda_handler(event, context):
    if event['parallel_no'] % 2 == 0:
        raise Exception('偶数です')

    return {
        'message': event['message'],
        'const_value': event['const_value']
    }

 

3番目のLambda本体

こちらもエラーハンドリングしたいため、パラレル番号が1の場合はエラーとしています。

また、最後は文字列を返すようにしました。

def lambda_handler(event, context):
    if event['parallel_no'] == 1:
        raise Exception('強制的にエラーとします')

    return 'only 3rd message.'

 

エラーハンドリングのLambda本体

タスクでエラーが発生した場合は

{
  "resource": "arn:aws:lambda:region:id:function:sfn_error_lambda",
  "input": {
    "Error": "Exception",
    "Cause": "{\"errorMessage\": \"\\u5076\\u6570\\u3067\\u3059\",
               \"errorType\": \"Exception\",
               \"stackTrace\": [\"  File \\\"/var/task/lambda_function.py\\\", line 5,
                  in lambda_handler\\n    raise Exception('\\u5076\\u6570\\u3067\\u3059')
              \\n\"]}"
  },
  "timeoutInSeconds": null
}

という値が渡されてきます。

この中の CauseJSON文字列のため、Lambdaで見えるようにして返します。

def lambda_handler(event, context):
    return {
        # JSONをPythonオブジェクト化することで、文字化けを直す
        'error_message': json.loads(event['Cause']),
    }

 

Functionオブジェクト

ほぼ同じ内容なので一つのメソッドで生成しています。

def create_other_lambda(self, function_name):
    function_path = str(self.lambda_path_base.joinpath(function_name))

    return Function(
        self,
        f'id_{function_name}',
        code=AssetCode(function_path),
        handler='lambda_function.lambda_handler',
        runtime=Runtime.PYTHON_3_7,
        function_name=f'sfn_{function_name}_lambda',
        memory_size=128,
        timeout=core.Duration.seconds(10),
    )
    
# 使う時
self.second_lambda = self.create_other_lambda('second')
self.third_lambda = self.create_other_lambda('third')
self.error_lambda = self.create_other_lambda('error')

 

サブのStateMachine (Step Functions) 作成

概要 (InputPath・OutputPath・ResultPathを試す)

ここまででパラレル実行する、サブのStateMachineのリソースが用意できました。

それらを組み合わせて、サブのStateMachineを作成していきます。

 
StateMachineでは、タスクという単位で処理を定義します。
タスク - AWS Step Functions

サブのStateMachineでは3つのタスクを用意します。

今回、InputPath・OutputPath・ResultPathを試そうと考えました。

 
そこで、こんな感じの設定にしました。

タスク名 Lambda InputPath ResultPath OutputPath
Second Task sfn_second_lambda $['first_result', 'parallel_no', 'message', 'context_name', 'const_value] $.second_result' ['second_result', 'parallel_no']
Third Task sfn_third_lambda $ (Lambdaの戻り値で上書き)
Error Task sfn_error_lambda

 
Second Taskでは、

  • InputPath
    • 前のタスクの結果から 'first_result', 'parallel_no', 'message', 'context_name', 'const_value だけ受け取って処理する
  • OutPath
    • Lambdaの戻り値を、入力値に second_result という項目を追加する
  • ResultPath
    • 次のタスクには 'second_result', 'parallel_no' のみ渡す

とします。

 
また、Third Taskでは、

  • ResultPath
    • 入力値をすべて捨て、Lambdaの戻り値だけを出力する

とします。

 

実際のソースコード

まず、複数タスクで使うため、1つのエラータスクを作成します。

Lambdaを起動するためにはInvokeFunctionクラスを使います。引数としてLambdaオブジェクトを設定します。
class InvokeFunction · AWS CDK

error_task = Task(
    self,
    'Error Task',
    task=InvokeFunction(self.error_lambda),
)

 
次に、Second Taskを生成します。

InputPath・OutputPath・ResultPathに対応する引数があるため、それぞれ指定します。

second_task = Task(
    self,
    'Second Task',
    task=InvokeFunction(self.second_lambda),

    # 渡されてきた項目を絞ってLambdaに渡す
    input_path="$['first_result', 'parallel_no', 'message', 'context_name', 'const_value]",

    # 結果は second_result という項目に入れる
    result_path='$.second_result',

    # 次のタスクに渡す項目は絞る
    output_path="$['second_result', 'parallel_no']"
)

 
Second Taskの中でエラーが発生した場合のハンドリングをするため、 add_catch でエラーが発生した時のタスクを追加します。
エラー処理 - AWS Step Functions

second_task.add_catch(error_task, errors=['States.ALL'])

 
Third Taskを作成します。こちらもエラーハンドリングを追加します。

third_task = Task(
    self,
    'Third Task',
    task=InvokeFunction(self.third_lambda),

    # third_lambdaの結果だけに差し替え
    result_path='$',
)
# こちらもエラーハンドリングを追加
third_task.add_catch(error_task, errors=['States.ALL'])

 
次に、Second Taskの後にThird Taskを実行できるよう、 next() にて設定します。

definition = second_task.next(third_task)

 
最後に、StateMachineを作成します。

return StateMachine(
    self,
    'Sub StateMachine',
    definition=definition,
    state_machine_name='sfn_sub_state_machine',
)

 

メインのStateMachine (Step Functions) の作成

最後のリソースとして、メインのStateMachineを作成します。

 
まずは First Task を作成します。ここは先程と同じです。

first_task = Task(
    self,
    'S3 Lambda Task',
    task=InvokeFunction(self.first_lambda, payload={'message': 'Hello world'}),
    comment='Main StateMachine',
)

 
次のタスクがパラレル実行するサブのStateMachineです。

最初にParallelオブジェクトを生成します。
class Parallel (construct) · AWS CDK

parallel_task = Parallel(
    self,
    'Parallel Task',
)

 
次に、パラレル実行の設定を行います。流れは以下となります。

  1. StartExecution オブジェクトで、使用するStateMachineやInputなどを指定します。
    class StartExecution · AWS CDK

  2. StartExecutionTask に渡します。

  3. TaskParallel.branch() に渡します。

for i in range(1, 4):
    # 1.
    sub_task = StartExecution(
        self.sub_state_machine,
        input={
            'parallel_no': i,
            'first_result.$': '$',

            # first_taskのレスポンスにある、messageをセット
            'message.$': '$.message',

            # コンテキストオブジェクトの名前をセット
            'context_name.$': '$$.State.Name',
            # 固定値を2つ追加(ただ、Taskのinputでignore_valueは無視)
            'const_value': 'ham',
            'ignore_value': 'ignore',
        },
    )

    # 2.
    invoke_sub_task = Task(
        self,
        f'Sub Task {i}',
        task=sub_task,
    )

    # 3.
    parallel_task.branch(invoke_sub_task)

 

以上でStepFunctionsが完成しました。

 

$と$$について

上記例では、

  • 'message.$': '$.message'
  • 'context_name.$': '$$.State.Name'

としている部分がありました。

$$$ については、それぞれ以下の意味となります。

$ について

パスを使用して値を選択するキーと値のペアの場合、キーの名前は .$ で終わる必要があります。 InputPath およびパラメータ - AWS Step Functions

 
$$ について

コンテキストオブジェクトにアクセスするには、パスを使用して状態の入力を選択したときと同様に、.$ を末尾に追加したパラメータ名をまず指定します。次に、入力の代わりにコンテキストオブジェクトデータにアクセスするには、$$. をパスの先頭に追加します。

コンテキストオブジェクト - AWS Step Functions

 

実行結果

ではStepFunctionsのConsoleより実行してみます。

 

メインのStateMachine

f:id:thinkAmi:20190929170656p:plain:w300

S3 Lambda TaskのLambdaFunctionScheduled

{
  "resource": "arn:aws:lambda:region:account_id:function:sfn_first_lambda",
  "input": {
    "message": "Hello world"
  },
  "timeoutInSeconds": null
}

S3 Lambda TaskのTaskStateExited

{
  "name": "S3 Lambda Task",
  "output": {
    "body": "Hello world \n value: 0.035671270119142284",
    "message": "Hello world"
  }
}

 

サブのStateMachine

3パターンあるため、それぞれ記載します。

  • すべて成功
  • Second Taskでエラー
  • Third Taskでエラー
すべて成功

f:id:thinkAmi:20190929171108p:plain:w300

Second TaskのLambdaFunctionScheduledでは、設定した

input_path="$['first_result', 'parallel_no', 'message', 'context_name', 'const_value']",

の通りのinputとなっています。

{
  "resource": "arn:aws:lambda:region:account_id:function:sfn_second_lambda",
  "input": {
    "first_result": {
      "body": "Hello world \n value: 0.035671270119142284",
      "message": "Hello world"
    },
    "parallel_no": 3,
    "message": "Hello world",
    "context_name": "Sub Task 3",
    "const_value": "ham"
  },
  "timeoutInSeconds": null
}

 
Second TaskのTaskStateExitedでも、

result_path='$.second_result',
output_path="$['second_result', 'parallel_no']"

の通りのoutputです。

{
  "name": "Second Task",
  "output": {
    "second_result": {
      "message": "Hello world",
      "const_value": "ham"
    },
    "parallel_no": 3
  }
}

 

Third TaskのLambdaFunctionScheduledはこんな感じ。Secondのoutputを引き継いでいます。

{
  "resource": "arn:aws:lambda:region:account_id:function:sfn_third_lambda",
  "input": {
    "second_result": {
      "message": "Hello world",
      "const_value": "ham"
    },
    "parallel_no": 3
  },
  "timeoutInSeconds": null
}

 

Third TaskのTaskStateExited

result_path='$',通り、outputはLambdaの戻り値の文字列だけになっています。

{
  "name": "Third Task",
  "output": "only 3rd message."
}

 

Second Taskでエラー

f:id:thinkAmi:20190929172018p:plain:w300

Second TaskのLambdaFunctionScheduledは以下の通り(抜粋)。

{
  "input": {
    "parallel_no": 2,
    "context_name": "Sub Task 2",
  },
}

 
Error TaskのTaskStateExitedを確認します。日本語がそのまま表示されています。

{
  "name": "Error Task",
  "output": {
    "error_message": {
      "errorMessage": "偶数です",
      "errorType": "Exception",
      "stackTrace": [
        "  File \"/var/task/lambda_function.py\", line 3, in lambda_handler\n    raise Exception('偶数です')\n"
      ]
    }
  }
}

 

Third Taskでエラー

f:id:thinkAmi:20190929172322p:plain:w300

Third TaskのLambdaFunctionScheduledは以下の通り(抜粋)。

{
    "parallel_no": 1
  },
}

 
Error TaskのTaskStateExitedを確認します。日本語がそのまま表示されています。

{
  "output": {
    "error_message": {
      "errorMessage": "強制的にエラーとします",
      "errorType": "Exception",
      "stackTrace": [
        "  File \"/var/task/lambda_function.py\", line 3, in lambda_handler\n    raise Exception('強制的にエラーとします')\n"
      ]
    }
  }
}

 

削除

cdk destroy にて削除できます。

$ cdk destroy
Are you sure you want to delete: step-functions (y/n)? y
...
 ✅  step-functions: destroyed

 

ただ、上記の方法で作成したS3は、removalpolicyの値により削除されません。
https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-s3.Bucket.html#removalpolicy

そのため、手動で削除するか、作成する時に以下のような設定を行います。
AWS CloudFormationでStackを削除したときにリソースを消さない設定 | DevelopersIO

 

動的並列処理について

先日、StepFunctionsでの動的並列処理がサポートされました。
AWS Step Functions がワークフローでの動的並列処理のサポートを追加

 
CDKでは、以下のissueがcloseとなれば利用できそうです。

 

ソースコード

GitHubに上げました。 step_functions ディレクトリが今回のファイルです。
https://github.com/thinkAmi-sandbox/AWS_CDK-sample

*1:例えばLambdaの場合は、 aws_cdk.aws_lambda が必要です:https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-readme.html

*2:もし、ポリシーを使う場合はこちら:https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-iam.Policy.html

#pyconjp 発表「知ろう!使おう!HDF5ファイル!」の落ち穂拾い

PyCon JP 2019にて発表をした際、いくつか質問をいただきました。

前回の記事にもあるように、当時きちんと回答できたか不安だったため、今回落ち穂拾いとしてまとめてみます。

なお、誤りや過不足などがありましたら、ご指摘いただけるとありがたいです。

   
目次

 

そもそも、何のデータを入れるためにHDFができたのか

The HDF Groupの History of HDF Group ページに記載がありましたので、引用します。
History of HDF Group

In 1987, the Graphics Foundations Task Force (GFTF) at the National Center for Supercomputing Applications (NCSA) at the University of Illinois at Urbana-Champaign, set out to create an architecture-independent software library and file format to address the need to move scientific data among the many different computing platforms in use at NCSA at that time. Additional goals for the format and library included the ability to store and access large objects efficiently, the ability to store many objects of different types together in one container, the ability to grow the format to accommodate new types of objects and object metadata, and the ability to access the stored data with both C and Fortran programs.

 

クロスプラットフォーム間で、Excelファイルの罫線や内容が壊れないか

発表時点でも検証していましたが、もう少し詳しく検証してみました。

用意したExcelファイルは、以下の(a)〜(d)の4種類です。

 
これらのファイルをHDF5ファイルにDatasetとして保存して、Mac/Windowsの双方で読み込んでみます。

 

自作ファイルの作成について

自作ファイルは

  • 罫線
  • 画像
  • 罫線
  • シートのロック

を持つExcelファイルを、 Mac + openpyxlで作成するものとします。

excel_creator.py として用意します。

import datetime
import pathlib

import openpyxl
from openpyxl.comments import Comment
from openpyxl.styles.borders import Border, Side, BORDER_THIN
from openpyxl.styles.colors import BLUE

BASE_DIR = pathlib.Path(__file__).resolve().parents[0]
INPUT_DIR = BASE_DIR.joinpath('input')
OUTPUT_DIR = BASE_DIR.joinpath('output', 'h5py')


wb = openpyxl.Workbook()

# grab the active worksheet
ws = wb.active

# セルにデータを入れる
ws['A1'] = 'Hello, world!'

# 日付データを入れる
ws['A2'] = datetime.datetime.now()

# 罫線を引く
side = Side(style=BORDER_THIN, color=BLUE)
border = Border(top=side, bottom=side, left=side, right=side)
a4 = ws['B3'].border = border

# コメントを入れる
ws['B4'].comment = Comment('ham', 'myauthor')

# 画像ファイル差し込み
img_path = INPUT_DIR.joinpath('shinanogold.png')
img = openpyxl.drawing.image.Image(img_path)
ws.add_image(img, 'C2')

# シートの保護
ws.protection.enable()

# 保存
output = OUTPUT_DIR.joinpath('original.xlsx')
wb.save(output)

 

公開ファイルの利用について

公開ファイルは xls 形式でした。

そのため、Mac上で手動で xlsx へと変換・保存したものを使用します。

 

HDF5ファイルとして保存するプログラム

h5pyを使い、 gleaning.py として作成します。

なお、いずれのExcelファイルも gzip で圧縮するものとします (create_dataset()の引数 compression を利用)。

import hashlib
import pathlib

import h5py

import numpy as np

BASE_DIR = pathlib.Path(__file__).resolve().parents[0]
INPUT_DIR = BASE_DIR.joinpath('input')
OUTPUT_DIR = BASE_DIR.joinpath('output', 'h5py')
TYPE_OF_BINARY = h5py.special_dtype(vlen=np.dtype('uint8'))

ORIGINAL_EXCEL_PATH = OUTPUT_DIR.joinpath('original.xlsx')
GLEANING_HDF5_PATH = OUTPUT_DIR.joinpath('gleaning.h5')

STATISTICS_3MB = OUTPUT_DIR.joinpath('statistics_kenporonbun-toukei201604.xlsx')
STATISTICS_LINE = OUTPUT_DIR.joinpath('statistics_nenpou09.xlsx')
STATISTICS_IMAGE = OUTPUT_DIR.joinpath('statistics_2017_1_02.xlsx')


def calculate_md5(path):
    return hashlib.md5(path.read_bytes()).hexdigest()


def compare_excel(from_path):
    print(f'{from_path.name} --------->')

    # 元ExcelのMD5を見る
    from_md5 = calculate_md5(from_path)
    print(f'from MD5: {from_md5}')

    dataset_name = f'excel_{from_path.stem}'

    # gzip圧縮して、ExcelをHDF5ファイルに入れる
    with h5py.File(GLEANING_HDF5_PATH, mode='a') as file:
        with from_path.open(mode='rb') as excel:
            excel_binary = excel.read()

        excel_data = np.frombuffer(excel_binary, dtype='uint8')
        ds_excel = file.create_dataset(
            dataset_name, excel_data.shape, dtype=TYPE_OF_BINARY, compression='gzip')
        ds_excel[0] = excel_data

    # 取り出して、MD5を見る
    with h5py.File(GLEANING_HDF5_PATH, mode='r') as file:
        dataset = file[dataset_name]
        export_path = from_path.parents[0].joinpath(f'{from_path.stem}_after{from_path.suffix}')
        with export_path.open('wb') as w:
            w.write(dataset[0])

    to_md5 = calculate_md5(export_path)
    print(f'to MD5  : {to_md5}')

    assert from_md5 == to_md5


if __name__ == '__main__':
    # きれいなHDF5ファイルを使うため、事前に削除しておく
    if GLEANING_HDF5_PATH.exists():
        GLEANING_HDF5_PATH.unlink()

    compare_excel(ORIGINAL_EXCEL_PATH)
    compare_excel(STATISTICS_3MB)
    compare_excel(STATISTICS_LINE)
    compare_excel(STATISTICS_IMAGE)

 

HDF5からExcelの入ったDatasetを読み込み、ローカルに保存するプログラム

こちらも h5py を使って excel_reader.py として作成します。

なお、Mac/Windows間で保存したファイルに差異がないか検証するため、MD5ハッシュをprintします。

import hashlib
import pathlib

import h5py

BASE_DIR = pathlib.Path(__file__).resolve().parents[0]
OUTPUT_DIR = BASE_DIR.joinpath('output', 'h5py')
ORIGINAL_EXCEL_PATH = OUTPUT_DIR.joinpath('original.xlsx')
GLEANING_HDF5_PATH = OUTPUT_DIR.joinpath('gleaning.h5')

STATISTICS_3MB = OUTPUT_DIR.joinpath('statistics_kenporonbun-toukei201604.xlsx')
STATISTICS_LINE = OUTPUT_DIR.joinpath('statistics_nenpou09.xlsx')
STATISTICS_IMAGE = OUTPUT_DIR.joinpath('statistics_2017_1_02.xlsx')


def calculate_md5(path):
    return hashlib.md5(path.read_bytes()).hexdigest()


def print_md5(path):

    dataset_name = f'excel_{path.stem}'
    with h5py.File(GLEANING_HDF5_PATH, mode='r') as file:
        dataset = file[dataset_name]
        export_path = path.parents[0].joinpath(f'{path.stem}_reader{path.suffix}')
        with export_path.open('wb') as w:
            w.write(dataset[0])

    to_md5 = calculate_md5(export_path)
    print(f'{path.name} MD5  : {to_md5}')


if __name__ == '__main__':
    print_md5(ORIGINAL_EXCEL_PATH)
    print_md5(STATISTICS_3MB)
    print_md5(STATISTICS_LINE)
    print_md5(STATISTICS_IMAGE)

 

MacでHDF5ファイルからExcelを取得した結果

MD5は以下の通りでした。

$ python excel_reader.py 
original.xlsx MD5  : af366474fc7f09cc5bcfdff764acf01d
statistics_kenporonbun-toukei201604.xlsx MD5  : bb82c493c80e362da47b96b9e8b28385
statistics_nenpou09.xlsx MD5  : 4de608b9452a34caa8a76f1fe8c4b265
statistics_2017_1_02.xlsx MD5  : 838d003d262f05f7e50585feab229f5d

 
また、取得した各ファイルを開いてみましたが、壊れた様子は特にありませんでした。

 

WindowsでHDF5ファイルからExcelを取得した結果

Windowsで実行した結果は以下のスクショの通りです。

f:id:thinkAmi:20190925222701p:plain:w400

 
テキストに落としてみた結果はこちら。MD5自体の差異はなさそうです。

>python excel_reader.py
original.xlsx MD5  : af366474fc7f09cc5bcfdff764acf01d
statistics_kenporonbun-toukei201604.xlsx MD5  : bb82c493c80e362da47b96b9e8b28385
statistics_nenpou09.xlsx MD5  : 4de608b9452a34caa8a76f1fe8c4b265
statistics_2017_1_02.xlsx MD5  : 838d003d262f05f7e50585feab229f5d

 
また、実際にWindowsExcelで開いてみましたが、「ファイルが壊れています」等の表示もなく、見た感じ大丈夫そうでした。

 

圧縮したDatasetを読み込む時は、展開しないとダメか

上記のExcelファイル検証で行いましたが、Datasetを作成する際、圧縮方法としてgzipを指定しました。

一方、読み込む時のソースコードでは、gzipからの展開は特に指定していません。

そのため、h5pyを使う限りは、展開する必要はなさそうです*1。  
 

zipファイルとの違い

今回の発表は、「NumPy方面でなくてもHDF5ファイルは使える」が主題でした。そのため、zipファイルとの機能の違いは確かに気になります。

調べたところでは、

  • HDF5ファイルには、パスワード設定機能がない

がありました。

ただ、プログラムから扱うことを考えると、以下のような機能を利用できるため、HDF5ファイルの方が扱いやすいのかなという印象です。

  • Datasetに紐づくAttributeに対して検索ができる
    • HDF5ファイルを作成する段階で、後から検索しやすいようなAttributeを色々と付けておく
  • (今回の主題とは外れますがが) NumPy方面と相性が良い
    • 上記の History of HDF Group からもうかがえる。
  • 圧縮して格納したDatasetを取り出す場合、展開する必要がない

 
もし、この点について詳しい方がいらっしゃいましたら、ご指摘いただけるとありがたいです。

 

圧縮アルゴリズムについて

What kind of compression methods does HDF5 support? より引用します。

HDF5 supports gzip (deflate), Szip, n-bit, scale-offset, and shuffling (with deflate) compression filters.

 
また、その他にも以下のページに記載がありました。

 

複数人で利用する時のロックはどうなるのか

HDF5 1.10.0以降、 SWMR (Single-Writer/Multiple-Reader) が導入されています。

詳細は以下のリンクとなります。

 
ちなみに、スレッドセーフについては、こちらで触れられています。

 

HDF5ファイルをSQLライクに扱いたい

HDF5ファイルでは、SQLを知らなくてもデータを階層的に保存することができます。

一方で、慣れたSQLライクにHDF5ファイルを保存したいことがあるかもしれません。

その場合は、HDFql というライブラリがあります。
The easy way to manage HDF5 data

 
そのページでは、こんな感じの例が挙げられています。

create file myFile.h5
use file myFile.h5
create dataset myGroup/myDataset as float enable zlib values(12.4)

 
以降は、発表ではふれなかった、スライドの内容です。

   

次のステップについて

HDF5ファイルについて詳しく知りたい場合は、以下の書籍が参考になります。
Python and HDF5 - O'Reilly Media | Andrew Collette著

 
また、h5pyやPyTablesの使い方については、以下の記事や発表を見るのが良いと思います。

あるいは、SciPy 2017のチュートリアルもあります。
tomkooij/scipy2017: SciPy 2017 tutorial: HDF5 take 2: h5py and PyTables

 

公式情報

The HDF Groupでは、いろいろな公式情報があります。

HDF Forumでは質疑応答が色々とありましたので、困ったら覗いてみるのもよいかもしれないです。

 

h5servを使うための準備

h5servを使うためには、少々手間がかかりましたので、メモ代わりに記載します。

自分の端末で使う場合、hostsファイルに設定

hostsファイルのIPアドレスに対する値は、 <ファイル名>.<適当なドメイン> という形で設定します。

例えば、h5servが動作している example.com にアクセスすると、 test.h5 ファイルを操作できるようにするには、以下のような設定となります。

127.0.0.1 test.example.com

 
hostsファイルの設定が終わったら、domain オプションでドメインを指定した上で、h5servを起動します。

$ python h5serv --port=5000 --toc_name=mytoc.h5 --domain=example.com

 

操作対象のファイルを作成

中身は空で問題ないですので、作成します。

作成先は、h5servのデータディレクトリである data の中になります。

また、ファイル名は test.h5 です。

p = Path(__file__).resolve().parents[0].joinpath('h5serv', 'data', 'test.h5')
with h5py.File(p, mode='w') as f:
    pass

 

この時点での data ディレクトリ構成
$ tree
.
├── public/
├── readme.txt
└── test.h5

 

動作確認

では、実際にHDF5ファイルを扱ってみます。今回は型定義を作成するところまでです。

# 型定義を作成
str_type = {'charSet': 'H5T_CSET_UTF8',
            'class':   'H5T_STRING',
            'strPad':  'H5T_STR_NULLTERM',
            'length':  'H5T_VARIABLE'}
payload = {'type': str_type, 'shape': 1}

# h5servにPOST
res1 = requests.post(
    URL_BASE + 'datasets',
    json=payload
).json()

 
結果を確認します。

新しく test.h5 ファイルが作成されました。

├── mytoc.h5
├── public/
├── readme.txt
└── test.h5

 
また、Datasetを見ると、型定義がされていることが分かります。

f:id:thinkAmi:20190925230419p:plain:w300

 
 

HDF5ファイルのバージョンについて、もう少し

HDF5 Library Release Version Numbers より、もう少し詳しく見てみます。

HDF5ファイルのバージョンが HDF5 x.y.z となっているとすると、x, y, zのそれぞれは以下を示します。

  • x: major version number
    • ライブラリやファイルフォーマットの大きな変更あり
  • y: minor version number
    • 新しい機能によるファイルフォーマットの変更
    • 偶数はstable、奇数はdevelop
  • z: release number
    • バグ修正等ライブラリの改善、フォーマットの変更なし

 

ソースコード

この記事で使ったソースコードについては、リポジトリに追加してあります。
https://github.com/thinkAmi/PyCon_JP_2019_talk/commit/df88400b033bdd64beecb8453b1f2542d7bc33a1

*1:他の言語、他のライブラリでは異なるかもしれませんが

#pyconjp PyCon JP 2019に参加しました & 発表しました

9/16(月・祝)・17(火)に大田区産業プラザ PiOで開催された「PyCon JP 2019」に参加 & 発表しました。
トップページ - PyCon JP 2019

 
去年同様、今年も無事に参加できてよかったです。
#pyconjp PyCon JP 2018に参加しました - メモ的な思考的な

 
動画もすでに公開されています。ありがとうございます。
PyConJP - YouTube

 
ここでは自分の発表、および全体を通してのメモを残します。誤りがありましたらご指摘ください。

 
目次

 

自分の発表「知ろう!使おう!HDF5ファイル!」

タイムテーブル上の詳細はこちら
知ろう!使おう!HDF5ファイル!

 
スライドです。

 
発表で使用したソースコードはこちらです。
https://github.com/thinkAmi/PyCon_JP_2019_talk

 
動画はこちら。7分くらいから始まるようです。
02-603_知ろう!使おう!HDF5ファイル!(thinkAmi) - YouTube

 
発表ですが、時間配分を誤り、持ち時間を発表で使い切ってしまったことから、時間内に質疑応答ができませんでした。本当に申し訳ないです。

本当は「この発表の次のステップ」についてふれたかったのですが、時間の都合上カットしました。資料だとp77〜あたりです。

 
ただ、ありがたいことに、発表終了後いくつか質問をいただきました。

覚えているのは

  • そもそも、何のデータを入れるためにHDFができたのか
  • クロスプラットフォーム間で、Excelファイルの罫線や内容が壊れないか (特に大きなサイズのExcelファイル)
  • zipファイルとの違い

あたりです。

ただ発表後も緊張が解けず、質問と回答の記憶があやふやです。そのため、もしかしたら質問の内容不足や誤りがあるかもしれません。その場合はお知らせください。

また、ちょっと検証したいこともあるため、検証後にBlog化したいです。

 
というところで、以降は今回のPyCon JP 2019のメモです。

 

参加したセッション

基調講演:Why Python is Eating the World

Cory Althoff 氏

 
書籍「独学プログラマー」の著者による基調講演でした。

Think PythonPythonを学びはじめてからの行動を聞き、やはり何か気になることがあれば行動することが大事だなと感じました。

また、後半のStart somethingのところを聞き、今の自分にできることとして、このBlogを引き続き書いていこうと感じました。

 

ExcelPython による社会インフラシステムの設定ファイルの自動生成

武山 文信 氏

 
設定ファイル という単語につられて参加しました。

お話の中で、設定ファイル生成ツールに求められる条件が整理されていましたので、もし同じような状況になったら参考にしたいです。

互換性を保ちながら、できるところだけでも自動化していく姿も印象に残りました。

 

Djangoで実践ドメイン駆動設計による実装

大島 和輝 氏

 
Djangoでのドメイン駆動設計が気になって参加しました。

セッションの中でもふれられていましたが、Djangoでのドメイン駆動設計例が見当たらないため、今回のソースコードを参考にして取り組んでみたいです。

Python製のDIコンテナ python-inject にも言及しており、後で見てみようと思いました。

 

SupportingPython3 in Large Scale Project

Kir Chou 氏

 

Python2からPython3に切り替えるにあたり、考えるべきことがいくつもあげられていました。

また、技術的な資料へのリンクも豊富にあるのが助かりました。

今のところ手元には移行するものはありませんが、今後出会う機会があれば、参考にしたいと感じました。

 

Python ウェブアプリケーションのためのプロファイラの実装

Yusuke Miyazaki 氏

 
昔、WSGIミドルウェアについて調べていた時に、 wsgi-lineprof を試したことがあったため、内部構造などを知りたくて参加しました。

wsgi-lineprofのアーキテクチャの図が分かりやすかったです。

また、プロファイラはどのような技術で構成されているかの紹介もあり、こんな便利な機能がいろいろとあるんだと勉強になりました。

 

Doujin-activity with Ren'Py

Daisuke Saito 氏

 
来週の技術書典7に参加予定なこともあり、 Doujin というタイトルに惹かれて参加しました。

Ren'Pyの概要と、簡単にゲームができあがるということが分かりました。

 

基調講演:Pythonで切り開く新しい農業

小池 誠 氏

 
きゅうりで機械学習 という話を読んだこともあり、楽しみな基調講演でした。

初めて見た時とは判別機の扱いが変わっていて、判別機が人間のサポートをするようにしたことで、熟練者の技術継承がしやすくなっているように見えました。

講演の中で特に印象に残ったのは、終わりの方の すべては学ぶためにとすれば、失敗ではなくなる。やってみなければわからない という言葉です。

それを聞き、次に活かせるような形で失敗するよう心がけようと思いました。

 

Pythonで始めてみよう関数型プログラミング

寺嶋 哲 氏

 
関数型プログラミングについて、その考え方とPythonで実現するためのライブラリ紹介がありました。

自分の今のコードに取り入れることができそうな関数型の考え方がいくつもあり、参考になりました。

あとで資料を見返して、理解を深めたいと感じました。

 

Pythonでライブをしよう - FoxDotを使った新時代のPython活用法 -

田中 慎太郎 氏

 
同僚です。

社内のPyCon JP 予行演習会にてライブコーディングを聴いた時から「当日は、この次のコマで自分が発表するから、この音楽セッションでリラックスしよう」と考えていました。

デモが進むに従って曲になっていったので、「あの変更でこんなふうに音が変化するんだ」と、とても不思議な感覚でした。音楽に詳しくないせいかもしれませんが、新鮮でした。

Youtube上だとFoxDotの音があまり残っていないようで残念ですが、ライブだと思えば仕方ないですね。。。

 

機械学習ライブラリのPython API作成方法

山入端 孝浩 氏

 

外部ライブラリのラッパーを作る時の大切なこと(既存との互換性やメモリ使用量など)についてふれられていました。

普通に使う時とpytestの時でメモリ使用量が異なるの、あまり着目したことがなかったので、参考になりました。

 

Ansibleを通じて「べき等性」を理解してみよう

Kazuya Takei 氏

 
Ansibleではどんなふうに冪等性を担保しているのか気になっていたこともあり、参加しました。

ソースコードの解説から、Ansibleががんばって標準出力を解析してべき等性を担保していると分かり、意外と泥臭いことをやってると分かりました。

あとは、Ansibleで管理できるルーターがほしいなーと思いました。

 

その他

同僚のスピーカーなど

上記にもありますが、私を含めて、会社から3名がスピーカーとして登壇しました。もう一人はこちら。
Pythonと便利ガジェット、サービス、ツールを使ってセンシング〜見る化してみよう

 
また、今年もぷろぷろ(@pro_proletariat)氏の呼びかけで、社内でPyCon JP 予行演習会が何回か開催されました。ありがとうございました。

 
あと、社内のPython使いの絵師さんが描いたマスコットキャラクター(ぎーらぼちゃん)も、無事デビューできてよかったです。

 

ブース

2日目の登壇ということもあり、精神的余裕があまりなく、ブース巡りはあまりできなかったのが残念でした。

1日目のはじめに、Python EDのガラガラでマグカップをいただきました。ありがとうございました。

 
また、書籍「独学プログラマー」のサイン会がありました。めったにない機会なので書籍を購入しサインを頂きました。ありがとうございました。

 

パーティ

ご挨拶したかった方々といろいろとお話できてよかったです。

Welcomeパーティの時もそうでしたが、年々パーティの雰囲気に慣れてきているのか、今年は自分から話しかけられたので成長した感がありました。

 
最後になりましたが、PyCon JP 2019を運営してくださったみなさま、参加者のみなさま、ありがとうございました。

来年も同じ場所で開催されるようですので、楽しみにして一年を過ごしたいと思います!

#agileshinano #glnagano Agile Jam in ながのに参加しました

8/24にギークラボ長野で開かれた「Agile Jam in ながの」に参加しました。
Agile Jam in ながの - connpass

 
平日夜に開催されているワークショップの拡大版で、複数のスクラムマスターの方々からお話を直接聞けるなど、とても有意義なイベントでした。

以下、簡単なメモです。

 
目次

 

オープニング・セッション

@shigeshibu44さん、@chinoppy0727さん

隣の方々と自己紹介をしました。

その時の手法が Moving Motivators で、自分のモチベーションがどこからやってくるのかを伝えるものでした。
Moving Motivators (CHAMPFROGS): Intrinsic Motivation Game - Management 3.0

 
自分は 好奇心熟達受容 を選びましたが、他の方々の内容を聞いて「そういう考え方もいいなー」と感じました。

そのため、Moving Motivatorsは

  • 自分の志向について、振り返りしやすい
  • チームビルディング時に、メンバー間でお互いの志向が知りやすい

など、とても良いツールだなと感じました。

 

セッション1

@itoyama_yuukiさん

全社ウォーターフォールの中、1つのプロジェクトをスクラムで始め、2年後にはほぼ全社スクラムになった流れを伝えてくださいました。

アジャイルはじめの一歩」について考えたりすることもあるので、どれも印象に残ったセッションでした。

  • スクラム導入のために
    • スクラム説明会をやる
      • 各自の役割を伝える
      • スクラムガイドを使って説明する
      • 何度も伝える
      • 自分から社内勉強会を開く
      • スクラムランチやSlackのチャンネルなど、誰もが相談しやすい環境を作る
    • 新規プロダクト or 区切りの良いところから導入する
      • 途中から導入するのは難しい
      • 既存の開発手法を無視すると宣言して始めた
        • 膨大なドキュメント作成への注力をやめる
        • 会社に受け入れる土壌があり、組織的なバックアップもあった
      • 途中から開発モデルが定義され、スムーズに
  • スクラムのプラクティスのうち、取り入れられるところがあれば取り入れる
    • 振り返りだけでもやってみる
    • 定例を開き、みんなで集まって話す
  • スクラムまわり
    • スクラムのイベントには時間を使う
    • 開発チームに対しては、リモートでスクラムをやっても問題ない
      • スクラムのイベントには、Zoomなどを使う
      • PO/SMがリモートだと難しいことかもしれない

 

セッション2

@massa142さん、@moon_in_naganoさん

資料:スクラムの現場 - slideship.com

 
セッションで一番印象に残ったのは

  • スクラム = 現状把握のためのフレームワーク
  • スクラムで大事なことは、透明性を担保すること
    • 開発に必要なものは、社員・業務委託に関わらず自由にアクセスさせる
    • 議事録を作り公開する

です。

その他は以下です。

  • スクラムのコミットメント
    • プランニングで計画を完了させるために最大限努力する
      • これがコミットメントだと、みんなの認識を合わせる
  • 見積もり
    • 工数ではなく、やることの相対的な大きさ
      • 基準チケットの何倍かかるかを見積もる
    • チケットの精度を高めるより、ベロシティが安定することが大事
      • 精度よりも、技術的な話をするのに時間を使う
  • ホワイトボードに問題点を書くことで、チーム vs ホワイトボードという構図にする
    • 個人の問題ではなくチームの問題にする
  • とりあえずやってみる
    • 分からないものについては議論しない
    • やってみた経験・学びを次に活かす
  • ポジティブ思考でやっていくの大事
    • KPTのProblemを歓迎する
  • アジャイルは、ウォーターフォールと対立するわけではない

 

ワークショップ

参加者で話し合いたい内容を挙げて、その内容について2つのテーブルに分かれて話し合うというワークショップでした。

スピーカーの方々へのQAやお互いの意見交換を行い、最後は模造紙にまとめ、ギークラボ長野の壁に張り出しました。

クロージングでは、スクラムのイベントについての紹介もありました。
Regional Scrum Gathering® Tokyo 2019

 

懇親会

場所を懇親会会場を移して、スクラムマスターの方々を交えて

などを行い、楽しい時間を過ごせました。

雰囲気です。

 
最後になりましたが、企画・運営・参加をされたみなさま、ありがとうございました。

*1:お酒が入っていたので、記憶があやしい...

AWS Amplifyで、カスタムカテゴリを作って、カスタムリソースを追加してみた

今まで、Amplifyが用意する以外のリソースを使いたい場合は、 <project_root>/amplify/backend/api/<API name>/stacks ディレクトリの中に、CloudFormation(CFn)ファイルを作成して対応してきました。
AWS AppSync + Amplify JavaScript + CustomResourcesで、既存のDynamoDBなどをDatasourceとしたリゾルバーを作成する - メモ的な思考的な

 
それ以外の方法がないかを探したところ、以下のissueコメントに、カスタムカテゴリを作っても追加できる旨が記載されていました。
Support for custom Resources/CloudFormation templates · Issue #80 · aws-amplify/amplify-cli

そこで、issueに書かれた方法で試してみたため、メモを残します。

 
ちなみに、「カスタムカテゴリ」と表現するのが正しいのかは微妙ですが、

$ amplify --help


| Category      |
| ------------- |
| analytics     |
...

と、元々あるAnalyticsやAPIはカテゴリ(Category)と呼ぶようです。

そのため、この記事ではそれにならって、今回自作するカテゴリのことをカスタムカテゴリと呼ぶことにします。

 
目次

 

環境

 
今回は amplify init & amplify api add で、APIモジュールを追加した後の環境で、

  • CognitoのIDプールを作成し、amplify init で作成した承認済/未認証ロールを紐付ける
  • AppSync GraphQLへのアクセスを許可するIAMポリシーを作成し、未承認ロールに割り当てる

というカスタムリソースを書いてみます。

amplify init & amplify api add は以下のような感じです。

# 初期化
$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project CustomCategory
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using none
? Source Directory Path:  src
? Distribution Directory Path: dist
? Build Command:  npm run-script build
? Start Command: npm run-script start
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default

# APIの追加
$ amplify api add
? Please select from one of the below mentioned services GraphQL
? Provide API name: CustomCategoryAPI
? Choose an authorization type for the API API key
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name MyType

 

カスタムカテゴリを追加 

ディレクトリの作成

<project_root>/amplify/backend/ ディレクトリの下に、 identity ディレクトリを作成します。

 

CFnテンプレートの作成

今回はIDプールとRoleのマッピングを行うため、

  • AWS::Cognito::IdentityPool
  • AWS::Cognito::IdentityPoolRoleAttachment

の2つのResourceが必要です。

そのため、このResourceが含まれるファイルを template.json として、上記の identity ディレクトリに作成します。

 

IDプールの作成

まずは AWS::Cognito::IdentityPool のリソースを作成します。

amplifyのenvとIDプールの名称をパラメータとして受け取り、CoginitoのIDプールを作成します。

AllowUnauthenticatedIdentities: true としてありますが、IDプールの 認証されていない ID に対してアクセスを有効にする にチェックを入れてみたかっただけで、特に深い意味はありません。

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters": {
    "env": {
      "Type": "String",
    },
    "IdentityPoolName": {
      "Type": "String"
    },
...
  },
  "Resources": {
    "IdentityPool": {
      "Type": "AWS::Cognito::IdentityPool",
      "Properties": {
        "IdentityPoolName": {
          "Fn::Join": [
            "__",
            [
              {
                "Ref": "IdentityPoolName"
              },
              {
                "Ref": "env"
              }
            ]
          ]
        },
        "AllowUnauthenticatedIdentities": "true"
      }
    },

 

amplify initで作成されたものをnested-cloudformation-stack.ymlで確認し、ロールを割り当て

AWS::Cognito::IdentityPoolRoleAttachment を設定するには、

  • 認証されていないロール
  • 認証されたロール

が必要です。

今回は、Amplifyの動きと同じく、amplify init で作成される認証されていないロールと認証されたロールを、自作のIDプールに割り当ててみます。

CFn内でamplify initで作成されるロールを参照する方法を探したところ、 <project_root>/amplify/backend/awscloudformation/nested-cloudformation-stack.yml に記載されているリソースを使えばよさそうでした。

今回は、

  • AuthRole
  • UnauthRole

のArnを Fn::GetAtt で参照します。

 
ただ、CFnのテンプレート内では参照できなかったため、

  • CFnテンプレート (template.json) は Parameter で外部から値を受け取る
  • CFnテンプレートに値を渡すためのファイル parameters.json を作成し、その中で Fn::GetAtt を使ってロールのArnを取得・設定する

としました。

関係する部分を抜粋したのは以下です。

template.json

{
  "AWSTemplateFormatVersion": "2010-09-09",
  ...
  "Parameters": {
    ...
    "AuthRoleArn": {
      "Type": "String"
    },
    "UnauthRoleArn": {
      "Type": "String"
    }
  },
  "Resources": {
    ...
    "IdentityPoolRoleMap": {
      "Type": "AWS::Cognito::IdentityPoolRoleAttachment",
      "Properties": {
        "IdentityPoolId": {
          "Ref": "IdentityPool"
        },
        "Roles": {
          "unauthenticated": {
            "Ref": "UnauthRoleArn"
          },
          "authenticated": {
            "Ref": "AuthRoleArn"
          }
        }
      },
      "DependsOn": "IdentityPool"
    }
  },
...
}

 
parameters.json

{
  "IdentityPoolName": "CustomIdentityPool",
  "AuthRoleArn": {
    "Fn::GetAtt": [
      "AuthRole",
      "Arn"
    ]
  },
  "UnauthRoleArn": {
    "Fn::GetAtt": [
      "UnauthRole",
      "Arn"
    ]
  }
}

 

amplify env checkout

このままの状態で amplify push しようとしても、まだAmplify CLIにはAPIしか認識されていません。

$ amplify status

Current Environment: develop

| Category | Resource name     | Operation | Provider plugin   |
| -------- | ----------------- | --------- | ----------------- |
| Api      | CustomCategoryAPI | Create    | awscloudformation |

 
そのため、amplify env checkout を行い、Amplify CLIに認識してもらいます。

# 環境の確認
$ amplify env list

| Environments |
| ------------ |
| *develop     |


# チェックアウト
$ amplify env checkout develop


# 認識された
$ amplify status

Current Environment: develop

| Category | Resource name      | Operation | Provider plugin   |
| -------- | ------------------ | --------- | ----------------- |
| Api      | CustomCategoryAPI  | Create    | awscloudformation |
| Identity | CustomIdentityPool | Create    | awscloudformation |

 
また、amplify push などの情報を持っている <project_root>/amplify/backend/amplify-meta.json も更新されました。

{
    "providers": {
        "awscloudformation": {
            ...
        }
    },
    "api": {
        "CustomCategoryAPI": {
            ...
            }
        }
    },
    // 追加された
    "identity": {
        "CustomIdentityPool": {
            "providerPlugin": "awscloudformation"
        }
    }
}

 

amplify pushして状況確認

カスタムカテゴリが認識されたため、amplify push すると、成功しました。

$ amplify push

Current Environment: develop

| Category | Resource name      | Operation | Provider plugin   |
| -------- | ------------------ | --------- | ----------------- |
| Api      | CustomCategoryAPI  | Create    | awscloudformation |
| Identity | CustomIdentityPool | Create    | awscloudformation |
? Are you sure you want to continue? Yes

GraphQL schema compiled successfully.

? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
...
✔ All resources are updated in the cloud

GraphQL endpoint: https://host.appsync-api.region.amazonaws.com/graphql
GraphQL API KEY: xxx

 
CognitoのIDプールを確認したところ、たしかに作成されていました。

f:id:thinkAmi:20190730221051p:plain:w450

 

IAMポリシーの作成と未承認ロールへの割り当て

まずは、AppSync GraphQLへのアクセスを許可するIAMポリシーを作成します。

IAMポリシーでGraphQLへのアクセスを許可するには "arn:aws:appsync:${AWS::Region}:${AWS::AccountId}:apis/${apiID}/types/Mutation/* のようなResourceに対して設定する必要があります。

ただ、この中の apiID については、カスタムカテゴリの中では取得できませんでした。一方、APIカテゴリの中では取得できました。

 
そのため、 <project_root>/amplify/backend/api/<API name>/stacks/ ディレクトリの中に IAMRole.json を作成します。

この中で

  • パラメータ AppSyncApiId を参照し、AppSyncのAPI IDを取得
  • パラメータ UnauthRoleName を参照し、未承認ロールに対しポリシーを割り当て

を行います。

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "AppSync GraphQL Policy",
  "Metadata": {},
  "Parameters": {
    "AppSyncApiId": {
      "Type": "String",
      "Description": "The id of the AppSync API associated with this project."
    },
    "env": {
      "Type": "String",
      "Description": "The environment name. e.g. Dev, Test, or Production",
      "Default": "NONE"
    },
    "UnauthRoleName": {
      "Type": "String"
    }
  },
  "Resources": {
    "AppSyncGraphQLPolicy": {
      "Type": "AWS::IAM::ManagedPolicy",
      "Properties": {
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "appsync:GraphQL"
              ],
              "Resource": [
                {
                  "Fn::Sub": [
                    "arn:aws:appsync:${AWS::Region}:${AWS::AccountId}:apis/${apiID}/types/Query/*",
                    {
                      "apiID": {
                        "Ref": "AppSyncApiId"
                      }
                    }
                  ]
                },
                {
                  "Fn::Sub": [
                    "arn:aws:appsync:${AWS::Region}:${AWS::AccountId}:apis/${apiID}/types/Mutation/*",
                    {
                      "apiID": {
                        "Ref": "AppSyncApiId"
                      }
                    }
                  ]
                }
              ]
            }
          ]
        },
        "ManagedPolicyName": {
          "Fn::Join": [
            "-",
            [
              "AppSyncGraphQLPolicy",
              {
                "Ref": "env"
              }
            ]
          ]
        },
        "Roles": [
          {
            "Ref": "UnauthRoleName"
          }
        ]
      }
    }
  },
  "Outputs": {
    "EmptyOutput": {
      "Description": "An empty output. You may delete this if you have at least one resource above.",
      "Value": ""
    }
  }
}

 

結果確認

未認証のロールに、AppSyncのGraphQLアクセスを許可するIAMポリシー AppSyncGraphQLPolicy-develop が割り当てられていました。

Cognito IDプールでの未認証ロール

f:id:thinkAmi:20190730222341p:plain:w450

ロールに割り当てられたポリシー

f:id:thinkAmi:20190730222416p:plain:w450

 

カスタムカテゴリを使わず、APIのカスタムリソースとして作成

ちなみに、今回作成したCognito IDプールのCFnファイルですが、カスタムカテゴリを使わなくても、APIカテゴリのカスタムリソースとすることも可能です。

<project_root>/amplify/backend/api/<API name>/build/cloud-formation-template.json を見ても、APIのカスタムリソースのパラメータが各リソースを参照できています。

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Description": "An auto-generated nested stack.",
    "Metadata": {},
    "Parameters": {
        ...
        "AuthRoleArn": {
            "Type": "String"
        },
        "UnauthRoleArn": {
            "Type": "String"
        }
    },
    "Resources": {
        "GraphQLAPI": {
            "Type": "AWS::AppSync::GraphQLApi",
            ...
        },
        "GraphQLAPIKey": {
            "Type": "AWS::AppSync::ApiKey",
            ...
        },
        "GraphQLSchema": {
            "Type": "AWS::AppSync::GraphQLSchema",
            ...
        },
        "MyType": {
            "Type": "AWS::CloudFormation::Stack",
            ...
        },
        "CustomResourcesjson": {
            "Type": "AWS::CloudFormation::Stack",
            ...
        },
        "IAMRolejson": {
            "Type": "AWS::CloudFormation::Stack",
            ...
        },
        "templatejson": {
            "Type": "AWS::CloudFormation::Stack",
            "Properties": {
                "Parameters": {
                    "env": {
                        "Ref": "env"
                    },
                    "StackIdentityPoolName": {
                        "Ref": "StackIdentityPoolName"
                    },
                    "AuthRoleArn": {
                        "Ref": "AuthRoleArn"
                    },
                    "UnauthRoleArn": {
                        "Ref": "UnauthRoleArn"
                    }
                },

 

まとめ

以上より、AWS Amplifyでカスタムリソースを作る場合は

  • <project_root>/amplify/backend/awscloudformation/nested-cloudformation-stack.yml
  • <project_root>/amplify/backend/api/<API name>/build/cloud-formation-template.json

などを見て使えるリソースを探しつつ、

  • APIの中のstacksの中にCFnファイルを作成
  • カスタムカテゴリを作成し、その中にCFnファイルを作成

のどちらを行い、必要なパラメータは parameters.json に記載すれば良いことが分かりました。

 

ソースコード

Githubに上げました。ディレクトcustom_category 以下が今回のファイルです。
https://github.com/thinkAmi-sandbox/AWS_AppSync_Amplify-sample

AWS Amplifyで、amplify env を使って、開発環境の共有と分離をしてみた

開発をしていると、AWS Amplifyを使ったソースコードGithubなどでチームで共有することがあります。

AppSync APIやDynamoDBなどの環境を同一にして、チームで開発する方法を探したところ、公式ドキュメントに amplify env を使う方法が記載されていました。

 
そこで、実際にどんな感じになるのか試してみたため、メモを残します。

 
目次

 

環境

 

共有するソースコード

今回は、以下のリポジトリをチーム間で共有し、開発を進めるものとします。なお、 team-provider-info.json ファイルには機密情報が含まれていたため、わざと空ファイルにしてあります。
https://github.com/thinkAmi-sandbox/appsync_app_for_multi_developer

 
DataSourceとしてDynamoDBを使います。schema.graphqlは以下の通りです。

type Todo {
    title: String!
    content: String
}

input CreateTodoInput {
    title: String!
    content: String
}


type Query {
    getTodo(title: String!): Todo
}

type Mutation {
    createTodo(input: CreateTodoInput!): Todo
}

 
また、動作確認としてAppSyncを使ったWebアプリを用意します。

各ボタンをクリックすると、対応するQueryやMutationを実行します。

import Amplify, {API} from 'aws-amplify';
import awsconfig from './aws-exports';
import * as queries from "./graphql/queries";
import * as mutations from "./graphql/mutations";


const queryButton = document.getElementById('query');
queryButton.addEventListener('click', () => {
  console.log('Run Query!');
  Amplify.configure(awsconfig);
  API.graphql(
    {
      query: queries.getTodo,
      authMode: 'API_KEY',
      variables: {
        title: "hello"
      }
    }
  ).then((data) => {
    console.log(data);
  })
    .catch((e) => {
      console.log('error!');
      console.log(e)
    });
});


const mutationButton = document.getElementById('mutation');
mutationButton.addEventListener('click', () => {
  console.log('Run Mutation!');

  const content = document.getElementById('content').value;
  Amplify.configure(awsconfig);
  API.graphql(
    {
      query: mutations.createTodo,
      authMode: 'API_KEY',
      variables: {
        input: {
          title: "hello",
          content: content
        }
      }
    }
  ).then((data) => {
    console.log(data);
  })
    .catch((e) => {
      console.log('error!');
      console.log(e)
    });
});

 
また、DynamoDBには以下が登録されているとします。

f:id:thinkAmi:20190727203118p:plain:w300

 

git clone & amplify init で既存のAppSync環境にアクセスする

上記のリポジトリを手元に持ってきます。

$ git clone git@github.com:thinkAmi-sandbox/appsync_app_for_multi_developer.git .

 
git cloneしただけではAmplifyの環境ができていないため、 amplify init を実行します。

ゼロから開発するときと異なり、 Do you want to use an existing environment? が表示されます。

ここでは既存のAppSync環境にアクセスするため、 Yes を選択します。

$ amplify init
Note: It is recommended to run this command from the root of your app directory

# 既存のAppSync環境へアクセスするため、 "Yes" を選択
? Do you want to use an existing environment? Yes

? Choose the environment you would like to use: dev
? Choose your default editor: Visual Studio Code
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default
✔ Initialized provider successfully.

 
amplify status で状況を確認します。既存の環境が認識されているようです。

$ amplify status

Current Environment: dev

| Category | Resource name     | Operation | Provider plugin   |
| -------- | ----------------- | --------- | ----------------- |
| Api      | MultiDeveloperAPI | No Change | awscloudformation |

 
動作を確認するため、必要なモジュールをインストールします。

$ npm install

 
別の開発環境で実行していると分かるよう、 webpack.config.js 中のポート番号を 9501 へと変更します。

devServer: {
...
  port: 9501

 
起動して動作を確認します。ポートは 9501 を使っているようです。

$ npm start

...
ℹ 「wds」: Project is running at http://localhost:9501/
...

 
Queryボタンを押すと、既存のDynamoDBの値が取得できました。

f:id:thinkAmi:20190727203154p:plain

 

新しい環境 staging を作成し、動作確認

続いて、 amplify env add で新しい環境 staging を作成します。

$ amplify env add
Note: It is recommended to run this command from the root of your app directory

# 既存の環境を使うか確認されるため、 "No" を選択
? Do you want to use an existing environment? No

# あとは、ゼロから開発したときと同様
? Enter a name for the environment staging
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default
⠋ Initializing project in the cloud...

# 新しくstackが作成される
CREATE_IN_PROGRESS multideveloper-staging-xxx AWS::CloudFormation::Stack             
CREATE_IN_PROGRESS DeploymentBucket                      AWS::S3::Bucket
...

 
実行後、 amplify env list すると、環境が増えていました。また、新しく作成した環境 staging にも切り替わっています。

f:id:thinkAmi:20190727204103p:plain:w150

 
また、 amplify status で状況を確認すると、 Create に変わっていました。

$ amplify status

Current Environment: staging

| Category | Resource name     | Operation | Provider plugin   |
| -------- | ----------------- | --------- | ----------------- |
| Api      | MultiDeveloperAPI | Create    | awscloudformation |

 
他に、team-provider-info.json ファイルにも、新規環境 staging が追加されています。
https://aws-amplify.github.io/docs/cli-toolchain/quickstart#sharing-projects-outside-the-team-

{
    "dev": {
        "awscloudformation": {
            "AuthRoleName": "xxx",
            "UnauthRoleArn": "xxx",
            "AuthRoleArn": "xxx",
            "Region": "xxx",
            "DeploymentBucketName": "xxx",
            "UnauthRoleName": "xxx",
            "StackName": "xxx",
            "StackId": "xxx"
        }
    },
    "staging": {
        "awscloudformation": {
            "AuthRoleName": "yyy",
            "UnauthRoleArn": "yyy",
            "AuthRoleArn": "yyy",
            "Region": "yyy",
            "DeploymentBucketName": "yyy",
            "UnauthRoleName": "yyy",
            "StackName": "yyy",
            "StackId": "yyy"
        }
    }
}

 
ためしに、この状態でQueryを実行してみたところ、エラーとなりました。

f:id:thinkAmi:20190727204004p:plain:w450

 
AppSync API 環境がまだ作成されていないようなので、 amplify push したところ、新しいAppSync APIが作成されました。

$ amplify push

Current Environment: staging

| Category | Resource name     | Operation | Provider plugin   |
| -------- | ----------------- | --------- | ----------------- |
| Api      | MultiDeveloperAPI | Create    | awscloudformation |
? Are you sure you want to continue? Yes

GraphQL schema compiled successfully.

? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
⠹ Updating resources in the cloud. This may take a few minutes...

...
CREATE_IN_PROGRESS apiMultiDeveloperAPI                  AWS::CloudFormation::Stack
...

 
各種AWSリソースも追加されています。

AppSync API

f:id:thinkAmi:20190727204252p:plain:w450

DynamoDB

f:id:thinkAmi:20190727204311p:plain:w450

S3

f:id:thinkAmi:20190727204338p:plain:w450

 
この状態でQueryやMutationを実行したところ、正常に動作しました。

f:id:thinkAmi:20190727204417p:plain:w450

 
DynamoDBにも登録されています。

f:id:thinkAmi:20190727204445p:plain:w300

 
以上より、 amplify env add で新しい環境を作成することで、既存の環境はそのままに、別の環境で作業できることが分かりました。

 

amplify env checkout で環境を切り替えて確認

再度、 dev 環境に切り替えた時に、元のリソースにアクセスできるかを確認します。

 
まずは、staging環境のDynamoDBの列contentsを bar へと更新します。

f:id:thinkAmi:20190727204728p:plain:w300

 
続いて、環境を切り替えます。

# 状況を確認
$ amplify env list

| Environments |
| ------------ |
| dev          |
| *staging     |

# checkout で切り替え
$ amplify env checkout dev
✔ Initialized provider successfully.
Initialized your environment successfully.

# 再度状況を確認
$ amplify env list

| Environments |
| ------------ |
| *dev         |
| staging      |

 
この状態でWebアプリのQueryを実行すると、dev環境のDynamoDBの値が取得できました。dev環境へアクセスできているようです。

f:id:thinkAmi:20190727204916p:plain:w450

 

注意:amplify delete は全環境を削除

amplify delete は実行時に

Are you sure you want to continue?(This would delete all the environments of the project from the cloud and wipe out all the local amplify resource files)

と表示されるように、全環境のリソースを削除します。うっかり amplify delete すると大変なことになります。

 
実際に試してみると

# env を確認
$ amplify env list

| Environments |
| ------------ |
| *dev         |
| staging      |


# stagingへと移動
$ amplify env checkout staging
✔ Initialized provider successfully.
Initialized your environment successfully.


# 削除
$ amplify delete
? Are you sure you want to continue?(This would delete all the environments of the project from the cloud and wipe out all the local amplify resource files) Yes

Deleting env:dev  # devを削除

Deleting env:staging  # stagingを削除
✔ Project deleted in the cloud
Project deleted locally.


# 確認すると、amplifyプロジェクト自体がない
$ amplify env list
/path/to/lib/node_modules/@aws-amplify/cli/node_modules/gluegun/build/index.js:13
    throw up;
    ^

Error: You are not working inside a valid amplify project.

となりました。

 
また、実際のリソースもありません。

DynamoDB

f:id:thinkAmi:20190728080221p:plain:w300

 
AppSync API

f:id:thinkAmi:20190728080245p:plain:w300