İçeriğe atla

SNS/SQS Cross-Account Fan-Out: AWS'de Multi-Account Event Dağıtımı

Amazon SNS ve SQS ile güvenli cross-account event dağıtımı: IAM policy'leri, KMS şifreleme, AWS CDK kurulumu ve production'da karşılaşılan yaygın sorunlar.

Ayhan Sipahi Ayhan Sipahi

Cross-account SNS/SQS fan-out, publisher hesabındaki tek bir SNS topic’in başka hesaplara ait SQS queue’lara event dağıtmasını sağlıyor; iki taraf da diğerine credential vermek zorunda kalmıyor. İşin büyük kısmı izin tarafında: bir topic policy, bir queue policy ve şifreleme devreye girdiğinde ikisiyle de uyumlu bir KMS key policy.

Tasarımı üç varsayılan taşıyor. Subscription’ı queue owner oluştursun, şifreli her queue customer-managed bir KMS key kullansın ve filtreleme SNS subscription seviyesinde yapılsın ki consumer’lar zaten atacakları mesajların ücretini ödemesin. CDK bağlantıları, FIFO sıralaması ve hesaplar arası dashboard’lar bu üç kararın üstüne biniyor.

Pattern Ne Kazandırıyor#

Multi-account bir AWS Organization’da bir ekibin ürettiği event’ler nadiren yalnızca o ekibi ilgilendiriyor. Hesaplar ekibe, servise veya environment’a göre ayrıldıktan sonra iş, ayrımı anlamlı kılan sınırı delmeden bu event’leri paylaşmaya geliyor.

Pattern’in yerini hak ettiği dört nokta var:

Yönetimsel izolasyon: Her hesap kendi kaynaklarını bağımsız olarak kontrol ediyor. Billing ekibi, ikisi de aynı kaynaktan event alsa bile fulfillment ekibinin altyapısını yanlışlıkla silemez.

Bağımsız scaling: Consumer hesaplar SQS processing’ini bağımsız olarak scale ediyor. Yavaş bir consumer yalnızca kendi hesabındaki kendi queue’sunu şişiriyor, diğerleri işlemeye devam ediyor.

Maliyet verimliliği: SNS’ten SQS’e delivery ücretsiz; yalnızca SNS publish’leri ve SQS operasyonları için ödeme yapıyorsun. Delivery ayağının kendisi için hiçbir ücret çıkmıyor, HTTP/S subscriber’larda ise her delivery ayrıca faturalanıyor.

Güvenlik sınırları: Her hesap kendi şifreleme, access policy’leri ve compliance kontrollerini implement ediyor. Security ekibi, publisher’da hiçbir değişiklik istemeden kendi hesabında sıkı key management uygulayabiliyor.

Mimari Genel Bakış#

Cross-account fan-out pattern’i nasıl çalışıyor:

Consumer Account C (333333333333)

Consumer Account B (222222222222)

Consumer Account A (111111111111)

Publisher Account (999999999999)

Subscribe

Subscribe

Subscribe

SNS Topic central-events

SQS Queue service-a-queue

Lambda Processor

SQS Queue service-b-queue

Lambda Processor

SQS Queue service-c-queue

Lambda Processor

Pattern üç seviyede doğru konfigürasyon gerektiriyor:

  1. SNS topic policy: Cross-account sns:Subscribe izni veriyor
  2. SQS queue policy: SNS service principal’ının sqs:SendMessage yapmasına izin veriyor
  3. KMS key policy (şifreli ise): SNS’in mesajları şifrelemesine/çözmesine izin veriyor

IAM Policy’leri ve İzinler#

Cross-account izinleri doğru yapmak kritik. Aşağıdaki pattern’ler production’da güvenilir şekilde çalışır.

SNS Topic Policy (Publisher Account)#

SNS topic açıkça hedef hesaplara sns:Subscribe izni vermelidir:

import * as sns from 'aws-cdk-lib/aws-sns';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class PublisherStack extends Stack {
  public readonly topic: sns.Topic;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // SNS topic oluştur
    this.topic = new sns.Topic(this, 'CentralEventTopic', {
      topicName: 'central-events',
      displayName: 'Central Event Distribution Topic',
    });

    // Cross-account subscribe izinleri ver
    this.topic.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowCrossAccountSubscribe',
        effect: iam.Effect.ALLOW,
        principals: [
          new iam.AccountPrincipal('111111111111'), // Account A
          new iam.AccountPrincipal('222222222222'), // Account B
          new iam.AccountPrincipal('333333333333'), // Account C
        ],
        actions: ['sns:Subscribe'],
        resources: [this.topic.topicArn],
      })
    );

    // İsteğe bağlı olarak tüm hesap yerine belirli IAM role'lere izin ver
    // Bu daha kısıtlayıcı ve least-privilege prensibini takip ediyor
    this.topic.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowSpecificRoleSubscribe',
        effect: iam.Effect.ALLOW,
        principals: [
          new iam.ArnPrincipal('arn:aws:iam::111111111111:role/ServiceARole'),
        ],
        actions: ['sns:Subscribe'],
        resources: [this.topic.topicArn],
      })
    );
  }
}

