ちょっとお久しぶりの会社ブログ投稿な照井です。
今年は会期中の過ごし方や旅程を自分のペースで行きたかったのでre:Inventは個人的に参加してきました。とても楽しかったです。
tl;dr
re:Inventでずっと欲しかったServerlessなリソースのディスカバリができるCloud Mapがリリースされたため、実証と実戦投入のための考察などしてみました。 結論から言うと「超便利。でも実戦投入するにはディスカバリ結果のキャッシュを上手く取り回す仕組みが必要だなー」って感じです。
Cloud Map https://aws.amazon.com/jp/cloud-map/
何がやりたいか
Cloud Mapは今までConsulやRoute53 APIとHealthCheckを駆使して自前で構築していたDNSベースのServiceDiscoveryがフルマネージドサービスでできる(Cloud MapもバックグラウンドでRoute53を使うことでDNSによるディスカバリを提供しているんですが)ということに加えて、HTTPベースでドメインを持たないリソースを登録してその登録された属性をクエリすることでディスカバリを行うことができます。
これを上手く使って、Kinesis Data Streams, SNS, DynamoDBのような固有ドメインのendpointを持たずリソース名でアクセスするサービスをディスカバリしたいわけです。例えば、 StreamName: foo, Environment: prod
のように論理名と属性をクエリすることで物理リソース名が返ってきてほしいわけです。
Serverlessでの今までのプラクティスとその問題点
AWS SAMやServerless Frameworkを利用している場合、今までは命名規則を決めてSSM Parameter Storeから取得させたり、CloudFormationのImport/ExportValueを使ってLambdaの環境変数から各種リソース名を注入するというプラクティスがあります。この方法は便利ですが、いくつか問題点がありました。
- 命名規則でリソース名の取り方が決まるためクエリの表現力が貧弱
- SSM Parameter Storeは無料で便利だが、大量アクセス耐性や可用性の保証が無い
- 環境変数から注入している場合、変数の中身はデプロイし直さないと変わらない
これらを良い感じにCloud Mapで解決したいわけです。
実証実験
では、AWS SAMフレームワークを使っていくつか実証実験してみます。
下準備
下記のようなテンプレートをデプロイしてCloud MapのNamespace, Serviceを作成し、そこにKinesisのStreamを env: prod
という属性と共に登録してみます。ちなみに2018-12-04現在、LambdaのPython3.6ランタイムに標準で入っているboto3はCloud Mapに対応していないので対応しているバージョンを同梱する必要があります。今回使っているバージョンは 1.9.58
です。
ExampleStream:
Type: AWS::Kinesis::Stream
Properties:
ShardCount: 1
ExampleNameSpace:
Type: AWS::ServiceDiscovery::HttpNamespace
Properties:
Name: sls.willy.works
ExampleService:
Type: AWS::ServiceDiscovery::Service
Properties:
Name: example-stream
NamespaceId: !Ref ExampleNameSpace
ExampleInstance:
Type: AWS::ServiceDiscovery::Instance
Properties:
InstanceAttributes:
env: prod
InstanceId: !Ref ExampleStream
ServiceId: !Ref ExampleService
ポイントはStreamの Name
属性をしていないことです。多くのAWSサービスはCloudFormationでリソース名を指定しなかった場合、Stack名とランダムな文字列などを使って一意なリソース名を勝手に付与してくれます。Cloud Mapを使うことで物理的な名前は扱わずにあくまで論理的な属性だけで名前解決を行いたいわけです。
NamespaceとServiceは実戦では別StackにしてExportValueして使ったほうが良さそうですが、実証実験なので今は一緒にしちゃってます。
実験①:素直に使ってみる
下記のような ServiceName: example-stream, env: prod
をクエリして結果を返すLambda Functionをデプロイして実行してみます。
import json
import boto3
instances = None
client = None
def hello(event, context):
global client
if client is None:
client = boto3.client('servicediscovery')
instances = client.discover_instances(
NamespaceName='sls.willy.works',
ServiceName='example-stream',
QueryParameters={
'env': 'prod'
}
)
return {
"statusCode": 200,
"body": json.dumps({
'stream_name': instances['Instances'][0]['InstanceId'],
})
}
curlで叩くとStreamの物理名が返ってきますね!
$ curl https://rfk78is730.execute-api.us-west-2.amazonaws.com/Prod/hello/
{"stream_name": "cloudmap-example-ExampleStream-16P1D92Y4IMAA"}
さて、ここで気になるのはCloud Mapをクエリする際のレイテンシです。毎回httpで引くと結構気になってくると思うんですよね・・・
実験②:クエリ結果をキャッシュする
クエリを行わない場合と比較するために下記のように雑にキャッシュするコードをデプロイしてみます。
import json
import boto3
instances = None
client = None
def world(event, context):
global client
global instances
if instances is None:
if client is None:
client = boto3.client('servicediscovery')
instances = client.discover_instances(
NamespaceName='sls.willy.works',
ServiceName='example-stream',
QueryParameters={
'env': 'prod'
}
)
return {
"statusCode": 200,
"body": json.dumps({
'stream_name': instances['Instances'][0]['InstanceId'],
})
}
当然ですが結果は同じです。
$ curl https://rfk78is730.execute-api.us-west-2.amazonaws.com/Prod/world/
{"stream_name": "cloudmap-example-ExampleStream-16P1D92Y4IMAA"}
まあ、キャッシュしてるんだからレイテンシは大幅に改善しているわけで。
結果
Cloud Mapをクエリした場合のレイテンシはランタイムがPython3.6, メモリ割り当て128MBで60~120ms(サンプル数が極端に少ないので参考程度)かかるようです。これはCloud MapのAPI課金も踏まえると、ちょっと毎回引くのは厳しいですね・・・
考察
キャッシュするとキャッシュをパージするタイミングの制御などの実装が必要になります。また、シンプルなディスカバリの実行をトリガーとしたTTLや確率ベースの実装だとキャッシュの更新タイミングにあたった場合のレイテンシは避けられません。コンテナの場合は例えばEnvoyのようにサイドカーパターンで同じホストに別のコンテナをデプロイして、ディスカバリはそちらに任せることで低レイテンシを保ちつつ良い感じにやってくれますが、Lambdaの場合はそのような選択肢は取れません。
ちなみにご参考までに今回のとても雑な検証コードは一応以下に公開してあります。 https://github.com/marcy-terui/lambda-cloudmap-example
さーて、ここからどうするかなー(続く・・・?