発表されてからだいぶ経ちますが、複数サービスのリソース情報を横断的に取得する社内ツールで活用できないかと思いAWS Cloud Control APIについて簡単に調べてみました。
基本的な操作に関する実際のコードなどは下記AWS News Blogにあるので割愛しています。

AWS Cloud Control API, a Uniform API to Access AWS & Third-Party Services

サービス概要

  • サードパーティ含め、さまざまなサービスのリソースのCRUD-L操作に対して統一されたAPIを提供するサービス。
    • CRUD-L:Create(作成), Read(読み取り), Update(更新), Delete(削除), and List(一覧表示)
    • 実行時は各操作にパラメーターとしてCloudFormationで見慣れたリソースタイプと属性(JSON)or 識別子を渡す。
    • 戻り値の型やエラーメッセージも同様に、すべての操作とリソースで統一されている。
  • リソースの管理をおこなうツールなど低レベルのAWSサービスAPIを必要とする場合、Cloud Control APIを利用すると各サービスごとに特化したコードを削減でき、コードの運用保守や新しいサービス・機能への追従が容易になる。

従来はサービスごとに異なるAPIを学習し、それぞれ固有のコードやスクリプトを作成する必要がありましたが、Cloud Control APIの統一されたAPIを利用することで共通化しシンプルにできるとのことです。
たとえば、Lambdaの関数リストとCloudFormationのスタックリストを取得する場合、それぞれListFunctionsListStacksを呼び出す必要がありましたが、Cloud Control APIならばどちらも同じListResourcesを呼び出すことで一覧を取得可能になります。

オペレーションの種類

Cloud Control APIには大きく分けて、リソース操作のリクエストを行うためのAPIオペレーションと、そのリソース操作リクエストを管理するためのAPIオペレーションの2種類があります。

リソース操作リクエストのAPIオペレーション

リソース操作のリクエストを行うAPIオペレーションは下記の5つです。

  • CreateResource:リソースを作成する。
  • UpdateResource:リソースを更新する。
  • GetResource:リソースの詳細を取得する。
  • DeleteResource:リソースを削除する。
  • ListResources:リソースの一覧を取得する。

すべての操作はCloud Control APIで作成したリソース以外に対しても同様に実行可能です。つまり、他のIaCサービス(CloudFormationやTerraformなど)で管理しているリソースに対しても操作可能なので、UpdateResourceやDeleteResourceといった破壊的操作をする際は注意が必要です。

リソース操作リクエストを管理するAPIオペレーション

リソース操作で発行したリクエストを管理するためのAPIもあります。

  • CancelResourceRequest:進行中(ステータスがIN_PROGRESSPENDING)のリクエストをキャンセルする。
  • GetResourceRequestStatus:リクエストの進行状況を取得する。
  • ListResourceRequests:アクティブなリクエストの一覧を取得する。

なお、リソース操作リクエストは7日後に期限切れになるとのことです。

セキュリティ・IAM関連

ドキュメントには

AWS CloudFormation provides the security architecture for Cloud Control API; because of this, you will need to configure CloudFormation to meet your security and compliance objectives when using Cloud Control API.

と記載があるため、セキュリティ関連はCloudFormationでのセキュリティに準拠しますが一部異なる点もあるので注意が必要です。

Cloud Control APIの実行権限

Cloud Control APIのアクションはCloudFormationの一部であるため、プレフィックスとしてcloudformationがついています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VisualEditor0",
      "Effect": "Allow",
      "Action": [
        "cloudformation:ListResources",
        "cloudformation:DeleteResource",
        "cloudformation:CancelResourceRequest",
        "cloudformation:GetResource",
        "cloudformation:UpdateResource",
        "cloudformation:GetResourceRequestStatus",
        "cloudformation:ListResourceRequests",
        "cloudformation:CreateResource"
      ],
      "Resource": "*"
    }
  ]
}

ただし、2023/09/06時点ではCloudFormationリソースレベルの権限CloudFormation条件の使用はサポートされていないとのことです。

サービスロールの利用

リソース操作リクエストを行うときに、RoleArnパラメーターを指定することでリソース操作を行うサービスロールを指定できます。
前述の通り、Cloud Control APIのアクションはCloudFormationの一部であるためCloudFormationでサービスロールを利用するときと同様に、ロールを引き受けることのできるサービスとしてAWS CloudFormation (cloudformation.amazonaws.com) を指定する必要があります。

リソースタイプ

現在対応しているリソースタイプ

現在サポートされているAWSのリソースタイプは下記で一覧できます。

