Kısa cevap: evet, ama S3 üç katmandan biri
Bir sohbet uygulaması üç ayrı iş yapar. Yeni mesajı gönderildiği anda odadaki herkese iletir. Mesajları hatırlar: odayı açan kullanıcı son birkaç düzine mesajı görür, uygulama "okunmamış ne var" gibi soruları cevaplar. Ve dosya saklar: sohbete bırakılan fotoğraflar, kısa videolar, sesli mesajlar ve belgeler.
S3 tipi nesne depolama üçüncü işte çok iyidir, ilk ikisi için tasarlanmamıştır. Bu yüzden "S3 üzerinde sohbet uygulaması yapabilir miyim?" sorusunun dürüst cevabı şu: evet, S3 dosya ve arşiv katmanı olur, yanında iki parça daha durur.
Uygulama sunucusu, her istemciye açık bir WebSocket bağlantısı tutar ve mesajları dağıtır. Küçüktür: Socket.io ile birkaç yüz satır Node.js, çalışan bir sohbet için yeterli.
Veritabanı, mesajları satır olarak saklar; böylece "bu odadaki son 50 mesaj" tek bir indeksli sorgu olur.
Nesne depolama, dosya olan her şey içindir. Veritabanını ve uygulama sunucusunu hafif tutar, çünkü ikisinin de megabaytlarca medyayı tutması ya da taşıması gerekmez.
Rehberin geri kalanı sınırın nereden geçtiğini anlatıyor, sonra üçünü CDNTR üzerinde birbirine bağlıyor. Bu rehberdeki demo uygulama CDNTR üzerinde canlı çalışıyor (Node.js, Express ve Socket.io): uygulama, Postgres, bucket bağlaması, imzalı yükleme ve indirme 2026-11-06'da bu demoda doğrulandı. Seçenek olarak işaretlenen kısımlar, yani Redis adapter'ı, arşivleme işi ve hazır sunucular, demonun parçası değildi.
Yalnızca S3 bir sohbet için neyi yapamaz
Bildirim (push) yok. S3 isteklere cevap verir, kendisi hiçbir istek başlatmaz. Yeni bir mesaj geldiğinde diğer istemcilere bunu söyleyen bir şey yoktur. Kendi sunucunuz olmadan istemcilerin yoklama (polling) yapması gerekir: her açık sekme ve telefon, odanın nesnelerini bir iki saniyede bir listeler. Bu hem yavaş hissettirir, hem istek israfıdır, hem de yine gerçek zamanlı değildir.
Ekleme (append) yok. Bir nesne bütün olarak yazılır; "bu dosyanın sonuna bir satır ekle" diye bir işlem yoktur. Bu yüzden her mesaj ya kendi nesnesi olur (her oda için günde binlerce küçük nesne) ya da daha büyük bir nesnenin yeniden yazılması; iki gönderen de birbirinin sürümünün üzerine yazmak için yarışır.
Sorgu yok. S3 anahtarları öneke (prefix) göre listeleyebilir, o kadar. "Bu odadaki son 50 mesaj", "son girişimden beri gelenler", "okunmamış sayısı", "fatura geçen mesajları ara": bunların hepsi veritabanı sorusudur. Zamana göre sıralı anahtarlar sizi yolun bir kısmına götürür, sonra durur.
Kullanıcı ve yetki yok. S3 tek bir şeye bakar: istek geçerli bir anahtarla imzalanmış mı. Kim hangi odada, kim hangi dosyayı görebilir, kim engelli: bu mantık uygulamanıza aittir. Tarayıcı doğrudan okuyup yazsın diye S3 anahtarlarını tarayıcıya vermek, her kullanıcıya bütün bucket'a erişim vermek demektir.
Bunların hiçbiri S3'ün kusuru değil. S3 bir dosya deposu, sohbet ise gerçek zamanlı ve durum tutan bir konuşma. Her aracı kendi işinde kullanın.
S3 bir sohbet uygulamasında neyi iyi yapar
Ekler. Fotoğraflar, videolar, sesli mesajlar, PDF'ler. Büyüktürler, bir kez yazılıp çok kez okunurlar: nesne depolamanın tam olarak yapıldığı iş. Veritabanı her mesaj için yalnızca bir anahtar ve dosya adı tutar; insanlar ne kadar medya paylaşırsa paylaşsın küçük ve hızlı kalır.
Mesaj arşivi. Eski konuşmalar nadiren okunur ama kaybolmamalıdır. Onları veritabanından çıkarıp S3'e sıkıştırılmış bloklar halinde (örneğin her oda için günde bir blok) taşımak, sık kullanılan tabloyu küçük tutar ve geçmişten hiçbir şey kaybettirmez.
Dışa aktarma ve yedek. "Verilerimi indir", oda dışa aktarmaları, gecelik veritabanı dökümleri: her biri bir dosyadır ve her biri bir bucket'a aittir.
Daha hafif bir uygulama sunucusu. Tarayıcı dosyayı doğrudan S3'e yüklediği ve doğrudan oradan indirdiği için (sonraki bölümler nasıl olduğunu gösteriyor), uygulama sunucusu hiç medya akıtmaz. Kısa ömürlü URL'ler imzalar ve küçük JSON mesajlar taşır; bu yüzden tek bir küçük container uzun süre yeter.
CDNTR'de s3.cdn.com.tr adresindeki S3 servisi nesneleri Türkiye'deki veri merkezlerimizde saklar ve S3 uyumludur: AWS SDK'ları özel bir endpoint ve path-style adresleme ile çalışır.
CDNTR üzerinde mimari, cdnctl ile kurulum
Uygulama Managed Container Apps üzerinde çalışır. Demo, Express ve Socket.io kullanan bir Node.js uygulaması; kaynak klasöründen cdnctl deploy ile yayınlandı. İmaj cluster içinde derlenir, registry gerekmez. Uygulama https://xxxx.cdn.com.tr gibi bir adres alır ve WebSocket trafiği CDN üzerinden geçer (demoda Socket.io taşıma yöntemi olarak websocket bildirdi). Container'lar size yeniyse temelleri CDN üzerinde Docker container rehberinde bulabilirsiniz.
Veritabanı yönetilen Postgres eklentisidir. --env-prefix DATABASE ile açıldığında uygulamaya DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER ve gizli DATABASE_PASSWORD değişkenlerini verir.
Dosyalar nesne depolama üzerindeki bir bucket'ta durur. Bucket'ı ve ona ait bir erişim anahtarını oluşturur, sonra ikisini --env-prefix S3 ile uygulamaya bağlarsınız. Uygulama S3_ENDPOINT (https://s3.cdn.com.tr), S3_BUCKET, S3_REGION (cdn-tr), S3_ACCESS_KEY_ID ve gizli S3_SECRET_ACCESS_KEY değişkenlerini alır. Anahtarlar yalnızca uygulamanın ortamında bulunur, tarayıcıya hiç gitmez.
Kurulumun tamamı dört adım. <account>, <app>, <bucket> ve <key> yerine önceki komutların yazdırdığı ID'leri koyun.
# 1. deploy the app from source (built in the cluster, no registry)
cdnctl deploy --dir . --account <account> --name chat-demo --healthcheck /health
# 2. managed Postgres -> DATABASE_HOST, _PORT, _NAME, _USER, _PASSWORD
cdnctl container addons enable-postgres --account <account> --app <app> --env-prefix DATABASE
# 3. a bucket and an access key for it
cdnctl object-storage buckets create --account <account> --name chat-demo-files
cdnctl object-storage access-keys create --account <account> --bucket <bucket>
# 4. bind them -> S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
cdnctl object-storage bindings create --account <account> --app <app> \
--bucket <bucket> --access-key <key> --env-prefix S3
Mesajlar: bir Postgres tablosu ve bir Socket.io işleyicisi
Bir mesaj bir satırdır: oda, yazan, metin ve ekler için nesne anahtarı ile özgün dosya adı. (room, id) üzerindeki indeks, bir sohbetin sürekli çalıştırdığı tek sorguyu, yani bir odanın son 50 mesajını, tablo ne kadar büyürse büyüsün kısa bir indeks taramasına çevirir.
Socket.io tarafı iki olaydan ibaret. join ile istemci bir odaya girer ve geçmişini alır. message ile sunucu önce satırı yazar, sonra kaydedilen satırı gönderen dahil odadaki herkese yayınlar. İstemcinin gönderdiğini değil veritabanının döndürdüğünü yayınlamak, her istemciye aynı id ve zaman damgasını verir; kaydedilmemiş bir mesaj da kimseye ulaşmaz.
socket.data.user, bağlantı açılırken sizin kimlik doğrulama middleware'iniz tarafından atanır. İstemci kim olduğunu mesaj içeriğinde kendisi söyleyemez.
Demo tek replika çalışıyor. Birden fazla replikada bir mesajın başka replikalara bağlı istemcilere de ulaşması gerekir; bunun olağan yolu yönetilen Redis eklentisi (cdnctl container addons enable-redis) ile Socket.io Redis adapter'ıdır. Redis'in kendisi için Redis nedir rehberine bakın.
Şema ve mesaj işleyicisi (Postgres, socket.io 4)
-- messages table
CREATE TABLE IF NOT EXISTS messages (
id bigserial PRIMARY KEY,
room text NOT NULL,
author text NOT NULL,
body text,
file_key text,
file_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS messages_room_id ON messages (room, id);
// server.js (socket.io 4)
io.on('connection', (socket) => {
socket.on('join', async (room) => {
socket.join(room);
const { rows } = await db.query(
`SELECT * FROM (SELECT * FROM messages WHERE room = $1
ORDER BY id DESC LIMIT 50) last50 ORDER BY id`, [room]);
socket.emit('history', rows);
});
socket.on('message', async ({ room, body, fileKey, fileName }) => {
const { rows } = await db.query(
`INSERT INTO messages (room, author, body, file_key, file_name)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[room, socket.data.user, body ?? null, fileKey ?? null, fileName ?? null]);
io.to(room).emit('message', rows[0]); // everyone in the room, at once
});
});
Yükleme: imzalı URL ile tarayıcıdan doğrudan S3'e
Dosyaları uygulama sunucusunun üzerinden geçirmek onu darboğaz yapar: her fotoğraf iki kez yüklenir, yolda bellek ve bant genişliği tüketir. Bunun yerine uygulama izni verir ve aradan çekilir.
1. Tarayıcı, dosyanın tipini ve boyutunu göndererek uygulamadan bir yükleme URL'i ister. Uygulama kullanıcıyı, odayı ve boyutu kontrol eder (demo 10 MB'a kadar izin veriyor), rooms/<room>/<uuid> gibi bir anahtar seçer ve 5 dakika geçerli, kısa ömürlü bir imzalı (presigned) PUT URL'i döndürür.
2. Tarayıcı dosyayı bu URL ile doğrudan S3'e PUT eder.
3. Tarayıcı sohbet mesajını nesne anahtarıyla birlikte gönderir; sunucu onu diğer mesajlar gibi kaydeder.
4. Bir dosyayı açmak için alıcı uygulamayı çağırır; uygulama erişimi kontrol eder ve yine 5 dakika geçerli bir imzalı GET URL'ine yönlendirir.
İmzalı URL, imzası sorgu dizesinde taşınan sıradan bir S3 isteğidir: elinde tutan, süresi dolana kadar, o tek anahtar üzerinde tam olarak o tek işlemi yapabilir. Fikri daha ayrıntılı olarak imzalı URL nedir rehberi anlatıyor. Uygulama boyutu imzalamadan önce kontrol eder; sınırı, süreyi ve anahtar biçimini kullanıcıların değiştiremeyeceği yerde, sunucuda tutun.
İmzalı Content-Type ile presigned PUT ve indirme yönlendirmesi
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'node:crypto';
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT, // https://s3.cdn.com.tr
region: process.env.S3_REGION, // cdn-tr
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
const MAX_BYTES = 10 * 1024 * 1024;
app.post('/api/upload-url', express.json(), async (req, res) => {
const { room, type, size } = req.body;
if (!type || !(size > 0) || size > MAX_BYTES) {
return res.status(400).json({ error: 'max 10 MB' });
}
const key = `rooms/${room}/${randomUUID()}`;
const url = await getSignedUrl(s3,
new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: type }),
{ expiresIn: 300, signableHeaders: new Set(['content-type']) });
res.json({ url, key });
});
app.get('/api/files', async (req, res) => {
// check here that the user may read this room
const url = await getSignedUrl(s3,
new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: req.query.key }),
{ expiresIn: 300 });
res.redirect(302, url);
});
Content-Type tuzağı: presigned PUT'ta 403 AccessDenied
Bu, insanlara bir öğleden sonraya mal oluyor; o yüzden kendi bölümünü hak ediyor. Bizim S3'ümüz Ceph RGW üzerinde çalışır ve RGW tek bir başlık konusunda AWS'ten katıdır: tarayıcı imzanın kapsamadığı bir Content-Type gönderirse presigned PUT 403 AccessDenied ile reddedilir. AWS imzalanmamış bu başlığı yok sayar; bu yüzden orada çalışan kod burada, anahtar sorunu gibi görünen bir hatayla düşebilir.
Çözüm, tipi URL'in imzasına katmak ve sonra tam olarak o tipi göndermektir. AWS SDK v3 ile PutObjectCommand içinde ContentType verin ve yukarıdaki kodda olduğu gibi getSignedUrl çağrısına signableHeaders: new Set(['content-type']) ekleyin. Tarayıcıda da PUT isteğine aynı Content-Type başlığını koyun.
İki ayrıntı bunu sağlam yapar. İki tarafta aynı yedek değeri kullanın: tipi olmayan bir dosya (file.type boş dize) aynı değerle, örneğin application/octet-stream ile hem imzalanmalı hem gönderilmeli. Ve bir kütüphanenin başlığı değiştirmesine izin vermeyin: bazı yükleme yardımcıları metin dosyalarına ; charset=utf-8 ekler, bu da artık imzayla eşleşmez.
Tarayıcı tarafı: sunucunun imzaladığı tipin aynısı
// browser: ask for a URL, PUT the file to S3, then send the message
const { url, key } = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room, type: file.type || 'application/octet-stream', size: file.size }),
}).then((r) => r.json());
const put = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type || 'application/octet-stream' }, // exactly the signed type
body: file,
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
socket.emit('message', { room, fileKey: key, fileName: file.name });
Bucket üzerinde CORS
Sayfa uygulamanızın adresinde çalışır, yükleme ise s3.cdn.com.tr adresine, yani başka bir origin'e gider. Bu yüzden tarayıcı PUT'tan önce bir preflight gönderir ve ancak bucket uygulamanızın origin'ine, PUT metoduna ve Content-Type başlığına izin veren bir CORS kuralıyla cevap verirse devam eder. Kural yoksa yükleme tarayıcıda başarısız olurken aynı istek curl ile çalışır; eksik CORS kuralının klasik işareti budur.
Bucket CORS kuralları S3 API üzerinden PutBucketCors ile tanımlanır. Demo bunu açılışta bir kez yapıyor; böylece kural, ona bağlı kodun yanında durur. * yerine kendi origin'inize izin verin: kural, başka sitelerin kendi sayfalarından bucket'ınızı kullanmasını engelleyen şeyin bir parçasıdır. Preflight'ları ve sık görülen hata mesajlarını CORS nedir rehberi ayrıntılı anlatıyor.
Doğrudan yükleme ve indirme için bucket CORS kuralı
import { PutBucketCorsCommand } from '@aws-sdk/client-s3';
// once at startup: let the app's own origin PUT and GET directly
await s3.send(new PutBucketCorsCommand({
Bucket: process.env.S3_BUCKET,
CORSConfiguration: {
CORSRules: [{
AllowedOrigins: ['https://xxxx.cdn.com.tr'], // your app's address
AllowedMethods: ['PUT', 'GET'],
AllowedHeaders: ['content-type'],
MaxAgeSeconds: 3600,
}],
},
}));
CDN üzerinden WebSocket: zaman aşımı ve yeniden bağlanma
CDN WebSocket'i dağıtım kuralı bazında taşır. Managed Container Apps alan adında WebSocket varsayılan olarak açıktır ve dağıtım kuralı başka bir değer vermedikçe boşta kalma zaman aşımı 300 saniyedir; bir kural 1 ile 3.600 saniye arasında herhangi bir değer verebilir. Zaman aşımı toplam süreyi değil sessizliği sayar; veri alışverişi süren bir bağlantı açık kalır. Socket.io varsayılan olarak her 25 saniyede bir kendi heartbeat'ini gönderir; bu da sessiz bir odayı varsayılan değerin rahatça içinde tutar. Ayarlar origin bağlantısı rehberinde anlatılıyor.
İstemciler kendiliğinden yeniden bağlanmalıdır. Açık WebSocket bağlantıları edge yapılandırması yeniden yüklendiğinde kapanabilir; telefonlar da ağ değiştirdikçe bağlantıyı düşürür. Socket.io kendisi yeniden bağlanır; düz WebSocket kullanıyorsanız artan bekleme süreli (backoff) bir yeniden bağlanma ekleyin.
Yeniden bağlandıktan sonra istemci kaçırdıklarını almalıdır. En basit yol yeniden join göndermek: sunucu odanın yakın geçmişini döndürür, istemci de mesaj id'sine göre birleştirir; böylece hiçbir şey iki kez görünmez.
Eski mesajları arşivlemek ya da hazır bir sohbet sunucusu kullanmak
Arşivleme. Demo her mesajı Postgres'te tutuyor; bu uzun süre sorunsuz işler. Tablo büyüdüğünde yaygın bir kalıp, periyodik bir işin örneğin 90 günden eski mesajları alıp her oda için günde bir JSON blok olarak S3'e yazması (archive/<room>/2026-08-01.json.gz) ve sonra bu satırları silmesidir. "Eski mesajları yükle" doğru bloğu imzalı bir GET ile S3'ten okur. Veritabanı insanların gerçekten kaydırdığı kısmı, S3 geri kalanını tutar.
Hazır sunucular. Kendi sohbetinizi yazmak istemiyorsanız Mattermost, Rocket.Chat ve Matrix Synapse yüklenen dosyaları S3 uyumlu depolamada saklayabilir. Aynı üç katman burada da geçerli: kendi sunucu süreçleri, kendi veritabanları ve dosyalar için bir bucket. CDNTR'de bu, bu rehberdeki container, veritabanı eklentisi ve bucket demektir; her projenin dokümantasyonunun istediği S3 endpoint'i, bucket ve anahtarlarla yapılandırılır.
Kontrol listesi: sohbette dosya yüklemeyi bozan hatalar
Bucket'ta CORS kuralı. Uygulamanızın origin'i, PUT ve GET, Content-Type başlığı. Eksikse yalnızca tarayıcıda başarısız bir yükleme olarak görünür.
İmzalı Content-Type. PUT URL'inin imzasına katın ve tam olarak o değeri gönderin; tipi olmayan dosyalar için de aynı yedek değeri kullanın. Aksi halde: 403 AccessDenied.
Kısa süre. Günler değil dakikalar. Demo hem PUT hem GET için 5 dakika kullanıyor; sızan bir URL neredeyse hemen işe yaramaz hale gelir.
Dosyalar uygulamadan geçmez. Yükleme ve indirme doğrudan tarayıcı ile S3 arasında olur. Uygulama imzalar ve yönlendirir, akıtmaz.
Otomatik yeniden bağlanma. WebSocket bağlantılarının kapanıp yeniden açılmasını bekleyin. Odaya yeniden katılın, geçmişi mesaj id'sine göre birleştirin.
Boyut sınırı. İmzalamadan önce bildirilen boyutu kontrol edin, sınırın üstündekini reddedin (demoda 10 MB).
Anahtarlar sunucuda kalır. S3 erişim anahtarları bağlamadan gelen değişkenlerle uygulamanın ortamında yaşar. Onları asla tarayıcıya göndermeyin, istemci koduna gömmeyin; tarayıcı yalnızca imzalı URL'leri görür.
İndirmede erişim kontrolü. Dosya bağlantılarını uygulamanın üzerinden geçirin ki uygulama yönlendirmeden önce kullanıcının o odayı görebildiğini doğrulasın.
Nasıl yapılır
Yardım merkezindeki adım adım rehberler:
S3 ile sohbet uygulaması hakkında sık sorulanlar
Yalnızca S3 ile sohbet uygulaması yapılır mı?
Gerçek zamanlı olanı yapılmaz. S3 yeni mesajları iletemez, nesnelere ekleme yapamaz ve onları sorgulayamaz; istemciler yoklama yapmak zorunda kalır, siz de anahtar listelerinin üzerine bir veritabanı yeniden kurarsınız. Dosyalar ve arşiv için S3, mesajlar için veritabanı, iletim için küçük bir WebSocket sunucusu kullanın.
İstemciler yeni mesajlar için S3'ü yoklasa olmaz mı?
Her açık istemci bir iki saniyede bir nesneleri listeler; bu yavaş hissettirir, istekleri katlar ve "son 50" ile "okunmamış" sorularını yine cevaplamaz. WebSocket bağlantısı ise mesajı, sunucu kaydettiği anda odadaki herkese ulaştırır.
Yüklenen dosyalar uygulama sunucumun üzerinden geçer mi?
Hayır. Uygulama, demoda 5 dakika geçerli bir imzalı PUT URL'i döndürür ve tarayıcı dosyayı doğrudan S3'e yükler. İndirme de aynı şekilde çalışır: uygulama erişimi kontrol eder ve imzalı bir GET URL'ine yönlendirir.
Presigned PUT neden 403 AccessDenied dönüyor?
Bizim S3'ümüzde en yaygın neden, imzanın kapsamadığı bir Content-Type başlığıdır. Tipi URL'in imzasına katın (AWS SDK v3 ile: PutObjectCommand içinde ContentType ve signableHeaders: new Set(['content-type'])) ve tarayıcıdan tam olarak o tipi gönderin. Tarayıcı bunun yerine CORS hatası gösteriyorsa bucket'a bir CORS kuralı gerekir.
WebSocket CDN üzerinden çalışır mı?
Evet. Managed Container Apps alan adında WebSocket varsayılan olarak açıktır; dağıtım kuralı başka bir değer vermedikçe boşta kalma zaman aşımı 300 saniyedir (bir kural 1 ile 3.600 saniye arası bir değer verebilir). İstemciler otomatik yeniden bağlanmalıdır, çünkü açık bağlantılar edge yapılandırması yeniden yüklendiğinde kapanabilir; Socket.io bunu kendisi yapar.
Socket.io ile bir sohbet için Redis gerekir mi?
Tek replikada gerekmez; demo tek replika çalışıyor. Birden fazla replikada bir mesajın diğerlerine bağlı istemcilere de ulaşması gerekir; bunun olağan yolu yönetilen Redis eklentisi ile Socket.io Redis adapter'ıdır.