yu nkt’s blog

nkty blog

I'm a backend engineer in the start up company. The purpose of this blog is sharing what I know and learn daily

SAMでLayerだけ更新したのにAliasが進まない理由と、確実にVersionを切る方法

はじめに

この記事では、Lambda と Layer を組み合わせた運用でハマりやすいポイントを 2 つ紹介します。 1つ目は、$LATEST を直接 invoke すると、更新中に「設定とコードの反映タイミングのズレ」を踏み得ること。 2つ目は、SAM の AutoPublishAlias を使っていても、Layer の更新だけでは alias が進まず、$LATEST と alias の実行結果がズレ得ることです。 最後に、再現例とともに、確実に回避するための設定(例: AutoPublishCodeSha256)もまとめます。

$LATEST は「一貫性のあるデプロイ状態」を保証しない

Lambda を使う際、バージョンやエイリアス(Qualifier)を指定せずに invoke すると、暗黙に $LATEST を実行することになります。

$LATEST は「未発行の最新版」であり、更新中も含めて内容が変わり得るため、アプリケーションとして一貫した状態(更新前か更新後か)を常に保証してくれるわけではありません。

特に Layer を利用している場合、Layer の設定変更(Layers の付け替え)と Function のコード変更(デプロイパッケージ更新)が、スタック更新の中で別タイミングに反映されることで、 「コードは古いのに Layer だけ変わった(またはその逆)」瞬間を踏む可能性があります。

例えば、Layerが以下のコードだとします。

def info(msg: str) -> None:
    print(f"[INFO] {msg}")

そして、そのLayerに依存するFunctionのコードが以下だとします。

from custom_logging import info

def lambda_handler(event, context):
    info("hello from layer")
    return {"ok": True}

Layer は以下のテンプレートで、Layer 用の CloudFormation スタックがあるとします。

AWSTemplateFormatVersion: "2010-09-09"
Description: Custom logging layer stack

Parameters:
  LayerS3Bucket:
    Type: String
  LayerS3Key:
    Type: String

Resources:
  CustomLoggingLayer:
    Type: AWS::Lambda::LayerVersion
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
    Properties:
      LayerName: custom-logging
      Content:
        S3Bucket: !Ref LayerS3Bucket
        S3Key: !Ref LayerS3Key
      CompatibleRuntimes:
        - python3.12

Outputs:
  CustomLoggingLayerArn:
    Value: !Ref CustomLoggingLayer

そして Function は、以下の SAM 用のテンプレートでデプロイされるものとします。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Demo function that depends on a layer

Parameters:
  CustomLoggingLayerArn:
    Type: String

Resources:
  DemoFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: demo-latest-layer-pitfall
      Runtime: python3.12
      Handler: app.lambda_handler
      CodeUri: app/
      Timeout: 10
      Layers:
        - !Ref CustomLoggingLayerArn
      Policies:
        - AWSLambdaBasicExecutionRole

この状態でFunctionのテンプレートからLayerの指定を取り除きます。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Demo function that used to depend on a layer

Parameters:
  CustomLoggingLayerArn:
    Type: String

Resources:
  DemoFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: demo-latest-layer-pitfall
      Runtime: python3.12
      Handler: app.lambda_handler
      CodeUri: app/
      Timeout: 10
      # Layers: ← removed
      Policies:
        - AWSLambdaBasicExecutionRole

Functionのコードを以下のようにして、Layerに依存しないコードにしたとします。

def lambda_handler(event, context):
    # no longer depends on custom_logging layer
    print("hello without layer")
    return {"ok": True}

このスタックを更新している最中に、タイミング悪く $LATEST への invoke が発生すると、以下のエラーが出る場合があります。

[ERROR] Runtime.ImportModuleError: Unable to import module 'app': No module named 'custom_logging'
Traceback (most recent call last):
...
INIT_REPORT Init Duration: xxx ms  Phase: init  Status: error  Error Type: Runtime.ImportModuleError
START RequestId: ... Version: $LATEST