クラウドコントロール API をサポートするリソースタイプ

リリース当初と比較するとAWS::S3::Bucketなども追加されて充実してきています。しかし、2023/09/06時点ではAWS::EC2::Instanceなど比較的利用頻度の高いリソースタイプでもまだ対応していないものが見受けられます。

サードパーティのものを含めたパブリックなリソースタイプはマネージドコンソールのCloudFormationレジストリからも確認可能です。ただし、ここで表示されるリソースタイプすべてがCloud Control APIをサポートしているわけではないので注意が必要です。

リソースタイプがCloud Control APIをサポートしているかの確認

cloudformation describe-typeコマンドを使用してリソースタイプの詳細を取得し、ProvisioningTypeの値を確認することでそのリソースタイプがサポートされているか否かを判断できます。
ProvisioningTypeの値がFULLY_MUTABLE、もしくはIMMUTABLEの場合はサポートされています。

試しにAWS::Logs::LogGroupについて確認してみると、FULLY_MUTABLEなのでサポート対象であることがわかります。

$ aws cloudformation describe-type --type RESOURCE --type-name AWS::Logs::LogGroup --query "ProvisioningType"
"FULLY_MUTABLE"

それに対してAWS::EC2::Instanceの場合、NON_PROVISIONABLEであるためサポート対象外です。

$ aws cloudformation describe-type --type RESOURCE --type-name AWS::EC2::Instance --query "ProvisioningType"
"NON_PROVISIONABLE"

リソースタイプごとの属性や操作に必要な権限の確認

リソースタイプスキーマを確認することで、リソースを操作するときに必要な属性に関する情報や、可能な操作とそれに必要な権限を確認できます。この確認もマネージドコンソール、cloudformation describe-typeコマンドのどちらでも可能です。

AWS::Lambda::Functionを例にcloudformation describe-typeコマンドを利用して確認してみます。
jqの部分はSchemaの値を抽出して整形するための処理です。

$ aws cloudformation describe-type --type RESOURCE --type-name AWS::Lambda::Function | jq -r .Schema | jq .