Önemli noktalar:

  • Organization-wide erişim için AccountPrincipal, spesifik roller için ArnPrincipal kullan
  • sns:Subscribe action’ı subscription oluşturmak için gerekli
  • Bu policy yalnızca subscription oluşturmayı kapsıyor; topic’e publish etmek ayrı bir izin
  • Kaynak VPC, IP aralığı veya diğer faktörlere göre kısıtlamak için condition’lar ekleyebilirsin

SQS Queue Policy (Consumer Account)#

Her consumer hesap, SNS service principal’ının mesaj göndermesine izin veren bir queue policy’sine ihtiyaç duyuyor:

import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

interface ConsumerStackProps extends StackProps {
  centralTopicArn: string; // Publisher account'tan ARN
}

export class ConsumerStack extends Stack {
  constructor(scope: Construct, id: string, props: ConsumerStackProps) {
    super(scope, id, props);

    // Başarısız mesajlar için dead letter queue oluştur
    const dlq = new sqs.Queue(this, 'EventDLQ', {
      queueName: 'events-dlq',
      retentionPeriod: Duration.days(14),
    });

    // Ana event queue'yu oluştur
    const queue = new sqs.Queue(this, 'EventQueue', {
      queueName: 'service-events',
      visibilityTimeout: Duration.seconds(30),
      receiveMessageWaitTime: Duration.seconds(20), // Long polling aktif et
      deadLetterQueue: {
        queue: dlq,
        maxReceiveCount: 3,
      },
    });

    // SNS'in mesaj göndermesine izin veren queue policy ekle
    queue.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowSNSPublish',
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('sns.amazonaws.com')],
        actions: ['sqs:SendMessage'],
        resources: [queue.queueArn],
        conditions: {
          ArnEquals: {
            'aws:SourceArn': props.centralTopicArn,
          },
        },
      })
    );

    // Cross-account SNS topic'i import et
    const centralTopic = sns.Topic.fromTopicArn(
      this,
      'CentralTopic',
      props.centralTopicArn
    );

    // Queue'yu topic'e subscribe et
    centralTopic.addSubscription(
      new subscriptions.SqsSubscription(queue, {
        rawMessageDelivery: false, // Sadece mesaj body'si almak için true yap
      })
    );
  }
}

Önemli detaylar:

  • aws:SourceArn ile Condition, başka SNS topic’lerin bu queue’ya göndermesini engelliyor
  • rawMessageDelivery: false mesajı SNS metadata’sıyla sarıyor (debugging için öneriliyor)
  • Sadece mesaj body’sini istiyorsan, SNS envelope’ını atlamak için rawMessageDelivery: true yap
  • Long polling (receiveMessageWaitTime) boş receive’leri ve maliyeti azaltıyor
  • Subscription’ın dead-letter queue’su, subscription ile aynı hesapta ve aynı region’da olmak zorunda; bu yüzden her consumer hesap kendi DLQ’sunu tutuyor

Çift Taraflı İzin#

Cross-account subscription için iki hesabın da izin vermesi gerekiyor:

  1. Publisher subscription’a izin veriyor: SNS topic policy consumer hesaba sns:Subscribe veriyor
  2. Consumer mesajları kabul ediyor: SQS queue policy SNS service principal’ının mesaj göndermesine izin veriyor
  3. Consumer subscription oluşturuyor: Queue owner topic ARN kullanarak sns:Subscribe çağırıyor

Bu çift taraflı izin zorunlu. İki taraftan birinde policy eksikse aynı “Access Denied” hatasını alıyorsun, o yüzden ölü bir subscription’ı ararken hatayı bildiren hesaba değil, iki hesaba birden bakmak gerekiyor.

KMS Şifreleme Konfigürasyonu#

Şifreleme cross-account setup’lara karmaşıklık ekliyor. AWS-managed key’ler hesap sınırları arasında çalışmadığı için şifreli bir queue’nun customer-managed key’e ihtiyacı oluyor.

AWS-Managed Key’ler Neden Çalışmıyor#

AWS-managed key (alias/aws/sqs) kullanarak şifreli bir SQS queue oluşturduğunda, key policy sadece o hesap içinde izinler veriyor. Publisher hesaptaki SNS servisi, consumer hesabın AWS-managed key’ini kullanamıyor.

Customer-Managed Key Kurulumu#

Şifreli queue’lar için çalışan bir pattern:

import * as kms from 'aws-cdk-lib/aws-kms';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as cdk from 'aws-cdk-lib';
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

interface EncryptedConsumerStackProps extends StackProps {
  centralTopicArn: string;
}