これが発生する理由は、$LATEST が「常にその時点の最新版」である一方で、スタック更新中は Lambda の設定更新やコード更新が段階的に進み得るためです。

補足: 公開済み Version(1, 2, ...)は immutable(不変)なので、本番トラフィックは Version + Alias に寄せるのが基本の防御策になります。

SAM の AutoPublishAlias は「Layer の更新」だけでは新しい Version を発行しないことがある

Lambdaでは、関数にVersionを発行し、VersionにAliasを切って運用するのが定石です。 SAM でも AWS::Serverless::FunctionAutoPublishAlias を指定すれば、デプロイ時に AWS::Lambda::Version が作られ、Alias が最新の Version を指すように更新されます。 このAliasに対してinvokeするのが、一般的です。

Lambdaで、Blue/Greenデプロイメントをする場合などでは、この運用は、概ね必須になります。

dev.classmethod.jp

ただし注意点があります。 Function のコードは一切変えずに、Layer だけ更新して Layers に新しい LayerVersion ARN を設定しても、AutoPublishAlias だけでは新しい Function Version が発行されないことがあります。

言い換えると、デプロイ後に

  • $LATEST は新しい Layer を見ている(設定更新は反映される)
  • しかし Alias(例: :live)は古い Version を指したまま(新しい Version が作られないと alias は進めない)

という「ズレ」が起きます。

よって、Layerを変更してデプロイしたら、そのLayerを使った処理をしてくれると思いきや、そうなっていない、ということが普通に起きえます。 個人的には、AWSのLambda(というかSAM)の最大の罠な気がします。

具体例を書くと、

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Demo function that depends on a layer (AutoPublishAlias only)

Parameters:
  CustomLoggingLayerArn:
    Type: String
    Description: LayerVersion ARN from the layer stack Outputs

Resources:
  DemoFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: demo-sam-layer-versioning
      Runtime: python3.12
      Handler: app.lambda_handler
      CodeUri: app/
      Timeout: 10
      Layers:
        - !Ref CustomLoggingLayerArn
      Policies:
        - AWSLambdaBasicExecutionRole
      AutoPublishAlias: live

Outputs:
  FunctionName:
    Value: !Ref DemoFunction
  LiveAliasArn:
    Value: !Sub "arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${DemoFunction}:live"

この Function のテンプレートに対して、パラメータに最新の LayerVersion ARN を指定して sam deploy を行っても、 新しい Function Version が発行されず、alias 経由の実行には Layer の更新が反映されないことがあります($LATEST には反映されても、alias が進まない)。

なぜこんなことが起きるのか

ポイントは、SAM の AutoPublishAlias はデフォルトでは「コード変更(主に CodeUri)をトリガーにして新しい Version を発行する」という点です。
そのため「LayerVersion ARN だけが変わった」状況では、新しい AWS::Lambda::Version が作られず、alias も進まないことがあります。

ただ、Layerとはいえ、コードの変更ではあるわけです。

では、SAMが、Versionを発行するべきと判断するのは何なのか、もう少し深掘りしていきます。

intrinsicとは

CloudFormation テンプレートには、値を動的に組み立てるための組み込み関数(式)があります。 これが一般に intrinsic functions(組み込み関数) と呼ばれるものです。代表例は次の通りです。

  • Ref!Ref):パラメータ値、疑似パラメータ、リソース参照などを取り出す
  • Fn::Sub!Sub):文字列の中で ${...} を展開する
  • Fn::GetAtt!GetAtt):リソースの属性値(ARN 等)を取り出す
  • Fn::Join / Fn::If / Fn::Equals など:条件分岐や文字列結合

ここで重要なポイントは、SAM は Transform(テンプレート展開)の段階で「Version を作るべきか」を判断している点です。 その判断は、デプロイ実行中に確定する「実 ARN」そのものではなく、Transform 時点で見えているテンプレート上の情報(プロパティの形)に依存します。

SAM を使わない(純 CloudFormation)場合

