Skip to content

فصل ۱۵ — یکپارچه‌سازی راه‌حل‌های ذخیره‌سازی با Kubernetes

در بسیاری از موارد، جدا کردن state از برنامه‌ها و ساخت microserviceهایی تا حد امکان stateless، به سیستم‌هایی با حداکثر قابلیت اطمینان و قابلیت مدیریت منجر می‌شود. با این حال، تقریباً هر سیستمی که پیچیدگی دارد، جایی در سیستم state دارد؛ از رکوردهای یک پایگاه داده تا shardهای index که نتایج موتور جستجوی وب را سرو می‌کنند. در نهایت باید داده‌ها را جایی ذخیره کنید.

یکپارچه‌سازی این داده‌ها با containerها و راه‌حل‌های orchestration container اغلب پیچیده‌ترین جنبهٔ ساخت یک سیستم توزیع‌شده است. این پیچیدگی تا حد زیادی از این واقعیت ناشی می‌شود که حرکت به سمت معماری‌های containerized همزمان حرکتی به سمت توسعهٔ برنامهٔ decoupled، immutable و declarative است. این الگوها برای برنامه‌های وب stateless نسبتاً آسان اعمال می‌شوند، اما حتی راه‌حل‌های ذخیره‌سازی «cloud-native» مثل Cassandra یا MongoDB شامل گام‌های imperative یا دستی برای راه‌اندازی راه‌حل replicated و قابل اعتماد هستند.

به‌عنوان مثال، راه‌اندازی ReplicaSet در MongoDB را در نظر بگیرید که شامل deploy کردن daemon Mongo و سپس اجرای دستور imperative برای شناسایی leader و همچنین شرکت‌کنندگان در cluster Mongo است. البته این گام‌ها را می‌توان script کرد، اما در دنیای containerized دیدن اینکه چگونه چنین دستوراتی را در deployment یکپارچه کرد، دشوار است. به‌همین‌ترتیب، حتی گرفتن نام‌های قابل resolve در DNS برای containerهای جداگانه در مجموعه‌ای replicated از containerها چالش‌برانگیز است.

پیچیدگی اضافی از gravity داده می‌آید. بیشتر سیستم‌های containerized در خلأ ساخته نمی‌شوند؛ معمولاً از سیستم‌های موجود که روی VM deploy شده‌اند تطبیق داده می‌شوند و این سیستم‌ها احتمالاً داده‌ای دارند که باید import یا migrate شود.

در نهایت، گذار به cloud اغلب به این معناست که ذخیره‌سازی یک سرویس cloud خارجی‌شده است و در آن زمینه هرگز واقعاً نمی‌تواند داخل cluster Kubernetes وجود داشته باشد.

این فصل رویکردهای مختلفی برای یکپارچه‌سازی ذخیره‌سازی در microserviceهای containerized در Kubernetes پوشش می‌دهد. ابتدا نحوهٔ import کردن راه‌حل‌های ذخیره‌سازی خارجی موجود (چه سرویس‌های cloud و چه روی VM) به Kubernetes را بررسی می‌کنیم. سپس نحوهٔ اجرای singletonهای قابل اعتماد داخل Kubernetes را کاوش می‌کنیم تا محیطی داشته باشید که تا حد زیادی با VMهایی که قبلاً راه‌حل‌های ذخیره‌سازی را روی آن‌ها deploy کرده‌اید مطابقت دارد. در پایان StatefulSetها را پوشش می‌دهیم که هنوز در حال توسعه‌اند اما آیندهٔ workloadهای stateful در Kubernetes را نشان می‌دهند.

Import کردن سرویس‌های خارجی

در بسیاری از موارد، ماشینی در شبکهٔ شما در حال اجراست که نوعی پایگاه داده روی آن اجرا می‌شود. در این موقعیت شاید نخواهید فوراً آن پایگاه داده را به containerها و Kubernetes منتقل کنید. شاید تیم دیگری آن را اجرا می‌کند، یا انتقال تدریجی انجام می‌دهید، یا کار migrate کردن داده‌ها صرفاً ارزش دردسرش را ندارد.

صرف‌نظر از دلایل ماندن، این سرور و سرویس legacy به Kubernetes منتقل نمی‌شوند — اما باز هم ارزش دارد این سرور را در Kubernetes نمایش دهید. وقتی این کار را انجام می‌دهید، از همهٔ primitiveهای naming و service-discovery داخلی Kubernetes بهره می‌برید. علاوه بر این، همهٔ برنامه‌هایتان را طوری پیکربندی می‌کنید که انگار پایگاه داده‌ای که روی ماشینی اجرا می‌شود در واقع یک Service Kubernetes است. این یعنی جایگزین کردن آن با پایگاه داده‌ای که Service Kubernetes است، بسیار ساده است. مثلاً در production ممکن است به پایگاه دادهٔ legacy روی ماشین تکیه کنید، اما برای continuous testing شاید پایگاه دادهٔ تست را به‌عنوان container موقت deploy کنید. چون برای هر اجرای تست ساخته و نابود می‌شود، persistence داده در continuous testing مهم نیست. نمایش هر دو پایگاه داده به‌عنوان Serviceهای Kubernetes به شما امکان می‌دهد پیکربندی یکسان در testing و production حفظ شود. وفاداری بالا بین test و production تضمین می‌کند که عبور تست‌ها به deployment موفق در production منجر شود.