export class EncryptedConsumerStack extends Stack {
  constructor(scope: Construct, id: string, props: EncryptedConsumerStackProps) {
    super(scope, id, props);

    // Customer-managed KMS key oluştur
    const queueKey = new kms.Key(this, 'QueueEncryptionKey', {
      description: 'KMS key for cross-account SQS queue encryption',
      enableKeyRotation: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN, // Key'leri silme
    });

    // SNS servisine key kullanma izni ver
    queueKey.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowSNSToUseKey',
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('sns.amazonaws.com')],
        actions: [
          'kms:Decrypt',
          'kms:GenerateDataKey',
        ],
        resources: ['*'],
        conditions: {
          StringEquals: {
            // SNS'in bu key'i sadece bu region'daki SQS için kullanmasını sağla
            'kms:ViaService': `sqs.${this.region}.amazonaws.com`,
          },
        },
      })
    );

    // Şifreli dead letter queue oluştur
    const dlq = new sqs.Queue(this, 'EncryptedEventDLQ', {
      queueName: 'encrypted-events-dlq',
      encryptionMasterKey: queueKey,
      retentionPeriod: Duration.days(14),
    });

    // Şifreli ana queue oluştur
    const queue = new sqs.Queue(this, 'EncryptedEventQueue', {
      queueName: 'encrypted-service-events',
      encryptionMasterKey: queueKey,
      visibilityTimeout: Duration.seconds(30),
      receiveMessageWaitTime: Duration.seconds(20),
      deadLetterQueue: {
        queue: dlq,
        maxReceiveCount: 3,
      },
    });

    // SNS'in mesaj göndermesine izin veren queue policy
    queue.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowSNSPublish',
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('sns.amazonaws.com')],
        actions: ['sqs:SendMessage'],
        resources: [queue.queueArn],
        conditions: {
          ArnEquals: {
            'aws:SourceArn': props.centralTopicArn,
          },
        },
      })
    );

    // Cross-account topic'i import et ve subscribe ol
    const centralTopic = sns.Topic.fromTopicArn(
      this,
      'CentralTopic',
      props.centralTopicArn
    );

    centralTopic.addSubscription(
      new subscriptions.SqsSubscription(queue)
    );
  }
}

Key policy gereksinimleri:

  • kms:Decrypt: SNS queue’ya gönderirken mesajları decrypt etmek için buna ihtiyaç duyuyor
  • kms:GenerateDataKey: Envelope encryption için gerekli
  • kms:ViaService condition: Key kullanımını belirli region’daki SQS servisine kısıtlıyor
  • Güvenlik best practice’leri için key rotation’ı aktif et

Maliyeti#

Customer-managed KMS key’ler key başına ayda $1’a, artı 10.000 istek başına $0.03’e mal oluyor. Şifreli cross-account delivery’de sığınılacak ücretsiz bir seçenek yok; consumer hesap başına bir key’i bütçeye ekle.

Maliyet Optimizasyonu için Mesaj Filtreleme#

SNS subscription filter’ları istenmeyen mesajların queue’lara ulaşmasını önleyerek maliyetleri azaltıyor. Filtreleme SQS ücretleri uygulanmadan önce SNS seviyesinde gerçekleşiyor.

Attribute-Based Filtreleme#

Mesaj attribute’ları basit, verimli filtreleme sağlıyor:

// Publisher: Attribute'larla yayınla
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';

const sns = new SNSClient({ region: 'us-east-1' });

await sns.send(
  new PublishCommand({
    TopicArn: 'arn:aws:sns:us-east-1:999999999999:central-events',
    Message: JSON.stringify({
      orderId: '12345',
      amount: 1500,
      region: 'us-east-1',
    }),
    MessageAttributes: {
      eventType: {
        DataType: 'String',
        StringValue: 'OrderCreated',
      },
      priority: {
        DataType: 'String',
        StringValue: 'high',
      },
      amount: {
        DataType: 'Number',
        StringValue: '1500',
      },
      region: {
        DataType: 'String',
        StringValue: 'us-east-1',
      },
    },
  })
);
// Consumer: Filter policy ile subscribe ol
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';

centralTopic.addSubscription(
  new subscriptions.SqsSubscription(highPriorityQueue, {
    filterPolicy: {
      // Sadece yüksek öncelikli event'ler
      priority: sns.SubscriptionFilter.stringFilter({
        allowlist: ['high', 'critical'],
      }),
      // Sadece büyük siparişler
      amount: sns.SubscriptionFilter.numericFilter({
        greaterThan: 1000,
      }),
    },
  })
);

centralTopic.addSubscription(
  new subscriptions.SqsSubscription(regionalQueue, {
    filterPolicy: {
      // Sadece belirli region'lar
      region: sns.SubscriptionFilter.stringFilter({
        allowlist: ['us-east-1', 'eu-west-1'],
      }),
    },
  })
);

// Analytics queue her şeyi alıyor (filter yok)
centralTopic.addSubscription(
  new subscriptions.SqsSubscription(analyticsQueue)
);

Payload-Based Filtreleme#

Payload-based filtreleme doğrudan mesaj body’si üzerinde eşleşiyor; publisher’ın routing alanlarını bir de message attribute olarak yazmasına gerek kalmıyor:

import { SNSClient, SubscribeCommand } from '@aws-sdk/client-sns';

const sns = new SNSClient({ region: 'us-east-1' });

await sns.send(
  new SubscribeCommand({
    TopicArn: 'arn:aws:sns:us-east-1:999999999999:central-events',
    Protocol: 'sqs',
    Endpoint: 'arn:aws:sqs:us-east-1:111111111111:service-events',
    Attributes: {
      FilterPolicyScope: 'MessageBody',
      FilterPolicy: JSON.stringify({
        order: {
          status: ['completed', 'shipped'],
          amount: [{ numeric: ['>', 1000] }],
        },
      }),
    },
  })
);

