۱۷ تیر ۲۵۸۵ · 5 دقیقه مطالعه
اپ REDIS_PORT رو میخوند. Kubernetes قبلش مقدارش رو عوض کرده بود.
داشتم یه اپ Django رو روی کلاستر homelab خودم دیپلوی میکردم. چیز عجیبی هم نبود: خود اپ، یه Postgres و یه Redis، هر کدوم پشت Service خودشون. Podها سبز بالا اومدن، fixtureها لود شدن، /health هم 200 برگردوند. بعد خواستم لاگین کنم و یه HTTP 500 خالی گرفتم، بدون هیچ body.
فقط هم لاگین نبود. endpoint مربوط به API schema هم 500 میداد. health همچنان کار میکرد، فایلهای static هم همینطور. چند تا request طول کشید تا الگوش رو ببینم: هر view که به cache دست میزد خطا میداد و بقیه چیزها سالم بودن.
بیرون کشیدن traceback از یه pod در حالت production
توی image مقدار DEBUG هاردکد شده بود روی False، پس body پاسخ خالی بود و لاگها فقط خود 500 رو نشون میدادن، نه exception پشتش رو. بهجای اینکه image رو با debug روشن دوباره بسازم، test client خود Django رو داخل pod اجرا کردم:
# kubectl exec -it <pod> -- python manage.py shell
from django.test import Client
c = Client(raise_request_exception=True)
c.post("/backend/token/", {"username": "admin", "password": "..."})
بخش مهمش raise_request_exception=True است. بهجای رندر کردن صفحه 500، test client همون exception اصلی رو مستقیم توی shell دوباره raise میکنه. نه تغییر کد لازم داره، نه redeploy، نه روشن کردن debug روی یه کلاستر زنده. traceback هم فوری اومد:
ValueError: Port could not be cast to integer value as 'tcp:'
یه port که با tcp: شروع میشه. connection string ردیسی که اپ ساخته بود این شکلی بود:
redis://redis:tcp://10.233.61.42:6379/1
اپ این URL رو همونطوری میسازه که بیشتر اپهای twelve-factor میسازن:
REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
REDIS_PORT = os.environ.get("REDIS_PORT", "6379")
CACHE_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/1"
متغیر REDIS_PORT توی محیط pod ست شده بود. نه توسط manifestهای من، نه chart، نه هیچ ConfigMap. مقدارش tcp://10.233.61.42:6379 بود، که port نیست. یه URL است.
کی REDIS_PORT رو میکنه یه URL؟
خود kubelet. وقتی یه pod استارت میشه، Kubernetes برای هر Service که توی namespace وجود داره یه بلوک متغیر محیطی تزریق میکنه. برای یه Service به اسم redis روی پورت 6379، هر pod توی اون namespace اینها رو میگیره:
REDIS_SERVICE_HOST=10.233.61.42
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.233.61.42:6379
REDIS_PORT_6379_TCP=tcp://10.233.61.42:6379
REDIS_PORT_6379_TCP_PROTO=tcp
REDIS_PORT_6379_TCP_PORT=6379
REDIS_PORT_6379_TCP_ADDR=10.233.61.42
این یه لایه سازگاری برای Docker Links است؛ همون مکانیزمی که Docker قبل از DNS باهاش کانتینرها رو به هم وصل میکرد. Kubernetes توی اولین نسخهش دقیقاً همین فرمت متغیرها رو برداشت تا کانتینرهایی که برای Docker Links نوشته شده بودن به کارشون ادامه بدن. اون مال بیشتر از یه دهه پیشه. service discovery مبتنی بر DNS برنده شد، دیگه هیچکس اپی نمینویسه که *_PORT_6379_TCP_ADDR بخونه، ولی این تزریق هنوز بهصورت پیشفرض روشنه. فیلدی که کنترلش میکنه enableServiceLinks است و پیشفرضش true است.
این تداخل به یه دلیل مشخص خطرناکه: <SERVICE_NAME>_PORT دقیقاً همون اسمیه که یه اپ twelve-factor خودش برای متغیرش انتخاب میکنه. Service رو redis صدا بزن و توی اپ REDIS_PORT رو بخون؛ یه تداخل تضمینی داری که Kubernetes برندهشه، چون محیط تزریقشده قبل از اجرای کد تو اونجاست. مقدارش حتی عدد هم نیست، چون Docker Links اون رو بهشکل یه URL با پروتکل فرمت میکرد.
Postgres من شانسی از همین تله در رفت. Kubernetes متغیر POSTGRES_PORT=tcp://... رو هم تزریق کرده بود، ولی اپ DB_HOST و DB_PORT رو میخونه، پس متغیرهای تزریقشده همونجا بدون استفاده موندن. بخش اعصابخردکن این failure mode همینه: اینکه گیرت بندازه یا نه، به یه تصادف نامرئی بین اسم Serviceهات و قرارداد config اپت بستگی داره.
راهحل
سه گزینه، به ترتیبی که خودم سراغشون میرم.
تزریق رو روی pod spec خاموش کن:
spec:
enableServiceLinks: false
این راهحل تمیزه. service discovery از طریق DNS دستنخورده میمونه؛ redis.namespace.svc هنوز resolve میشه. فقط اون بلوک متغیرهای legacy حذف میشه، و اگه اپت روی هر کلاستری از ده سال اخیر اجرا شده باشه، هیچچیزی اونها رو نمیخونده.
اگه نمیتونی به pod spec دست بزنی، متغیر متداخل رو صریح ست کن:
env:
- name: REDIS_PORT
value: "6379"
متغیرهایی که توی container spec تعریف میشن نسبت به service linkها اولویت دارن، پس مقدار صریح روی مقدار تزریقشده سایه میندازه. این جواب میده، ولی هر بار فقط یه تداخل رو حل میکنه و مکانیزم برای Service بعدی که یکی اضافه میکنه مسلح میمونه.
گزینه سوم نظم توی نامگذاریه: هیچوقت Service رو طوری اسمگذاری نکن که <NAME>_PORT با چیزی که اپت میخونه یکی بشه. من به این یکی اعتماد ندارم. لازمهش اینه که هر همکار آیندهای موقع اسم گذاشتن روی یه Service، یه قانون تزریق از دوران Docker رو بدونه، و این دقیقاً همون جور دانشیه که دود میشه میره هوا.
یه ویژگی دیگه هم ارزش دونستن داره: متغیرها موقع استارت pod تزریق میشن، اون هم برای Serviceهایی که همون لحظه وجود دارن. اگه pod قبل از ساخته شدن Service بالا بیاد، متغیر نیست؛ همون pod رو یه هفته بعد restart کن و یهو هست. دیپلویی که کار میکرد میتونه توی reschedule بعدی بشکنه بدون اینکه هیچ manifestی عوض شده باشه. این همون کلاس خطاییه که توی آپدیت ConfigMap که هیچوقت به pod در حال اجرا نمیرسه دیدیم: پایپلاین دیپلوی سبزه و خرابی فقط در runtime وجود داره.
چی از این ماجرا یاد بگیریم
Kubernetes هنوز رفتارهای سازگاریای رو با خودش حمل میکنه که از بیشتر کلاسترهایی که روشون اجرا میشه قدیمیترن، و تا وقتی توی متغیرهای محیطی تو فرود نیومدن نامرئی میمونن. مثل finalizerها، بیشتر آدمها enableServiceLinks رو روزی یاد میگیرن که جلوشون رو گرفته.
دو تا عادت به دردت میخوره. وقتی یه اپ توی pod بدرفتاری میکنه ولی همهجای دیگه سالمه، قبل از اینکه بازم کد بخونی محیط واقعی pod رو چاپ کن (kubectl exec <pod> -- env | sort)؛ جواب «این رو کی ست کرده؟» بعضی وقتها خود پلتفرمه. و توی chartهایی که کنترلشون دست خودته، برای هر workloadای که صریحاً به متغیرهای Docker Links نیاز نداره enableServiceLinks: false رو پیشفرض کن، که امروز یعنی تقریباً همهشون.