برای دیدن عملی چگونگی حفظ وفاداری بالا بین development و production، به‌خاطر داشته باشید همهٔ اشیاء Kubernetes در namespaceها deploy می‌شوند. فرض کنید namespaceهای test و production تعریف شده‌اند. سرویس test با شیئی مثل زیر import می‌شود:

yaml
kind: Service
metadata:
  name: my-database
  # توجه به namespace 'test' اینجا
  namespace: test
...

سرویس production همان است، به‌جز namespace متفاوت:

yaml
kind: Service
metadata:
  name: my-database
  # توجه به namespace 'prod' اینجا
  namespace: prod
...

وقتی Pod را در namespace test deploy کنید و سرویس my-database را lookup کند، اشاره‌ای به my-database.test.svc.cluster.internal دریافت می‌کند که به نوبهٔ خود به پایگاه دادهٔ test اشاره می‌کند. در مقابل، وقتی Pod deploy‌شده در namespace prod همان نام (my-database) را lookup کند، اشاره‌ای به my-database.prod.svc.cluster.internal دریافت می‌کند که پایگاه دادهٔ production است. بنابراین همان نام سرویس در دو namespace متفاوت به دو سرویس متفاوت resolve می‌شود. برای جزئیات بیشتر نحوهٔ کار این، فصل ۷ را ببینید.

تکنیک‌های زیر همه از پایگاه داده یا سرویس‌های ذخیره‌سازی دیگر استفاده می‌کنند، اما این رویکردها به‌خوبی برای سرویس‌های دیگری که داخل cluster Kubernetes شما اجرا نمی‌شوند هم قابل استفاده‌اند.

Serviceهای بدون Selector

وقتی برای اولین بار Serviceها را معرفی کردیم، زیاد دربارهٔ queryهای label و نحوهٔ استفاده از آن‌ها برای شناسایی مجموعهٔ پویای Podهایی که backend یک Service خاص هستند صحبت کردیم. با سرویس‌های خارجی، با این حال، چنین query labelای وجود ندارد. در عوض، معمولاً نام DNS دارید که به سرور مشخصی که پایگاه داده را اجرا می‌کند اشاره می‌کند. برای مثال ما، فرض کنیم این سرور database.company.com نام دارد.

برای import کردن این سرویس پایگاه دادهٔ خارجی به Kubernetes، با ساخت Service بدون Pod selector که به نام DNS سرور پایگاه داده اشاره می‌کند شروع می‌کنیم (مثال ۱۵-۱).

مثال ۱۵-۱. dns-service.yaml

yaml
kind: Service
apiVersion: v1
metadata:
  name: external-database
spec:
  type: ExternalName
  externalName: database.company.com

وقتی یک Service معمولی Kubernetes ساخته می‌شود، آدرس IP هم ساخته می‌شود و سرویس DNS Kubernetes با رکورد A که به آن آدرس IP اشاره می‌کند پر می‌شود. وقتی Service از نوع ExternalName می‌سازید، سرویس DNS Kubernetes به‌جای آن با رکورد CNAME که به نام خارجی مشخص‌شده اشاره می‌کند (در این مورد database.company.com) پر می‌شود. وقتی برنامه‌ای در cluster lookup DNS برای hostname external-database.svc.default.cluster انجام می‌دهد، پروتکل DNS آن نام را به database.company.com alias می‌کند. این سپس به آدرس IP سرور پایگاه دادهٔ خارجی شما resolve می‌شود. به این ترتیب، همهٔ containerها در Kubernetes باور دارند با Serviceای صحبت می‌کنند که با containerهای دیگر پشتیبانی می‌شود، در حالی که در واقع به پایگاه دادهٔ خارجی redirect می‌شوند.

توجه کنید این محدود به پایگاه‌داده‌هایی که روی infrastructure خودتان اجرا می‌کنید نیست. بسیاری از پایگاه‌داده‌ها و سرویس‌های cloud هنگام دسترسی به پایگاه داده نام DNS به شما می‌دهند (مثلاً my-database.databases.cloudprovider.com). می‌توانید این نام DNS را به‌عنوان externalName استفاده کنید. این پایگاه دادهٔ ارائه‌شده توسط cloud را به namespace cluster Kubernetes شما import می‌کند.

گاهی، با این حال، آدرس DNS برای سرویس پایگاه دادهٔ خارجی ندارید، فقط آدرس IP. در چنین مواردی همچنان می‌توان این سرویس را به‌عنوان Service Kubernetes import کرد، اما عملیات کمی متفاوت است. ابتدا Service بدون label selector می‌سازید، اما بدون نوع ExternalName که قبلاً استفاده کردیم (مثال ۱۵-۲).