Filter policy faydaları:

  • Her subscriber yalnızca policy’sine uyan mesajları alıyor ve SQS istek ücretini de yalnızca onlar için ödüyor
  • Kazanç, subscriber’ın topic hacminin ne kadarını elediğine bağlı; yoğun bir topic’teki dar kapsamlı consumer en çok kazanan taraf oluyor
  • Filter değişikliklerinin yayılması 15 dakikayı bulabiliyor
  • Kota topic başına 200, hesap başına 10.000 filter policy

FIFO Topic’ler ve Queue’lar#

FIFO (First-In-First-Out) topic’ler kesin sıralama ve deduplication sağlıyor. Mesaj sırası önemli olduğunda kullan.

FIFO Ne Zaman Kullanılmalı#

FIFO şu durumlarda mantıklı:

  • Sıranın önemli olduğu sipariş işleme workflow’ları
  • Tam bir kez işleme gerektiren finansal işlemler
  • Sırasıyla gerçekleşmesi gereken state machine geçişleri
  • Sıranın nihai durumu etkilediği envanter güncellemeleri

FIFO Kurulum Gereksinimleri#

import * as sns from 'aws-cdk-lib/aws-sns';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';

// FIFO topic oluştur (publisher account)
const fifoTopic = new sns.Topic(this, 'OrderEventTopic', {
  topicName: 'order-events.fifo',
  fifo: true,
  contentBasedDeduplication: true,
  // Throughput mesaj grubu başına sayılıyor, grup başına saniyede 300 mesaj
  fifoThroughputScope: sns.FifoThroughputScope.MESSAGE_GROUP,
});

// FIFO queue oluştur (consumer account)
const fifoQueue = new sqs.Queue(this, 'OrderQueue', {
  queueName: 'orders.fifo',
  fifo: true,
  contentBasedDeduplication: true,
});

// FIFO topic sadece FIFO queue'lara subscribe olabilir
fifoTopic.addSubscription(
  new subscriptions.SqsSubscription(fifoQueue)
);

FIFO topic’lere yayınlama:

import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';

const sns = new SNSClient({ region: 'us-east-1' });

await sns.send(
  new PublishCommand({
    TopicArn: 'arn:aws:sns:us-east-1:999999999999:order-events.fifo',
    Message: JSON.stringify({ orderId: '12345', status: 'created' }),
    MessageGroupId: 'order-region-us-east-1', // Sıralama için gerekli
    MessageDeduplicationId: 'order-12345-created', // contentBasedDeduplication aktifse opsiyonel
  })
);

Throughput kapsamında dikkat edilecekler:

  • Varsayılan olan FifoThroughputScope: Topic, FIFO topic’i saniyede 3.000 mesaj veya 20 MB ile sınırlıyor; hangisi önce dolarsa
  • FifoThroughputScope: MessageGroup throughput’u grup başına sayıyor ve grup başına tavan saniyede 300 mesaj; yani topic’in toplam throughput’u aktif grup sayısıyla birlikte artıyor
  • Hesap bazındaki publish kotası bunun üstünde ayrıca işliyor ve region’a göre değişiyor: us-east-1’de saniyede 30.000 mesaj, çoğu region’da 3.000
  • Mesaj gruplarını hâlihazırda doğal paralelliği olan bir alana göre böl (müşteri, region, tenant); tek bir global grup ID’si tüm topic’i sıraya sokuyor

Yaygın Hatalar ve Çözümler#

Production’da tutarlı biçimde ortaya çıkan sorunlar ve çözümleri aşağıda.

Hata 1: Subscription “PendingConfirmation” Gösteriyor#

Belirti: Subscription oluşturuldu ama “PendingConfirmation” durumunda takılı kaldı. Mesajlar akmıyor.

Kök neden: Topic owner subscription’ı oluşturduğunda (queue owner yerine), SNS manuel olarak onaylanması gereken bir confirmation mesajı gönderiyor.

Çözüm: Her zaman queue owner’ın subscription oluşturmasını sağla:

// TERCİH EDİLEN: Queue owner subscribe oluyor (consumer account'ta)
const centralTopic = sns.Topic.fromTopicArn(
  this,
  'CentralTopic',
  'arn:aws:sns:us-east-1:999999999999:central-events'
);

centralTopic.addSubscription(
  new subscriptions.SqsSubscription(queue)
);
// Subscribe eden queue owner olduğu için onay otomatik

Eğer topic owner subscription oluşturmalıysa, onayı otomatikleştir:

# Subscription'ları otomatik onaylamak için Python scripti
import boto3
import json

sqs = boto3.client('sqs')
queue_url = 'https://sqs.us-east-1.amazonaws.com/111111111111/service-events'

# Confirmation mesajı için poll et
response = sqs.receive_message(
    QueueUrl=queue_url,
    MaxNumberOfMessages=1,
    WaitTimeSeconds=10
)

for message in response.get('Messages', []):
    body = json.loads(message['Body'])

    if 'SubscribeURL' in body:
        # URL'yi ziyaret ederek subscription'ı onayla
        import urllib.request
        urllib.request.urlopen(body['SubscribeURL'])
        print(f"Subscription onaylandı: {body['SubscribeURL']}")

        # Confirmation mesajını sil
        sqs.delete_message(
            QueueUrl=queue_url,
            ReceiptHandle=message['ReceiptHandle']
        )