SAM を使わない場合(=純粋な CloudFormation テンプレートだけの場合)、役割分担は基本的にこうなります。

  • aws cloudformationコマンドを実行するローカル: テンプレートを用意し、パラメータを指定して CloudFormation に渡す
  • CloudFormation(AWS サービス): スタックの作成/更新の実行中に intrinsic を評価・展開する

もう少しだけ具体化すると、

  • Parameters はスタック更新開始時点で値が確定し、!Ref はそれに置き換えられる
  • !Ref / !GetAttリソース参照の場合、その物理 ID や ARN はリソース作成後に確定するため、「実行の途中で」値が確定して使えるようになる

つまり CloudFormation は、スタック操作の中で intrinsic を評価しつつ、リソースを作成・更新します。

SAM を使う場合

SAM テンプレートは Transform: AWS::Serverless-2016-10-31 を使います。 これは、CloudFormation がスタックを作る前に、SAM がテンプレートを “通常の CloudFormation リソース” に展開する工程が入る、ということです。

たとえば SAM の

  • AWS::Serverless::Function
  • AWS::Serverless::LayerVersion

は、そのままでは CloudFormation が直接作るリソースではありません。

SAM translator がこれらを

  • AWS::Lambda::Function
  • AWS::Lambda::Version
  • AWS::Lambda::Alias
  • (その他、必要な IAM やログ周り)

といった通常リソースに展開します。

ここが重要で、AutoPublishAlias が「新しい Version を発行するかどうか」を決めるのは、SAM の展開(変換)ロジックの中です。 言い換えると、SAM は「今回のデプロイで新しい AWS::Lambda::Version を作るべきか?」を、Transform 時点での差分検知ロジックで判断します。

intrinsic の展開タイミングが、なぜ関係するのか

LayerVersion ARN を別スタックから受け取り、Function スタックに パラメータとして渡す構成では、Function 側の Layers は多くの場合こうなります。

  • Layers: - !Ref CustomLoggingLayerArn(パラメータ参照)

ここでの !Ref は intrinsic です。

  • CloudFormation の実行では、パラメータ値が確定しているので、$LATESTLayers 更新は反映できます。
  • しかし SAM の AutoPublishAlias の Version 発行判断は、Transform(変換)段階の差分判定ロジックに依存します。

そして実務的には、今回の現象を決めている要因は次の設計です。

  • AutoPublishAlias はデフォルトでは「コード差分(主に CodeUri)」を Version 発行の主トリガーとしている
  • 「LayerVersion ARN だけの差し替え」は、(少なくともデフォルト設定では)Version を必ず増やす変更として扱われない

その結果、

  • $LATEST は新しい Layer を参照できる(=Function の設定更新が走る)
  • でも新しい発行済み Version は作られない(=alias が進まない)
  • alias 経由の実行は古い Version のまま(=古い Layer のまま)

という “乖離” が起きます。

AutoPublishAliasAllProperties を使えばいいのでは?

答えは、「万能ではない」です。

AutoPublishAliasAllProperties: true は「Function のプロパティ差分も含めて」Version 発行判断に使いますが、 Layers については参照の形や解決可否に制約があり、今議論しているような、 別スタックから LayerVersion ARN をパラメータで受け取る構成 では、期待どおりに差分検知できないケースがあります。

直感的には「LayerVersion ARN の“実値”まで見てほしい」と思うのですが、Transform 時点で見える情報だけで判断している以上、取りこぼしが起き得ます。

もう少し踏み込むと、ここで言っている「差分」とは “デプロイで渡したパラメータ値の差分” ではなく、Transform 時点で見えるテンプレート(YAML/JSON)の構造上の差分です。

たとえば、Function 側のテンプレートが次のようになっているとします。

  • Layers: - !Ref CustomLoggingLayerArn

このとき、sam deploy --parameter-overrides CustomLoggingLayerArn=...:1...:2 に変えても、SAM translator から見える Layers の見た目は常に「Ref でパラメータを参照している」という同じ構造のままです。 (SAM は Transform 中に「CustomLoggingLayerArn の実値が何か」までは確定できない/扱わない)