مثال ۱۵-۲. external-ip-service.yaml

yaml
kind: Service
apiVersion: v1
metadata:
  name: external-ip-database

در این نقطه، Kubernetes آدرس IP مجازی برای این Service تخصیص می‌دهد و رکورد A برای آن پر می‌کند. اما چون selector برای Service نیست، endpointای برای redirect ترافیک توسط load balancer پر نمی‌شود.

با توجه به اینکه این سرویس خارجی است، کاربر مسئول پر کردن دستی endpointها با resource Endpoints است (مثال ۱۵-۳).

مثال ۱۵-۳. external-ip-endpoints.yaml

yaml
kind: Endpoints
apiVersion: v1
metadata:
  name: external-ip-database
subsets:
  - addresses:
    - ip: 192.168.0.1
    ports:
    - port: 3306

اگر بیش از یک آدرس IP برای redundancy دارید، می‌توانید آن‌ها را در آرایهٔ addresses تکرار کنید. وقتی endpointها پر شدند، load balancer شروع به redirect ترافیک از Service Kubernetes شما به آدرس(های) IP endpoint می‌کند.

چون کاربر مسئولیت به‌روز نگه داشتن آدرس IP سرور را بر عهده گرفته، باید یا تضمین کنید هرگز تغییر نکند یا مطمئن شوید فرآیند خودکاری رکورد Endpoints را به‌روز می‌کند.

محدودیت‌های سرویس‌های خارجی: Health Checking

سرویس‌های خارجی در Kubernetes یک محدودیت مهم دارند: هیچ health checking انجام نمی‌دهند. کاربر مسئول اطمینان از این است که endpoint یا نام DNS ارائه‌شده به Kubernetes به اندازهٔ لازم برای برنامه قابل اعتماد باشد.

اجرای Singletonهای قابل اعتماد

چالش اجرای راه‌حل‌های ذخیره‌سازی در Kubernetes اغلب این است که primitiveهایی مثل ReplicaSet انتظار دارند هر container یکسان و قابل جایگزینی باشد، اما برای بیشتر راه‌حل‌های ذخیره‌سازی اینطور نیست. یک گزینه برای رفع این موضوع استفاده از primitiveهای Kubernetes است، اما تلاش برای replicate کردن ذخیره‌سازی نکنید. در عوض، ساده یک Pod واحد اجرا کنید که پایگاه داده یا راه‌حل ذخیره‌سازی دیگر را اجرا می‌کند. به این ترتیب چالش‌های اجرای ذخیره‌سازی replicated در Kubernetes رخ نمی‌دهد، چون replication نیست.

در نگاه اول، این ممکن است برخلاف اصول ساخت سیستم‌های توزیع‌شدهٔ قابل اعتماد به نظر برسد، اما به‌طور کلی از اجرای پایگاه داده یا infrastructure ذخیره‌سازی روی یک ماشین مجازی یا فیزیکی واحد کمتر قابل اعتماد نیست — همان‌طور که بسیاری از مردم فعلاً سیستم‌هایشان را ساخته‌اند. در واقع، اگر سیستم را درست ساختار دهید، تنها چیزی که قربانی می‌کنید downtime بالقوه برای upgradeها یا در صورت خرابی ماشین است. در حالی که برای سیستم‌های بزرگ‌مقیاس یا mission-critical شاید قابل قبول نباشد، برای بسیاری از برنامه‌های کوچک‌مقیاس این نوع downtime محدود trade-off معقولی برای کاهش پیچیدگی است. اگر برای شما اینطور نیست، آزادانه این بخش را رد کنید و یا سرویس‌های موجود را طبق بخش قبلی import کنید، یا به StatefulSetهای native Kubernetes بروید که در بخش بعدی توضیح داده می‌شوند. برای بقیه، نحوهٔ ساخت singletonهای قابل اعتماد برای ذخیره‌سازی داده را مرور می‌کنیم.

اجرای Singleton MySQL

در این بخش، نحوهٔ اجرای نمونهٔ singleton قابل اعتماد پایگاه دادهٔ MySQL به‌عنوان Pod در Kubernetes و نحوهٔ expose کردن آن singleton به برنامه‌های دیگر در cluster را توضیح می‌دهیم.

برای این کار، سه شیء پایه می‌سازیم:

  • یک PersistentVolume برای مدیریت طول عمر ذخیره‌سازی روی دیسک مستقل از طول عمر برنامهٔ MySQL در حال اجرا
  • یک Pod MySQL که برنامهٔ MySQL را اجرا می‌کند
  • یک Service که این Pod را به containerهای دیگر در cluster expose می‌کند