URL üzerinden onaylamanın bilinmesi gereken bir yan etkisi var. AuthenticateOnUnsubscribe yalnızca subscription’ı queue owner oluşturduğunda otomatik olarak açılıyor ve kimlik doğrulaması olmadan açılmış bir link için sonradan açılamıyor. Bu durumda kimliği doğrulanmamış bir unsubscribe isteği subscription’ı düşürebiliyor.

Hata 2: KMS Key Access Denied#

Belirti: Mesajlar SNS’e yayınlanıyor ama şifreli SQS queue’da hiç görünmüyor. SNS metriklerinde hata yok.

Kök neden: SNS servisi KMS key’i şifreleme için kullanma iznine sahip değil.

Çözüm: KMS key policy’nin SNS’e gerekli izinleri verdiğini doğrula:

// KMS key policy'nin bunu içerdiğinden emin ol
queueKey.addToResourcePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    principals: [new iam.ServicePrincipal('sns.amazonaws.com')],
    actions: [
      'kms:Decrypt',
      'kms:GenerateDataKey',
    ],
    resources: ['*'],
    conditions: {
      StringEquals: {
        'kms:ViaService': `sqs.${this.region}.amazonaws.com`,
      },
    },
  })
);

Troubleshooting ipucu: KMS AccessDenied hataları için CloudTrail loglarını kontrol et:

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt \
  --max-results 50 \
  --region us-east-1 \
  --query 'Events[?ErrorCode==`AccessDenied`]'

Hata 3: Region Uyuşmazlığı#

Belirti: Her iki policy de doğru görünmesine rağmen mesajlar hiç ulaşmıyor veya subscription reddediliyor.

Kök neden: Topic ile queue farklı region’larda, ama zincirdeki bir parça hâlâ tek bir region’a sabitlenmiş durumda. En sık iki suçlu var: topic’in region’ını içinde taşıyan aws:SourceArn condition’ı ve subscription ile aynı hesapta, aynı region’da olmak zorunda olan dead-letter queue.

Çözüm: Topic’i ve queue’ları tek region’da tutmak daha basit varsayılan; üstelik region’lar arası delivery gecikmesini ve veri transferi ücretini de ortadan kaldırıyor. Bir consumer gerçekten başka region’da çalışmak zorundaysa, hedef region’daki bir topic’e yeniden publish eden Lambda üzerinden forward et:

// Region 1 (us-east-1): Orijinal topic
const sourceTopicEast = new sns.Topic(this, 'SourceTopicEast', {
  topicName: 'events-us-east-1',
});

// Lambda forwarder region 2 topic'ine yayınlıyor
const forwarder = new lambda.Function(this, 'RegionForwarder', {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: 'index.handler',
  code: lambda.Code.fromInline(`
    const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns');
    const sns = new SNSClient({ region: 'eu-west-1' });

    exports.handler = async (event) => {
      for (const record of event.Records) {
        await sns.send(new PublishCommand({
          TopicArn: process.env.TARGET_TOPIC_ARN,
          Message: record.Sns.Message,
          MessageAttributes: record.Sns.MessageAttributes,
        }));
      }
    };
  `),
  environment: {
    TARGET_TOPIC_ARN: 'arn:aws:sns:eu-west-1:999999999999:events-eu-west-1',
  },
});

sourceTopicEast.addSubscription(
  new subscriptions.LambdaSubscription(forwarder)
);

Hata 4: Mesaj Boyutu Limitleri#

Belirti: Bazı mesajlar başarıyla gönderiliyor, diğerleri sessizce kayboluyor.

Kök neden: SNS ve SQS’in her ikisinde de 256 KB mesaj boyutu limiti var. Bu limiti aşan mesajlar bildirim olmadan düşürülüyor.

Çözüm: Mesajları 256 KB altında tut veya büyük payload’lar için S3 kullan:

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';

const s3 = new S3Client({ region: 'us-east-1' });
const sns = new SNSClient({ region: 'us-east-1' });

async function publishLargeMessage(topicArn: string, payload: any) {
  const payloadSize = Buffer.byteLength(JSON.stringify(payload));

  if (payloadSize > 200_000) {
    // Büyük payload'u S3'e kaydet
    const messageId = crypto.randomUUID();
    const s3Key = `messages/${messageId}.json`;

    await s3.send(
      new PutObjectCommand({
        Bucket: 'large-message-payloads',
        Key: s3Key,
        Body: JSON.stringify(payload),
      })
    );

    // S3 object'e referans yayınla
    await sns.send(
      new PublishCommand({
        TopicArn: topicArn,
        Message: JSON.stringify({
          type: 'S3Reference',
          bucket: 'large-message-payloads',
          key: s3Key,
        }),
      })
    );
  } else {
    // Küçük mesajlar için direkt yayınla
    await sns.send(
      new PublishCommand({
        TopicArn: topicArn,
        Message: JSON.stringify(payload),
      })
    );
  }
}

Hata 5: Filter Policy’ler Etkili Olmuyor#

Belirti: Filter policy’ye rağmen mesajlar hala gönderiliyor.