実装の観点では、AutoPublishAliasAllProperties の Version 発行判断は概ねこういう流れです。

  1. Function の Properties を辞書化したものをベースに、ハッシュ(Version の logical id の材料)を作る
  2. Layers が含まれている場合、Layer の参照から“logical id”を取り出せる形(Ref / Fn::GetAtt)のときだけ、その logical id でテンプレート内の Resources を引いて Layer リソース定義(Properties)を取得する
  3. 取得できた Layer 定義の Properties を、Function 側のハッシュ入力に混ぜる(=Layer の更新も差分として扱える)

ここで別スタック構成だと、!Ref CustomLoggingLayerArn の logical id は「パラメータ名」になってしまい、テンプレート内 Resources から Layer 定義を引けません。 その結果、SAM は「Layer の中身(LayerVersion ARN の実値)まで辿ってハッシュに混ぜる」ことができず、Function の Version 発行判断に Layer 更新が入りません

逆に言うと、同一テンプレート内に AWS::Lambda::LayerVersion が定義されていて、Function の Layers がそれを !Ref/!GetAtt で参照できる形になっている場合は、Layer 側 Properties をハッシュ入力に混ぜられるため、AutoPublishAliasAllProperties でも期待どおりに Version が進むケースがあります。

github.com

ここでは、回避策として、 AutoPublishCodeSha256 でLayerも含めたハッシュを渡して強制的にバージョンを発行させる、という手が提案されています。

ではどうするの?

上で提案されているように、 AutoPublishCodeSha256 を渡すのが、最も確実な方法と言えます。

テンプレートを以下のように記載します。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Demo function that depends on a layer (force publish with AutoPublishCodeSha256)

Parameters:
  CustomLoggingLayerArn:
    Type: String
    Description: LayerVersion ARN from the layer stack Outputs

  AutoPublishCodeSha256Override:
    Type: String
    Description: >-
      Any string. When this value changes, SAM will publish a new Lambda Version
      for the AutoPublishAlias even if the function code didn't change.
      Recommended: sha256 of the referenced LayerVersion ARN(s).

Resources:
  DemoFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: demo-sam-layer-versioning
      Runtime: python3.12
      Handler: app.lambda_handler
      CodeUri: app/
      Timeout: 10
      Layers:
        - !Ref CustomLoggingLayerArn
      Policies:
        - AWSLambdaBasicExecutionRole
      AutoPublishAlias: live
      AutoPublishCodeSha256: !Ref AutoPublishCodeSha256Override

Outputs:
  FunctionName:
    Value: !Ref DemoFunction
  LiveAliasArn:
    Value: !Sub "arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${DemoFunction}:live"

そして、これをデプロイするスクリプトでは、このように記載します。

#!/usr/bin/env bash
set -euo pipefail

# Deploys the function stack via SAM.
# Usage:
#   ./scripts/deploy_function.sh <stack-name> <layer-version-arn> <template-basename> [region] [auto-publish-code-sha256]
# Example:
#   ./scripts/deploy_function.sh demo-fn-stack arn:aws:lambda:...:layer:custom-logging:1 template.yaml ap-northeast-1
#   ./scripts/deploy_function.sh demo-fn-stack arn:aws:lambda:...:layer:custom-logging:2 template_all_properties.yaml ap-northeast-1
#   ./scripts/deploy_function.sh demo-fn-stack arn:aws:lambda:...:layer:custom-logging:2 template_code_sha256.yaml ap-northeast-1

STACK_NAME=${1:?stack-name required}
LAYER_ARN=${2:?layer arn required}
TEMPLATE_BASENAME=${3:-template.yaml}
REGION=${4:-}
CODE_SHA256_OVERRIDE=${5:-}

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$ROOT_DIR/function-stack/${TEMPLATE_BASENAME}"

if [[ ! -f "$TEMPLATE" ]]; then
  echo "Template not found: $TEMPLATE" >&2
  exit 1
fi

SAM_REGION_ARGS=()
if [[ -n "$REGION" ]]; then
  SAM_REGION_ARGS+=(--region "$REGION")
