एक bucket के मालिक दो हों, तो आखिरी apply करने वाला जीतता है
S3 lifecycle का एक rule जोड़ते ही terraform apply fail हो गया। वजह थी वह structure जिसमें एक ही bucket की lifecycle configuration के मालिक दो resources थे। S3 lifecycle rule स्तर पर नहीं, document स्तर पर चलता है, इसलिए आखिरी विजेता दूसरे के rules चुपचाप मिटा देता है।
मुख्य सारांश
presign तो हुआ पर commit नहीं हुए upload objects को वापस पाने के लिए tmp/ prefix और lifecycle expiration rule डिज़ाइन किया। rule merge करते ही staging apply fail हुआ, और वजह निकली यह कि module और environment configuration एक ही physical bucket की lifecycle के अलग-अलग मालिक थे। S3 का lifecycle API document स्तर पर पूरा replace करता है, इसलिए दो मालिक हर apply पर एक-दूसरे के rules overwrite करते हैं। module वाले resource को removed block (destroy=false) से सिर्फ state में भुलवाकर single owner बनाया। document स्तर के resource का मालिक एक ही होना चाहिए।
इस पेज पर
शुरुआत एक मामूली सफाई की समस्या से हुई। उपयोगकर्ता जिस रास्ते से media files (recording, image) अपलोड करते हैं, वह presigned URL वाला तरीका है। server upload के लिए URL जारी करता है, client सीधे S3 पर फ़ाइल चढ़ाता है, फिर commit API call करके बताता है कि "इस key को औपचारिक asset के रूप में दर्ज कर लो"। दिक्कत तब है जब presign तो मिल गया पर commit कभी नहीं आया। app बीच में crash हो जाए, network कट जाए, या user screen छोड़ दे, तो bucket में एक ऐसा object रह जाता है जो कहीं भी दर्ज नहीं है।
मैं इसे lifecycle rule से साफ करना चाहता था, पर rule लगा ही नहीं सकता था। commit हो चुके और न हुए objects एक ही prefix में घुले-मिले थे, इसलिए "पुराना है तो मिटा दो" कहने वाला rule औपचारिक assets को भी मिटा देता। daily upload quota बढ़ोतरी को रोके हुए तो था, पर recovery का कोई रास्ता नहीं है, यह तथ्य जस का तस था।
डिज़ाइन: जो commit नहीं हुआ, वह tmp में रहता है
समाधान की रीढ़ है key namespace का बँटवारा।
- presign
tmp/prefix के साथ अस्थायी key जारी करता है। - commit जब validation (HEAD से existence check, Content-Type, size की ऊपरी सीमा) पार कर लेता है, तो CopyObject से object को औपचारिक key पर promote करता है और tmp वाला original मिटा देता है।
- जिस object का commit आखिर तक नहीं आया, वह
tmp/में पड़ा रहता है और lifecycle rule उसे 7 दिन बाद expire कर देता है।
इस तरह lifecycle को सिर्फ tmp/ देखना है। औपचारिक assets शुरू से ही rule की पहुंच से बाहर हैं। client बदलने की जरूरत नहीं पड़ी। तीनों upload flows पूरे पढ़कर पुष्टि की, सब commit response में आई key को ही आगे के calls में इस्तेमाल करते हैं, इसलिए server key का रूप बदल दे तो भी उन्हें पता नहीं चलता। deploy के वक्त हवा में तैर रही पुरानी शक्ल वाली keys का commit भी पुराने रास्ते से वैसे ही process होता है।
यहां एक जाल है। इस bucket पर versioning चालू है। versioned bucket में expiration object को मिटाता नहीं, बस delete marker चढ़ा देता है, और असली bytes noncurrent version बनकर पड़े रहते हैं। noncurrent_version_expiration (1 दिन) को जोड़ी में न लगाएं तो सफाई करते रहिए, storage एक byte भी नहीं घटता।
design review भी काम आया। promotion logic पर review बिठाया तो तीन पक्के defects निकले। admin के custom key path से tmp/ key को final key के तौर पर commit कर दें तो lifecycle live asset को उड़ा देगा वाला छेद (shared commit boundary पर tmp final key block की), promotion का sourceKey एक arbitrary copy primitive बन जाने की समस्या (tmp और final key की बराबरी enforce की), और एक ही tmp key का एक साथ commit हो तो एक तरफ का copy 404 से मर जाता है वाली idempotency समस्या। तीनों को code में ठीक करके आगे बढ़ा।
और फिर apply fail हो गया
lifecycle rule को terraform में जोड़कर merge किया। staging apply fail हो गया।
error था lifecycle configuration write का timeout, पर खोदने पर निकला कि timeout तो लक्षण है, बीमारी structure में है। इस bucket की lifecycle के मालिक दो terraform resources थे। एक image resize module के अंदर था (module अपने काम के rules खुद लगाता था), दूसरा environment-wise bucket configuration file में (जहां इस बार tmp rule जोड़ा गया)। एक ही physical bucket, मालिक दो।
S3 के lifecycle API में "एक rule जोड़ना" नाम की कोई चीज़ नहीं है। PutBucketLifecycleConfiguration पूरा document बदल देता है। terraform का aws_s3_bucket_lifecycle_configuration भी उसी पर खड़ा है, इसलिए यह resource अपनी जानकारी के rules से bucket का पूरा lifecycle document overwrite करता है। मालिक दो हों तो हर एक सिर्फ अपने rules वाला document एक-दूसरे पर ठेलता है। एक ही apply में दोनों मिलें तो concurrent write से timeout आता है, और अलग-अलग मिलें तो और बुरा। बिना किसी error के success दिखाते हुए आखिरी विजेता दूसरे के rules मिटा देता है।
इस पूरे किस्से का सबसे सिहरा देने वाला हिस्सा यही है। timeout तो शोर मचाने वाली विफलता थी, एक तरह से गनीमत। यह structure जिंदा रह जाता, तो अगली बार image resize module को छूने वाला कोई भी apply अभी-अभी डाला गया tmp expiration rule चुपचाप मिटा देता। plan में वह बस उस bucket resource के in-place update की एक पंक्ति भर दिखता, और uncommitted objects फिर हमेशा के लिए जमा होने लगते। सफाई का rule गायब हो गया है, यह पकड़ने वाली कोई monitoring कहीं नहीं थी।
दरअसल इस बिंदु पर code review के दौर में एक बार उंगली उठ भी चुकी थी, "लगता है दो resources एक ही bucket को छू रहे हैं" वाले शक के रूप में। तब उसे potential risk मानकर रख दिया गया, पर merge के फ़ौरन बाद apply fail हुआ और potential को reality बनने में चंद मिनट भी नहीं लगे।
समाधान: मालिक एक ही हो
सुधार की दिशा साफ थी। per bucket lifecycle का मालिक एक ही होना चाहिए। module से lifecycle resource हटाया, environment configuration file को single owner बनाया और module जो rules लगाता था उन्हें भी उधर ही ले गया।
यहां जो औज़ार काम आया वह है terraform का removed block। module से resource declaration यूं ही मिटा दें तो terraform असली lifecycle configuration को destroy करने दौड़ता है। यानी अभी live चल रहे rules को मिटाकर दोबारा बनाने की एक window खुल जाती। removed block में destroy = false दें तो terraform उस resource को सिर्फ state में भूलता है। असल चीज़ जस की तस रहती है, सिर्फ बही-खाते से नाम कटता है, और ownership बिना downtime के environment configuration की तरफ चली जाती है।
सुधार के बाद staging apply पास हुआ, prod तक पहुंचाने के बाद AWS API से असली bucket state खुद जाकर देखी। दोनों buckets में tmp expiration rule जिंदा था और पहले के तीनों rules भी सही-सलामत। सिर्फ terraform plan देखकर क्यों नहीं रुका, यह इसी घटना ने सिखाया था। plan हर मालिक का अपना बही-खाता भर है, और document overwrite करने वाले structure में दो बही-खाते एक-दूसरे को जानते ही नहीं।
जो सीख बची
document स्तर के resource का मालिक एक ही होना चाहिए। बात सिर्फ S3 lifecycle की नहीं है। bucket policy, CORS configuration, notification configuration जैसे "पूरा बदल दो" वाले API पर खड़े सारे terraform resources का यही स्वभाव है। एक ही target की ओर इशारा करती declarations module और environment configuration में एक-एक हों, तो compile error की जगह runtime का चुपचाप overwrite मिलता है। नया rule जोड़ने से पहले यह खोजना चाहिए कि उस document का कोई मालिक पहले से तो नहीं बैठा है।
एक और बात। resource declaration मिटाना और असल चीज़ मिटाना दो अलग काम हैं, और terraform यह फ़र्क removed block से जताता है। मकसद ownership transfer हो, तो destroy से गुज़रते ही zero-downtime टूट जाता है।
आखिर में, recovery का रास्ता key design से शुरू होता है। "commit हुआ और नहीं हुआ" जैसे अलग-अलग उम्र वाले data एक ही namespace में रहें, तो कोई भी सफाई का rule सुरक्षित तरीके से नहीं लग सकता। उम्र अलग है तो पहले रहने की जगह अलग करनी चाहिए। वैसे यह तरीका पहले से जमा हो चुके uncommitted objects को पीछे जाकर वापस नहीं ला पाता। उन objects को औपचारिक assets से अलग पहचानने का कोई तरीका ही नहीं (यही तो इस पूरी समस्या का शुरुआती बिंदु था), इसलिए जानबूझकर नई uploads से लागू होने का दायरा चुना।
अक्सर पूछे जाने वाले सवाल
terraform apply के बाद S3 lifecycle rules गायब हो जाते हैं। ऐसा क्यों होता है?
aws_s3_bucket_lifecycle_configuration वह resource है जो bucket के पूरे lifecycle configuration document का मालिक होता है। एक ही bucket की ओर इशारा करने वाले ऐसे दो resources (जैसे एक module में और एक environment configuration में) हों, तो हर एक सिर्फ अपनी जानकारी के rules से पूरा document replace करता है, इसलिए जो पक्ष आखिर में apply होता है वह दूसरे के rules मिटा देता है। दोनों एक ही apply में हों तो concurrent write से timeout भी आ सकता है। per bucket owning resource एक ही रखें।
versioned S3 bucket पर lifecycle expiration लगाया, फिर भी storage नहीं घट रहा।
versioned bucket में expiration object को मिटाता नहीं, बल्कि delete marker चढ़ाकर मौजूदा version को noncurrent बना देता है। साथ में noncurrent_version_expiration न लगाएं तो bytes कभी वापस नहीं मिलते। expiration rule और noncurrent version expiration को एक जोड़ी के रूप में डिज़ाइन करना चाहिए।
संबंधित लेख
- 💻 डेवलपमेंट
न ली गई छूट - Spot और Graviton सस्ते क्यों हैं
AWS का बिल लगातार दो महीने बजट से ऊपर गया। घटाने लायक संसाधन ढूंढते हुए पता चला कि असली लीवर 'कितना इस्तेमाल करते हैं' नहीं, 'कैसे ख़रीदते हैं' था। Spot और Graviton सस्ते क्यों हैं, और फ़िज़ूलख़र्ची जैसा दिखने पर भी बंद न करने वाला ख़र्च (RDS Proxy) - बिल के हर आंकड़े में छिपी 'वजह' की कहानी।
- 💻 डेवलपमेंट
Test हरे थे, पर notification एक बार भी नहीं गया था
Queue में job डालने वाला code पहले दिन से हर बार fail हो रहा था। error निगल लिया गया, और mock की बदौलत test हरे रहते थे। एक colon ने तीन feature चुपचाप मार दिए, यह उसी की कहानी है।
- 💻 डेवलपमेंट
अलर्ट आया कि DB मर गया। DB कभी मरा ही नहीं था
एक ही दिन में चार CRITICAL अलर्ट जमा हो गए। 504, Prisma P2028, एडमिन 500, और 'DATABASE सर्विस डाउन'। सबसे पहले RDS मेट्रिक खोले तो 21 घंटे तक CPU अधिकतम 19.7% पर बिल्कुल ठीक चल रहा था। धीमा DB नहीं था, धीमी थी वह राउंड ट्रिप जो एक रिक्वेस्ट में पच्चीस बार अटलांटिक पार करती थी। और 'डाउन' वाला अलर्ट हेल्थ चेक ने ख़ुद ही पैदा किया था।