Kök neden: Filter policy’lerin yayılması 15 dakika kadar sürebiliyor, veya mesaj attribute’ları filter formatıyla eşleşmiyor.

Çözüm: Yayılmayı bekle ve attribute formatını doğrula:

// Mesaj attribute'larının filter beklentileriyle eşleştiğini doğrula
await sns.send(
  new PublishCommand({
    TopicArn: topicArn,
    Message: JSON.stringify({ orderId: '12345' }),
    MessageAttributes: {
      eventType: {
        DataType: 'String',
        StringValue: 'OrderCreated', // Filter ile tam eşleşmelidir
      },
      priority: {
        DataType: 'Number', // Numeric filter'lar için Number tipini kullan
        StringValue: '5',
      },
    },
  })
);

// Filtreleme etkinliğini izle
const filterMetric = new cloudwatch.Metric({
  namespace: 'AWS/SNS',
  metricName: 'NumberOfNotificationsFilteredOut',
  dimensionsMap: {
    TopicName: 'central-events',
  },
});

Monitoring ve Observability#

Cross-account mesajlaşma için etkili monitoring önemli. Hem publisher hem de consumer taraflarında görünürlüğe ihtiyacın var.

Önemli SNS Metrikleri#

import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatch_actions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';

// SNS delivery başarısızlıklarını izle
new cloudwatch.Alarm(this, 'SNSDeliveryFailures', {
  metric: topic.metricNumberOfNotificationsFailed({
    statistic: 'Sum',
    period: Duration.minutes(5),
  }),
  threshold: 10,
  evaluationPeriods: 2,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
  alarmDescription: 'SNS mesaj delivery başarısız olduğunda uyarı ver',
});

// Filter etkinliğini izle
new cloudwatch.Alarm(this, 'HighFilterRate', {
  metric: topic.metricNumberOfNotificationsFilteredOut({
    statistic: 'Sum',
    period: Duration.minutes(5),
  }),
  threshold: 1000,
  evaluationPeriods: 1,
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
  alarmDescription: 'Filter oranı olağandışı yüksek olduğunda uyarı ver',
});

Önemli SQS Metrikleri#

// Queue depth'i izle
new cloudwatch.Alarm(this, 'QueueDepthAlarm', {
  metric: queue.metricApproximateNumberOfMessagesVisible({
    statistic: 'Average',
    period: Duration.minutes(5),
  }),
  threshold: 1000,
  evaluationPeriods: 2,
  alarmDescription: 'Queue depth çok büyüdüğünde uyarı ver',
});

// Processing lag'i izle
new cloudwatch.Alarm(this, 'OldMessageAlarm', {
  metric: queue.metricApproximateAgeOfOldestMessage({
    statistic: 'Maximum',
    period: Duration.minutes(1),
  }),
  threshold: 300, // 5 dakika
  evaluationPeriods: 3,
  alarmDescription: 'Mesajlar zamanında işlenmediğinde uyarı ver',
});

// DLQ'yu izle
new cloudwatch.Alarm(this, 'DLQMessages', {
  metric: dlq.metricApproximateNumberOfMessagesVisible({
    statistic: 'Sum',
    period: Duration.minutes(5),
  }),
  threshold: 1,
  evaluationPeriods: 1,
  alarmDescription: 'DLQ\'da herhangi bir mesaj olduğunda uyarı ver',
});

Cross-Account CloudWatch Observability#

Hesaplar arası birleşik monitoring için CloudWatch Observability Access Manager kullan:

import * as oam from 'aws-cdk-lib/aws-oam';

// Monitoring account'ta: Sink oluştur
const sink = new oam.CfnSink(this, 'MonitoringSink', {
  name: 'central-monitoring-sink',
  policy: {
    Version: '2012-10-17',
    Statement: [
      {
        Effect: 'Allow',
        Principal: {
          AWS: [
            'arn:aws:iam::111111111111:root',
            'arn:aws:iam::222222222222:root',
            'arn:aws:iam::333333333333:root',
          ],
        },
        Action: ['oam:CreateLink', 'oam:UpdateLink'],
        Resource: '*',
      },
    ],
  },
});

// Her source account'ta: Sink'e link oluştur
const link = new oam.CfnLink(this, 'MonitoringLink', {
  resourceTypes: ['AWS::CloudWatch::Metric', 'AWS::Logs::LogGroup'],
  sinkIdentifier: 'arn:aws:oam:us-east-1:999999999999:sink/sink-id',
});

Bu, veri transfer maliyeti olmadan (aynı region içinde) tüm hesaplardan metrikleri gösteren tek bir dashboard sağlıyor.

Maliyet Analizi#

Maliyetleri anlamak mimarini optimize etmene yardımcı oluyor.

Fiyatlandırma Dökümü (2025)#

SNS maliyetleri:

  • İlk 1 milyon istek/ay: ÜCRETSİZ
  • Free tier’ın üstünde: Milyon yayın başına $0.50
  • SNS’den SQS’e delivery: ÜCRETSİZ (büyük maliyet avantajı)

SQS maliyetleri:

  • İlk 1 milyon istek/ay: ÜCRETSİZ
  • Standard queue: Milyon istek başına $0.40
  • FIFO queue: Milyon istek başına $0.50
  • Her 64 KB chunk = 1 istek (256 KB mesaj = 4 istek)