fi

PARAM_OVERRIDES=("CustomLoggingLayerArn=$LAYER_ARN")

if [[ "$TEMPLATE_BASENAME" == "template_code_sha256.yaml" ]]; then
  if [[ -z "$CODE_SHA256_OVERRIDE" ]]; then
    CODE_SHA256_OVERRIDE="$(printf '%s' "$LAYER_ARN" | shasum -a 256 | awk '{print $1}')"
  fi
  PARAM_OVERRIDES+=("AutoPublishCodeSha256Override=$CODE_SHA256_OVERRIDE")
fi

sam deploy \
  --stack-name "$STACK_NAME" \
  --template-file "$TEMPLATE" \
  --resolve-s3 \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides "${PARAM_OVERRIDES[@]}" \
  --no-confirm-changeset \
  --no-fail-on-empty-changeset \
  "${SAM_REGION_ARGS[@]}"

要するに、LayerのバージョンのARNのハッシュ値を与えてあげる、という感じです。

その他の方法(実験的)

AWS::LanguageExtensions を併用し、LayerVersion ARN(テンプレート上の参照)を Function の環境変数へダミーで埋め込んで、 「Function のプロパティ差分」として見せることで Version 発行を促す、という発想があります。

ただし、これは “LayerVersion ARN の実値が必ず事前に分かる” という意味ではありません(別スタック参照や ImportValue/SSM 等の解決タイミング次第では、狙いどおりに差分にならないことがあります)。

以下のようにFunctionのテンプレートを記載するイメージです。

AWSTemplateFormatVersion: "2010-09-09"
Transform:
  - AWS::LanguageExtensions
  - AWS::Serverless-2016-10-31
Description: Demo function that depends on a layer (LanguageExtensions + dummy env var)

Parameters:
  CustomLoggingLayerArn:
    Type: String
    Description: LayerVersion ARN from the layer stack Outputs (or any resolved ARN string)

Resources:
  DemoFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: demo-sam-layer-versioning
      Runtime: python3.12
      Handler: app.lambda_handler
      CodeUri: app/
      Timeout: 10
      Layers:
        - !Ref CustomLoggingLayerArn
      Environment:
        Variables:
          # Dummy variable to try to make the layer version change visible as a Function property change.
          # Note: whether this triggers a new published Version depends on how/when the value is resolved.
          LAYER_VERSION_ARN_DUMMY: !Ref CustomLoggingLayerArn

          # Example of LanguageExtensions usage (canonical JSON string)
          LAYER_INFO_JSON:
            Fn::ToJsonString:
              LayerArn: !Ref CustomLoggingLayerArn
      Policies:
        - AWSLambdaBasicExecutionRole
      AutoPublishAlias: live
      AutoPublishAliasAllProperties: true

Outputs:
  FunctionName:
    Value: !Ref DemoFunction
  LiveAliasArn:
    Value: !Sub "arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${DemoFunction}:live"

しかし、この方法には注意点があります。 AWS::LanguageExtensionsAWS::Serverless-2016-10-31(SAM Transform)を併用すると、SAM が生成するリソースの論理IDが 固定値扱い になって変わり得るのに、DependsOn 側ではその論理IDをintrinsicで組み立てられず、依存関係を正しく書けなくなる、という問題が生じます。

github.com

SAM は、テンプレートに書いていないリソースを内部で自動生成することがあります(典型例が AWS::Serverless::Api から生成される Stage など)。 一方で DependsOn では !Join / !Sub などの intrinsic が使えません。つまり「生成される論理IDに合わせて DependsOn を動的に組み立てる」ことができません。

例えば、テンプレートで、

  • resApiGateway : AWS::Serverless::Api
  • resUsagePlan : AWS::ApiGateway::UsagePlan(UsagePlanはSAMではなく素のCFN)

を書いたとすると、SAMでは、自動的に、 AWS::Serverless::Api から勝手に AWS::ApiGateway::Stage を作ります。