در فصل ۵ PersistentVolumeها را توضیح دادیم، اما مرور سریع منطقی است. PersistentVolume مکان ذخیره‌سازی است که طول عمرش مستقل از هر Pod یا container است. این برای راه‌حل‌های ذخیره‌سازی پایدار بسیار مفید است جایی که نمایش روی دیسک پایگاه داده باید حتی اگر containerهای اجراکنندهٔ برنامهٔ پایگاه داده crash کنند یا به ماشین‌های دیگر منتقل شوند، باقی بماند. اگر برنامه به ماشین دیگری منتقل شود، volume باید با آن منتقل شود و داده حفظ شود. جدا کردن ذخیره‌سازی داده به‌عنوان PersistentVolume این را ممکن می‌سازد.

برای شروع، PersistentVolume برای استفادهٔ پایگاه دادهٔ MySQL می‌سازیم. این مثال برای حداکثر portability از NFS استفاده می‌کند، اما Kubernetes انواع driver زیادی برای PersistentVolume پشتیبانی می‌کند. مثلاً driverهای PersistentVolume برای همهٔ ارائه‌دهندگان عمومی cloud و بسیاری از ارائه‌دهندگان private cloud وجود دارد. برای استفاده از این راه‌حل‌ها، کافی است nfs را با نوع volume مناسب cloud provider جایگزین کنید (مثلاً azure، awsElasticBlockStore یا gcePersistentDisk). در همهٔ موارد، این تغییر همهٔ چیزی است که نیاز دارید. Kubernetes می‌داند چگونه دیسک ذخیره‌سازی مناسب را در cloud provider مربوطه بسازد. این نمونهٔ عالی از ساده‌سازی توسعهٔ سیستم‌های توزیع‌شدهٔ قابل اعتماد توسط Kubernetes است.

مثال ۱۵-۴ شیء PersistentVolume را نشان می‌دهد.

مثال ۱۵-۴. nfs-volume.yaml

yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: database
  labels:
    volume: my-volume
spec:
  accessModes:
  - ReadWriteMany
  capacity:
    storage: 1Gi
  nfs:
    server: 192.168.0.1
    path: "/exports"

این شیء NFS PersistentVolume با ۱ گیگابایت فضای ذخیره‌سازی تعریف می‌کند.

می‌توانیم این PersistentVolume را مثل همیشه با دستور زیر بسازیم:

$ kubectl apply -f nfs-volume.yaml

حالا که PersistentVolume ساخته‌ایم، باید آن PersistentVolume را برای Pod claim کنیم. این کار را با شیء PersistentVolumeClaim انجام می‌دهیم (مثال ۱۵-۵).

مثال ۱۵-۵. nfs-volume-claim.yaml

yaml
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: database
spec:
  accessModes:
  - ReadWriteMany
  resources:
    requests:
      storage: 1Gi
  selector:
    matchLabels:
      volume: my-volume

فیلد selector از labelها برای یافتن volume مطابق تعریف‌شدهٔ قبلی استفاده می‌کند.

این نوع indirection ممکن است بیش از حد پیچیده به نظر برسد، اما هدف دارد — تعریف Pod ما را از تعریف ذخیره‌سازی جدا می‌کند. می‌توانید volumeها را مستقیماً داخل مشخصات Pod اعلام کنید، اما این مشخصات Pod را به provider volume خاص (مثلاً cloud عمومی یا خصوصی مشخص) قفل می‌کند. با استفاده از volume claim، می‌توانید مشخصات Pod را cloud-agnostic نگه دارید؛ ساده volumeهای مختلف، مختص cloud، بسازید و با PersistentVolumeClaim آن‌ها را به هم bind کنید. علاوه بر این، در بسیاری از موارد، controller PersistentVolume در واقع خودکار volume برای شما می‌سازد — جزئیات بیشتر این فرآیند در بخش بعدی است.

حالا که volume را claim کرده‌ایم، می‌توانیم از ReplicaSet برای ساخت Pod singleton استفاده کنیم. ممکن است عجیب به نظر برسد که از ReplicaSet برای مدیریت یک Pod واحد استفاده می‌کنیم، اما برای قابلیت اطمینان لازم است. به‌خاطر داشته باشید وقتی یک Pod برهنه به ماشین schedule شد، برای همیشه به آن ماشین bind است. اگر ماشین خراب شود، هر Podی روی آن ماشین که توسط controller سطح بالاتری مثل ReplicaSet مدیریت نمی‌شود همراه ماشین ناپدید می‌شود و جای دیگری reschedule نمی‌شود. در نتیجه، برای اطمینان از reschedule شدن Pod پایگاه داده در حضور خرابی ماشین، از controller سطح بالاتر ReplicaSet با اندازهٔ replica یک استفاده می‌کنیم تا پایگاه داده را مدیریت کند (مثال ۱۵-۶).

مثال ۱۵-۶. mysql-replicaset.yaml

