Dağıtılmış Sistemlerde Dayanıklılık Nasıl Sağlanır? — Dayanıklılık Pattern’ları Üzerine
“Ability of system to provide acceptable behaviour even when one or more parts of the system fail”
Sistemin bir veya daha fazla parçası arızalandığında bile sistemin kabul edilebilir davranış sağlama yeteneğidir dayanıklılık.
Dağıtılmış sistemlerle çalışırken her zaman “anything could happen” bir numaralı kuralını hatırlamak gerekir. Ağ sorunları, hizmetin erişilebilir olmaması, uygulama yavaşlığı vb. sorunlarla karşılaşabiliriz. Bir sistemdeki sorun başka bir sistemin davranışını/performansını da etkileyebilir. Bu tür beklenmedik arızalarla/ağ sorunlarıyla uğraşmak ve çözebilmek zor olabilir. Sistemin bu tür arızalardan kurtulabilmesi ve işlevsel kalabilmesi sistemi daha “dayanıklı” hale getirir. Ayrıca, birbirine bağlı hizmetlerde art arda gelen arızaları da önler.
Dayanıklı uygulamalar oluşturmak ve çalıştırmak zordur, uygulamanızın dayanıklılığını arttırmak ise devam eden bir yolculuktur. Dikkatli bir planlamayla, uygulamanızın hatalara dayanma yeteneğini geliştirebilirsiniz. Uygun süreçler ve kurum kültürüyle, uygulamanızın dayanıklılığını daha da arttırmak için başarısızlıklardan da ders çıkarabilirsiniz.
Dayanıklılık, mimarinizin tüm düzeylerinde planlama gerektirir. Altyapınızı ve ağınızı nasıl düzenlediğinizi, uygulamanızı ve veri depolamanızı nasıl tasarladığınızı etkiler.
Bir önceki yazımızda dayanıklı sistemler/mimariler kurmamızı sağlayacak başlıca yaklaşımlardan söz etmiştik. Bu yazımızda ise aynı amaca hizmet etmesi için faydalanabileceğimiz başlıca pattern’lardan söz edeceğiz.
Resiliency Patterns:
1. Timeouts
Amaç: Servislerimizi tasarlarken network üzerinden yapılacak herhangi bir erişim için bir zaman aşımı belirleyerek hizmet yavaşlığı/kullanılamazlık sorunlarını da dikkate almak gerekir. Böylece, bağımlı hizmetler erişilebilir olmadığında bile temel hizmetlerin beklendiği gibi çalışmasını ve yanıt vermesini sağlayabiliriz.
Timeout kullanmanın başlıca avantajları:
a. Bağımlı hizmetler mevcut olmadığında bile temel hizmetlerin her zaman çalışmasını sağlamak.
b. Sonsuza kadar beklemeleri önlemek.
c. Thread’leri bloke etmemek.
d. Herhangi bir ileti/çağrım dizisini engellememek.
e. Network (Ağ) ile ilgili sorunları ele almak ve bazı cache’e (Önbelleğe) alınmış yanıtları kullanarak sistemin çalışır durumda kalmasını sağlamak.
Nerelerde Timeout Vermeliyiz?
Connect Timeout: HTTP, DB (JDBC,ODBC), TCP, JMS, FTP, CTG vb. kullandığımız tüm bağlantı protokollerinde “connection” açarken beklediğimiz süreyi sınırlandırmalıyız.
Response/Read Timeout: HTTP,DB (JDBC,ODBC), TCP,JMS,FTP,CTG vb. kullandığımız tüm bağlantı protokollerinde çağırdığımız ya da bağlandığımız servise/sisteme request’imizi yaptıktan sonra cevabını beklediğimiz süredir. Bu süreyi set etmezsek sonsuza dek uygulamamız cevap bekler durumda kalabilir.
Bağlandığımız sisteme ya da kullandığımız protokole göre parametre adı değişiklik gösterebilir. Örneğin; database‘e bağlanıp SQL query çalıştırıyorsak, “query timeout” parametresi ile cevabı bekleme süremizi belirleriz.
Önemli nokta olarak zincirleme servis çağrımlarında response bekleme süreleri dıştan içeriye doğru mantıklı bir marj içerisinde azalarak gitmeli ve işlemin en uzun sürdüğü katmandaki/sistemdeki süreye de dikkat edilmelidir.
Mainframe CTG Idle Timeout: CTG üzerinden başlatılan Trx’ının start ve commit‘i arasında geçen süreyi sınırlandırmak için kullanılır.
Connection Pool Wait Timeout: Connection Pool’dan uygun bir connection çekebilmek için beklediğimiz süredir.
Queue/Topic Receive Timeout: Queue ya da topic üzerinden mesajı tüketmek/okuyabilmek için beklediğimiz süredir. Yani aslında bir tür “response/read timeout” parametresidir. JMS spesifikasyonundaki Consumer.receive() metodu üzerinden kullanılır. Bu süre içerisinde mesajı alamıyorsak uygun şekilde timeout durumunu yönetmeliyiz.
Queue/Topic Message Expiration Time/TimeToLive: Queue/topic üzerinden mesajlaşan uygulamalarda, mesajın consumer/receiver tarafından işleme alınması için set edilen geçerlilik süresidir. Bu süreyi aşan mesajlar otomatik olarak queue manager tarafından queue’dan silinir ve undelivered/dead letter queue’lara atılabilir.
JMS spesifikasyonundan gelen temel bir parametredir. Genel adı “time-to-live” olarak geçen bu parametre ms cinsinden değer içerek şekilde set edilir ve JMS Header’daki JMSExpiration değerinin belirlenmesinde kullanılır. JMSExpiration=mesajın gönderildiği ana ait GMT+Time to Live olarak hesaplanır. Parametre JMS spesifikasyonundaki Producer.send() metodu üzerinden kullanılır.
Time to Live <= jms receive timeout olması önerilir.
2. Throttling/Rate Limiting:
Amaç:
Sunduğumuz API ya da servislerimize aynı anda çağrım ve çalışma sınırı yani kota/rate limit koymak, sistemlerin yoğunluk altındayken belli bir servis/API bazlı yaşanan sorunlardan dolayı ya da tek bir istemciden gelen çok sayıda istek nedeni ile cevap veremez hale gelmeden ayakta kalabilmesini ve işlevini sürdürebilmesini sağlayacaktır. Özellikle dışarıya API‘lerimizi sunduğumuz katmanlarda (DDOS ataklarını da önleyecektir) ve yoğun işlem hacmine sahip katmanlarımızda API ve servis bazlı kotaları tanımlamamız gereklidir.
a. Verdiğimiz kota değerlerine ulaşıldığında yeni istekler reject edilir.
b. Client IP ya da Client ID bazlı anlık request sayısı sınırlanabilir (100 rps gibi).
c. Global yani server üzerindeki toplam request sayısı bazlı verilebilir (1000 rps gibi).
3. Load Shedding/Back Pressure:
Amaç:
Bu yöntem de temelde rate limit ile aynı amaca hizmet eder. Fakat sistemin genel kaynak durumunu yani CPU, RAM, thread usage, latency gibi parametreleri baz alarak aşırı yük koşulları oluştuğunda yeni gelen isteklerin reject edilmesini ve sistemin ayakta kalmasını sağlar.
Tradeoff: Bazı müşteriler/işlemler reject edilir.
4. Circuit Breaker:
Amaç:
Bu pattern, uygulamanın bir remote servisi çağırmaya veya paylaşılan bir kaynağa erişmeye çalışmasını, bu işlemin başarısız olma olasılığı yüksekse engelleyebilmek ve sistemde başarısız olacak çağrımların tekrar etmesi sonucu oluşacak bloklanma, hizmet kesintileri, aşırı kaynak kullanımı gibi sorunlarla karşılaşılmasını önleyerek, sistemin sağlıklı çalışmasını amaçlar. Fail-fast-move-on yaklaşımını destekleyen bir pattern’dir.
Circuit breaker desenini ve sahip olduğu state/durum şemasını biraz daha detaylı açıklamaya çalışalım. Sistemimizde ardışık olarak birbirine request gönderen bir dizi servisimiz olur. Bu dizideki servislerin down olma ihtimali vardır ve eğer herhangi biri request’lere cevap veremezse bu bütün akışı aksatıp bir dizi ardışık hataya sebep olabilir. Eğer bu cevap dönemeyen request’i yapan çok fazla caller/tüketici olursa bu da kaynak tüketimini kritik düzeyde arttırıp birçok servisi veya bütün sistemi etkileyecek bir dizi ciddi soruna yol açabilir.
Circuit breaker bu gibi durumlarda bir devre anahtarı gibi davranarak belli bir threshold’un (Eşik değeri) üstünde hata alındığında, response dönemeyen servise gelen request’leri servise iletmeden (open_state), request’lere bir hata mesajı veya bilgi verici mesajla dönüş yaparak sistemin gereksiz yere aşırı yüklenmesini engeller. Belli bir timeout’tan sonra gelen request’lerin bir kısmını servise ileterek, test yapıp sistem durumunu kontrol eder (half_open state). Eğer sıkıntı giderilmiş ise bütün isteklerin geçmesine izin verir (closed state) ve sistem düzeni korunmuş olur. Eğer test çağrıları da hata mesajı alıyorsa, yine request’lere error dönüp bu döngüyü devam ettirir.
Doğru Eşik Kriterlerinin Belirlenmesi:
Eşik değeri diye bahsettiğimiz kriter aslında sadece istek sayısından oluşan bir kavram değildir. Circuit breaker pattern’i için kullanılabilecek bazı yaygın eşik kriterleri:
a Sunucu zaman aşımı (Timeout)
b Hata sayısı
c Beklenmeyen response kodları
d Aşırı kaynak tüketimi
Örneğin, kullanıcı bilgileri üzerinden işlem yapan bir servis için işlem süresi daha önemliyken, ödeme sistemiyle ilgili bir serviste ise hata sayısı eşik kriterlerinden en önemlisi olabilir.
5. Retry & Exponential backoff & Jitter:
Amaç:
Servis çağrımlarımızın başarısız olması durumunda tekrar denenmesine uygun senaryolarımız için retry/yeniden deneme şablonunu kullanabilir ve tekrar çağrım yaparak işlemin geçici sorunlardan dolayı başarısız olmasını engelleyebiliriz.
Bu şablonu kullanırken dikkat edilmesi gereken en önemli parametreler maksimum kaç kez tekrar deneme yapılabileceği ve denemeler arasındaki bekleme süresidir.
Exponential Backoff ve Jitter pattern’ları artan aralıklı ve rastgele aralıklı yeniden denemelerle cevap veremeyen servisin toparlanmasına imkan tanımak amacıyla kullanılabilecek olan pattern’lerdir.
Maksimum deneme sayısı, Exponential backoff ve Jitter gibi policy’leri kullanmadan yapılan yeniden denemeler, hizmet sorunlarını şiddetlendirebilir. Bir hizmetin aşırı yüklendiğini veya sorun yaşadığını düşünelim. İstemciler maksimum deneme sayısı, Exponential Backoff ve Jitter ile yapılandırılmamışsa, birden çok istemci hizmet hatasıyla karşılaştığında hemen yeniden hizmeti çağırmayı deneyeceklerdir. Yüzlerce veya binlerce istemci, bir hizmet tam kapasiteye ulaşmadan önce belirli bir sıklıkta yeniden denediğinde de hizmetin hiçbir zaman kurtarılamayacağı bir durum oluşturabilir. Bunu önlemek için kullanılan bir dizi farklı yeniden deneme politikası vardır; ancak Exponential Backoff bunlardan en popüleri olabilir.
a Exponential Backoff (Üstel Geri Çekme): Bu politika, yapılacak örneğin maksimum 3 adet denemenin, çağrılan servisten hata/timeout alındıkça 2 sn., 4 sn. ve 8 sn. olarak giderek artacak aralıklarla yapılmasını sağlar.
b Jitter: Exponential Backoff politikasına ek olarak, yeniden denemeler bazı rastgeleliklerle, yani Jitter ile birleştirilmelidir. Jitter, her işleme rastgele bir süre ekleyerek birden çok istemcinin tam olarak aynı sıklıkta yeniden denemesini engeller.
6. Bulkhead:
Amaç: Bir hatanın/sorunun tüm sistemi batırmamasını sağlamaktır. Bunun için uygulama birden fazla bileşene bölünmeli ve kaynaklar bir bileşenin arızalanması diğerini etkilemeyecek şekilde izole edilmelidir.
Bulkhead (Bölme perdesi/duvarı), somut uygulanabilir kaynak limitleri yoluyla izolasyon sağlar. Bölmeler yazılımın her yerindedir. Semaforlar, WorkerPool, ConnectionPool, Servis/Süreç yalıtımı ve ağ yalıtımı bölmelere örnektir.
Aşağıdaki diyagramda, hizmetleri tek tek çağıran bağlantı havuzları temelinde yapılandırılmış bölme perdeleri gösterilmektedir. A hizmeti başarısız olursa veya başka bir soruna neden olursa bağlantı havuzu ayrılır, böylece yalnızca A hizmetine atanmış iş parçacığı havuzlarını kullanan iş yükleri etkilenir. B ve C hizmetini kullanan iş yükleri etkilenmez ve kesinti olmadan çalışmaya devam eder.
Bir sonraki diyagramda tek bir hizmeti çağıran birden çok istemci gösterilmektedir. Her istemciye ayrı bir hizmet örneği atanır. 1. istemci çok fazla istekte bulunmuş ve kendi örneğini sonuna kadar doldurmuştur. Her bir hizmet örneği diğerlerinden ayrılmış olduğu için diğer istemciler çağrı yapmaya devam edebilir.
Avantajlar:
a Tüketicileri ve hizmetleri zincirleme hatalara karşı yalıtır. Bir tüketiciyi veya hizmeti etkileyen bir sorun, kendi bölmesi içinde izole edilerek tüm çözümün başarısız olması önlenebilir.
b Bir hizmet hatası durumunda uygulamanın diğer hizmetleri ve özellikleri çalışmaya devam eder.
c Uygulamaların kullanımına yönelik farklı bir hizmet kalitesi sunan hizmetler dağıtmanızı sağlar. Yüksek öncelikli bir tüketici havuzu, yüksek öncelikli hizmetleri kullanacak şekilde yapılandırılabilir.
Dezavantajlar:
a İş parçacıklarını hizmetlere atamak için işi ve iş hacmini iyi bilmeniz gerekir, bu da karmaşıklığı artırır.
b Hizmet kaynağı tahsisleri dinamik değildir. Bir hizmetin verimi düşük olduğunda, kaynakları boşta kalır. Kaynak gerektiren diğer hizmetler bu hizmetin kaynaklarını kullanamaz.
Bir sonraki yazımızda görüşmek üzere…