Fan-out maliyet örneği (4 queue’ya 1 milyon mesaj, free tier hariç):

  • SNS yayınları: 1M × $0.50 = $0.50
  • SNS’den SQS’e delivery: ÜCRETSİZ
  • SQS alımları: 4M × $0.40 = $1.60
  • SQS silmeleri: 4M × $0.40 = $1.60
  • Toplam: $3.70

%50 mesaj filtreleme ile:

  • SNS yayınları: 1M × $0.50 = $0.50
  • Filtreli delivery’ler: 2M mesaj gönderildi
  • SQS alımları: 2M × $0.40 = $0.80
  • SQS silmeleri: 2M × $0.40 = $0.80
  • Toplam: $2.10 (%43 maliyet azalması)

KMS maliyetleri (şifreli queue’lar için):

  • Customer-managed key: Key başına ayda $1
  • KMS istekleri: 10.000 istek başına $0.03
  • İstek hacmi KmsDataKeyReusePeriodSeconds değerine bağlı: uzun bir yeniden kullanım penceresi tek bir data key’i çok sayıda mesaja yayıyor

Maliyet Optimizasyon Stratejileri#

  1. Subscription seviyesinde filtrele: consumer’lar zaten atacakları mesajlar için receive ve delete ücreti ödemiyor
  2. SQS long polling’i aktif et: tek bir ücretli receive, boş dönmek yerine 20 saniyeye kadar bekliyor
  3. Batch operasyonlarını kullan: API çağrısı başına 10 mesaja kadar
  4. Mesajları 64 KB altında tut: her 64 KB’lık parça ayrı bir istek olarak faturalanıyor
  5. Sıralama kritik değilse Standard queue kullan: milyon istek başına $0.50 yerine $0.40

Alternatif Yaklaşımlar#

Fan-out’un yetmediği yerde üç komşu servis devreye giriyor: routing karmaşıklığı, replay ihtiyacı ve senkron iş.

EventBridge#

Ne zaman kullanılmalı:

  • Karmaşık event routing gerekiyor (100+ kural)
  • Schema registry ve validasyon gerekli
  • Event replay yeteneği önemli
  • 30+ AWS servisiyle entegrasyon

Ödünleşimler:

  • Milyon event başına $1.00; aynı hacim SNS’te $0.50
  • JSONPath benzeri syntax ile daha güçlü filtreleme
  • Yerleşik schema discovery ve validation
  • Native cross-account event bus’ları
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';

const bus = new events.EventBus(this, 'CentralBus', {
  eventBusName: 'central-events',
});

// Cross-account erişim izni ver
bus.grantPutEventsTo(new iam.AccountPrincipal('111111111111'));

// Karmaşık filtreleme
new events.Rule(this, 'HighValueOrders', {
  eventBus: bus,
  eventPattern: {
    source: ['com.myapp.orders'],
    detailType: ['OrderCreated'],
    detail: {
      amount: [{ numeric: ['>', 1000] }],
      region: ['us-east-1', 'us-west-2'],
      status: ['pending', 'processing'],
    },
  },
  targets: [new targets.SqsQueue(queue)],
});

Kinesis Data Streams#

Ne zaman kullanılmalı:

  • Sıralı stream processing gerekiyor
  • Replay yeteneği gerekli (365 güne kadar)
  • Farklı hızlarda okuyan birden fazla consumer
  • Real-time analytics kullanım alanları

Ödünleşimler:

  • Daha pahalı (shard saat başına $0.015 + PUT maliyetleri)
  • Karmaşık shard yönetimi
  • Discrete event’lerden çok streaming analytics için daha iyi
  • Daha yüksek operasyonel yük

Direkt Lambda Invocation#

Ne zaman kullanılmalı:

  • Synchronous processing kabul edilebilir
  • Event hacmi Lambda concurrent execution limitlerinin altında
  • Queue yönetimine gerek yok
  • Basit, hızlı processing mantığı

Ödünleşimler:

  • Yerleşik retry queue’ları yok
  • Cold start etkisi hesaba katılmalı
  • Lambda concurrency ile sınırlı
  • Scaling için queue’lardan daha az esnek

Uçtan Uca Multi-Account Kurulumu#

Publisher ve consumer stack’leri, birbirine bağlanmış hâliyle:

// publisher-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

export class PublisherStack extends cdk.Stack {
  public readonly topicArn: string;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const topic = new sns.Topic(this, 'CentralEvents', {
      topicName: 'central-events',
      displayName: 'Central Event Distribution',
    });

    // Birden fazla consumer account'a izin ver
    topic.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowConsumerSubscribe',
        principals: [
          new iam.AccountPrincipal('111111111111'),
          new iam.AccountPrincipal('222222222222'),
          new iam.AccountPrincipal('333333333333'),
        ],
        actions: ['sns:Subscribe'],
        resources: [topic.topicArn],
      })
    );

    this.topicArn = topic.topicArn;

    // Cross-stack referansları için output
    new cdk.CfnOutput(this, 'TopicArnOutput', {
      value: topic.topicArn,
      exportName: 'CentralEventTopicArn',
    });
  }
}