yaml
apiVersion: extensions/v1
kind: ReplicaSet
metadata:
  name: mysql
  # labelها تا بتوانیم Service را به این Pod bind کنیم
  labels:
    app: mysql
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
      - name: database
        image: mysql
        resources:
          requests:
             cpu: 1
             memory: 2Gi
        env:
        # متغیرهای محیطی برای امنیت best practice نیستند،
        # اما برای اختصار در مثال از آن‌ها استفاده می‌کنیم.
        # فصل ۱۱ را برای گزینه‌های بهتر ببینید.
        - name: MYSQL_ROOT_PASSWORD
          value: some-password-here
        livenessProbe:
          tcpSocket:
             port: 3306
        ports:
        - containerPort: 3306
        volumeMounts:
          - name: database
             # /var/lib/mysql جایی است که MySQL پایگاه‌های داده‌اش را ذخیره می‌کند
             mountPath: "/var/lib/mysql"
      volumes:
      - name: database
        persistentVolumeClaim:
          claimName: database

وقتی ReplicaSet را بسازیم، به نوبهٔ خود Podای اجراکنندهٔ MySQL با دیسک پایدار ساخته‌شدهٔ اولیه را می‌سازد. گام نهایی expose کردن این به‌عنوان Service Kubernetes است (مثال ۱۵-۷).

مثال ۱۵-۷. mysql-service.yaml

yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  ports:
  - port: 3306
    protocol: TCP
  selector:
    app: mysql

حالا نمونهٔ singleton قابل اعتماد MySQL در cluster ما در حال اجراست و به‌عنوان Service به نام mysql expose شده که می‌توانیم در نام دامنهٔ کامل mysql.svc.default.cluster به آن دسترسی داشته باشیم.

دستورالعمل‌های مشابه برای انواع مختلف data store قابل استفاده است، و اگر نیازهایتان ساده است و می‌توانید downtime محدود در برابر خرابی ماشین یا هنگام نیاز به upgrade نرم‌افزار پایگاه داده را تحمل کنید، singleton قابل اعتماد ممکن است رویکرد درست ذخیره‌سازی برای برنامهٔ شما باشد.

Dynamic Volume Provisioning

بسیاری از clusterها همچنین dynamic volume provisioning دارند. با dynamic volume provisioning، اپراتور cluster یک یا چند شیء StorageClass می‌سازد.

مثال ۱۵-۸ یک storage class پیش‌فرض را نشان می‌دهد که خودکار اشیاء دیسک را روی پلتفرم Microsoft Azure provision می‌کند.

مثال ۱۵-۸. storageclass.yaml

yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: default
  annotations:
    storageclass.beta.kubernetes.io/is-default-class: "true"
  labels:
    kubernetes.io/cluster-service: "true"
provisioner: kubernetes.io/azure-disk

وقتی storage class برای cluster ساخته شد، می‌توانید در PersistentVolumeClaim به این storage class اشاره کنید، به‌جای اشاره به PersistentVolume مشخص. وقتی dynamic provisioner این storage claim را می‌بیند، از volume driver مناسب برای ساخت volume و bind کردن آن به PersistentVolumeClaim استفاده می‌کند.

مثال ۱۵-۹ نمونه‌ای از PersistentVolumeClaim که از storage class پیش‌فرض تعریف‌شده برای claim کردن PersistentVolume تازه‌ساخته استفاده می‌کند را نشان می‌دهد.

مثال ۱۵-۹. dynamic-volume-claim.yaml

yaml
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: my-claim
  annotations:
    volume.beta.kubernetes.io/storage-class: default
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi

annotation volume.beta.kubernetes.io/storage-class چیزی است که این claim را به storage class ساخته‌شده لینک می‌کند.

provision خودکار PersistentVolume ویژگی عالی است که ساخت و مدیریت برنامه‌های stateful در Kubernetes را به‌طور قابل توجهی آسان‌تر می‌کند. با این حال، طول عمر این PersistentVolumeها توسط reclamation policy مربوط به PersistentVolumeClaim تعیین می‌شود و پیش‌فرض bind کردن آن طول عمر به طول عمر Podای است که volume را می‌سازد.

این یعنی اگر به‌طور اتفاقی Pod را حذف کنید (مثلاً از طریق scale-down یا رویداد دیگر)، volume هم حذف می‌شود. در حالی که در برخی شرایط شاید همان چیزی باشد که می‌خواهید، باید مراقب باشید PersistentVolumeهای پایدار را به‌طور تصادفی حذف نکنید.

PersistentVolumeها برای برنامه‌های سنتی که ذخیره‌سازی نیاز دارند عالی‌اند، اما اگر نیاز به توسعهٔ ذخیره‌سازی high-availability و scalable به روش native Kubernetes دارید، می‌توان از شیء StatefulSet تازه منتشرشده استفاده کرد. در بخش بعدی نحوهٔ deploy کردن MongoDB با StatefulSetها را توضیح می‌دهیم.

ذخیره‌سازی Native Kubernetes با StatefulSetها

وقتی Kubernetes برای اولین بار توسعه یافت، تأکید زیادی روی همگنی همهٔ replicaها در مجموعهٔ replicated بود. در این طراحی، هیچ replica هویت یا پیکربندی فردی نداشت. به توسعه‌دهندهٔ برنامه واگذار بود طراحی‌ای تعیین کند که بتواند این هویت را برای برنامهٔش برقرار کند.

