Cloud Storage: Blob Storage'da Container'lar, Access Tier'lar, SAS ve Managed Identity ile Kitap Kapakları
Kitap kapakları private bir Blob Storage container'ında durmalı ve tarayıcıya account key'le değil, managed identity'nin aldığı user delegation key ile imzalanmış kısa ömürlü bir SAS ile açılmalı. Redundancy seçimini, access tier'ları ve lifecycle policy'yi, magic byte doğrulamalı kapak yükleme endpoint'ini, Azurite'in OAuth modunda gerçek SAS testlerini, Storage Blob Delegator rolünü ve S3 karşılıklarını anlatıyoruz.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.404), Azure.Storage.Blobs 12.30.1, Azure.Identity 1.21.0, Entity Framework Core 10.0.15, xUnit v3 4.0.3, Azurite 3.37.0 ve bir SQL Server 2025 container'ı ile denenmiştir. Blob işlemleri yerelde, OAuth modunda çalışan Azurite'e karşı yapıldı; Azure'da storage account ya da rol ataması oluşturulmadı. Azure tarafındaki davranış Microsoft Learn'den, liste fiyatları Azure Retail Prices API'den alındı (14 Kasım 2026).
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kapaklar covers container'ında duruyor. 3. yazıda Bookstore.Api'nin managed identity'sine yalnız bu container'da "Storage Blob Data Contributor" rolünü verdik: okuyabilir, yazabilir, silebilir. Şimdi API, tarayıcının kapağı doğrudan storage'dan indirebilmesi için bir user delegation SAS (Shared Access Signature; süresi ve yetkisi sınırlı, imzalı erişim adresi) üretmek istiyor. Bu rol yeterli mi? İkinci soru: SAS'lı bir adres yanlışlıkla bir log'a düştü ve süresi bitmedi. Onu nasıl geçersiz kılarsın? İki cevap da yazının içinde.
Storage Account Türleri ve Redundancy
Azure'da blob, file share, queue ve table aynı kaynağın, storage account'un altında yaşar. Yeni bir uygulama için seçim neredeyse her zaman Standard general-purpose v2 hesabıdır; Premium block blob hesabı, çok sayıda küçük ve gecikmeye duyarlı işlem için SSD (hızlı, flash tabanlı disk) üzerinde çalışır ve GB başına daha pahalıdır. Bookstore'un hesabı stbookstoredevweu, 3. yazıda Standard olarak ve en ucuz redundancy seçeneğiyle açıldı.
Redundancy, verinin kaç kopya ve nerede tutulduğunu belirler. LRS (Locally Redundant Storage; tek veri merkezinde üç kopya) en ucuzudur; Learn yıllık en az on bir dokuzluk dayanıklılık veriyor ama veri merkezi giderse veri de erişilemez olur. ZRS (Zone-Redundant Storage; aynı region'da en az üç availability zone'a eşzamanlı kopya) bir zone'un kaybına dayanır. GRS (Geo-Redundant Storage; birincil region'da LRS, eşli region'a asenkron kopya) ve GZRS (birincil region'da ZRS ile geo kopya) bir region'ın kaybına karşıdır; ikincil kopyayı failover yapmadan okuyamazsın. Okumak istiyorsan RA-GRS ya da RA-GZRS seçersin. Asenkron kopyanın bedeli veri kaybı riskidir: Learn, unplanned failover'da veri kaybı beklendiğini ve hesabın yeni region'da LRS'e döndüğünü söylüyor.
Bookstore için karar kısa. Kapaklar yeniden yüklenebilir, kaybolurlarsa iş durmaz; dev ve staging için LRS yeter. Production'da ZRS mantıklı: fiyat farkı küçük (West Europe'ta hot tier'da GB başına aylık 0,0196 dolara karşı 0,024 dolar liste fiyatı) ve bir zone arızasında kapaklar kaybolmaz. Geo kopyayı, kapakların bulunmamasının satış kaybı demek olduğu bir noktaya gelince düşünürüz. Bir kısıt daha var: archive tier yalnız LRS, GRS ve RA-GRS hesaplarda çalışıyor.
Object storage tek tür değil. Blob Storage, HTTP üzerinden erişilen object storage'dır. Azure Files, SMB (Windows'un dosya paylaşım protokolü) ya da NFS (Unix dünyasının ağ dosya sistemi protokolü) ile bağlanan bir dosya paylaşımıdır; eski bir uygulama dosyaları diske yazıyorsa ve kodunu değiştiremiyorsan işe yarar. Managed disk ise bir VM'e takılan blok cihazıdır. Aşağıdaki tablo AWS (Amazon Web Services; Amazon'un sunduğu bulut platformu) karşılıklarını veriyor: S3 (Simple Storage Service; AWS'nin object storage servisi), EFS (Elastic File System; AWS'nin yönetilen ağ dosya sistemi) ve EBS (Elastic Block Store; AWS'deki sanal makinelere takılan disk servisi).
| Azure | AWS karşılığı | Not |
|---|---|---|
| Blob Storage, container | S3, bucket | Object storage; HTTP üzerinden erişim |
| Hot, cool, cold, archive tier | S3 Standard, Standard-IA, Glacier Instant Retrieval, Glacier Deep Archive | Asgari süreler: Azure 30/90/180 gün, AWS 30/90/180 gün |
| Lifecycle management policy | S3 Lifecycle configuration | Yaşa ya da son erişime göre tier değiştirme ve silme |
| User delegation SAS | Presigned URL | İkisi de en fazla 7 gün geçerli |
| Azure Files | EFS, FSx | Ağ üzerinden dosya paylaşımı |
| Managed disk | EBS | VM'e bağlı blok disk |
Container, Public Access ve Veri Koruma
Container, blob'ların durduğu düz bir isim alanıdır; klasör gibi görünen exports/2026/11/ önekleri aslında blob adının parçasıdır. Erişim hesap ve container seviyesinde ayarlanır. Learn'e göre Resource Manager üzerinden açılan hesaplarda anonim erişim varsayılan olarak kapalı; 3. yazıdaki script bunu --allow-blob-public-access false ile bir de açıkça yazıyordu. Container'ı "kapaklar herkese açık olsun" diye public yapmak, ona yanlışlıkla konan her dosyayı da açar.
Veri koruma için iki ayar var. Soft delete, silinen ya da üzerine yazılan blob'u belirlediğin süre (1 ile 365 gün arası) boyunca geri getirilebilir tutar; portal'dan açılan yeni hesaplarda blob soft delete varsayılan olarak 7 günle açık geliyor. Container soft delete ayrı bir ayardır: blob soft delete, silinen bir container'ı geri getirmez. Versioning ise her yazmada bir önceki sürümü saklar. Bookstore'da versioning'i açmıyoruz, çünkü kapakların üzerine hiç yazılmıyor: her yükleme rastgele adlı yeni bir blob oluşturuyor. Eski kapağı silmek yeterli; soft delete onu 14 gün saklıyor. Soft delete'teki veri normal veri gibi ücretlendirilir.
Access Tier'lar ve Lifecycle Policy
Blob'un ne kadar sık okunduğuna göre dört tier var. Hot tier'da saklamak pahalı, okumak ucuz. Cool, cold ve archive'da saklama ucuzlar, okuma ve erken silme pahalılaşır: cool için en az 30, cold için 90, archive için 180 gün tutmazsan kalan günlerin ücretini ödersin. West Europe'taki LRS liste fiyatları (14 Kasım 2026, GB başına aylık): hot 0,0196, cool 0,01, cold 0,0045, archive 0,0018 dolar. Archive'dan okumanın GB başı ücreti 0,024 dolar, 10.000 okuma işlemi ise 6 dolar. Fiyatlar değişir.
Archive diğerlerinden farklı: blob çevrim dışıdır. Önce rehydrate etmen, yani hot, cool ya da cold'a geri almanı gerekir. Learn'e göre standart öncelikle bu 15 saate kadar sürebiliyor, yüksek öncelikle 10 GB'tan küçük bir blob için bir saatin altına inebiliyor. Bunu Azurite'te denedik. Arşivlenmiş bir blob'u okumaya çalışan kod:
await cover.SetAccessTierAsync(AccessTier.Archive);
try
{
await cover.DownloadContentAsync();
}
catch (RequestFailedException ex)
{
Console.WriteLine($"{ex.Status} {ex.ErrorCode}: {ex.Message.Split('\n')[0]}");
}
409 BlobArchived: This operation is not permitted on an archived blob.
tier=Archive archiveStatus=
tier=Hot archiveStatus=
Hata Azure'dakiyle aynı. Ama son satıra dikkat: Azurite blob'u hot'a anında geri aldı. Azure'da bu adımdan sonra archiveStatus saatlerce rehydrate-pending-to-hot kalır. Emülatörde çalışan bir akışın gerçek süresi emülatörde görünmez; archive'a giden bir şeyi okuyan kodu, saatlerce beklemeyi kaldıracak şekilde tasarla.
Tier'ı elle değiştirmek yerine lifecycle policy yazarsın. Kurallar platform tarafında çalışır; Learn'e göre bir değişikliğin ilk çalıştırmaya yansıması 24 saati bulabilir. Bookstore'un policy'si iki kural içeriyor:
{
"rules": [
{
"name": "exports-cool-then-cold-then-delete",
"enabled": true,
"type": "Lifecycle",
"definition": {
"filters": {
"blobTypes": ["blockBlob"],
"prefixMatch": ["exports/"]
},
"actions": {
"baseBlob": {
"tierToCool": { "daysAfterModificationGreaterThan": 30 },
"tierToCold": { "daysAfterModificationGreaterThan": 90 },
"delete": { "daysAfterModificationGreaterThan": 1825 }
}
}
}
},
{
"name": "covers-unread-for-a-year-to-cool",
"enabled": true,
"type": "Lifecycle",
"definition": {
"filters": {
"blobTypes": ["blockBlob"],
"prefixMatch": ["covers/"]
},
"actions": {
"baseBlob": {
"tierToCool": { "daysAfterLastAccessTimeGreaterThan": 365 },
"enableAutoTierToHotFromCool": true
}
}
}
}
]
}
İlk kural back office'in günlük satış export'larını 30 gün sonra cool'a, 90 gün sonra cold'a taşıyor ve beş yıl sonra siliyor. Beş yıl yalnız bir örnek; gerçek süreye muhasebe karar verir. İkinci kural son erişime bakıyor: bir yıl boyunca kimsenin açmadığı kapak cool'a iniyor, biri açınca enableAutoTierToHotFromCool sayesinde hot'a geri dönüyor. Bu koşul için hesapta last access tracking'in açık olması gerekiyor. Kapakları archive'a göndermiyoruz: katalogda bir kitabı açan müşteri 15 saat beklemez. Yeni bir seçenek olarak smart tier da var: Learn'e göre nesneleri erişime göre kendisi hot, cool ve cold arasında taşıyor, erken silme ücreti almıyor ama ZRS ya da GZRS hesap istiyor.
Kapak Yükleme Endpoint'i
Mağaza çalışanı kapağı Bookstore.Api'ye yüklüyor, API doğruluyor ve blob olarak yazıyor. İlk iş dosyanın gerçekten resim olduğunu anlamak. Dosya adı ve Content-Type header'ı istemciden gelir, ikisine de güvenilmez; güvenilen tek şey dosyanın ilk baytları. JPEG (fotoğraflar için yaygın kayıplı resim biçimi), PNG (kayıpsız resim biçimi) ve WebP, ilk baytlarındaki imzalardan tanınıyor:
namespace Bookstore.Storage;
public sealed record CoverFormat(string ContentType, string Extension);
public static class CoverImage
{
public const long MaxBytes = 2 * 1024 * 1024;
private static readonly CoverFormat Jpeg = new("image/jpeg", ".jpg");
private static readonly CoverFormat Png = new("image/png", ".png");
private static readonly CoverFormat WebP = new("image/webp", ".webp");
// Dosya adı ve Content-Type header'ı istemciden gelir; güvenilen tek şey ilk baytlar
public static CoverFormat? Detect(ReadOnlySpan<byte> header) => header switch
{
[0xFF, 0xD8, 0xFF, ..] => Jpeg,
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => Png,
[0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => WebP,
_ => null,
};
}
Endpoint:
using System.Security.Cryptography;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Bookstore.Data;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Storage;
public sealed record CoverUploaded(string Isbn, string BlobName, string ContentType, long Size);
public static class CoverEndpoints
{
public static IEndpointRouteBuilder MapCoverEndpoints(this IEndpointRouteBuilder app)
{
RouteGroupBuilder covers = app.MapGroup("/api/books/{isbn}/cover").WithTags("Covers");
// Authentication eklenince yalnız mağaza çalışanları; formu bir tarayıcı sayfası değil, API istemcisi gönderir
covers.MapPut("/", UploadCover)
.DisableAntiforgery()
.WithFormOptions(multipartBodyLengthLimit: CoverImage.MaxBytes + 64 * 1024);
covers.MapGet("/", RedirectToCover);
return app;
}
private static async Task<Results<Created<CoverUploaded>, NotFound, ProblemHttpResult>> UploadCover(
string isbn, IFormFile file, BookstoreContext db, BlobContainerClient covers,
ILoggerFactory loggerFactory, CancellationToken ct)
{
var book = await db.Books.Where(b => b.Isbn == isbn).Select(b => new { b.Id, b.CoverBlobName }).FirstOrDefaultAsync(ct);
if (book is null)
{
return TypedResults.NotFound();
}
if (file.Length is 0 or > CoverImage.MaxBytes)
{
return TypedResults.Problem($"The cover must be between 1 byte and {CoverImage.MaxBytes} bytes.", statusCode: 400);
}
await using Stream stream = file.OpenReadStream();
byte[] header = new byte[12];
int read = await stream.ReadAtLeastAsync(header, header.Length, throwOnEndOfStream: false, ct);
if (CoverImage.Detect(header.AsSpan(0, read)) is not { } format)
{
return TypedResults.Problem("Only JPEG, PNG and WebP images are accepted.", statusCode: 415);
}
stream.Position = 0;
// İstemcinin dosya adı asla: rastgele ad tahmin edilemez, çakışmaz, kaçış karakteri gerektirmez
string blobName = $"{isbn}-{RandomNumberGenerator.GetHexString(32, lowercase: true)}{format.Extension}";
BlobClient blob = covers.GetBlobClient(blobName);
await blob.UploadAsync(stream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = format.ContentType, CacheControl = "public, max-age=31536000, immutable" },
Metadata = new Dictionary<string, string> { ["isbn"] = isbn },
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All },
}, ct);
await db.Books.Where(b => b.Id == book.Id)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.CoverBlobName, blobName), ct);
if (book.CoverBlobName is { } oldName)
{
// Soft delete eski kapağı saklama süresi boyunca tutar
await covers.DeleteBlobIfExistsAsync(oldName, cancellationToken: ct);
}
loggerFactory.CreateLogger(typeof(CoverEndpoints)).LogInformation(
"Stored cover {BlobName} ({Size} bytes, {ContentType}) for {Isbn}", blobName, file.Length, format.ContentType, isbn);
return TypedResults.Created($"/api/books/{isbn}/cover", new CoverUploaded(isbn, blobName, format.ContentType, file.Length));
}
private static async Task<Results<RedirectHttpResult, NotFound>> RedirectToCover(
string isbn, BookstoreContext db, CoverUrlSigner signer, HttpContext http, TimeProvider clock, CancellationToken ct)
{
string? blobName = await db.Books.Where(b => b.Isbn == isbn).Select(b => b.CoverBlobName).FirstOrDefaultAsync(ct);
if (blobName is null)
{
return TypedResults.NotFound();
}
SignedCoverUrl signed = await signer.SignAsync(blobName, ct);
// Yönlendirme en fazla imzalı adresin süresi dolana kadar (bir pay bırakarak) cache'lenir
int maxAge = Math.Max(0, (int)(signed.ExpiresOn - clock.GetUtcNow()).TotalSeconds - 300);
http.Response.Headers.CacheControl = $"private, max-age={maxAge}";
return TypedResults.Redirect(signed.Url.ToString());
}
}
Kararları sırayla açalım. Boyut iki yerden sınırlanıyor: WithFormOptions multipart gövdesini 2 MB'ın biraz üstünde kesiyor, kod da dosyanın kendisini kontrol ediyor. Sınır vermezsen ASP.NET Core'un varsayılanları geçerli olur: Kestrel'de istek gövdesi için yaklaşık 28,6 MB, multipart gövde için 128 MB. Blob adı istemcinin dosya adından değil, ISBN (kitapların uluslararası standart numarası) ve 32 karakterlik rastgele bir değerden üretiliyor: tahmin edilemez, çakışmaz, kaçış karakteri gerektirmez. IfNoneMatch = ETag.All, aynı adda bir blob varsa yazmayı reddeder; rastgele adla bu hiç olmamalı ama olursa sessizce üzerine yazmak yerine hata almak isteriz. Cache-Control: immutable da bu tasarımın sonucu: bir blob'un içeriği hiç değişmediği için tarayıcı ve CDN (Content Delivery Network; içeriği kullanıcıya yakın sunuculardan dağıtan ağ) onu bir yıl saklayabilir.
Testi yazarken bir sürprizle karşılaştık. 3 MB'lık bir dosya gönderdiğimizde 400 yerine 500 aldık:
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "IFormFile file" from the request body as form.
---> System.IO.InvalidDataException: Multipart body length limit 2162688 exceeded.
Development ortamında minimal API, okunamayan gövde için exception fırlatıyor; exception handler da bilmediği her exception'ı 500'e çeviriyor. Program.cs'te StatusCodeSelector ile BadHttpRequestException'ın kendi status code'unu kullanmasını sağladık:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = ex => ex is BadHttpRequestException bad ? bad.StatusCode : StatusCodes.Status500InternalServerError,
});
Book'a kapağın blob adını tutan CoverBlobName kolonu eklendi; AddBookCover migration'ı bu kolonu varchar(100) olarak ekliyor.
User Delegation SAS ile Okuma Adresi
Kapağı API üzerinden stream etmek, her resim isteğinde API'nin kaynaklarını harcar; tarayıcıyı doğrudan blob'a göndermek daha iyi. Container private olduğu için tarayıcıya bir SAS adresi gerekiyor. SAS üç türlü: account SAS ve service SAS account key ile imzalanır, user delegation SAS ise Entra ID ile alınan bir user delegation key ile. Microsoft ilkini değil sonuncuyu öneriyor; bizim hesapta shared key 3. yazıda kapatıldığı için başka seçenek de yok.
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
namespace Bookstore.Storage;
// Kısa ömürlü okuma adreslerini user delegation key ile imzalar: hiçbir yerde account key yok.
public sealed class CoverUrlSigner(BlobServiceClient serviceClient, BlobContainerClient covers, TimeProvider clock)
{
private static readonly TimeSpan KeyLifetime = TimeSpan.FromDays(1);
private static readonly TimeSpan ClockSkew = TimeSpan.FromMinutes(15);
private readonly SemaphoreSlim keyLock = new(1, 1);
private UserDelegationKey? key;
public async Task<SignedCoverUrl> SignAsync(string blobName, CancellationToken ct)
{
// Aynı saat içindeki her istek aynı adresi alır (bir sonraki saatin sonuna kadar geçerli), böylece cache'lenebilir
DateTimeOffset now = clock.GetUtcNow();
DateTimeOffset hourStart = new(now.Year, now.Month, now.Day, now.Hour, 0, 0, TimeSpan.Zero);
DateTimeOffset expiresOn = hourStart.AddHours(2);
UserDelegationKey delegationKey = await GetKeyAsync(now, expiresOn, ct);
BlobSasBuilder sas = new(BlobSasPermissions.Read, expiresOn)
{
BlobContainerName = covers.Name,
BlobName = blobName,
Resource = "b",
StartsOn = hourStart - ClockSkew,
Protocol = SasProtocol.Https,
};
Uri url = covers.GetBlobClient(blobName).GenerateUserDelegationSasUri(sas, delegationKey);
return new SignedCoverUrl(url, expiresOn);
}
private async Task<UserDelegationKey> GetKeyAsync(DateTimeOffset now, DateTimeOffset neededUntil, CancellationToken ct)
{
await keyLock.WaitAsync(ct);
try
{
// Delegation key'in süresi dolunca SAS da çalışmaz; bu yüzden key, SAS'tan uzun yaşamalı
if (key is null || key.SignedExpiresOn < neededUntil)
{
BlobGetUserDelegationKeyOptions options = new(now + KeyLifetime) { StartsOn = now - ClockSkew };
key = (await serviceClient.GetUserDelegationKeyAsync(options, ct)).Value;
}
return key;
}
finally
{
keyLock.Release();
}
}
}
public sealed record SignedCoverUrl(Uri Url, DateTimeOffset ExpiresOn);
User delegation key en fazla 7 gün geçerli olabilir ve almak bir ağ çağrısı. Her istekte almak yerine bir gün geçerli bir key alıp bellekte tutuyoruz. SAS'ın süresi saat başına hizalı: aynı saat içindeki bütün istekler aynı adresi alıyor, bu yüzden tarayıcı ve CDN resmi tekrar indirmiyor. Başlangıç zamanı 15 dakika geriye alınmış, çünkü Learn'e göre sunucular arasındaki saat farkı iki yönde de 15 dakikayı bulabiliyor. GET /api/books/{isbn}/cover bu adrese 302 ile yönlendiriyor. Testte gelen Location:
Location: https://127.0.0.1:52710/devstoreaccount1/covers/9780441172719-0d9810feaa27d9defaf9acc68386b8f3.png?skoid=11111111-1111-1111-1111-111111111111&sktid=00000000-0000-0000-0000-000000000000&skt=2026-11-14T20%3A28%3A00Z&ske=2026-11-15T20%3A43%3A00Z&sks=b&skv=2026-06-06&sv=2026-10-06&spr=https&st=2026-11-14T19%3A45%3A00Z&se=2026-11-14T22%3A00%3A00Z&sr=b&sp=r&sig=<signature>
sk ile başlayan parametreler key'e ait: skoid key'i alan kimliğin object ID'si, skt ve ske key'in geçerlilik aralığı. Geri kalanlar SAS'ın kendisi: sr=b tek bir blob, sp=r yalnız okuma, spr=https yalnız HTTPS, st ve se geçerlilik aralığı. İmza bunların hepsini kapsıyor. Adresi curl ile açtık, sonra iki şeyi değiştirdik:
$ curl -s -i --cacert cert.pem "$SAS_URL"
HTTP/1.1 200 OK
Server: Azurite-Blob/3.37.0
x-ms-meta-isbn: 9780441172719
content-length: 70
content-type: image/png
cache-control: public, max-age=31536000, immutable
...
$ curl -s -i --cacert cert.pem "${SAS_URL/sp=r&/sp=rw&}" | head -1
HTTP/1.1 403 Server failed to authenticate the request. Make sure the value of the Authorization header is formed correctly including the signature.
$ curl -s --cacert cert.pem -X PUT -H "x-ms-blob-type: BlockBlob" --data-binary "x" "$SAS_URL" | grep -o "<Code>[^<]*"
<Code>AuthorizationPermissionMismatch
İzinleri büyütmek imzayı bozuyor ve 403 geliyor. Okuma SAS'ıyla yazmaya çalışmak da 403 alıyor, bu sefer farklı bir kodla: imza geçerli ama izin yok. sig değerini burada gizledik; geçerli bir SAS adresi, süresi dolana kadar bir parola gibi davranır.
Yerelde Denemek: Azurite ve OAuth
Azurite'i her zamanki gibi connection string'le kullanınca user delegation key almak mümkün değil. Bunu ilk denemede gördük:
FAIL 403 AuthenticationFailed
Bu Azure'la tutarlı: user delegation key yalnız Entra ID token'ıyla alınabiliyor. Azurite'in --oauth basic modu burada işe yarıyor: bearer token kabul ediyor, token'ın issuer, audience ve süresini kontrol ediyor ama imzasını doğrulamıyor. Bu mod HTTPS istediği için kendi imzaladığımız bir sertifika ürettik ve Azurite'i onunla başlattık; komutlar reponun README'sinde. Test projesinde, Azurite'e Entra ID'ninkine benzeyen ama imzasız bir token veren küçük bir credential var:
using System.Text;
using System.Text.Json;
using Azure.Core;
using Azure.Core.Pipeline;
using Azure.Storage.Blobs;
namespace Bookstore.Api.Tests;
// "--oauth basic" ve kendi imzaladığımız sertifikayla başlatılan Azurite, bearer token'ın issuer, audience
// ve süresini kontrol eder, imzasını değil. Azure olmadan user delegation SAS yolunu denemeye bu yeter.
public static class AzuriteOAuth
{
// ör. https://127.0.0.1:10000/devstoreaccount1 (bkz. README.md)
public static Uri? ServiceUri { get; } =
Environment.GetEnvironmentVariable("AZURITE_OAUTH_BLOB_URI") is { Length: > 0 } uri ? new Uri(uri) : null;
public static BlobServiceClient CreateServiceClient() =>
new(ServiceUri, new FakeEntraTokenCredential(), new BlobClientOptions { Transport = new HttpClientTransport(CreateHandler()) });
// Yalnız test kodu: Azurite'in kendi imzalı sertifikası loopback adresleri için kabul edilir
public static HttpClientHandler CreateHandler() => new()
{
ServerCertificateCustomValidationCallback = (request, _, _, errors) =>
errors == System.Net.Security.SslPolicyErrors.None || request.RequestUri!.IsLoopback,
};
private sealed class FakeEntraTokenCredential : TokenCredential
{
private const string TenantId = "00000000-0000-0000-0000-000000000000";
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
string header = Encode(new { alg = "RS256", typ = "JWT" });
string payload = Encode(new
{
aud = "https://storage.azure.com",
iss = $"https://sts.windows.net/{TenantId}/",
tid = TenantId,
oid = "11111111-1111-1111-1111-111111111111",
iat = now.AddMinutes(-5).ToUnixTimeSeconds(),
nbf = now.AddMinutes(-5).ToUnixTimeSeconds(),
exp = now.AddHours(1).ToUnixTimeSeconds(),
});
return new AccessToken($"{header}.{payload}.unsigned", now.AddHours(1));
}
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) =>
ValueTask.FromResult(GetToken(requestContext, cancellationToken));
private static string Encode(object value) =>
Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(value)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
Böylece testler, Azure'daki managed identity akışının aynısını yürütüyor: token, user delegation key, SAS. Test sınıfının ilk testi yukarıdaki adımları sırayla kontrol ediyor; ikinci test yeni bir kapağın eski blob'u sildiğini, diğerleri 415, 400 ve 404 durumlarını doğruluyor. AZURITE_OAUTH_BLOB_URI tanımlı değilse Azurite isteyen testler atlanıyor. Çözümün tamamında:
$ dotnet test
Test run summary: Passed!
total: 33
failed: 0
succeeded: 33
Azure Tarafı: Roller, İptal ve CDN
Script veri korumayı açıyor, SAS süresine sınır koyuyor, policy'yi yüklüyor ve eksik rolü veriyor:
#!/usr/bin/env bash
# Kapaklar için Blob Storage: veri koruma, lifecycle kuralları ve Bookstore.Api'nin user delegation SAS imzalamasını sağlayan rol.
set -euo pipefail
: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 07-storage.sh <dev|staging|prod>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"
az account set --subscription "$AZURE_SUBSCRIPTION_ID"
PRINCIPAL_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query principalId --output tsv)
STORAGE_ID=$(az storage account show --name "$STORAGE" --resource-group "$RG" --query id --output tsv)
# Silinen ya da üzerine yazılan blob 14 gün geri getirilebilir; silinen container da öyle
az storage account blob-service-properties update --account-name "$STORAGE" --resource-group "$RG" \
--enable-delete-retention true --delete-retention-days 14 \
--enable-container-delete-retention true --container-delete-retention-days 14 \
--enable-last-access-tracking true
# Back office export'ları; covers 03-identity.sh ile, covers-thumbnails 06-functions.sh ile geldi
az storage container-rm create --storage-account "$STORAGE" --name exports
# Süresi 3 saatten uzun ya da başlangıç zamanı olmayan bir SAS'la gelen istekler reddedilir
az storage account update --name "$STORAGE" --resource-group "$RG" \
--sas-expiration-period 0.03:00:00 --sas-expiration-action Block
# Lifecycle kuralları platform tarafında çalışır; değişiklikten sonraki ilk çalıştırma 24 saati bulabilir
az storage account management-policy create --account-name "$STORAGE" --resource-group "$RG" \
--policy @infra/cli/07-lifecycle-policy.json
# generateUserDelegationKey hesap seviyesinde bir işlem: container scope'undaki veri rolü bunu veremez
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
--role "Storage Blob Delegator" --scope "$STORAGE_ID"
Tahmin sorusunun ilk cevabı son komutta. User delegation key'i almak için gereken generateUserDelegationKey işlemi hesap seviyesinde bir işlem; Learn'e göre bu izni veren rol hesap, resource group ya da subscription scope'unda atanmalı. Container scope'undaki "Storage Blob Data Contributor" bu yüzden key almaya yetmiyor. Hesap scope'unda yalnız bu işlemi içeren "Storage Blob Delegator" rolünü veriyoruz; veri izinleri yine container scope'unda kalıyor. Learn önemli bir kuralı daha söylüyor: user delegation SAS'ın verdiği izin, SAS'taki izinlerle key'i alan kimliğin RBAC (Role-Based Access Control; rol tabanlı yetkilendirme) izinlerinin kesişimi. API'nin yetkisi yalnız covers'ta olduğu için ürettiği bir SAS'la exports'taki bir dosya okunamaz.
İkinci cevap: user delegation SAS'ı tek tek iptal edemezsin. En hızlı yol, hesaptaki bütün user delegation key'leri geçersiz kılan Revoke User Delegation Keys işlemi (az storage account revoke-delegation-keys); bu, o key'lerle üretilmiş bütün SAS'ları da bozar. Azure key'leri ve rol atamalarını cache'te tuttuğu için iptal hemen etkili olmayabilir. Kimliğin rolünü geri almak da SAS'ları işlevsiz bırakır, çünkü izin kesişiminin bir tarafı boşalır. Sızan adresin zararını sınırlayan asıl şey kısa süre: bizim SAS'larımız en fazla iki saat geçerli.
Kapakları müşteriye yakın sunmak için önlerine Azure Front Door koyarsın. Premium plan, origin'e Private Link üzerinden bağlanabiliyor; böylece storage hesabının public erişimini kapatabilirsin. Front Door'un managed identity ile origin'e kimlik doğrulaması Kasım 2026'da hâlâ preview ve Learn'e göre Private Link'li origin'lerle birlikte kullanılamıyor. Eski Azure CDN Standard from Microsoft (classic) 30 Eylül 2027'de emekliye ayrılıyor ve yeni profil açılamıyor. Ayrıntılar Domain, DNS ve CDN serisinde. Maliyet tarafında egress önemli: Azure'dan internete çıkan trafiğin her ay ilk 100 GB'ı ücretsiz, sonrası Avrupa ve Kuzey Amerika için GB başına 0,087 dolar liste fiyatı. CDN'de cache'lenen her kapak bu çıkışı azaltır.
Üretimde Dikkat
Hesaba bir SAS expiration policy koy. Script'te üç saat sınırı var: süresi bundan uzun ya da başlangıç zamanı (st) olmayan bir SAS kullanıldığında Log eylemi bunu log'a yazar, Block isteği reddeder. Policy user delegation SAS'lar için de geçerli; bizim SAS'larımızın başlangıç zamanı ve iki saat on beş dakikalık aralığı buna uyuyor. SAS üretimi Azure'da denetlenemez, ama kullanımı storage log'larında görünür.
Yükleme endpoint'ini izle. Reddedilen dosyaların sayısı ve türü, biri sisteme resim dışında bir şey yüklemeye çalışıyorsa ilk sinyaldir. 6. yazıdaki function yine de resim olmayan bir şey görürse poison kuyruğuna düşürüyor; bu kuyruğun boş kaldığını kontrol et.
Sık Düşülen Tuzaklar
Account key'i app setting'e koymak. Key, hesaptaki her şeye süresiz erişim verir ve kim kullandığını söylemez. Shared key'i kapatıp yalnız Entra ID ile çalışmak, bu riski tamamen ortadan kaldırır.
Archive'ı ucuz yedek sanmak. Saklama ucuz ama okuma saatler sürer ve 180 gün dolmadan silinen ya da taşınan blob için kalan sürenin ücreti ödenir. Ara sıra okunacak veri için cold daha doğru bir seçim.
İstemcinin dosya adını blob adı yapmak. ../../invoice.pdf gibi adlar, aynı adla üzerine yazma ve tahmin edilebilir adresler bu yoldan gelir.
Egress'i unutmak. Storage'ın GB başına saklama ücreti küçük görünür; popüler bir kapağın milyonlarca kez doğrudan storage'dan indirilmesi ise fatura kalemini değiştirir. Önünde bir CDN ve uzun cache süresi bu yüzden önemli.
Küçük Bir Deneme
★☆☆ CoverImage.Detect'e GIF (animasyonu da destekleyen eski bir resim biçimi) desteği ekle (GIF87a ve GIF89a imzaları) ve testini yaz. Sonra GIF'i kabul etmenin neden iyi bir fikir olmayabileceğini düşün: 6. yazıdaki function bu dosyayla ne yapar?
★★☆ CoverUrlSigner'ı, SAS süresi ve key süresi configuration'dan gelecek şekilde değiştir. SAS süresi key süresinden uzun verilirse ne olmalı? Bir test yazıp kararını kodla.
★★★ Yeni bir kapak yüklendiğinde eski kapağın 6. yazıda üretilen küçük resmi covers-thumbnails'ta kalıyor. Bunu temizlemenin iki yolunu karşılaştır: API'de silmek ya da lifecycle policy ile. Hangisinin hangi durumda doğru olduğunu yaz ve birini uygula.
Bir sonraki yazıda BookstoreDb'ye geçiyoruz: Azure SQL Database, Managed Instance, PostgreSQL Flexible Server ve Cosmos DB (Azure'un dağıtık NoSQL veritabanı) arasındaki seçim, otomatik yedekler ve point-in-time restore, bacpac ile taşıma ve Entity Framework Core'da bağlantı dayanıklılığı.