بخش دوم: DNS و Service Discovery در Kubernetes
در این بخش مسیر DNS داخل Kubernetes بررسی میشود؛ از Service DNS record و CoreDNS تا search domain، ndots، caching و timeout. هدف این است که بفهمیم قبل از رسیدن request به Service، name resolution چه نقشی دارد و چرا خیلی از incidentهای شبکه در production از همین نقطه شروع میشوند.
DNS و Service Discovery در Kubernetes
در بخش اول یک تلاش کردیم یک مدل ذهنی بسازیم و گفتیم وقتی یک پاد نام یک Service را صدا میزند، اولین اتفاق مهم resolve شدن آن نام است. آنجا عمداً وارد جزئیات DNS نشدیم و فقط گفتیم «DNS نام را به آدرس مناسب تبدیل میکند». حالا وقتش رسیده همین جمله ساده را باز کنیم.
در عمل، خیلی از خطاهایی که اول شبیه «مشکل شبکه» یا «مشکل Service» به نظر میرسند، از همین مرحلهname resolution شروع شدهاند. ریکوئست هنوز به Service نرسیده، هنوز NAT انجام نشده، هنوز بک اندی انتخاب نشده، اما اپلیکیشن از همین نقطه کند شده یا ارور گرفته است.
اینجا هم قرار نیست کل دنیای DNS و CoreDNS رو زیر و رو کنیم. تمرکز روی همان بخشی است که در مسیر واقعی یک request قرار میگیرد: نام چطور ساخته میشود، چطور resolve میشود، چه چیزی آن را cache میکند، و معمولاً کجاها خراب میشود.
چرا DNS نقطه شروع خیلی از incidentهاست؟
یک جمله معروف هست: «It’s always DNS.» شوخی است، ولی خب بیراه هم نیست.
دلیلش ساده است. DNS تقریباً اولین مرحله در مسیر بیشتر کانکشنهای داخل کلاستر است. وقتی اپلیکیشن بهجای آیپی از نام استفاده میکند — که در کوبرنتیز تقریباً همیشه همینطور است — قبل از هر چیز باید آن نام به آدرس ای پی تبدیل شود. اگر این مرحله کند باشد، کل کانکشن کند میشود. اگر fail شود، اپلیکیشن اصلاً به مرحله بعدی نمیرسد.
مشکل اینجاست که خطای DNS همیشه خودش را با اسم DNS نشان نمیدهد. اپلیکیشن ممکن است فقط بگوید connection timed out یا could not connect to host. بعد میرویم سراغ Service، EndpointSlice، NetworkPolicy، firewall و هر چیز دیگری. در حالی که مشکل اصلی این بوده که نام اصلاً resolve نشده، یا با تأخیر resolve شده است.(دومی عموما گمراه کننده تر است)
برای همین برای DNS در این Learning Path یک بخش جداگانه نوشتم. اگر این مرحله را خوب بشناسیم، موقع incident خیلی سریعتر میتوانیم آن را بررسی کنیم؛ یا DNS را از لیست متهمها حذف میکنیم، یا همان اول میفهمیم مشکل از همین لایه است.
آشنایی با CoreDNS؛ DNS server پیشفرض کوبرنتیز
در کلاسترهای امروزی، DNS داخلی معمولاً با CoreDNS سرو میشود.
CoreDNS یک DNS server قابلتوسعه و plugin-based است. مدتهاست جای kube-dns قدیمی را گرفته و حالا add-on پیشفرض DNS در کوبرنتیز است. در یک cluster استاندارد، CoreDNS معمولاً به شکل یک Deployment داخل نیم اسپیس kube-system اجرا میشود، اغلب با دو replica یا بیشتر، و جلوی آن یک Service از نوع ClusterIP قرار دارد.
اسم این Service معمولاً هنوز kube-dns است؛ با اینکه هنوز پشت آن CoreDNS اجرا میشود. این اسم برای backward compatibility نگه داشته شده و گاهی آدم را گیج میکند. پس اگر Serviceای به اسم kube-dns دیدیم ولی پادها CoreDNS بودند، همهچیز طبیعی است.
root@pedram:~# kubectl get svc -n kube-system | grep -i kube-dns
kube-dns ClusterIP 10.96.0.10 <none> 53/UDP,53/TCP,9153/TCP 24d
و چون DNS هم پشت ClusterIP است، تمام چیزهایی که در بخش اول درباره Service و data plane گفتیم، برای ترافیک مربوط به DNS هم صدق میکند. این نکته بعداً وقتی به conntrack و timeout برسیم، خیلی مهم میشود.
برای دیدن config مربوط به CoreDNS در بیشتر کلاسترها میتوانیم از این command شروع کنیم:
root@pedram:~# kubectl -n kube-system get configmap coredns -o yaml
این ConfigMap چیزی به اسم Corefile دارد؛ همان فایل کانفیگی که رفتار CoreDNS را مشخص میکند. جلوتر به بخشهای مهمش برمیگردیم.
تنظیم DNS داخل Pod
قبل از اینکه اپلیکیشن ها بتواند از DNS استفاده کند، باید بداند DNS server کجاست. این کار را kubelet انجام میدهد.
وقتی یک پاد ساخته میشود، kubelet فایل /etc/resolv.conf داخل آن پاد را طوری تنظیم میکند که به DNS داخلی کلاستر اشاره کند. یعنی اپلیکیشن لازم نیست خودش بداند CoreDNS کجا اجرا میشود؛ kubelet این آدرس را از قبل برایش مینویسد.
یک نمونه معمول از resolv.conf داخل پاد چیزی شبیه این است:
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
این سه خط، عملاً رفتار DNS داخل پاد را میسازند:
- خط nameserver همان ClusterIP مربوط به DNS Service است. اگر CoreDNS قطع باشد یا این آدرس reachable نباشد, name resolution عملاً از کار میافتد.
- خط search لیست search domainهایی است که به نامهای کوتاه اضافه میشوند. کمی جلوتر دقیقتر میبینیم چطور.
- خط options ndots:5 تعیین میکند resolver چه زمانی یک نام را با search domain امتحان کند و چه زمانی آن را کامل فرض کند. همین گزینه یکی از پرتکرارترین علتهای مشکل performance در DNS است.
اینکه دقیقاً چه چیزی داخل resolv.conf نوشته شود، به dnsPolicy و dnsConfig همان پاد بستگی دارد. به این دو مورد جلوتر میرسیم. فعلاً فقط این را در ذهن داشته باشیم خوبه که: تنظیم DNS داخل پاد کار kubelet است.
انواع DNS record در Kubernetes
حالا ببینیم کوبرنتیز چه recordهایی میسازد. این قسمت برای فهم Service Discovery مهم است.
کوبرنتیز برای Serviceها و در بعضی شرایط برای پادها، DNS record میسازد. چیزی که باید از همین اول روشن باشد این است که رفتار DNS برای همه Serviceها یکسان نیست.
رکورد A و AAAA برای Serviceهای معمولی
یک Service معمولی، یعنی Serviceای که ClusterIP دارد و headless نیست، یک رکورد A و/یا AAAA میگیرد. اینکه A باشد یا AAAA یا هر دو، به IP family آن Service بستگی دارد؛ IPv4، IPv6 یا dual-stack.
نام این record معمولاً چنین شکلی دارد:
my-svc.my-namespace.svc.cluster-domain.example
در بیشتر کلاسترها، cluster domain همان cluster.local است. پس در عمل معمولاً با چیزی شبیه این سروکار داریم:
my-svc.my-namespace.svc.cluster.local
این نام به ClusterIP همان Service ریزالو میشود، نه به ای پی پاد.
این همان حرف بخش اول است. DNS برای یک Service معمولی فقط نام را به virtual IP مربوط به Service تبدیل میکند. DNS بک اند واقعی را انتخاب نمیکند. انتخاب بک اند بعداً در data plane اتفاق میافتد؛ مثلاً توسط kube-proxy یا جایگزین آن. DNS فقط یک ورودی پایدار به کلاینت میدهد.
رکورد SRV وقتی پورت Service اسم دارد
برای پورت هایی که در Service اسمدار هستند، یعنی named port دارند، کوبرنتیز رکورد SRV هم میسازد. این مورد در بیشتر اپ های روزمره زیاد جلوی چشم نیست، اما برای بعضی پروتکلها و سیستمهایی که service discovery مبتنی بر SRV دارند مهم میشود.
شکل کلی این record اینطوریه:
_port-name._protocol.my-svc.my-namespace.svc.cluster.local
headless Service؛ وقتی DNS مستقیم Pod IP میدهد
اینجا یک تفاوت مهم داریم که حتماً باید درست جا بیفتد. یک headless Service یعنی Serviceای که ClusterIP ندارد. در manifest، فیلد clusterIP: None گذاشته میشود. برای Service از این مدل DNS رفتار متفاوتی دارد.
برخلاف Service معمولی که نامش به یک ClusterIP واحد resolve میشود، نام headless Service به مجموعهای از ای پی های پادهای پشت آن Service ریزالو میشود. یعنی DNS بهجای virtual IP، مستقیماً ای پی های واقعی را برمیگرداند. از اینجا به بعد کلاینت باید خودش با این مجموعه کار کند؛ مثلاً یکی را انتخاب کند، round-robin ساده انجام دهد، یا منطق خودش را داشته باشد.( یا اصلا به ما چه)
پس مدل ذهنی اینطوری میشود:
- در Service معمولی، DNS نام Service را به ClusterIP تبدیل میکند و بعد data plane تصمیم میگیرد ترافیک به کدام بکاند برود.
- در سرویس headless، پاسخ DNS مستقیم IP پادهاست و دیگر آن لایه virtual IP و NAT مربوط به Service در مسیر نیست.
برای همین headless Service معمولاً جایی استفاده میشود که اپلیکیشن میخواهد خودش لیست Podها را بداند یا مستقیم با یک پاد مشخص حرف بزند. StatefulSetها نمونه خیلی رایج این مدل هستند.
رکورد DNS برای Podها
کوبرنتیز بهصورت پیشفرض برای هر پاد، یک رکورد A با اسم دلخواه و قابلحدس نمیسازد.
رفتار رکورد پاد به چند چیز بستگی دارد؛ از جمله فیلدهای hostname و subdomain در Pod spec و اینکه آیا یک headless Service مرتبط وجود دارد یا نه. در حالت معمول، اگر بخواهیم یک پاد با نام ثابت و قابلپیشبینی صدا زده شود، ترکیب hostname/subdomain و یک headless Service لازم داریم. StatefulSetها دقیقاً از همین مکانیزم استفاده میکنند تا هر پاد یک نام پایدار داشته باشد.
برای headless Service یک رفتار خاص دیگر هم هست. اگر یک پاد فقط subdomain داشته باشد و hostname نداشته باشد، تنها رکوردی که ساخته میشود رکورد A مربوط به خود headless Service است؛ مثلاً چیزی شبیه:
default-subdomain.my-namespace.svc.cluster.local
که به IP پاد یا پادهای پشت همان headless Service اشاره میکند.. چون برای نام پادها بهصورت پیشفرض رکورد A اختصاصی ساخته نمیشود، اگر رکورد A اختصاصی برای خود پاد میخواهیم، داشتن hostname الزامی است.
مسیر یک DNS query از داخل Pod؛ قدمبهقدم
حالا این تکهها را کنار هم بگذاریم و مسیر واقعی یک query را ببینیم.
فرض میکنیم یک اپلیکیشن داخل پاد میخواهد به Serviceای به اسم بک اند در همان namespace وصل شود. مسیر ذهنی تقریباً این است:
- برنامه تابعی مثل ()getaddrinfo یا معادل آن را صدا میزند تا نام را resolve کند.
- resolver فایل /etc/resolv.conf داخل کانتینر را میخواند.
- از همان فایل میفهمد nameserver کجاست، search domainها چه هستند، و مقدار ndots چند است.(بالاتر دیدیم)
- بر اساس تعداد نقطههای داخل name و مقدار ndots، تصمیم میگیرد اول نام را با search domainها امتحان کند یا مستقیم بهصورت کامل.
- query به ClusterIP مربوط به CoreDNS فرستاده میشود.
- ترافیک مربوط به ClusterIP، درست مثل هر Service دیگری، در data plane به یکی از پادهای CoreDNS هدایت میشود.
- CoreDNS بر اساس Corefile و plugin مربوط به کوبرنتیز، نام را resolve میکند و جواب را برمیگرداند.
- برای یک Service معمولی، جواب همان ClusterIP است.
- حالا اپلیکیشن یک آیپی دارد. تازه از اینجا به بعد، مسیر Service شروع میشود؛ یعنی ترافیک وارد NAT میشود و در نهایت data plane یکی از بکاندها را انتخاب میکند.
بهتر است دو نکته را از این مسیر جدا نگه داریم. اول اینکه خود DNS query هم از مسیر Service و data plane عبور میکند؛ پس DNS میتواند قربانی مشکلات همان لایه شود، از جمله conntrack. دوم اینکه منطق search domain و ndots قبل از ارسال query اثر میگذارد، و همینجا میتواند یک resolve ساده را به چند query تبدیل کند.
شکل زیر همین مسیر را یکجا نشان میدهد؛ از لحظهای که کلاینت name را صدا میزند تا جایی که برای یک Service معمولی، جواب همان ClusterIP است. پایین شکل هم منطق ndots را برای یک نام کوتاه نشان میدهد:
نقش search domain و ndots؛ مهمترین قسمت برای فهم تأخیر
همانطور که دیدیم، resolv.conf داخل پاد دو بخش مهم دارد: لیست search و گزینه options ndots:5. این دو با هم رفتار resolver را تعیین میکنند.
search domain چه کاری میکند؟
search domain باعث میشود داخل کلاستر همیشه مجبور نباشیم نام کامل یک Service را بنویسیم. مثلاً وقتی فقط backend را مینویسیم، resolver همان نام کوتاه را میگیرد و search domainها را یکییکی به آن اضافه میکند تا به یک نام کامل برسد.
با این search list:
search default.svc.cluster.local svc.cluster.local cluster.local
اگر شرایط append شدن برقرار باشد، resolver نام backend را تقریباً به این ترتیب امتحان میکند:
- backend.default.svc.cluster.local
- backend.svc.cluster.local
- backend.cluster.local
- backend
برای یک Service در همان namespace، معمولاً همان گزینه اول جواب میدهد و کار تمام میشود. یعنی resolver نام کوتاه backend را با اولین search domain کامل میکند و به backend.default.svc.cluster.local میرسد. پس معمولاً اصلاً نوبت به گزینههای بعدی نمیرسد. به همین دلیل داخل همان namespace میتوانیم فقط backend بنویسیم. برای namespace دیگر هم معمولاً چیزی مثل backend.other-namespace مینویسیم و resolver بقیهاش را با search domain کامل میکند.
تا اینجا search domain خیلی هم مفید است. مشکل وقتی شروع میشود که با ndots ترکیب شود و مقصد، یک نام خارجی باشد.
ndots دقیقاً چه میگه؟
مقدار ndots:5 به resolver میگوید: اگر تعداد نقطههای داخل name کمتر از ۵ باشد، اول آن را با search domainها امتحان کن. فقط وقتی تعداد نقطهها به آن حد برسد، نام را از همان اول کامل فرض کن.
برای نامهای داخلی کوتاه، این رفتار خوب است. اما برای نامهای خارجی میتواند دردسر درست کند.
فرض کنیم اپلیکیشن میخواهد به یک سرویس خارجی مثل این وصل شود:
api.example.com
این نام دو نقطه دارد؛ کمتر از ۵. پس resolver اول search domainها را امتحان میکند:
- api.example.com.default.svc.cluster.local → NXDOMAIN
- api.example.com.svc.cluster.local → NXDOMAIN
- api.example.com.cluster.local → NXDOMAIN
- api.example.com → success
یعنی برای یک resolve ساده، چهار نام candidate امتحان میشود؛ سهتای اول هم از قبل معلوم است که شکست میخورند. در عمل، چون resolverهای رایج معمولاً برای هر نام queryهای A و AAAA را جداگانه میفرستند، تعداد queryهای واقعی میتواند بیشتر از این عدد باشد. برای همین این رفتار در کلاسترهای شلوغ، فشار قابلتوجهی روی DNS ایجاد میکند در یک کلاستر شلوغ با هزاران پاد، همین رفتار میتواند تبدیل شود به میلیونها query اضافه در ساعت. نتیجهاش هم فشار روی CoreDNS است، هم latency اضافه برای connectionهای خارجی.
راهحلها برای مشکل ndots
برای این مشکل چند راه شناختهشده وجود دارد. انتخاب بین آنها بستگی دارد به اینکه مشکل چقدر گسترده است و workload چه رفتاری دارد.
- استفاده از FQDN با نقطه انتهایی: این راهحل معمولاً باید در سمت اپلیکیشن یا کانفیگ همان workload اعمال شود. اگر نام مقصد را به شکل
api.example.com.بنویسیم، یعنی به resolver میگوییم این نام کامل است و لازم نیست search domainهای داخل کلاستر را به آن بچسباند. نتیجهاش این است که برای چنین مقصدی، queryهای اضافه و بیفایده کمتر میشوند. - کم کردن ndots در سطح خود پاد: برای Podهایی که زیاد به سرویسهای خارجی وصل میشوند، میتوانیم در dnsConfig مقدار ndots را کمتر کنیم. مثلاً با مقدار 2، نامهایی مثل api.example.com مستقیم امتحان میشوند، ولی نامهای داخلی کوتاه همچنان از search domain بهره میبرند:
spec:
dnsConfig:
options:
- name: ndots
value: "2"
- استفاده از یک DNS cache در سطح نود یا NodeLocal DNSCache: این راهکار جوابها را روی هر نود بهصورت لوکال cache میکند. حتی queryهای ناموفقی که از search domain میآیند هم میتوانند از cache سریع جواب بگیرند؛ همان negative caching. جلوتر در بخش timeout دوباره به این مورد برمیگردیم.
caching؛ کجا و چطور جوابها نگه داشته میشوند؟
کش کردن در مسیر DNS از یک طرف به پرفورمنس کمک میکند، اما از طرف دیگر میتواند تی شوت را گمراهکننده کند. برای همین باید بدانیم جوابهای DNS ممکن است در کدام نقطههای مسیر cache شوند.
چند نقطه مهم برای caching داریم:
- CoreDNS: یکی از پلاگینهای رایج CoreDNS، پلاگین
cacheاست که در Corefile فعال و تنظیم میشود. این پلاگین جوابهای DNS را برای مدت مشخصی نگه میدارد. در بسیاری از ستاپ های رایج، مقداری مثل ۳۰ ثانیه برای آن دیده میشود، اما این عدد بخشی از تنظیمات عملیاتی کلاستر است و باید از ConfigMap همان محیط خوانده شود. - TTL خود recordها: هر جواب DNS یک TTL دارد که میگوید تا چه زمانی میتوان آن را cache کرد.
- NodeLocal DNSCache: اگر فعال باشد. این یک لایه cache روی هر نود است و جلوتر دوباره دربارهاش صحبت میکنیم.
- خود اپلیکیشن یا runtime: بعضی زبانها و runtimeها DNS caching خودشان را دارند. این دیگر مستقیماً به کوبرنتیز ربط ندارد، ولی در incident میتواند آدم را گمراه کند. ممکن است کوبرنتیز جواب جدید بدهد، ولی اپلیکیشن هنوز جواب قدیمی را از cache خودش مصرف کند.
مشکل timeout؛ تأخیر ۵ ثانیهای معروف DNS
این یکی از معروفترین و اعصابخردکنترین مشکلهای DNS در کوبرنتیز است. بهتر است بشناسیمش، چون دقیقاً از همان incidentهایی است که اول شبیه مشکل DNS یا Service دیده میشوند، ولی ریشهشان جای دیگری است.
علامتش معمولاً این است: requestها بیشتر وقتها سریعاند، اما هر از گاهی یک تأخیر تقریباً ۵ ثانیهای دیده میشود. اپ عادی کار میکند، بعد ناگهان یک latency spike دور و بر ۵ ثانیه میزنه. چون همیشه رخ نمیدهد، پیدا کردنش سختتر هم میشود.
ریشه مشکل کجاست؟
ریشه این مشکل معمولاً خود CoreDNS نیست. مشکل در مسیر شبکه کرنل است و به یک race condition در ماژول conntrack در لینوکس برمیگردد.
در بخش اول گفتیم وقتی ترافیک از طریق ClusterIP عبور میکند، در data plane با NAT به backend واقعی هدایت میشود و conntrack هم state آن کانکشن را نگه میدارد. حالا اگر یادمان باشد DNS query هم به ClusterIP مربوط به DNS Service میرود. پس همین مسیر، همین NAT و همین conntrack برای DNS هم اتفاق میافتد.
از طرف دیگر، DNS بهصورت پیشفرض روی UDP کار میکند. resolverهای مبتنی بر glibc و musl معمولاً queryهای مربوط به رکوردهای A و AAAA را تقریباً همزمان و از یک socket میفرستند. پس این رفتار فقط مخصوص glibc نیست؛ imageهای مبتنی بر musl مثل Alpine هم میتوانند در معرض همین race باشند. وقتی این دو پکت UDP تقریباً همزمان وارد کرنل میشوند، یک race condition شناختهشده در conntrack میتواند باعث شود یکی از پکتها drop شود.
شکل زیر همین مسیر را خلاصه میکند؛ از دو packet موازی A و AAAA تا جایی که race در conntrack یکی را drop میکند و resolver تا timeout صبر میکند:
پس چیزی که از بیرون شبیه «DNS کند است» به نظر میرسد، در واقع میتواند پکت دراپ در سطح کرنل باشد؛ آن هم فقط در شرایط همزمانی خاص. برای همین در محیط تست شاید کم دیده شود، اما در عملیاتی و زیر بار، خودش را نشان میدهد. البته این رفتار را هم نباید برای همه نسخههای کرنل و همه پیادهسازیها یکسان فرض کنیم. بخشی از raceهای conntrack در کرنل با پچ های بعدی کمتر شدهاند، اما اصل نکته برای عیبیابی هنوز مهم است: اگر DNS timeoutهای پراکنده و نزدیک به ۵ ثانیه میبینیم، باید نسخه kernel، datapath، kube-proxy mode و CNI همان کلاستر را هم وارد بررسی کنیم؛ نه اینکه فقط CoreDNS را مقصر بدانیم.
چه چیزهایی کمک میکنند؟
برای این مشکل چند مسیر شناختهشده وجود دارد. اثر هرکدام هم به نسخه kernel، شکل کلاستر و رفتار workload بستگی دارد.
- NodeLocal DNSCache: ایده این است که روی هر نود یک DNS cache لوکال داشته باشیم و پادها بهجای رفتن مستقیم به ClusterIP مربوط به CoreDNS، اول از cache لوکال همان node بپرسند. چون این مسیر local است و معمولاً طوری تنظیم میشود که از مسیر conntrack مربوط به NAT عبور نکند، فشار روی conntrack و احتمال race کمتر میشود. در کلاسترهای بزرگ، این یکی از رایجترین راهکارهاست.
- تنظیم resolver برای کم کردن race: گزینههایی مثل single-request-reopen در resolv.conf رفتار resolver مبتنی بر glibc را تغییر میدهند. در glibc، ریزالور معمولاً برای requestهای A و AAAA از همان socket استفاده میکند. اگر دو request از یک پورت درست handle نشوند، single-request-reopen باعث میشود ریزالور socket را ببندد و قبل از فرستادن request دوم، socket جدید باز کند. گزینه single-request رفتار دیگری دارد و lookupهای IPv4 و IPv6 را sequential انجام میدهد. این گزینهها را میتوان با dnsConfig به پاد داد، اما اثر واقعی آنها به resolver داخل image بستگی دارد؛ بنابراین بیشتر برای imageهای مبتنی بر glibc معنی دارند. imageهای مبتنی بر musl مثل Alpine هم میتوانند در معرض همین race باشند، اما این mitigationهای مخصوص glibc روی آنها قابل اتکا نیستند.
- استفاده از TCP برای DNS: چون بخشی از مشکل از lossy بودن UDP میآید، اجبار به استفاده از TCP میتواند آن را دور بزند. البته هزینه و trade-off خودش را دارد و نباید بدون فکر بهعنوان نسخه عمومی استفاده شود.
برای production، نکته مهم این است که قبل از انتخاب راهحل، بفهمیم این مشکل ربطی به manifest یا config خود Service ندارد. این یک مسئله کرنل و conntrack است. اگر فقط داخل YAML دنبالش بگردیم، پیدایش نمیکنیم. این دقیقاً همان فاصلهای است که در بخش اول گفتیم: بین لایه API و data plane واقعی.
NodeLocal DNSCache؛ این راهکار دقیقاً چه میکند؟
چون NodeLocal DNSCache هم در ماجرای ndots کمک میکند و هم برای مشکل تأخیر ۵ ثانیهای DNS زیاد استفاده میشود، بهتر است فقط از اسمش رد نشویم. اینجا کمی دقیقتر میبینیم پشت صحنه چه اتفاقی میافتد.
NodeLocal DNSCache یک DaemonSet است که روی هر نود یک نمونه CoreDNS را در حالت cache اجرا میکند. این agent روی یک آدرس محلی بدون تداخل گوش میدهد؛ معمولاً از محدوده link-local مثل 169.254.0.0/16 انتخاب میشود، و در خیلی از نصبها نمونهای مثل 169.254.20.10 دیده میشود. عدد دقیق به manifest و تنظیمات همان کلاستر بستگی دارد.
نکته کلیدی اینجاست که NodeLocal DNSCache مسیر DNS را لوکال تر میکند. در مسیر معمول، پاد query را به ClusterIP سرویس kube-dns میفرستد و این ترافیک با رول های kube-proxy به یکی از endpointهای CoreDNS میرسد؛ یعنی DNAT و conntrack هم وارد مسیر میشوند. اما وقتی NodeLocal DNSCache فعال است، پاد اول به cache همان نود query میفرستد و این مسیر از DNAT و conntrack مربوط به ClusterIP عبور نمیکند. نتیجه این است که همان race و همان فشار روی conntrack table که میتواند باعث تأخیرهای DNS شود، برای queryهای local کمتر وارد بازی میشود.
یک جزئیات پیادهسازی هم وجود دارد که در پروداکشن مهم میشود: رفتار NodeLocal DNSCache به mode مربوط به kube-proxy بستگی دارد. در حالت iptables معمولاً پاد node-local-dns هم روی آدرس local و هم روی ClusterIP سرویس kube-dns گوش میدهد. اما در حالت IPVS، مسیر معمولاً نیاز دارد مقدار cluster DNS در kubelet به آدرس local مربوط به NodeLocal DNSCache تغییر کند. پس هنگام فعال کردن این قابلیت، باید manifest و kubelet config را مطابق mode همان کلاستر بررسی کنیم.
در بعضی پیکربندیهای رایج NodeLocal DNSCache، وقتی جواب داخل cache محلی نیست، درخواست به سمت kube-dns یا CoreDNS مرکزی با TCP فرستاده میشود. این کار کمک میکند مشکلهای رایج DNS روی UDP کمتر شوند؛ چون کانکشن های TCP بعد از بسته شدن، سریعتر از conntrack جمع میشوند، اما entryهای UDP باید تا timeout باقی بمانند. نتیجه این است که احتمال tail latency ناشی از پکتهای دراپ شده و timeoutهای DNS کمتر میشود.
dnsPolicy و dnsConfig؛ کنترل رفتار DNS هر Pod
تا اینجا فرض کردیم همه پادها همان resolv.conf استاندارد را دارند. ولی این رفتار قابل کنترل است.
کوبرنتیز اجازه میدهد برای هر پاد policy مربوط به DNS را جداگانه تنظیم کنیم. این کار با فیلد dnsPolicy در Pod spec انجام میشود. مقدارهای اصلی آن اینها هستند:
- ClusterFirst: حالت پیشفرض برای بیشتر پادهاست. هر query که با cluster domain، مثلاً cluster.local، مطابقت داشته باشد به DNS داخلی cluster میرود. بقیه queryها، مثل نامهای اینترنتی، به upstream nameserverی forward میشوند که از نود به ارث رسیده است.این تفکیک را خود CoreDNS انجام میدهد، نه resolver داخل پاد
- Default: با اینکه اسمش Default است، پیشفرض معمول پادها نیست. در این حالت پاد همان DNS configی را میگیرد که روی نود وجود دارد. یعنی DNS داخلی کلاستر را بهصورت خودکار نمیشناسد.
- ClusterFirstWithHostNet: برای پادهایی است که از hostNetwork استفاده میکنند و در عین حال میخواهیم DNS داخلی کلاستر را هم داشته باشند.
- None: یعنی کوبرنتیز هیچ DNS configی اعمال نکند و ما خودمان همهچیز را از طریق dnsConfig تعریف میکنیم.
در کنار این، dnsConfig اجازه میدهد جزئیات بیشتری را تغییر بدهیم: nameserverهای اضافه، search domainهای اضافه، و optionهایی مثل ndots یا single-request-reopen. این فیلد میتواند کنار dnsPolicy استفاده شود تا config پایه را تکمیل کند. در حالت None هم میتوانیم کل config را خودمان بسازیم.
یک محدودیت که خوب است بشناسیم: تعداد و طول search domainها
این مورد معمولاً تا وقتی به آن نخوریم دیده نمیشود. ولی چون در DNS همین جزئیات کوچک گاهی دردسر درست میکنند، بهتر است همینجا به آن اشاره کنیم.
برای search domainها هم سقف وجود دارد؛ فقط این سقف همیشه ثابت نبوده است. در نسخههای قدیمیتر کتابخانه glibc، لیست search domainها به ۶ دامنه و در مجموع ۲۵۶ کاراکتر محدود بود. اما در کوبرنتیز 1.28، محدودیت بزرگتر DNS config به وضعیت stable رسید. در نسخههای جدید، خود کوبرنتیز در مرحله اعتبارسنجی اجازه میدهد DNS config نهایی، یعنی ترکیب تنظیمات نود و dnsConfig همان پاد، تا ۳۲ search domain و در مجموع ۲۰۴۸ کاراکتر داشته باشد.
با این حال، این عددها به این معنی نیستند که باید search domain را بیحساب زیاد کنیم. resolver داخل image، نسخه runtime و حتی تنظیمات نود هم میتوانند روی رفتار نهایی اثر بگذارند. مثلاً بعضی runtimeهای قدیمی با تعداد زیاد search domain مشکل داشتند و ممکن بود پاد را در حالت Pending نگه دارند. تعداد nameserver هم سقف جداگانه دارد و معمولاً بیشتر از سه nameserver قابل اتکا نیست.
نگاه عملی در production؛ وقتی DNS مشکوک است از کجا شروع کنیم؟
تا اینجا اجزای مسیر DNS را شناختیم. حالا بهتر است یک ترتیب ساده برای عیبیابی داشته باشیم؛ نه برای همه مشکلهای شبکه، فقط برای وقتی که DNS مشکوک است.
وقتی اتصال خراب میشود، بهتر است از پیچیدهترین سناریو شروع نکنیم. چند بررسی ساده معمولاً مسیر را خیلی زود روشن میکند:
- در قدم اول مطمئن شویم اپلیکیشن نام درستی را صدا میزند. namespace درست است؟ از نام کوتاه استفاده شده یا نام کامل؟
- از داخل همان پاد هم تست بگیریم. با یک debug container یا ابزار مناسب DNS ببینیم نام اصلاً resolve میشود یا نه.
- وضعیت پادهای CoreDNS را جداگانه بررسی کنیم. CrashLoopBackOff، OOMKilled یا restart زیاد یعنی مشکل احتمالاً فقط محدود به یک اپلیکیشن نیست.
- تنظیمات Corefile را هم از قلم نیندازیم. گاهی یک تغییر کوچک یا یک اشتباه ساده در ConfigMap میتواند CoreDNS را از کار بیندازد.
- زمانی که نام درست resolve میشود ولی اتصال هنوز برقرار نمیشود، احتمالاً DNS مقصر اصلی نیست. شاید Service endpoint سالم ندارد، شاید پادهای بکاند ready نیستند، یا مشکل در مسیر بعدی است.
- در تأخیرهای نزدیک به ۵ ثانیه، فقط به کند بودن CoreDNS فکر نکنیم. ممکن است پای UDP، conntrack یا timeout resolver وسط باشد.
- برای آدرسهای خارجی، ndots و search domainها را هم بررسی کنیم. گاهی قبل از رسیدن به نام خارجی، چند query داخلی بیفایده تولید میشود.
- در کلاستری که NetworkPolicy دارد، مسیر پورت ۵۳ را حتماً چک کنیم. بستن DNS از سمت policy یکی از آن خطاهایی است که ظاهرش شبیه خراب بودن CoreDNS دیده میشود.
هدف این ترتیب این است که زودتر بفهمیم DNS واقعاً مشکل اصلی است یا فقط اولین جایی است که خطا خودش را نشان میدهد. وقتی این لایه را جدا کنیم، ادامه عیبیابی خیلی تمیزتر میشود.
یک تله مهم: NetworkPolicy و DNS
این نکته را جدا میآورم، چون دقیقاً روی مرز DNS و NetworkPolicy قرار دارد و اگر حواسمان به آن نباشد، عیبیابی را گمراهکننده میکند.
در بخش اول فقط جایگاه NetworkPolicy را در مسیر ترافیک دیدیم. اینجا یک نکته عملیتر به آن اضافه میکنیم: اگر برای پادها egress policy مینویسیم، باید مطمئن شویم ترافیک DNS هم اجازه خروج دارد. حالا که مسیر DNS را دیدیم، دلیلش روشنتر است: پاد برای وصل شدن به خیلی از مقصدها، قبل از هر چیز باید بتواند نام را resolve کند.
اگر در یک NetworkPolicy مسیر پورت ۵۳ را به سمت DNS باز نکنیم، queryهای DNS ممکن است بیسروصدا drop شوند. معمولاً حداقل UDP لازم است و در بعضی سناریوها TCP هم باید در نظر گرفته شود. از بیرون شاید فقط یک timeout یا خطای اتصال ببینیم؛ در حالی که مشکل اصلی این است که پاد اصلاً اجازه نداشته سؤال DNS را به مقصد برساند.
اگر NodeLocal DNSCache فعال باشد، باید مقصد واقعی DNS در همان کلاستر را هم در نظر بگیریم؛ چون پاد ممکن است به آدرس local همان نود query بفرستد، نه مستقیم به Service مرکزی DNS.
پس هر وقت egress policy مینویسیم، مسیر DNS را هم باید باز بگذاریم. وگرنه پالیسی ای که قرار بوده فقط خروجیهای غیرمجاز را محدود کند، میتواند name resolution همان workload را هم مسدود کند. جزئیات بیشتر را در بخش خودش صحبت میکنیم.
ما یکبار همین اشتباه را تجربه کردیم و بخشی از ارتباطات یک کلاستر عملاً از کار افتاد. شانس آوردیم که کلاستر عملیاتی نبود. همان تجربه کافی بود که از آن به بعد DNS را در پالیسی ها موضوع فرعی حساب نکنم.
نگاه عمیقتر: Service، ExternalNameای که فقط DNS است
تا اینجا دو حالت دیدیم: Service معمولی که به ClusterIP ریزالو میشود، و headless Service که مستقیم ای پی پادها را برمیگرداند. یک نوع سوم هم هست که دقیقاً به بحث DNS مربوط میشود: ExternalName.
این نوع Service را جداگانه و کمی عمیقتر میآورم، چون نمونه خیلی خوبی از حرف اصلی این بخش است: گاهی کل ماجرای یک Service فقط در لایه DNS اتفاق میافتد و هیچ data plane، NAT یا backend selectionای در کار نیست.
ExternalName دقیقاً چه میکند؟
یک Service از نوع ExternalName هیچ ClusterIP، هیچ selector و هیچ EndpointSlice ندارد. کارش فقط این است که در DNS داخلی cluster یک رکورد CNAME بسازد؛ یعنی نام داخلی Service به یک نام خارجی اشاره کند.
نمونه manifest:
apiVersion: v1
kind: Service
metadata:
name: my-database
namespace: prod
spec:
type: ExternalName
externalName: my.database.example.com
با این تعریف، وقتی چیزی داخل cluster نام زیر را resolve کند:
my-database.prod.svc.cluster.local
DNS یک رکورد CNAME با مقدار زیر برمیگرداند:
my.database.example.com
از آن به بعد resolver مسیر CNAME را دنبال میکند تا در نهایت به رکوردهای A یا AAAA مقصد خارجی برسد.
نکته این است که در redirection، ExternalName در سطح DNS اتفاق میافتد، نه با proxy یا forwarding. برخلاف ClusterIP، اینجا NAT، kube-proxy و انتخاب backend نداریم. این Service فقط یک alias در DNS است.
این دقیقاً به همان مدل ذهنی بخش اول برمیگردد: هر objectی که در API کوبرنتیز میبینیم، الزاماً یک چیز واقعی در data plane پشت خودش ندارد. ExternalName نمونه کامل همین ماجراست؛ در API یک Service واقعی داریم، اما پشت آن نه ClusterIP داریم، نه EndpointSlice، نه NAT و نه انتخاب بکاند. همه چیز در لایه DNS اتفاق میافتد.
این برای چه کاری خوب است؟
کاربرد اصلی ExternalName این است که یک سرویس خارجی را با یک نام داخلی و پایدار در اختیار اپلیکیشن بگذاریم. مثلاً یک دیتابیس managed که endpointش طولانی و provider-specific است.
بهجای اینکه آن نام طولانی را در config همه اپلیکیشن ها هاردکود کنیم، یک ExternalName میسازیم به اسم my-database و application فقط با همین نام داخلی کار میکند. اگر بعداً endpoint خارجی عوض شد، فقط همان Service را تغییر میدهیم و اپلیکیشن ها دستنخورده میمانند.
حتی اگر یک روز تصمیم بگیریم دیتابیس را داخل کلاستر بیاوریم، میتوانیم پادها را بالا بیاوریم، selector یا endpoint مناسب بگذاریم و type همان Service را تغییر بدهیم؛ بدون اینکه نامی که اپلیکیشن به آن اشاره میکند عوض شود.
تلهها؛ اینجا همانجایی است که آدم گیر میکند
ExternalName ساده به نظر میرسد، ولی چند رفتار غیرشهودی دارد که اگر حواسمان نباشد، در review یا production اذیت میکند.
اول، با IP کار نمیکند، فقط با hostname. فیلد externalName برای نام DNS canonical است، نه IP. اگر IP بگذاریم، کوبرنتیز ممکن است object را قبول کند، اما Service درست کار نمیکند. دلیلش هم ساده است: قرار است CNAME ساخته شود و CNAME ذاتاً به نام اشاره میکند، نه IP. اگر هدف یک IP خارجی است، راه درست معمولاً Service بدون selector همراه با endpoint دستی است؛ نه ExternalName.
دوم، مشکل Host header در HTTP. وقتی اپلیکیشن به یک internal name مثل my-database وصل میشود، مقدار Host header هم معمولاً همان my-database میماند، نه external name واقعی مثل my.database.example.com. خیلی از سرورهای HTTP بر اساس همین Host تصمیم میگیرند چه جوابی بدهند. اگر آن Host را نشناسند، ممکن است خطا بدهند یا جواب غیرمنتظره برگردانند. حتی ممکن است لینکهای داخل response به external name واقعی اشاره کنند، نه به اسمی که کلاینت استفاده کرده است.
سوم، مشکل TLS و گواهی. این مورد در محیط های عملیاتی خیلی جدی است. وقتی مقصد خارجی HTTPS دارد، certificateای که برمیگرداند برای نام خارجی واقعی صادر شده، نه برای نام داخلیای که کلاینت به آن وصل شده است. اگر کلاینت درست hostname verification انجام دهد، که باید بدهد، certificate با نامی که کلاینت استفاده کرده match نمیشود و connection با خطای TLS رد میشود. راه معمول این است که اپلیکیشن را طوری تنظیم کنیم که برای اعتبارسنجی certificate از نام خارجی واقعی یا SNI/server name درست استفاده کند. اما این یعنی ExternalName دیگر کاملاً شفاف و بیدردسر نیست.
چهارم، load balancing ندارد. ExternalName خودش هیچ load balancing انجام نمیدهد. اگر نام خارجی به چند ای پی resolve شود، اینکه چطور بین آنها توزیع شود به رفتار resolver و اپلیکیشن بستگی دارد. خبری از health check، backend selection یا توزیع هوشمند بار نیست.
چرا این مثال را مطرح کردیم؟
این بخش فقط برای معرفی یک type از Service نیست. ExternalName یک مثال خوب برای جمعبندی کل این درس است.
اگر ExternalName داریم و چیزی کار نمیکند، ولی مدل ذهنی ما دقیق نباشد، ممکن است برویم دنبال EndpointSlice، kube-proxy، NAT، NetworkPolicy و data plane. در حالی که این Service اصلاً ClusterIP و EndpointSlice و backend ندارد. تمام داستانش در DNS است. مشکل یا در CNAME است، یا در رفتار کلاینت با Host header و TLS.
وقتی بدانیم ExternalName یعنی «فقط یک رکورد DNS»، خیلی سریع میفهمیم کجا را باید نگاه کنیم و کجا را اصلاً نباید بگردیم.
نتیجه
DNS در کوبرنتیز فقط یک تبدیل ساده «نام به IP» نیست. یک مرحله واقعی در مسیر هر request است و خودش چند لایه دارد.
در این بخش از CoreDNS شروع کردیم؛ همان DNS server رایج داخل کلاستر که خودش هم پشت یک ClusterIP قرار دارد. همین نکته مهم است، چون ترافیک DNS هم مثل بقیه Serviceها از مسیر data plane عبور میکند. بعد سراغ تنظیمات داخل پاد رفتیم و دیدیم kubelet فایل resolv.conf را میسازد؛ همان جایی که nameserver، search و ndots رفتار اصلی name resolution را تعیین میکنند.
از آنجا به رفتار Serviceها رسیدیم: Service معمولی به ClusterIP ریزالو میشود، اما headless Service مستقیم IP پادها را برمیگرداند. رکوردهای مربوط به پادها هم شرطهای خودشان را دارند و فقط در بعضی حالتها نام پایدار و قابلپیشبینی میسازند.
از همه مهمتر، دیدیم چرا این لایه در incidentها اینقدر نقش دارد. ndots میتواند هر resolve خارجی را به چند query تبدیل کند. caching میتواند تغییرات را با تأخیر نشان دهد. تأخیر ۵ ثانیهای معروف، الزاماً از خود DNS نمیآید و ممکن است ریشهاش race در conntrack و lossy بودن UDP باشد. یک NetworkPolicy بیدقت هم میتواند در سکوت کل DNS را قطع کند.
اگر این مدل را داشته باشیم، دفعه بعد که یک connection timed out مرموز دیدیم، مستقیم نمیپریم سراغ Service و backend. اول از خودمان میپرسیم: «اصلاً این نام resolve شد؟ چقدر طول کشید؟ از کدام مسیر رفت؟»
یادداشت فنی
این نسخه با مستندات رسمی Kubernetes درباره DNS for Services and Pods، Service از جمله نوع ExternalName، CoreDNS، dnsPolicy/dnsConfig و NodeLocal DNSCache تطبیق داده شده است؛ بر اساس مستندات رسمی و نسخههای پایدار فعلی. با این حال، رفتار واقعی همیشه باید با نسخه Kubernetes، نوع و نسخه resolver داخل image، تنظیمات Corefile، فعال بودن یا نبودن NodeLocal DNSCache، نسخه kernel روی nodeها و policyهای شبکه همان cluster بررسی شود. مخصوصاً جزئیات مربوط به محدودیت search domainها، رفتار پیشفرض ndots و راهکارهای مربوط به تأخیر conntrack/UDP میتوانند بین محیطها و نسخهها تفاوت داشته باشند.
منابع و مطالعهٔ بیشتر
- DNS record typesمستندات رسمی
مرجع کوتاه برای انواع رایج رکوردهای DNS مثل A، AAAA، CNAME، SRV، MX، TXT، NS، SOA، PTR و CAA.
- DNS for Services and Podsمستندات رسمی
مرجع اصلی رفتار DNS برای Serviceها و Podها، شامل رکوردهای A/AAAA، headless Service، رکوردهای Pod، dnsPolicy، dnsConfig و محدودیتهای DNS config.
- Serviceمستندات رسمی
مرجع رسمی Service در Kubernetes، شامل ClusterIP، headless Service، EndpointSlice، publishNotReadyAddresses و Service از نوع ExternalName.
- Debugging DNS Resolutionمستندات رسمی
راهنمای رسمی عیبیابی DNS در Kubernetes، شامل بررسی CoreDNS، سرویس kube-dns، فایل resolv.conf و تست resolution از داخل Pod.
- Using NodeLocal DNSCache in Kubernetes Clustersمستندات رسمی
مرجع رسمی NodeLocal DNSCache، شامل هدف طراحی، مسیر cache محلی روی هر نود، کاهش conntrack entries و رفتار upstream با TCP.
- KEP-1024 - NodeLocal DNSCacheمستندات رسمی
سند طراحی NodeLocal DNSCache و توضیح دقیقتر درباره دلیل وجود این قابلیت، مسیر local cache و کم کردن latency و فشار روی kube-dns/CoreDNS.
KEP مربوط به Expanded DNS Configuration؛ زمینه قابلیت افزایش سقف search domainها و محدودیتهای جدید DNS config در Kubernetes.
- Network Policiesمستندات رسمی
مرجع رسمی NetworkPolicy در Kubernetes؛ برای توضیح اثر egress policy روی ترافیک خروجی Podها، از جمله ترافیک DNS روی پورت ۵۳.
- CoreDNS cache pluginمستندات رسمی
مستند رسمی پلاگین cache در CoreDNS؛ برای توضیح caching، TTL، positive cache و negative cache در مسیر DNS.
- CoreDNS kubernetes pluginمستندات رسمی
مستند رسمی پلاگین kubernetes در CoreDNS؛ برای توضیح اینکه CoreDNS چطور نامهای داخل cluster.local را بر اساس API کوبرنتیز resolve میکند.
- resolv.conf(5)مستندات رسمی
مرجع رفتار resolver در glibc؛ شامل nameserver، search، ndots، timeout، attempts، single-request، single-request-reopen و use-vc.
issue مرجع درباره timeoutهای DNS نزدیک به ۵ ثانیه بهخاطر race در conntrack هنگام queryهای همزمان A و AAAA روی UDP.
- Kubernetes Without kube-proxyمستندات رسمی
مرجع Cilium برای kube-proxy replacement و datapath مبتنی بر eBPF؛ برای شفافسازی اینکه مسیر Service در همه CNIها شبیه iptables/nftables و conntrack کلاسیک نیست.