در حالی که این رویکرد جداسازی زیادی برای سیستم orchestration فراهم می‌کند، توسعهٔ برنامه‌های stateful را بسیار دشوار می‌کند. پس از input قابل توجه از جامعه و آزمایش زیاد با برنامه‌های stateful موجود، StatefulSetها در Kubernetes نسخهٔ ۱.۵ معرفی شدند.

ویژگی‌های StatefulSetها

StatefulSetها گروه‌های replicated از Pod هستند، مشابه ReplicaSetها. اما برخلاف ReplicaSet، ویژگی‌های منحصربه‌فردی دارند:

  • هر replica یک hostname پایدار با index یکتا دریافت می‌کند (مثلاً database-0، database-1 و غیره).
  • هر replica به ترتیب از پایین‌ترین به بالاترین index ساخته می‌شود و ساخت تا زمانی که Pod در index قبلی healthy و در دسترس باشد block می‌شود. این برای scale up هم اعمال می‌شود.
  • وقتی StatefulSet حذف می‌شود، هر یک از Podهای replica مدیریت‌شده نیز به ترتیب از بالاترین به پایین‌ترین حذف می‌شوند. این برای scale down تعداد replicaها هم اعمال می‌شود.

مشخص می‌شود این مجموعهٔ ساده از الزامات deploy کردن برنامه‌های ذخیره‌سازی روی Kubernetes را به‌شدت آسان‌تر می‌کند. مثلاً ترکیب hostnameهای پایدار (مثلاً database-0) و محدودیت‌های ordering یعنی همهٔ replicaها به‌جز اولی می‌توانند برای discovery و برقراری replication quorum به‌طور قابل اعتماد به database-0 اشاره کنند.

MongoDB Replicated دستی با StatefulSetها

در این بخش، cluster MongoDB replicated deploy می‌کنیم. فعلاً راه‌اندازی replication خود به‌صورت دستی انجام می‌شود تا حس کنید StatefulSetها چگونه کار می‌کنند. در نهایت این راه‌اندازی را هم خودکار می‌کنیم.

برای شروع، مجموعهٔ replicated از سه Pod MongoDB با شیء StatefulSet می‌سازیم (مثال ۱۵-۱۰).

مثال ۱۵-۱۰. mongo-simple.yaml

yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongo
spec:
  serviceName: "mongo"
  replicas: 3
  template:
    metadata:
      labels:
        app: mongo
    spec:
      containers:
      - name: mongodb
        image: mongo:3.4.1
        command:
        - mongod
        - --replSet
        - rs0
        ports:
        - containerPort: 27017
          name: peer

همان‌طور که می‌بینید، تعریف مشابه تعاریف ReplicaSetهایی است که قبلاً دیدیم. تنها تغییرات در فیلدهای apiVersion و kind است.

StatefulSet را بسازید:

$ kubectl apply -f mongo-simple.yaml

وقتی ساخته شد، تفاوت‌های بین ReplicaSet و StatefulSet آشکار می‌شود. kubectl get pods را اجرا کنید و احتمالاً می‌بینید:

NAME        READY         STATUS            RESTARTS         AGE
mongo-0     1/1           Running           0                1m
mongo-1     0/1           ContainerCreating 0                10s

دو تفاوت مهم بین این و آنچه با ReplicaSet می‌بینید وجود دارد. اول اینکه هر Pod replicated یک index عددی (۰، ۱، …) دارد، به‌جای پسوند تصادفی که توسط controller ReplicaSet اضافه می‌شود. دوم اینکه Podها به‌آرامی به ترتیب ساخته می‌شوند، نه همه با هم مثل ReplicaSet.

وقتی StatefulSet ساخته شد، همچنین باید Service «headless» برای مدیریت ورودی‌های DNS برای StatefulSet بسازیم. در Kubernetes Serviceای «headless» نامیده می‌شود اگر آدرس IP مجازی cluster نداشته باشد. چون با StatefulSetها هر Pod هویت یکتا دارد، داشتن آدرس IP load-balancing برای سرویس replicated واقعاً معنا ندارد. می‌توانید Service headless با clusterIP: None در مشخصات Service بسازید (مثال ۱۵-۱۱).

مثال ۱۵-۱۱. mongo-service.yaml

yaml
apiVersion: v1
kind: Service
metadata:
  name: mongo
spec:
  ports:
  - port: 27017
    name: peer
  clusterIP: None
  selector:
    app: mongo