そうすると、CloudFormation に渡されるテンプレートでは、resApiGatewayAWS::ApiGateway::RestApi 等に展開され、さらに Stage(AWS::ApiGateway::Stage)も生成されます。

AWS::ApiGateway::Stage は、元々のテンプレに書かれていないので、論理IDはSAMが決める必要があります。 そして SAM はその論理IDを決めるとき、StageName の値を材料にする実装になっています。

LanguageExtensions 無しの場合、StageName: !Ref paramEnvironment は intrinsic のまま SAM に渡るため、SAM は StageName を固定文字列として扱えず、生成される論理IDが「固定パターン」になります。

LanguageExtensions ありの場合、テンプレートが SAM に渡る前に前処理が入り、!Ref paramEnvironment が(Issue の例では)local のような固定文字列に見える形になってしまい、 生成される論理IDが別名(...local... を含む名前)になることがあります。

結果として Stage の論理IDが resApiGatewaylocalStage になってしまいます。

その結果、テンプレートに DependsOn: resApiGatewayStage と書いていた場合に、生成後のテンプレート上では resApiGatewayStage という論理IDが存在せず、依存関係が壊れます。

このように、AWS::LanguageExtensionsAWS::Serverless-2016-10-31 の併用は、裏側で生成されるリソースの論理IDが変わり得るのに、DependsOn が動的に書けない、という罠を踏む可能性があります。

終わりに

LambdaとLayerは、あまりにも広く使われており、Functionのバージョンは、本番環境などでは必須と言える機能ですが、意外な罠があるので注意が必要です。

AWS IoT rule, Topic Ruleの用語整理

AWS IoTの用語で紛らわしいものに、AWS IoT Ruleと、Topic Ruleです。

どちらも、AWS IoTのトピックに届いたメッセージを、その後どうするか(別のリソースに配信するなど)、を表すAWSリソースです。

この二つは、どこが違うのでしょうか?

正解は、違いません。全く同じで、同じ概念です。

AWS CLIを使ってルールを作成するコマンドは以下のとおりなので、システム的には、Topic Ruleが正式で、それを至る所でTopicを省略し、その後、AWS IoTの中の、と言う意味合いでAWS IoTをつけて、AWS IoT Ruleとなっています。

aws iot create-topic-rule --rule-name myrule --topic-rule-payload file://myrule.json

CloudFormationでは、 AWS::IoT::TopicRule と言うリソース名です。

ただし、SAMには、本当に、IoTRuleと言う用語が出てきます。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Minimal SAM: IoT Rule -> Lambda

Resources:
  IotMessageHandler:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.12
      Handler: app.lambda_handler
      InlineCode: |
        import json

        def lambda_handler(event, context):
            # IoT Rule の SELECT * の結果が event に入ります
            print("event:", json.dumps(event))
            return {"ok": True}

      Events:
        FromIotTopic:
          Type: IoTRule  # <= !!ここ!!
          Properties:
            AwsIotSqlVersion: "2016-03-23"
            # 例: devices/<deviceId>/telemetry に publish されたものを拾う
            Sql: "SELECT * FROM 'devices/+/telemetry'"

SAM は内部的に以下を行なっています。

  1. AWS::IoT::TopicRule を生成
  2. SQL(SELECT … FROM …)を設定
  3. Lambda を Action として紐づけ
  4. Lambda invoke 用の permission も自動生成

あくまで、SAM専用の用語で、IoTRule ≠ TopicRuleですが、IoTRuleの中でTopicRuleが作られます(使われます)。

MQTT5のちょっと分かりにくい仕様の解説

MQTTは、石油ガス業界で、衛星経由で石油パイプラインをモニタリングするために、1999年に主にIBMによって開発されたプロトコルで、現在では、多くのIoT分野のシステムで利用されています(参考)。

現状、広く使われているバージョンは、OASISで2014年に標準化されたv3.1.1ですが、2019年にMQTT Version 5が標準化されています。

