マルチリージョン並列デプロイで発生するサービスリンクロール競合問題とその対策
こんにちは。サービス開発室の武田です。
AWSのリージョナルサービスをマルチリージョンに展開する際、処理時間短縮のために並列実行を採用することがあります。しかし、この並列実行が思わぬ問題を引き起こすケースがあります。
今回は、GuardDuty Malware Protectionをマルチリージョンで並列有効化した際に遭遇したサービスリンクロール(SLR)の競合問題と、その対策についてまとめます。
発生した事象
複数のAWSリージョンで同時にGuardDuty Malware Protectionを有効化しようとしたところ、一部のリージョンで有効化に失敗するという問題が発生しました。
APIは例外を発生させず 一見問題なく完了しているのですが、一部リージョンでEBS_MALWARE_PROTECTIONが有効化されていません。
CloudTrailを確認すると、当該リージョンで次のようなレスポンスが記録されていました。
{
"responseElements": {
"detectorId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"unprocessedDataSources": {
"malwareProtection": {
"scanEc2InstanceWithFindings": {
"ebsVolumes": {
"status": "DISABLED",
"reason": "Internal server error."
}
}
}
}
}
}
unprocessedDataSourcesにInternal server error.が記録されています。
原因
GuardDuty Malware Protectionを有効化すると、AWSは自動的にサービスリンクロールを作成します。具体的にはAWSServiceRoleForAmazonGuardDutyMalwareProtectionというロールです。
ここでポイントになるのは、サービスリンクロールはIAMリソースであり、グローバルリソースであるという点です。
複数リージョンで並列にMalware Protectionを有効化すると、各リージョンの処理が同時にこのグローバルなSLRを作成しようとします。最初のリクエストがSLRの作成を開始すると、作成完了までの間に来た他のリクエストは競合により、サイレントに失敗すると考えられます。
問題が発生するコード例
次のように複数リージョンで並列にGuardDuty Detectorを作成すると、競合する可能性があります。
import asyncio
import boto3
async def enable_guardduty(region: str):
"""各リージョンでGuardDutyを有効化"""
session = boto3.Session(region_name=region)
guardduty = session.client("guardduty")
# この呼び出しがSLRを自動作成しようとする
guardduty.create_detector(
Enable=True,
Features=[
{
"Name": "EBS_MALWARE_PROTECTION",
"Status": "ENABLED"
}
]
)
async def main():
regions = ["ap-northeast-1", "us-east-1", "eu-west-1", "ap-southeast-1"]
# 並列実行すると競合が発生する可能性がある
await asyncio.gather(*[
enable_guardduty(region) for region in regions
])
対策:サービスリンクロールの事前作成
サービスを有効化する前に、必要なサービスリンクロールを明示的に作成しておくことで、競合を回避できます。
import asyncio
import boto3
from botocore.exceptions import ClientError
def create_service_linked_role_if_not_exists(
service_name: str,
role_name: str
) -> None:
"""サービスリンクロールを事前作成する
Args:
service_name: AWSサービス名(例: malware-protection.guardduty.amazonaws.com)
role_name: 作成されるロール名(例: AWSServiceRoleForAmazonGuardDutyMalwareProtection)
"""
iam = boto3.client("iam")
# 既に存在するか確認
try:
iam.get_role(RoleName=role_name)
print(f"SLR {role_name} は既に存在します")
return
except ClientError as e:
if e.response["Error"]["Code"] != "NoSuchEntity":
raise
iam.create_service_linked_role(AWSServiceName=service_name)
print(f"SLR {role_name} を作成しました")
async def enable_guardduty(region: str):
"""各リージョンでGuardDutyを有効化"""
session = boto3.Session(region_name=region)
guardduty = session.client("guardduty")
guardduty.create_detector(
Enable=True,
Features=[
{
"Name": "EBS_MALWARE_PROTECTION",
"Status": "ENABLED"
}
]
)
print(f"{region}: GuardDuty を有効化しました")
async def main():
regions = ["ap-northeast-1", "us-east-1", "eu-west-1", "ap-southeast-1"]
# 1. まずSLRを事前作成(グローバルなので1回でOK)
create_service_linked_role_if_not_exists(
service_name="malware-protection.guardduty.amazonaws.com",
role_name="AWSServiceRoleForAmazonGuardDutyMalwareProtection"
)
# 2. SLR作成完了後に並列でGuardDutyを有効化
await asyncio.gather(*[
enable_guardduty(region) for region in regions
])
if __name__ == "__main__":
asyncio.run(main())
この対策でポイントとなるのは次の3点です。
SLR作成は1回だけ実行します。IAMはグローバルサービスですので、どのリージョンから作成しても同じです。またすでに存在する場合はスキップし、冪等性を確保しています。そしてSLR作成完了後にサービス有効化を並列実行することで、順序を保証しています。
同様の問題が発生する条件
今回はGuardDuty Malware Protectionで問題が発生しました。次の3つの条件をすべて満たす場合、同様の問題を引き起こす可能性があります。
- リージョナルリソースであること(各リージョンで個別に有効化が必要なサービス)
- 有効化時に暗黙的にSLRを作成すること(サービス有効化APIが内部でSLRを自動作成する)
- 並列実行すること(複数リージョンで同時に有効化処理を実行)
SLRを使用するサービスであっても、グローバルサービスであったり、SLRが事前に作成されている場合は問題にならないでしょう。
まとめ
サービスリンクロールはグローバルリソースであり、複数リージョンから同時に作成しようとすると競合する可能性があります。対策として、サービス有効化前にSLRを明示的に事前作成しておくとよいでしょう。GuardDuty以外にも、SLRを使用するサービスのマルチリージョン並列デプロイでは同様の問題に注意が必要です。
マルチリージョン展開の自動化を行う際は、グローバルリソースの存在を意識した設計を心がけてください。