وقتی آن Service را بسازید، معمولاً چهار ورودی DNS پر می‌شود. مثل همیشه mongo.default.svc.cluster.local ساخته می‌شود، اما برخلاف Service استاندارد، انجام lookup DNS روی این hostname همهٔ آدرس‌های StatefulSet را برمی‌گرداند. علاوه بر این، ورودی‌هایی برای mongo-0.mongo.default.svc.cluster.local و همچنین mongo-1.mongo و mongo-2.mongo ساخته می‌شوند. هر کدام به آدرس IP مشخص replica index در StatefulSet resolve می‌شوند. بنابراین با StatefulSetها نام‌های پایدار و تعریف‌شده برای هر replica در مجموعه دارید. این اغلب هنگام پیکربندی راه‌حل ذخیره‌سازی replicated بسیار مفید است. می‌توانید این ورودی‌های DNS را با اجرای دستورات زیر در یکی از replicaهای Mongo ببینید:

$ kubectl run -it --rm --image busybox busybox ping mongo-1.mongo

سپس replication Mongo را به‌صورت دستی با این hostnameهای per-Pod راه‌اندازی می‌کنیم.

mongo-0.mongo را به‌عنوان primary اولیه انتخاب می‌کنیم. ابزار mongo را در آن Pod اجرا کنید:

$ kubectl exec -it mongo-0 mongo
> rs.initiate( {
  _id: "rs0",
  members:[ { _id: 0, host: "mongo-0.mongo:27017" } ]
 });
 OK

این دستور به mongodb می‌گوید ReplicaSet rs0 را با mongo-0.mongo به‌عنوان replica primary آغاز کند.

نام rs0 دلخواه است. هر چه بخواهید می‌توانید استفاده کنید، اما باید در تعریف StatefulSet mongo.yaml هم تغییر دهید.

وقتی ReplicaSet Mongo را آغاز کردید، replicaهای باقی‌مانده را با اجرای دستورات زیر در ابزار mongo روی Pod mongo-0.mongo اضافه کنید:

> rs.add("mongo-1.mongo:27017");
> rs.add("mongo-2.mongo:27017");

همان‌طور که می‌بینید، از نام‌های DNS مختص replica برای اضافه کردن آن‌ها به‌عنوان replica در cluster Mongo استفاده می‌کنیم. در این نقطه کار تمام است. MongoDB replicated ما up و running است. اما واقعاً به اندازهٔ دلخواه ما خودکار نیست — در بخش بعد می‌بینیم چگونه از scriptها برای خودکارسازی راه‌اندازی استفاده کنیم.

خودکارسازی ساخت Cluster MongoDB

برای خودکارسازی deploy cluster MongoDB مبتنی بر StatefulSet، container اضافی به Podهایمان اضافه می‌کنیم تا initialization را انجام دهد.

برای پیکربندی این Pod بدون ساخت Docker image جدید، از ConfigMap استفاده می‌کنیم تا script را به image موجود MongoDB اضافه کنیم. containerی که اضافه می‌کنیم:

yaml
...
       - name: init-mongo
         image: mongo:3.4.1
         command:
         - bash
         - /config/init.sh
         volumeMounts:
         - name: config
           mountPath: /config
       volumes:
       - name: config
         configMap:
           name: "mongo-init"

توجه کنید volume ConfigMap به نام mongo-init mount می‌کند. این ConfigMap scriptای دارد که initialization ما را انجام می‌دهد. ابتدا script تعیین می‌کند آیا روی mongo-0 اجرا می‌شود یا نه. اگر روی mongo-0 باشد، ReplicaSet را با همان دستوری که قبلاً imperative اجرا کردیم می‌سازد. اگر روی replica Mongo دیگری باشد، تا وجود ReplicaSet منتظر می‌ماند و سپس خود را به‌عنوان عضو آن ReplicaSet ثبت می‌کند.

مثال ۱۵-۱۲ شیء ConfigMap کامل را دارد.

مثال ۱۵-۱۲. mongo-configmap.yaml

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mongo-init
data:
  init.sh: |
    #!/bin/bash

      # باید منتظر بمانیم تا readiness health check عبور کند تا نام‌های mongo resolve شوند.
      # این کمی عجیب است.
      until ping -c 1 ${HOSTNAME}.mongo; do
        echo "waiting for DNS (${HOSTNAME}.mongo)..."
        sleep 2
      done

      until /usr/bin/mongo --eval 'printjson(db.serverStatus())'; do
        echo "connecting to local mongo..."
        sleep 2
      done
      echo "connected to local."

   HOST=mongo-0.mongo:27017

   until /usr/bin/mongo --host=${HOST} --eval 'printjson(db.serverStatus())'; do
     echo "connecting to remote mongo..."
     sleep 2
   done
   echo "connected to remote."

   if [[ "${HOSTNAME}" != 'mongo-0' ]]; then
     until /usr/bin/mongo --host=${HOST} --eval="printjson(rs.status())" \
            | grep -v "no replset config has been received"; do
        echo "waiting for replication set initialization"
        sleep 2
     done
     echo "adding self to mongo-0"
      /usr/bin/mongo --host=${HOST} \
         --eval="printjson(rs.add('${HOSTNAME}.mongo'))"
   fi

   if [[ "${HOSTNAME}" == 'mongo-0' ]]; then
      echo "initializing replica set"
      /usr/bin/mongo --eval="printjson(rs.initiate(\
          {'_id': 'rs0', 'members': [{'_id': 0, \
           'host': 'mongo-0.mongo:27017'}]}))"
   fi
   echo "initialized"

   while true; do
     sleep 3600
   done

