در این مقاله و چند مقالهی آینده در مورد نحوه عملکرد شبکه در کوبرنتیز مینویسم. ایدهی نوشتن در این مورد هنگامی به وجود آمد که پس از مصاحبه با یک شرکت خارجی به این نتیجه رسیدم که به اندازهی کافی در مورد جزئیات پشت پرده شبکه کوبرنتیز عمیق نیستم. بنابراین این مقاله خلاصه مطالعهی من در این زمینه هست. ساختار اصلی این مقاله بر اساس یکی از پستهای وبلاگ آقای کوین سوکوچف است.
در این مقاله نحوهی ارتباطهای داخل کلاستر رو بررسی میکنیم؛ که شامل موارد زیر میشه:
پادها کوچکترین واحدهای قابل دیپلوی در کوبرنتیز هستن. یعنی شما وقتی که بخواید برنامهتون رو توی کوبرنتیز با استفاده از کانتینرهای داکر اجرا کنین باید از پادها استفاده کنید. پادها در واقع بستههایی هستن که شامل کانتینر مورد نظر شما، یک آدرس IP منحصر به فرد، فضای ذخیرهسازی مورد نیاز و ... میشن. پاد میتونه شامل یک یا چند کانتینر بشه. البته معمولا هر پاد فقط شامل یک کانتینر میشه. ولی اگر چندین کانینر رو داخل یک پاد تعریف کینم، کوبرنتیز همهی کانتینرهای یک پاد رو کنار هم و داخل یک نود اجرا میکنه. یعنی همه کانتینرها داخل یک پاد با یک آدرس IP مشخص ایجاد میشن.
نودها ماشینهایی هستن که کلاستر کوبرنتیز رو اجرا میکنن. فرقی نمیکنه؛ نودها میتونن ماشینهای مجازی یا سرورهای فیزیکی باشن. در واقع پادها روی نودها اجرا میشن.
میشه گفت توی کوبرنتیز تمام اتفاقات یک API Call هستن که توسط Kubernetes API Server یا kube-apiserver سرویسدهی میشن. API server دروازهی ارتباط با etcd محسوب میشه. etcd دقیقا چه کاری میکنه؟ حالت مورد نظر ما که با ریسورسهای مختلف مثل Deployments مشخص میکنیم رو نگهداری میکنه. مثلا اینکه ۴ تا پاد از Nginx با فلان مشخصات ایجاد بشه. حالا اگه بخوایم حالت کلاستر رو عوض کنیم، یک API Call به API server میزنیم و وضعیت جدید دلخواه خودمون رو مشخص میکنیم.
پروسههای kube-scheduler و kubelet مولفههایی هستند که وضعیت مورد نظری که ما به صورت انتزاعی مشخص کردیم رو ایجاد میکنند. یعنی بعد از اینکه با استفاده از API server به کوبرنتیز گفتیم که چه حالتی رو برای ما ایجاد کنه، این سرویسها هستند که تضمین میکنن وضعیت فعلی کلاستر همونی هست که ما میخوایم. چه جوری این اتفاق میوفته؟ این سرویسها به صورت دائمی در حال بررسی API server هستن؛ و وقتی تغییری ایجاد بشه، واکنش نشون میدن و تغییرات رو اعمال میکنن.
خوب بیاید این مورد رو با یک مثال بررسی کنیم. وقتی که من با kubectl دیپلویمنت پایینی رو اجرا میکنم، در واقع دارم درخواستم رو برای API server میفرستم. API server هم این وضعیت رو توی etcd ذخیره میکنه. توی این دیپلویمنت دارم به صورت ساده به کوبرنتیز میگم که ۳ تا پاد با کانتینر nginx نسخهی ۱.۱۴.۲ که روی پورت ۸۰ گوش میکنن رو ایجاد کنه و برچسب app: nginx بهشون بچسبونه.
خوب بعد از اینکه این درخواست رو فرستادیم، چه جوری توی کلاستر اعمال میشه؟ scheduler متوجه این تغییر میشه و وظیفهش اینه که تصمیم بگیره پادهای جدید روی چه نودهایی (با توجه به وضعیت اون نودها) ایجاد بشن. وقتی تصمیمگیری کرد، نتیجهش رو دوباره توی etcd مینویسه. چه جوری مینویسه؟ درخواستش رو میفرسته برای API server.
حالا نوبت یک سرویس دیگه هست که واقعا پادها رو روی نودهای انتخاب شده ایجاد کنه. اسم این سرویس که روی همهی نودهای کلاستر وجود داره kubelet هست. kubelet متوجه تغییرات میشه و زیرساخت شبکهی مورد نیاز پاد رو ایجاد میکنه. یعنی تمام مواردی که باید وجود داشته باشن که ما و پادهای دیگه بتونیم با این پاد جدید nginx ارتباط برقرار کنیم رو درست میکنه.
خوب حالا که اجزای اولیه کوبرنتیز رو با هم مرور کردیم بریم سراغ اینکه ارتباط پادها و شبکه توی کوبرنتیز چه جوری کار میکنه.
اگر تاحالا خودتون کلاستر کوبرنتیز رو نصب کرده باشین حتما دیدن که برای شبکه باید از یک CNI استفاده کنید. CNIهای مختلفی مثل Flannel و Calico وجود دارن که وظیفه ایجاد کردن شبکه و ارتباطها در کوبرنتیز رو به عهده دارن. در واقع کوبرنتیز فقط مشخص میکنه که یک CNI باید چه خصوصیاتی داشته باشه؛ و این CNIها هستن که به روشهای مختلف این ویژگیها رو برآورده میکنن. کوبرنتیز چه ویژگیهایی رو مشخص کرده؟
خب حالا با توجه به این ۳ محدودیت، هر کدوم از CNIها باید مشکلات شبکهای پایین رو حل کنن:
در ادامه بررسی میکنیم که در کوبرنتیز هر کدام از مسائل بالا چگونه حل میشن.
میدونیم که داکر از cgroups و namespace برای محدود کردن منابع و فضای کانتینرها استفاده میکنه. برای اینکه ببینیم چه namespaceهایی الان روی سیستم عامل وجود داره میتونیم از دستور lsns استفاده کنیم. در کل ۶ تا namespace مختلف وجود داره. اگر بخواهیم فقط namespaceهای مربوط به شبکه رو ببینیم از “-t net” استفاده میکنیم:
در تصویر بالا یکسری namespace که مربوط به دیپلویمنت nginx هست رو میبینیم.
با ip هم میتونیم خودمون یک namespace جدید (با نام) ایجاد کنیم:
# ip netns add namespace_test
و مثلا bash رو داخل این namespace اجرا کنیم:
# ip netns exec namespace_test bash
حالا اگر دوباره lsns رو اجرا کنیم، میبینیم که namespace تستی ایجاد شده:
راهحل دیگه برای دیدن namespaceهای با نام استفاده از دستور ip هست:
# ip netns list
به صورت پیشفرض لینوکس همهی پروسهها رو داخل namespace روت اجرا میکنه. بنابراین اگر یک ماشینمجازی با یک اینترفیس فیزیکی شبکه رو در نظر بگیریم، چیزی شبیه به شکل زیر رو خواهیم داشت:
یک پادِ کوبرنتیز به صورت یک کانتینر یا مجموعهای از کانتینرهای داکر داخل یک namespace اجرا میشه. بنابراین تمام کانتینرهای داخل یک پاد یک آدرس IP و فضای پورت یکسان دارن. پس کانتینرهای یک پاد میتونن از طریق localhost و پورتهای مختلف همدیگر رو پیدا کنن. دقیقا مثل زمانی که چند تا سرویس رو روی یک ماشین نصب میکنیم.
همیشه یک کانتینر مخفی به نام “pause” در هر پاد کوبرنتیز اجرا میشه. وظیفهی اصلی این کانتینر اینه که اگه همهی کانتینرهای یک پاد از بین رفتن، namespace مربوط به اون پاد رو نگه داره.
حالا اگر فرض کنیم که دو پاد مختلف روی ماشین مجازی اجرا کنیم، فضای شبکه چیزی شبیه به تصویر زیر خواهد بود:
در شکل بالا، اگر کانتینر ۱ در پاد ۲ که مثلا nginx هست بخواد با کانتینر ۲ که یک اپلیکیشن جاوا هست که روی پورت ۸۰۸۰ گوش میکنه ارتباط برقرار کنه، میتونه از آدرس http://localhost:8080 استفاده کنه.
اگر دو پاد در یک نود بخوان با هم ارتباط برقرار کنن چه اتفاقی میوفته؟ فرض کنیم دو تا پاد داریم؛ یکی با کانتینر nginx و یکی با کانتینر اپلیکیشن جاوا، که هر دو روی یک ماشین قرار دارن. یعنی در این حالت دو namespace شبکهی مختلف داریم، که پادهای این دو namespace میخوان با هم ارتباط برقرار کنن. ارتباط بین namespaceها با چه مکانیزمی برقرار میشه؟
در لینوکس میشه با استفاده از “Virtual Ethernet Device” یا همون اینترفیسهای “veth” (که به احتمال خیلی زیاد روی ماشینی که داکر روش نصب شده دیدید)، namespaceهای مختلف رو به هم متصل کرد. در این حالت، باید به ازای هر namespace یک جفت veth ایجاد بشه. یکی از vethها در سمت root namespace قرار میگیره، و دیگری سمت namespace پاد؛ مثل شکل زیر:
خوب پس تا اینجا هر پاد فکر میکنه که یک اینترفیس واقعی (eth) داره، که توسط اون میتونه به root namespace وصل بشه. سوال بعدی اینه که ارتباط بین veth0 و veth1 چه جوری برقرار میشه؟
برای ایجاد ارتباط بین veth0 و veth1 باید آنها رو به یک bridge متصل کنیم. bridge چیه؟ مثل یک سوئیچ فیزیکی لایه ۲، منتها به صورت نرمافزاری داخل سیستمعامل. bridgeهای لینوکس هم دقیقا مثل سوئیچهای واقعی از یک “Forwarding table” برای انتقال بستهها به پورتهای متناظر (با توجه به آدرس MAC مقصد و مبدا) استفاده میکنن. طبیعتا در اینجا هم برای پیدا کردن آدرس MAC متناظر با هر آدرس IP از پروتکل ARP استفاده میشه.
نام bridge در کوبرنتیز cbr0 هست. بنابراین شکل قبل با اضافه شدن bridge به صورت زیر درمیاد:
پس داستان زندگی سفر یک بسته از پاد nginx به پاد java به این شکل میشه: پاد nginx یک بسته رو به اینترفیس خودش (eth0) میفرسته؛ اینترفیس eth با استفاده از یک اینترفیس مجازی veth0 به root namespace وصل شده. بسته وقتی به veth0 میرسه، veth0 اون رو برای cbr0 (همون bridge) میفرسته. cbr0 با استفاده از پروتکل ARP متوجه میشه که باید بسته رو برای veth1 ارسال کنه. در نهایت، بسته از طریق veth1 برای eth0 در namespace پاد ۲ و کانتینر جاوا ارسال میشه. بنابراین، هر پاد تنها با اینترفیس eth0 خودش صحبت میکنه.
خوب الان که متوجه شدیم دو پاد روی یک نود چه جوری با هم ارتباط برقرار میکنن، بررسی میکنیم که اگر روی دو نود متفاوت باشن چه اتفاقی میوفته؟
فرض کنیم در شکل پایین پاد ۱ بخواهد با پاد ۳ ارتباط برقرار کنه:
ارتباط به این شکل خواهد بود:
بسته از طریق eth0 پاد ۱ ارسال میشه، و از طریق veth1 به root namespace ماشین مجازی اول میرسه. Bridge در اینجا دوباره پروتکل ARP رو برای پیدا کردن MAC پاد ۳ اجرا میکنه. ولی اجرای این پروتکل به نتیجه نمیرسه. چون پاد ۳ روی یک ماشین دیگه قرار داره و بستههای پروتکل ARP اصلا بهش نمیرسه. بنابراین bridge بسته رو برای “default route” خودش که eth0 ماشین مجازی هست میفرسته. در این حالت، بسته ماشین مجازی رو ترک میکنه و وارد شبکه میشه، و نهایتا به eth0 ماشین مجازی مقصد میرسه. حالا فرآیند برعکس ماشین مجازی اول که بررسی کردیم اتفاق میوفته و بسته از طریق veth3 به پاد ۳ میرسه.
اینکه ماشین مجازی اول میفهمه چه جوری میفهمه که باید بسته رو برای ماشین مجازی دوم ارسال کنه (فرض کنید ۱۰ تا ماشین دیگه هم وجود داره)، یعنی اینکه از کجا میفهمه که آدرس پاد سوم روی کدوم ماشین مجازی وجود داره بستگی به CNI داره که باید توی یک مقاله به صورت جداگانه بررسی بشه. فعلا فرض کنید که این اتفاق به درستی انجام میشه.
تا اینجا بررسی کردیم که ارتباط بین پادها و کانتینرها چگونه با آدرس IP برقرار میشه. اما مسئلهی دیگه اینجاست که آدرس پادها همیشه در حال تغییره. این مسئله دلایل متعددی داره. مثلا، تعداد پادهای یک اپلیکیشن ممکنه به دلیل autoscaling کم یا زیاد بشن، یا ممکنه مشکلی برای یک پاد ایجاد و restart بشه. اینها نمونههایی هستند که نشون میده ممکنه آدرس پادها بدون اخطار قبلی تغییر پیدا کنه. کوبرنتیز این مشکل رو چه جوری حل میکنه؟ یک لایهی انتزاعی روی پادها به نام سرویس ایجاد میکنه. در واقع سرویس مثل یک loadbalancer روی یک مجموعه از پادها تعریف میشه و با یک آدرس IP مجازی شناسایی میشه. هر ترافیکی که برای سرویس ارسال بشه، در نهایت روی یکی از پادهای مربوط به اون سرویس پخش میشه. بنابراین پادهای دیگه کافیه که فقط آدرس سرویس (که عوض نمیشه) رو بدونن. انگار تنها یک پاد با آدرس ثابت وجود داره. در واقع وظیفهی سرویس پنهان کردن این جزئیات از دید موجودیتهای دیگه هست. اما این عمل load balancing دقیقا به چه شکل انجام میشه؟
کوبرنتیز برای توزیع بار از فریمورک netfilter استفاده میکنه. netfilter چه کارهایی میتونه انجام بده که به درد کوبرنتیز و توزیع بار میخوره؟
اینها کارهایی هستند که کوبرنتیز از طریق اونها میتونه به درستی بستهها رو داخل شبکه هدایت کنه.
اما با چه وسیلهای میشه netfilter رو پیکربندی کرد؟ iptables. ابزار user-spaceای که برای دستکاری و هدایت بستهها با استفاده از فریمورک netfilter ایجاد شده. چه سرویسی در کوبرنتیز وظیفهی تغییر دادن ruleهای iptables رو به عهده داره؟
پروسهی kube-proxy.گفتیم که kube-proxy همیشه در حال بررسی API server هست، و هر موقع تغییری رو شناسایی کنه، اقدامات مورد نیاز رو انجام میده. یعنی هر زمانی که تغییری روی یک پاد یا سرویس منجر به بهروزرسانی آدرس IP پاد یا سرویس بشه، kube-proxy به شکلی ruleهای iptables رو تغییر میده که ترافیک به درستی به سمت اون سرویس و پاد هدایت بشه. به عبارت دیگه، وقتی ترافیکی برای یک سرویس ارسال میشه، یکی از پادهای در دسترس اون سرویس انتخاب میشه، و iptables آدرس مقصد ترافیک رو از آدرس سرویس به آدرس پاد تغییر میده. در نتیجه، در نهایت ترافیک برای پاد فرستاده میشه. هر زمانی هم که پادی اضافه یا کم بشه، ruleset مربوط به iptables تغییر میکنه تا شامل آدرسهای درست بشه. در جهت برعکس، زمانی که ترافیک از سمت پاد برای کلاینت درخواست دهنده ارسال میشه، iptables آدرس مبدا بسته رو از آدرس پاد به آدرس سرویس تغییر میده. به این ترتیب، کلاینت فکر میکنه تنها داره با یک پاد (با آدرس سرویس) صحبت میکنه.
از کوبرنتیز نسخهی 1.11 به بعد یک آپشن جدید برای توزیع بار وجود داره: IPVS. IPVS هم روی فریمورک netfilter ایجاد شده و توزیع بار رو در لایهی transport در کرنل لینوکس انجام میده. هنگامی که یک سرویس رو تعریف میکنیم، میتونیم مشخص کنیم که از IPVS یا iptables استفاده کنه. برای اطلاعات بیشتر میتونید به اینجا و اینجا مراجعه کنید:
در واقع IPVS بخشی از LVS یا Linux Virtual Server هست که به عنوان یک load balancer جلوی تعدادی اصطلاحا “real server” قرار میگیره، و میتونه درخواستهای TCP و UDP رو برای “real servers” ارسال کنه. این خصوصیات باعث میشه که IPVS انتخاب طبیعیتری برای توزیع بار نسبت به iptables در کوبرنتیز باشه. بنابراین باید انتظار داشته باشیم که در آینده IPVS انتخاب پیشفرض برای توزیع بار کوبرنتیز باشه.
خوب حالا میتونیم بفهمیم که انتقال یک بسته از پاد به سرویس به چه شکلی انجام میشه.
مثل قبل، بسته پاد رو از eth0 ترک میکنه و از طریق veth1 به bridge میرسه. پروتکل ARP اطلاعاتی در مورد سرویس نداره و در نتیجه بسته رو برای “default route” یا اینترفیس فیزیکی eth0 میفرسته. اما قبل از اینکه بسته به eth0 برسه، توسط iptables دریافت میشه. iptables بر اساس قوانینی که توسط kube-proxy نوشته شده، آدرس مقصد بسته رو از آدرس IP سرویس به آدرس IP یک پاد پشت سرویس تغییر میده، و فرضا آدرس مقصد جدید آدرس IP پاد ۴ (شکل قبلی) در یک نود دیگه میشه.
در واقع iptables عمل توزیع بار رو انجام داد، و بعد از آن با مکانیزم پاد-به-پاد که بررسی کردیم منتقل میشه.
در قسمتهای بعدی DNS و ارتباط خارج از کلاستر رو بررسی خواهیم کرد.