返ってきた値を確認すると、propertiesセクションには属性やそのデータタイプ、必須であるかどうか、許容値や必須パターンなどの制約が定義されています。

  "properties": {
    "Description": {
      "description": "A description of the function.",
      "type": "string",
      "maxLength": 256
    },
    "TracingConfig": {
      "description": "Set Mode to Active to sample and trace a subset of incoming requests with AWS X-Ray.",
      "$ref": "#/definitions/TracingConfig"
    },
    "VpcConfig": {
      "description": "For network connectivity to AWS resources in a VPC, specify a list of security groups and subnets in the VPC.",
      "$ref": "#/definitions/VpcConfig"
    },
    "RuntimeManagementConfig": {
      "description": "RuntimeManagementConfig",
      "$ref": "#/definitions/RuntimeManagementConfig"
    },
    "ReservedConcurrentExecutions": {
      "description": "The number of simultaneous executions to reserve for the function.",
      "type": "integer",
      "minimum": 0
    },
    "SnapStart": {
      "description": "The SnapStart setting of your function",
      "$ref": "#/definitions/SnapStart"
    },
    "FileSystemConfigs": {
      "maxItems": 1,
      "description": "Connection settings for an Amazon EFS file system. To connect a function to a file system, a mount target must be available in every Availability Zone that your function connects to. If your template contains an AWS::EFS::MountTarget resource, you must also specify a DependsOn attribute to ensure that the mount target is created or updated before the function.",
      "type": "array",
      "items": {
        "$ref": "#/definitions/FileSystemConfig"
      }
    },
    "FunctionName": {
      "minLength": 1,
      "description": "The name of the Lambda function, up to 64 characters in length. If you don't specify a name, AWS CloudFormation generates one.",
      "type": "string"
    },
    "Runtime": {
      "description": "The identifier of the function's runtime.",
      "type": "string"
    },
    "KmsKeyArn": {
      "pattern": "^(arn:(aws[a-zA-Z-]*)?:[a-z0-9-.]+:.*)|()$",
      "description": "The ARN of the AWS Key Management Service (AWS KMS) key that's used to encrypt your function's environment variables. If it's not provided, AWS Lambda uses a default service key.",
      "type": "string"
    },
    ...

handlersセクションには、リソースタイプが対応している操作と、その操作のために必要な権限が定義されています。

  "handlers": {
    "read": {
      "permissions": [
        "lambda:GetFunction",
        "lambda:GetFunctionCodeSigningConfig"
      ]
    },
    "create": {
      "permissions": [
        "lambda:CreateFunction",
        "lambda:GetFunction",
        "lambda:PutFunctionConcurrency",
        "iam:PassRole",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeSubnets",
        "ec2:DescribeVpcs",
        "elasticfilesystem:DescribeMountTargets",
        "kms:CreateGrant",
        "kms:Decrypt",
        "kms:Encrypt",
        "kms:GenerateDataKey",
        "lambda:GetCodeSigningConfig",
        "lambda:GetFunctionCodeSigningConfig",
        "lambda:GetLayerVersion",
        "lambda:GetRuntimeManagementConfig",
        "lambda:PutRuntimeManagementConfig",
        "lambda:TagResource"
      ]
    },
    "update": {
      "permissions": [
        "lambda:DeleteFunctionConcurrency",
        "lambda:GetFunction",
        "lambda:PutFunctionConcurrency",
        "lambda:ListTags",
        "lambda:TagResource",
        "lambda:UntagResource",
        "lambda:UpdateFunctionConfiguration",
        "lambda:UpdateFunctionCode",
        "iam:PassRole",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeSubnets",
        "ec2:DescribeVpcs",
        "kms:CreateGrant",
        "kms:Decrypt",
        "kms:GenerateDataKey",
        "lambda:GetRuntimeManagementConfig",
        "lambda:PutRuntimeManagementConfig",
        "lambda:PutFunctionCodeSigningConfig",
        "lambda:DeleteFunctionCodeSigningConfig",
        "lambda:GetCodeSigningConfig",
        "lambda:GetFunctionCodeSigningConfig"
      ]
    },
    "list": {
      "permissions": [
        "lambda:ListFunctions"
      ]
    },
    "delete": {
      "permissions": [
        "lambda:DeleteFunction",
        "ec2:DescribeNetworkInterfaces"
      ]
    }
  },

リソースタイプスキーマの詳細についてはリソースタイプ定義スキーマをご確認ください。

リソース操作リクエストの冪等性を保証する

リソースの作成CreateResource、更新UpdateResource、削除DeleteResourceのリクエストにはClientTokenパラメーターを指定できます。
これによってリクエストの冪等性が保証されます。

IdentifierであるFunctionNameを指定せずにLambda関数を作成する場合を例に確認してみます。
まず、client-tokenを設定せずにcreate-resourceコマンドを実行します。

$ aws cloudcontrol create-resource          \
           --type-name AWS::Lambda::Function   \
           --desired-state '{"Code":{"S3Bucket":"cloudcontrol-test","S3Key":"sample_code.zip"},"Role":"arn:aws:iam::123456789012:role/service-role/cloudcontrol-test-role","Runtime":"python3.11","Handler":"lambda_function.lambda_handler"}' 

{
    "ProgressEvent": {
        "TypeName": "AWS::Lambda::Function",
        "RequestToken": "f7158354-42fc-4c81-819a-c61cb01607f4",
        "Operation": "CREATE",
        "OperationStatus": "IN_PROGRESS",
        "EventTime": "2023-09-07T00:50:27.215000+00:00"
    }
}

次に、このリクエストのステータスを確認します。

$ aws cloudcontrol get-resource-request-status --request-token f7158354-42fc-4c81-819a-c61cb01607f4

{
    "ProgressEvent": {
        "TypeName": "AWS::Lambda::Function",
        "Identifier": "6PymYsTEpYWRnyIss4G2Nhx9j-YSi4mBv61sWK",
        "RequestToken": "f7158354-42fc-4c81-819a-c61cb01607f4",
        "Operation": "CREATE",
        "OperationStatus": "SUCCESS",
        "EventTime": "2023-09-07T00:50:33.361000+00:00"
    }
}

OperationStatusSUCCESSIdentifier6PymYsTEpYWRnyIss4G2Nhx9j-YSi4mBv61sWKなので、関数名6PymYsTEpYWRnyIss4G2Nhx9j-YSi4mBv61sWKというLambda関数が作成されたことがわかります。

続けて同じコマンドを再度実行して、リクエストのステータスを確認してみます。
意図せずに同じ設定のリクエストを多重送信してしまった想定です。

$ aws cloudcontrol create-resource          \
           --type-name AWS::Lambda::Function   \
           --desired-state '{"Code":{"S3Bucket":"cloudcontrol-test","S3Key":"sample_code.zip"},"Role":"arn:aws:iam::123456789012:role/service-role/cloudcontrol-test-role","Runtime":"python3.11","Handler":"lambda_function.lambda_handler"}' 

{
    "ProgressEvent": {
        "TypeName": "AWS::Lambda::Function",
        "RequestToken": "aec3963b-a73f-4492-b38d-806dc9620ac5",
        "Operation": "CREATE",
        "OperationStatus": "IN_PROGRESS",
        "EventTime": "2023-09-07T00:50:37.793000+00:00"
    }
}

$ aws cloudcontrol get-resource-request-status --request-token aec3963b-a73f-4492-b38d-806dc9620ac5

{
    "ProgressEvent": {
        "TypeName": "AWS::Lambda::Function",
        "Identifier": "5kOz9vkTaOJswXEHV8P6AJVVS-NoA3niuHCQxu",
        "RequestToken": "aec3963b-a73f-4492-b38d-806dc9620ac5",
        "Operation": "CREATE",
        "OperationStatus": "SUCCESS",
        "EventTime": "2023-09-07T00:50:43.878000+00:00"
    }
}

さきほどとはRequestTokenが異なるため別のリクエストとして処理されていることがわかります。
OperationStatusIdentifierを確認すると、まったく同じ設定で別のLambda関数5kOz9vkTaOJswXEHV8P6AJVVS-NoA3niuHCQxuが作成されています。

この例ではIdentifierを指定していないため複数のリソースが作成される結果となっていますが、たとえばIdentifierやその他競合が発生してしまうような設定をしたうえで、作成処理に時間がかかるリソースのCreateResourceが多重でリクエストされてしまった場合、意図せぬエラーの発生や想定外のリソースが作成されてしまうことが予想されます。

次にClientTokenパラメーターを指定して、複数回おなじコマンドを連続実行してみます。

$ aws cloudcontrol create-resource          \
           --type-name AWS::Lambda::Function   \
           --desired-state '{"Code":{"S3Bucket":"cloudcontrol-test","S3Key":"sample_code.zip"},"Role":"arn:aws:iam::123456789012:role/service-role/cloudcontrol-test-role","Runtime":"python3.11","Handler":"lambda_function.lambda_handler"}'  \
           --client-token f63g2prinrei8pey

{
    "ProgressEvent": {
        "TypeName": "AWS::Lambda::Function",
        "RequestToken": "3232173a-7fa7-48d1-90b9-0de15eab75f4",
        "Operation": "CREATE",
        "OperationStatus": "IN_PROGRESS",
        "EventTime": "2023-09-07T00:51:05.678000+00:00"
    }
}

1回目のリクエストはとくに変わりはありません。

$ aws cloudcontrol create-resource          \
           --type-name AWS::Lambda::Function   \
           --desired-state '{"Code":{"S3Bucket":"cloudcontrol-test","S3Key":"sample_code.zip"},"Role":"arn:aws:iam::123456789012:role/service-role/cloudcontrol-test-role","Runtime":"python3.11","Handler":"lambda_function.lambda_handler"}'  \
           --client-token f63g2prinrei8pey

{
    "ProgressEvent": {
        "TypeName": "AWS::Lambda::Function",
        "Identifier": "c587rQZAHO1ni2ct4iME79ukb-LaL8mfpQLR6S",
        "RequestToken": "3232173a-7fa7-48d1-90b9-0de15eab75f4",
        "Operation": "CREATE",
        "OperationStatus": "IN_PROGRESS",
        "EventTime": "2023-09-07T00:51:06.238000+00:00",
        "RetryAfter": "2023-09-07T00:51:11.238000+00:00"
    }
}

2回目のリクエストの結果を見ると、RequestTokenの値が1回目の値と同じであり、同一リクエストとして処理されていることがわかります。
作成されたLambda関数も関数名c587rQZAHO1ni2ct4iME79ukb-LaL8mfpQLR6Sのみで、複数個作成されることはありませんでした。

このようにClientTokenパラメーターを指定することで冪等性を保証でき、リトライや意図せぬ多重実行の対応などが容易になります。

作成、更新、削除リクエストをする際は

  • 常にClientTokenを設定する
  • トークンはUUIDなどのリクエストごとに一意な値になるものを生成する

がベストプラクティスです。

まとめ

統一されたAPIというのは横断的にリソースを扱う際には非常に強力に思えました。
ネックがあるとすれば「操作したいリソースタイプがCloud Control APIをサポートしているか」ということろですが、そこが許されるのであれば積極的に採用していきたいサービスです。
今後もサポートされているリソースタイプが続々追加されていくことを期待したいと思います。

参考

AWS News Blog
AWSブログ(日本語)
ユーザーガイド
API リファレンス