این script فعلاً پس از initialize کردن cluster برای همیشه sleep می‌کند. هر container در Pod باید RestartPolicy یکسان داشته باشد. چون نمی‌خواهیم container اصلی Mongo restart شود، باید container initialization ما هم برای همیشه اجرا شود وگرنه Kubernetes ممکن است فکر کند Pod Mongo ما unhealthy است.

جمع‌بندی همه، مثال ۱۵-۱۳ StatefulSet کامل است که از ConfigMap استفاده می‌کند.

مثال ۱۵-۱۳. mongo.yaml

yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongo
spec:
  serviceName: "mongo"
  replicas: 3
  template:
    metadata:
      labels:
        app: mongo
    spec:
      containers:
      - name: mongodb
        image: mongo:3.4.1
        command:
        - mongod
        - --replSet
        - rs0
        ports:
        - containerPort: 27017
          name: web
      # این container سرور mongodb را initialize می‌کند، سپس sleep می‌کند.
      - name: init-mongo
        image: mongo:3.4.1
        command:
        - bash
        - /config/init.sh
        volumeMounts:
        - name: config
          mountPath: /config
      volumes:
      - name: config
        configMap:
          name: "mongo-init"

با همهٔ این فایل‌ها، می‌توانید cluster Mongo بسازید:

$ kubectl apply -f mongo-config-map.yaml
$ kubectl apply -f mongo-service.yaml
$ kubectl apply -f mongo.yaml

یا اگر بخواهید، همه را در یک فایل YAML ترکیب کنید که اشیاء جدا با --- از هم جدا شده‌اند. همان ترتیب را حفظ کنید، چون تعریف StatefulSet به وجود تعریف ConfigMap وابسته است.

PersistentVolumeها و StatefulSetها

برای ذخیره‌سازی پایدار، باید PersistentVolume را در دایرکتوری /data/db mount کنید. در template Pod باید آن را به‌روز کنید تا PersistentVolumeClaim را به آن دایرکتوری mount کند:

yaml
...
               volumeMounts:
               - name: database
                 mountPath: /data/db

در حالی که این رویکرد مشابه آنچه با singletonهای قابل اعتماد دیدیم است، چون StatefulSet بیش از یک Pod replicate می‌کند نمی‌توانید ساده به PersistentVolumeClaim اشاره کنید. در عوض باید template PersistentVolumeClaim اضافه کنید. می‌توانید claim template را مشابه Pod template بدانید، اما به‌جای ساخت Pod، volume claim می‌سازد. باید موارد زیر را به پایین تعریف StatefulSet اضافه کنید:

yaml
volumeClaimTemplates:
- metadata:
    name: database
    annotations:
      volume.alpha.kubernetes.io/storage-class: anything
  spec:
    accessModes: [ "ReadWriteOnce" ]
    resources:
      requests:
        storage: 100Gi

وقتی template volume claim به تعریف StatefulSet اضافه می‌کنید، هر بار controller StatefulSet Podای بخشی از StatefulSet می‌سازد، PersistentVolumeClaim بر اساس این template به‌عنوان بخشی از آن Pod می‌سازد.

برای کار درست این PersistentVolumeهای replicated، یا باید autoprovisioning برای PersistentVolumeها راه‌اندازی شده باشد، یا مجموعه‌ای از اشیاء PersistentVolume را از قبل پر کنید تا controller StatefulSet از آن‌ها استفاده کند. اگر claimای قابل ساخت نباشد، controller StatefulSet نمی‌تواند Podهای مربوطه را بسازد.

یک چیز نهایی: Readiness Probeها

قطعهٔ نهایی productionize کردن cluster MongoDB ما اضافه کردن liveness check به containerهای سرو‌دهندهٔ Mongo است. همان‌طور که در «Health Checks» در صفحه ۵۴ آموختیم، liveness probe برای تعیین اینکه آیا container درست کار می‌کند استفاده می‌شود. برای liveness checkها می‌توانیم خود ابزار mongo را با اضافه کردن موارد زیر به template Pod در شیء StatefulSet استفاده کنیم:

yaml
...
 livenessProbe:
    exec:
      command:
      - /usr/bin/mongo
      - --eval
      - db.serverStatus()
    initialDelaySeconds: 10
    timeoutSeconds: 10
...

خلاصه

وقتی StatefulSetها، PersistentVolumeClaimها و liveness probing را ترکیب کردیم، نصب MongoDB cloud-native سخت‌شده و scalable روی Kubernetes داریم. در حالی که این مثال با MongoDB سروکار داشت، گام‌های ساخت StatefulSetها برای مدیریت راه‌حل‌های ذخیره‌سازی دیگر بسیار مشابه است و الگوهای مشابهی قابل دنبال کردن است.