MQTT Version 5に関しては、多くのブログ等で情報が発信されており、前述でリンクしたVersion 5の仕様にも、Appendix C.で3.1.1からの差分の整理が書かれています。 そのため、今さら新機能などの概要紹介はしませんが、一部で概要だけでは分かりにくい部分もあるので、個人的にそう感じたものを調査してみました。

Session expiry

MQTTのセッションに関する新機能です。

Appendix C.の文章を直訳するとこうなります。

Clean Session フラグは、既存のセッションを使用せずにセッションを開始することを示す Clean Start フラグと、 切断後にどれくらいの期間セッションを保持するかを示す Session Expiry Interval の 2 つに分割された。 Session Expiry Interval は、切断時に変更することができる。 Clean Start を 1 に設定し、かつ Session Expiry Interval を 0 に設定することは、MQTT v3.1.1 において Clean Session を 1 に設定することと等価である。

まず、ここでいう"セッション"を整理する必要がありますが、そのためには、MQTTのQoSの概念の理解が必要です。

MQTTでは、QoSという概念があります。

QoS=0は、At most once。つまり、Publisher側が送ったメッセージは、Subscriber側に最大でも1度だけ届くものです。重複はないが欠損がありうる状況です。

QoS=1は、At least once。つまり、メッセージは最低でも一回届くものであり、ブローカーとSubscriberがメッセージを受け取った時にPUBACKを返し、送信側がそれが届くまで再度送るということで、少なくとも1回は配信されるよう再送されることを保証します。欠損はないが重複がありうる状況です。

QoS=2は、Exactly once。(MQTTプロトコルレベルでは)メッセージの重複処理も欠損もない状況です。ブローカーやSubscriberは、メッセージを受け取ってもすぐに処理はせず、まずは受け取ったことを知らせるPUBREC (Publish Received)を返し、その後送信側がそのメッセージを処理しても良いという通知のPUBREL (Publish Release)を送り、受信側がそれ受け取ったら処理を行って処理完了したらPUBCOMP (Publish Complete)を返して、それを持って送信側がメッセージを削除する流れです。

qiita.com

QoS=0では、Publisherは、ほとんど状態というものを持ちません(ブローカーにメッセージを送る -> Publisherでメッセージを破棄、があるのでなくはないですが)。 それに対して、QoS=1, 2は、明確に、Publishの処理に関して処理状態があります。

ネットワークの断絶などがあったときに、Publisherやブローカーが、各メッセージをどこまで処理したら(=どういう処理状態か)を保持し続けることが、ここでいう セッション です。

v3.1.1では、Publisher-ブローカーや、ブローカー-Subscriber間で、Keep Alive timeoutに到達した時に、そのセッション情報を破棄するか、継続保持するか(実装依存だがプロトコルとしては半永久的に持ち続けるか)、の二択でした。これが、Clean Sessionフラグです。1なら破棄、0なら保持です。

これに対し、Version 5では、新しい接続の際に既存のセッションを使うか(Clean Start)、と、いつまで保持し続けるか(Session Expiry Interval)、の二つのパラメータで、設定するようになりました。

Clean Startは0なら既存セッションを使い、1なら使わない、となります。

Clean Startが0で、Session Expiry Intervalが30sなら、切断後30秒以内でブローカーが保持しているセッションがあれば、再接続時に既存の残っているセッション情報を使ってMQTTの通信を続きから行います。 Clean Startが1で、Session Expiry Intervalが0なら、v3.1.1の時のClean Session=1と同等で、接続時にクリーンな状態でセッションを開始します。

では、Clean Startが1で、Session Expiry Interval > 0の場合は、どうなるでしょうか? 再接続時に必ず新しいセッションを開始しますが、セッション情報がSession Expiry Interval間は残り続けていることになります。基本的には、セッション情報のメモリ使用の無駄に思えるので、運用としてはこの設定には大抵の場合は意味がない(避けた方がいい)と思われます(障害調査や移行期間などでは、一時的に意味があるかもしれませんが)。

Shared Subscriptions

これも、Version 5の新機能です。

Appendix C.の文章を直訳するとこうなります。

サブスクリプションのコンシューマをロードバランスできるようにするため、共有サブスクリプションのサポートを追加する。