// consumer-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as kms from 'aws-cdk-lib/aws-kms';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import { Construct } from 'constructs';

interface ConsumerStackProps extends cdk.StackProps {
  centralTopicArn: string;
  serviceName: string;
}

export class ConsumerStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ConsumerStackProps) {
    super(scope, id, props);

    // Şifreleme için KMS key oluştur
    const encryptionKey = new kms.Key(this, 'EncryptionKey', {
      description: `${props.serviceName} event'leri için şifreleme key'i`,
      enableKeyRotation: true,
    });

    // SNS'e key kullanma izni ver
    encryptionKey.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.ServicePrincipal('sns.amazonaws.com')],
        actions: ['kms:Decrypt', 'kms:GenerateDataKey'],
        resources: ['*'],
        conditions: {
          StringEquals: {
            'kms:ViaService': `sqs.${this.region}.amazonaws.com`,
          },
        },
      })
    );

    // DLQ oluştur
    const dlq = new sqs.Queue(this, 'DLQ', {
      queueName: `${props.serviceName}-dlq`,
      encryptionMasterKey: encryptionKey,
      retentionPeriod: cdk.Duration.days(14),
    });

    // Ana queue oluştur
    const queue = new sqs.Queue(this, 'EventQueue', {
      queueName: `${props.serviceName}-events`,
      encryptionMasterKey: encryptionKey,
      visibilityTimeout: cdk.Duration.seconds(30),
      receiveMessageWaitTime: cdk.Duration.seconds(20),
      deadLetterQueue: {
        queue: dlq,
        maxReceiveCount: 3,
      },
    });

    // SNS'in mesaj göndermesine izin ver
    queue.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.ServicePrincipal('sns.amazonaws.com')],
        actions: ['sqs:SendMessage'],
        resources: [queue.queueArn],
        conditions: {
          ArnEquals: {
            'aws:SourceArn': props.centralTopicArn,
          },
        },
      })
    );

    // Central topic'e subscribe ol
    const centralTopic = sns.Topic.fromTopicArn(
      this,
      'CentralTopic',
      props.centralTopicArn
    );

    centralTopic.addSubscription(
      new subscriptions.SqsSubscription(queue, {
        rawMessageDelivery: false,
      })
    );

    // Processor Lambda oluştur
    const processor = new lambda.Function(this, 'EventProcessor', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        exports.handler = async (event) => {
          for (const record of event.Records) {
            const snsMessage = JSON.parse(record.body);
            const message = JSON.parse(snsMessage.Message);

            console.log('Mesaj işleniyor:', message);

            // İş mantığın buraya

            // Handler başarılı olursa mesaj otomatik olarak siliniyor
          }
        };
      `),
      timeout: cdk.Duration.seconds(30),
      environment: {
        SERVICE_NAME: props.serviceName,
      },
    });

    // Queue'yu Lambda'ya bağla
    processor.addEventSource(
      new lambdaEventSources.SqsEventSource(queue, {
        batchSize: 10,
        reportBatchItemFailures: true,
      })
    );

    // CloudWatch alarmları
    new cloudwatch.Alarm(this, 'QueueDepthAlarm', {
      metric: queue.metricApproximateNumberOfMessagesVisible(),
      threshold: 1000,
      evaluationPeriods: 2,
    });

    new cloudwatch.Alarm(this, 'DLQMessagesAlarm', {
      metric: dlq.metricApproximateNumberOfMessagesVisible(),
      threshold: 1,
      evaluationPeriods: 1,
    });

    new cloudwatch.Alarm(this, 'ProcessorErrorsAlarm', {
      metric: processor.metricErrors(),
      threshold: 10,
      evaluationPeriods: 2,
    });
  }
}

Varsayılanlar Nerede Geçerli#

Queue owner’ın açtığı subscription, customer-managed key ve subscription seviyesinde filtreleme sıradan durumu karşılıyor: birkaç hesap, tek region ve S3 pointer’ı yerine mesajın kendisi olarak taşınabilecek kadar küçük event’ler. Birinci varsayılan, publisher kontrol etmediği hesaplar adına subscription açmak zorunda kaldığında bozuluyor; onay otomasyonu ve beraberindeki unsubscribe uyarısı tam da orada işe yarıyor. Bir compliance sınırı ikincisini daha ileri taşıyabilir: queue başına ayrı key ve daha dar bir kms:ViaService kapsamı. Doğrudan atlanacak olansa üçüncüsü; consumer gerçekten her mesajı istiyorsa filter policy hiçbir şey kazandırmıyor, üstüne hesaba katılacak bir yayılma gecikmesi ekliyor.

Bunlardan önce tasarımı yeniden şekillendiren iki kısıt var. Sıralama gereksinimi iki ucu birden FIFO’ya taşıyor ve throughput kolu mesaj grubu bölümlemesi hâline geliyor. İkinci bir region delivery’yi forwarder’ın arkasına alıyor, çünkü subscription’ın dead-letter queue’su topic’in peşinden region değiştiremiyor. Attribute ve payload eşleştirmesini aşan routing kuralları veya replay ihtiyacı ise EventBridge ya da Kinesis’i işaret ediyor.

Kaynaklar#

İlgili yazılar