一つのトピックに対して、複数のSubscriberがいる場合、v3.1.1もVersion 5も、メッセージはファンアウトされ、全てのサブスクライバーに同じメッセージが届きます。

Shared Subscriptionsでは、一つのメッセージが、複数のSubcriberのうちの一つに届く、という機能です。 仕様の説明にある通り、ロードバランサーのような負荷分散の機能として利用することができます。

ここで、このメッセージの配信ロジックに、QoSが特に組み込まれていない(少なくとも仕様には書かれていない)点には少し注意です。 例えば、あるSubscriberは最大QoS=0とし、PublisherがQoS=1でメッセージを送った場合に、ブローカーが配信先のSubscriberを選ぶ際に、別のSubscriberが最大QoS=1でサブスクライブしていたとしても、QoS=0のSubscriberが選択され、そのメッセージ配信のQoSが0に降格される可能性はある、ということです。

また、QoS=2でPublishしたとしても、送信途中での通信途絶により、PUBCOMPの途絶によって別クライアントへのメッセージの送り直しがありえる、と捉えるのが妥当だと思います。 MQTTの仕様書では、QoS=2でのExactly Onceは、制御パケットと状態を使った handshake で保証される、という記述があり、このハンドシェイクは1:1の対向関係でのみ成り立つ内容であり、Shared Subscriptionsにおける、アプリ全体での保証までは含まれていない、と捉えた方が、無難だろうと思われます(実装にもよるかもしれませんが、要注意です)。

ちょっと疲れたので、一旦ここまでにします。

他は、読めば大体イメージが湧く機能改善や新機能だと思いますが、user propertiesなど、気になるものは追って深掘りしようかと思います。

AWSのGoライブラリではデフォルトでリトライが入っている

AWSのGoライブラリではデフォルトでリトライが入っています。

github.com

項目 デフォルト値
最大試行回数 3回(初回 + リトライ2回)
リトライモード Standard
最大待機時間 20秒
バックオフ戦略 Exponential Jitter

Standardモードというのは、以下の特徴を持つリトライ方式のことです。

これに対して、Adaptiveモードもありますが、それはサーバーからのスロットリング応答に応じて、送信レートを動的に調整するモードです。 Adaptiveモードは、実験的で今後振る舞いが変わる可能性がある、とコードにはコメントで書かれています。

デフォルトでリトライがあるということは、以下に注意です。

  • 二重更新
  • リトライによる処理遅延(すぐに失敗して処理が終わらない)
  • 順序の逆転

基本的に、上記が許容できない場合は、リトライを意図的にしないような設定が必要です。

なお、Exponential Jitterは、以下の計算式です。

 delay = random(0.0, 1.0) × 2^{attempt} (秒)

github.com

固定の指数バックオフ(例: 1秒→2秒→4秒)だと、同時に失敗した多数のクライアントが同じタイミングでリトライし、再び一斉にサーバーに負荷がかかります。 ランダム化により、リトライのタイミングが分散され、サーバー負荷が平準化されます。

Link preview生成

このブログも結構放置気味でしたが、先日久々に投稿したら、はてなブログサイトマップGoogleに読み取られず、インデックスに登録されない自体が発生してました。

様々な試行錯誤はしたものの、原因が分からず、GoogleBloggerWordpressに移行しようかと検討中です。

これらの比較は、別記事にしますが、Bloggerを試した所、リンクプレビュー(link preview)の機能が無いことが分かり、流石に味けないので、自分でリンクプレビューを作成することを試みました。

続きを読む

ブラウザでPythonを実行できるPyScript

PyCon US 2022が、4月27日~5月3日に開催されていますが、そのキーノートで、PyScriptというプロジェクトが、AnacondaのCEO、Peter Wang氏から発表され、私のTwitter界隈では大きく注目されています。 今回は、Pythonの旬の話題として、PyScriptを取り上げようと思います。

なお、まだ発表されて間もないなので、本記事に誤り・語弊があれば追って修正します(ご指摘ください)。

続きを読む