Но зачем


Знаю, о чем вы думаете — Kubernetes? На домашнем сервере? Кто может быть настолько сумасшедшим? Что ж, раньше я согласился бы, однако недавно кое-что изменило мое мнение.


Я начал работать в небольшом стартапе, в котором нет DevOps разработчиков со знанием Kubernetes (в дальнейшем K8s), и даже будучи старым ненавистником K8s из-за его громоздкости, был вынужден признать, что мне не хватает его программного подхода к деплойментам и доступу к подам. Также должен признать, что азарт от укрощения настолько навороченного зверя давно будоражит меня. И вообще, K8s захватывает мир — так что лишние знания не навредят.


Я все еще не большой фанат K8s, однако с Docker всё плохо, и его проект Swarm давно мертв; Nomad ненамного лучше (или не на 100% бесплатен, так как некоторые функции находятся за «корпоративной» стеной платного доступа), а Mesos не набрал критической массы. Все это, к сожалению, делает K8s последней оставшейся технологией оркестрации контейнеров производственного уровня. Не воспринимайте это как похвалу — мы знаем, что в IT успех иногда не равняется качеству (см. Window в 1995 году). И, как я уже сказал, он слишком громоздкий, но недавние улучшения инструментария значительно упростили работу с ним.


Причина, по которой я буду использовать его для своего личного сервера, в основном сводится к воспроизводимости. В моей текущей системе запущено около 35 контейнеров для множества сервисов, таких как wiki, сервер потоковой передачи музыки Airsonic, MinIO хранилище, совместимое с S3 API, и много чего еще, плюс сервера Samba и NFS, к которым обращается Kodi на моем Shield TV и четыре рабочих ПК/ноутбука дома.


Уже почти 5 лет я довольствовался запуском всего этого на OpenMediaVault, однако темпы его развития замедлились, и, будучи основанным на Debian, он страдает от проблемы «релизов». Каждый раз, когда выходит новый выпуск Debian, что-то неизбежно ломается на некоторое время. Я жил с этим с Debian 8 или 9, но недавний 11-й выпуск изрядно все поломал, поэтому настало время перемен. Я также подозреваю, что замедление развития OpenMediaVault связано с увеличившейся популярностью K8s среди «типичных» владельцев NAS, если судить по количество «easy» шаблонов K8s на посвященных ему Discord-серверах и Github. Доверие к шаблонам, использующим что попало для решения задачи, — не мой стиль, и если я доверяю чему-то управление своим домашним сервером, то должен непременно понимать что к чему.


Еще мне не нужно убивать уйму времени на обслуживание — обновления автоматизированы, и я редко что-то настраиваю после изначальной установки. На данный момент идет 161-й день аптайма! Однако воспроизведение моей системы было бы по большей части ручной работенкой. Переустановить OpenMediaVault, добавить плагин ZFS, импортировать мой 4-дисковый пул ZFS, настроить Samba и NFS, переустановить Portainer, заново импортировать все мои docker-compose файлы… это уже перебор. K8s же управляет состоянием кластера, поэтому (теоретически) можно просто переустановить мой сервер, добавить поддержку ZFS, импортировать пул, запустить скрипт, который воссоздает все деплойменты, и вуаля! В теории.


Минуточку. Если вы совершенно новичок — что вообще такое Kubernetes?


Краткий обзор Kubernetes


Kubernetes (по-гречески «кормчий») — это продукт для оркестрации контейнеров, изначально созданный в Google. Однако они не часто используют его внутри компании, что подтверждает теорию о том, что это тщательно продуманный Троянский конь, гарантирующий, что ни одна конкурирующая компания никогда не бросит им вызов в будущем, потому что конкуренты будут тратить все свое время на управление этой штукой (это, как известно, сложно).


В двух словах, вы устанавливаете его на сервере или, что более вероятно, на кластере, а затем развертываете на нем различные типы рабочих нагрузок. Он заботится о создании контейнеров, их масштабировании, создании пространства имен, управлении сетевыми правами доступа, и тому подобное. В основном вы взаимодействуете с ним путем написания YAML файлов и их применения к кластеру, обычно при помощи инструмента командной строки под названием kubectl, который проверяет и преобразует YAML в полезную нагрузку JSON, которая затем отправляется в REST API эндпоинт кластера.


В K8s есть много концепций, однако я остановлюсь на основных:


  • Под (Pod), основная рабочая единица, которая грубо говоря представляет собой один контейнер или набор контейнеров. Поды гарантированно присутствуют на одном и том же узле кластера. K8s назначает подам IP-адреса, поэтому вам не нужно управлять ими. Контейнеры внутри пода доступны друг для друга, но не контейнеры, запущенные на других подах. Вам не следует напрямую управлять подами, это работа Сервисов.
  • Сервисы (Services) являются точками входа в наборы подов и упрощают управление ими как единым целым. Они не управляют подами напрямую (вместо этого используют ReplicaSets), но в большинстве случаев вам даже не нужно знать, что это такое. Сервисы идентифицируют поды, которыми они управляют, с помощью меток.
  • Метки (Labels). К каждому объекту в K8s можно прикрепить метаданные. Метки — это одна из их форм, аннотации — другая. Большинство действий в K8s можно ограничить с помощью селектора, указывающего на определенные метки.
  • Тома (Volumes), как и в Docker, соединяют контейнеры с хранилищем. На работе у вас будет S3 или что-то подобное с аналогичными гарантиями, но для домашнего сервера мы будем использовать тип тома hostPath, который непосредственно сопоставляется с папками на сервере. В большинстве случаев K8s немного усложняет это — для фактического доступа вам необходимо объявить Persistent Volume (PV) и PersistentVolumeClaim (PVC). Вы можете ограничиться hostPath в конфигурации развертывания, однако PVS и PVCS дадут вам больше контроля над использованием тома.
  • Конфигурации развертывания (deployments) являются своего рода главными рабочими единицами. Они объявляют, какой Docker-образ использовать, какие сервисы являются частью развертывания, какие тома монтировать и какие порты экспортировать, и заботятся о дополнительных вопросах безопасности.
  • ConfigMaps — это место, где хранятся данные конфигурации в форме ключ-значение. Среду для развертывания можно взять из ConfigMap — полностью или с определенными ключами.
  • Ingress. Без этого ваши поды заработают, но будут отрезаны от внешнего мира. В этой статье используются nginx ingresses.
  • Jobs и CronJobs — это разовые или периодические рабочие нагрузки, которые можно выполнять.

Существует еще несколько концепций, которые необходимо освоить, сторонние инструменты могут расширить кластер K8s с помощью пользовательских объектов под названием CRD. Официальная документация — хорошее место, где можно узнать больше. На данный момент все это поможет нам пройти долгий путь к эффективному рабочему примеру.


Давайте сделаем это!


Шаг 1 — установка Linux


Для начала я рекомендую использовать VirtualBox (он бесплатный) и установить базовую виртуальную машину Debian 11 без рабочего стола, просто с запущенным OpenSSH. Должно работать и с другими дистрибутивами, но большая часть тестирования проходила на Debian. В будущем я планирую перейти на Arch, чтобы избежать «проблемы с релизами», но хорошего понемножку. После освоения настройки виртуальной машины, переход на физический сервер не должен представлять проблемы.


Чтобы предотвратить повторную установку с нуля в случае ошибки (у меня их было много пока не разобрался), можно клонировать виртуальную машину. Таким образом, вы просто удаляете виртуальную машину-клон, снова клонируете главную и повторяете попытку. Также можно использовать снэпшоты главной виртуальной машине, но, по-моему, клонирование более интуитивно.



Клонирование виртуальной машины


Убедитесь, что сетевой адаптер вашей клонированной виртуальной машины установлен на Bridged и имеет тот же MAC-адрес, что и основная виртуальная машина, чтобы все время получать один и тот же IP-адрес. Это также упростит проброс портов на вашем домашнем маршрутизаторе.



Установка MAC-адреса для сетевого адаптера виртуальной машины


Убедитесь, что следующие порты на вашем домашнем маршрутизаторе проброшены на IP-адрес виртуальной машины:


  • 80/TCP http
  • 443/TCP https

Если вы не находитесь в той же локальной сети, что и виртуальная машина, или используйте удаленный сервер (DigitalOcean, Amazon и т.д.), то также пробросьте следующие порты:


  • 22/TCP ssh
  • 6443/TCP K8s API
  • 10250/UDP kubelet

Прежде чем продолжить, убедитесь, что добавили свой SSH-ключ на сервер и получаете приглашение командной строки c root правами без запроса пароля, когда подключаетесь к нему по SSH. Если добавить:


Host k3s
  User root
  Hostname <your VM or server's IP>

в ваш файл .ssh/config, то при команде ssh k3s вы должны получить вышеупомянутое приглашение с правами root.


Также следует установить kubectl. Я рекомендую плагин asdf.


Шаг 2 — установка k3s


Полноценный Kubernetes является слишком комплексным и требует больших ресурсов, поэтому мы будем использовать облегченную альтернативу под названием K3s, гибкое single-binary решение, на 100 % совместимое с обычными K8s.


Чтобы установить K3s и взаимодействовать с нашим сервером, я буду использовать Makefile (старая школа — мой стиль). Вверху несколько переменных, которые вам нужно указать:


# set your host IP and name
HOST_IP=192.168.1.60
HOST=k3s
# do not change the next line
KUBECTL=kubectl --kubeconfig ~/.kube/k3s-vm-config

С IP все понятно, HOST— это метка сервера в файле .ssh/config, как указано выше. Использовать её проще, чем user@HOST_IP, но не стесняйтесь изменять файл Makefile по своему усмотрению. Назначение переменной KUBECTL прояснится, как только мы установим K3s. Добавьте в Makefile следующую цель:


k3s_install:
  ssh ${HOST} 'export INSTALL_K3S_EXEC=" --no-deploy servicelb --no-deploy traefik"; \
    curl -sfL https://get.k3s.io | sh -'
  scp ${HOST}:/etc/rancher/k3s/k3s.yaml .
  sed -r 's/(\b[0-9]{1,3}\.){3}[0-9]{1,3}\b'/"${HOST_IP}"/ k3s.yaml > ~/.kube/k3s-vm-config && rm k3s.yaml

ОК, здесь нужно кое-что прояснить. В первой строке происходит подключение к серверу по Ssh и установка K3s с пропуском нескольких компонентов.


  • servicelb нам не нужна балансировка нагрузки на одном сервере
  • traefik мы будем использовать nginx для ingresses, поэтому нет необходимости устанавливать этот ingress-контроллер

Во второй строке происходит копирование с сервера файла k3s.yaml, который создается после установки и включает сертификат для связи с его API. Третья строка заменяет в локальной копии IP-адрес 127.0.0.1 в конфигурации сервера IP-адресом сервера и копирует файл в директорию .kube вашей директории $HOME (убедитесь, что она существует). Именно здесь kubectl найдет его, так как мы явно установили переменную KUBECTL в Makefile для этого файла.


Ожидаемый вывод:


ssh k3s 'export INSTALL_K3S_EXEC=" --no-deploy servicelb --no-deploy traefik"; \
  curl -sfL https://get.k3s.io | sh -'
[INFO]  Finding release for channel stable
[INFO]  Using v1.21.7+k3s1 as release
[INFO]  Downloading hash https://github.com/k3s-io/k3s/releases/download/v1.21.7+k3s1/sha256sum-amd64.txt
[INFO]  Downloading binary https://github.com/k3s-io/k3s/releases/download/v1.21.7+k3s1/k3s
[INFO]  Verifying binary download
[INFO]  Installing k3s to /usr/local/bin/k3s
[INFO]  Skipping installation of SELinux RPM
[INFO]  Creating /usr/local/bin/kubectl symlink to k3s
[INFO]  Creating /usr/local/bin/crictl symlink to k3s
[INFO]  Creating /usr/local/bin/ctr symlink to k3s
[INFO]  Creating killall script /usr/local/bin/k3s-killall.sh
[INFO]  Creating uninstall script /usr/local/bin/k3s-uninstall.sh
[INFO]  env: Creating environment file /etc/systemd/system/k3s.service.env
[INFO]  systemd: Creating service file /etc/systemd/system/k3s.service
[INFO]  systemd: Enabling k3s unit
Created symlink /etc/systemd/system/multi-user.target.wants/k3s.service → /etc/systemd/system/k3s.service.
[INFO]  systemd: Starting k3s
scp k3s:/etc/rancher/k3s/k3s.yaml .
k3s.yaml                                         100% 2957    13.1MB/s   00:00
sed -r 's/(\b[0-9]{1,3}\.){3}[0-9]{1,3}\b'/"YOUR HOST IP HERE"/ k3s.yaml > ~/.kube/k3s-vm-config && rm k3s.yaml

Я предполагаю, что в вашем дистрибутиве, как и в большинстве других, sed установлен. Чтобы проверить, что все работает, простая команда kubectl --kubeconfig ~/.kube/k3s-vm-config get nodes должна вывести:


NAME     STATUS   ROLES                  AGE    VERSION
k3s-vm   Ready    control-plane,master   2m4s   v1.21.7+k3s1

Наш кластер K8s теперь готов к приему рабочих нагрузок!


Шаг 2.5 Клиенты (опционально)


Если вы хотите иметь приятный пользовательский интерфейс для взаимодействия с


K8s, есть два варианта:


  • k9s (CLI) Мне очень нравится, с ним легко работать, идеально подходит для удаленных систем.


k9s


  • Lens (GUI) Недавно перешел на него, здесь мне нравятся интегрированные метрики


Lens


Эти программы должны найти наши настройки кластера в ~/.kube.


Шаг 3 — nginx ingress, Let’s Encrypt и хранилище


Следующая цель в нашем Makefile устанавливает nginx ingress-контроллер и менеджер сертификатов Let’s Encrypt, чтобы наши деплойменты могли иметь валидные сертификаты TLS (бесплатно!). Также там есть класс хранилища по умолчанию, чтобы наши нагрузки без установленного класса использовали дефолтный.


base:
  ${KUBECTL} apply -f k8s/ingress-nginx-v1.0.4.yml
  ${KUBECTL} wait --namespace ingress-nginx \
            --for=condition=ready pod \
            --selector=app.kubernetes.io/component=controller \
            --timeout=60s
  ${KUBECTL} apply -f k8s/cert-manager-v1.0.4.yaml
  @echo
  @echo "waiting for cert-manager pods to be ready... "
  ${KUBECTL} wait --namespace=cert-manager --for=condition=ready pod --all --timeout=60s
  ${KUBECTL} apply -f k8s/lets-encrypt-staging.yml
  ${KUBECTL} apply -f k8s/lets-encrypt-prod.yml

Найти используемые мной файлы можно тут. Nginx ingress YAML получен отсюда, но с одной модификацией на строке 323:


dnsPolicy: ClusterFirstWithHostNet
hostNetwork: true

таким образом мы можем правильно использовать DNS для нашего случая с одним сервером. Более подробная информация здесь.


Файл cert-manager слишком большой, чтобы его можно было полностью просмотреть, не стесняйтесь обращаться к документации по нему. Для выдачи сертификатов Let's Encrypt нам понадобиться определенный объект ClusterIssuer. Мы будем использовать два, один для staging API и один для production. Используйте staging issuer для экспериментов, так как в этом случае нет ограничений на скорость выдачи сертификатов, однако имейте в виду, что сертификаты будут недействительными. Обязательно замените адрес электронной почты в обоих issuers на свой собственный.


# k8s/lets-encrypt-staging.yml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
  namespace: cert-manager
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: YOUR.EMAIL@DOMAIN.TLD
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
    - http01:
        ingress:
          class: nginx

# k8s/lets-encrypt-prod.yml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
  namespace: cert-manager
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: YOUR.EMAIL@DOMAIN.TLD
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
    - http01:
        ingress:
          class: nginx

Если бы мы выполнили все инструкции kubectl apply одну за другой, процесс, вероятно, завершился бы неудачей, так как нам нужно переходить к cert-менеджеру уже с готовым ingress-контроллером. С этой целью в kubectl есть удобная подкоманда wait, которая может принимать условия и метки (помните их?) и останавливает процесс до тех пор, пока не будут готовы необходимые компоненты. Рассмотрим подробнее отрывок из примера выше:


${KUBECTL} wait --namespace ingress-nginx \
            --for=condition=ready pod \
            --selector=app.kubernetes.io/component=controller \
            --timeout=60s

Здесь происходит ожидание в течение 60 секунд, пока все поды, соответствующие селектору app.kubernetes.io/component=controller, не станут иметь состояние ready. Если истечет время ожидания, Makefile остановится. Однако не беспокойтесь, если в какой-то из целей возникнет ошибка, так как все они являются идемпотентными. В этом случае можно запустить make base несколько раз, и если в кластере уже есть определения, они просто останутся неизменными. Попробуйте!


Шаг 4 — Portrainer


Мне все еще очень нравится, когда Portainer управляет моим сервером, и, по удачному стечению обстоятельств, он поддерживает как K8s, так и Docker. Давайте постепенно перейдем к соответствующим частям файла YAML:


---
apiVersion: v1
kind: Namespace
metadata:
  name: portainer

Достаточно просто, Portainer определяет собственное пространство имен.


---
apiVersion: v1
kind: PersistentVolume
metadata:
  labels:
    type: local
  name: portainer-pv
spec:
  storageClassName: local-storage
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: "/zpool/volumes/portainer/claim"
---
# Source: portainer/templates/pvc.yaml
kind: "PersistentVolumeClaim"
apiVersion: "v1"
metadata:
  name: portainer
  namespace: portainer
  annotations:
    volume.alpha.kubernetes.io/storage-class: "generic"
  labels:
    io.portainer.kubernetes.application.stack: portainer
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
spec:
  accessModes:
    - "ReadWriteOnce"
  resources:
    requests:
      storage: "1Gi"

Этот том (и связанный с ним claim), где Portainer хранит свою конфигурацию. Обратите внимание, что в объявление PersistentVolume можно включить nodeAffinity, чтобы соответствовать имени хоста сервера (или виртуальной машины). Я пока не нашел способа сделать это лучше.


---
# Source: portainer/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: portainer
  namespace: portainer
  labels:
    io.portainer.kubernetes.application.stack: portainer
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
spec:
  type: NodePort
  ports:
    - port: 9000
      targetPort: 9000
      protocol: TCP
      name: http
      nodePort: 30777
    - port: 9443
      targetPort: 9443
      protocol: TCP
      name: https
      nodePort: 30779
    - port: 30776
      targetPort: 30776
      protocol: TCP
      name: edge
      nodePort: 30776
  selector:
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer

Здесь мы видим определение сервиса. Обратите внимание, как указаны порты (наш ingress будет использовать только один из них). Теперь перейдем к конфигурации развертывания.


---
# Source: portainer/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: portainer
  namespace: portainer
  labels:
    io.portainer.kubernetes.application.stack: portainer
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
spec:
  replicas: 1
  strategy:
    type: "Recreate"
  selector:
    matchLabels:
      app.kubernetes.io/name: portainer
      app.kubernetes.io/instance: portainer
  template:
    metadata:
      labels:
        app.kubernetes.io/name: portainer
        app.kubernetes.io/instance: portainer
    spec:
      nodeSelector:
        {}
      serviceAccountName: portainer-sa-clusteradmin
      volumes:
        - name: portainer-pv
          persistentVolumeClaim:
            claimName: portainer
      containers:
        - name: portainer
          image: "portainer/portainer-ce:latest"
          imagePullPolicy: Always
          args:
          - '--tunnel-port=30776'
          volumeMounts:
            - name: portainer-pv
              mountPath: /data
          ports:
            - name: http
              containerPort: 9000
              protocol: TCP
            - name: https
              containerPort: 9443
              protocol: TCP
            - name: tcp-edge
              containerPort: 8000
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /
              port: 9443
              scheme: HTTPS
          readinessProbe:
            httpGet:
              path: /
              port: 9443
              scheme: HTTPS
          resources:
            {}

Большую часть этого файла занимают метки метаданных, это то, что связывает все вместе. Мы видим монтирование тома, используемый Doker-образ, порты, а также определения проб readiness и liveness. Они используются в K8s для определения того, готовы ли поды, а также работают и реагируют ли они соответственно.


---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portainer-ingress
  namespace: portainer
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
  rules:
    - host: portainer.domain.tld
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: portainer
                port:
                  name: http
  tls:
    - hosts:
      - portainer.domain.tld
      secretName: portainer-staging-secret-tls

Наконец, ingress, который сопоставляет фактическое доменное имя с этим сервисом. Убедитесь, что у вас есть домен, указывающий на IP-адрес вашего сервера, так как распознаватель вызовов Let's Encrypt зависит от его доступности извне. В нашем случае потребуются записи, указывающие на ваш IP-адрес для domain.tld и *.domain.tld.


Обратите внимание, как мы получаем сертификат — нам нужно добавить в ingress аннотацию cert-manager.io/cluster-issuer : letsencrypt-staging (или prod) и ключ tls с именем хоста и именем секрета, в котором будет храниться ключ TLS. Если сертификат вам не нужен, просто удалите аннотацию и ключ tls.


Kustomize

Здесь следует отметить одну вещь: я использую Kustomize для управления файлами YAML при развертывании. Это связано с тем, что другой инструмент, Kompose, выводит множество различных YAML файлов при преобразовании docker-compose файлов в файлы K8s. Kustomize упрощает их одновременное применение.


Итак, вот файлы, необходимые для развертывания Portainer:


# stacks/portainer/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - portainer.yaml

# stacks/portainer/portainer.yaml
---
apiVersion: v1
kind: Namespace
metadata:
  name: portainer
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: portainer-sa-clusteradmin
  namespace: portainer
  labels:
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
---
apiVersion: v1
kind: PersistentVolume
metadata:
  labels:
    type: local
  name: portainer-pv
spec:
  storageClassName: local-storage
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: "/zpool/volumes/portainer/claim"
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - k3s-vm
---
# Source: portainer/templates/pvc.yaml
kind: "PersistentVolumeClaim"
apiVersion: "v1"
metadata:
  name: portainer
  namespace: portainer
  annotations:
    volume.alpha.kubernetes.io/storage-class: "generic"
  labels:
    io.portainer.kubernetes.application.stack: portainer
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
spec:
  accessModes:
    - "ReadWriteOnce"
  resources:
    requests:
      storage: "1Gi"
---
# Source: portainer/templates/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: portainer
  labels:
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  namespace: portainer
  name: portainer-sa-clusteradmin
---
# Source: portainer/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: portainer
  namespace: portainer
  labels:
    io.portainer.kubernetes.application.stack: portainer
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
spec:
  type: NodePort
  ports:
    - port: 9000
      targetPort: 9000
      protocol: TCP
      name: http
      nodePort: 30777
    - port: 9443
      targetPort: 9443
      protocol: TCP
      name: https
      nodePort: 30779
    - port: 30776
      targetPort: 30776
      protocol: TCP
      name: edge
      nodePort: 30776
  selector:
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
---
# Source: portainer/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: portainer
  namespace: portainer
  labels:
    io.portainer.kubernetes.application.stack: portainer
    app.kubernetes.io/name: portainer
    app.kubernetes.io/instance: portainer
    app.kubernetes.io/version: "ce-latest-ee-2.10.0"
spec:
  replicas: 1
  strategy:
    type: "Recreate"
  selector:
    matchLabels:
      app.kubernetes.io/name: portainer
      app.kubernetes.io/instance: portainer
  template:
    metadata:
      labels:
        app.kubernetes.io/name: portainer
        app.kubernetes.io/instance: portainer
    spec:
      nodeSelector:
        {}
      serviceAccountName: portainer-sa-clusteradmin
      volumes:
        - name: portainer-pv
          persistentVolumeClaim:
            claimName: portainer
      containers:
        - name: portainer
          image: "portainer/portainer-ce:latest"
          imagePullPolicy: Always
          args:
          - '--tunnel-port=30776'
          volumeMounts:
            - name: portainer-pv
              mountPath: /data
          ports:
            - name: http
              containerPort: 9000
              protocol: TCP
            - name: https
              containerPort: 9443
              protocol: TCP
            - name: tcp-edge
              containerPort: 8000
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /
              port: 9443
              scheme: HTTPS
          readinessProbe:
            httpGet:
              path: /
              port: 9443
              scheme: HTTPS
          resources:
            {}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portainer-ingress
  namespace: portainer
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
  rules:
    - host: portainer.domain.tld
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: portainer
                port:
                  name: http
  tls:
    - hosts:
      - portainer.domain.tld
      secretName: portainer-staging-secret-tls

Цель в Makefike:


portainer:
  ${KUBECTL} apply -k stacks/portainer

Ожидаемый вывод:


> make portainer
kubectl --kubeconfig ~/.kube/k3s-vm-config apply -k stacks/portainer
namespace/portainer created
serviceaccount/portainer-sa-clusteradmin created
clusterrolebinding.rbac.authorization.k8s.io/portainer created
service/portainer created
persistentvolume/portainer-pv created
persistentvolumeclaim/portainer created
deployment.apps/portainer created
ingress.networking.k8s.io/portainer-ingress created

Так как она идемпотентна, то при повторном запуске вы должны увидеть следующие:


> make portainer
kubectl --kubeconfig ~/.kube/k3s-vm-config apply -k stacks/portainer
namespace/portainer unchanged
serviceaccount/portainer-sa-clusteradmin unchanged
clusterrolebinding.rbac.authorization.k8s.io/portainer unchanged
service/portainer unchanged
persistentvolume/portainer-pv unchanged
persistentvolumeclaim/portainer unchanged
deployment.apps/portainer configured
ingress.networking.k8s.io/portainer-ingress unchanged

Шаг 5 — Samba share


Запустить сервер Samba в кластере очень просто. Вот наши файлы YAML:


# stacks/samba/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

secretGenerator:
  - name: smbcredentials
    envs:
    - auth.env

resources:
  - deployment.yaml
  - service.yaml

Здесь у нас kustomization со множеством файлов. Когда мы применяем apply -k к директории, в которой находится этот файл, все они объединятся в один.


Сервис достаточно простой:


# stacks/samba/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: smb-server
spec:
  ports:
    - port: 445
      protocol: TCP
      name: smb
  selector:
    app: smb-server

Конфигурация развертывания тоже:


# stacks/samba/deployment.yaml
kind: Deployment
apiVersion: apps/v1
metadata:
  name: smb-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: smb-server
  strategy:
    type: Recreate
  template:
    metadata:
      name: smb-server
      labels:
        app: smb-server
    spec:
      volumes:
        - name: smb-volume
          hostPath:
            path: /zpool/shares/smb
            type: DirectoryOrCreate
      containers:
        - name: smb-server
          image: dperson/samba
          args: [
            "-u",
            "$(USERNAME1);$(PASSWORD1)",
            "-u",
            "$(USERNAME2);$(PASSWORD2)",
            "-s",
            # name;path;browsable;read-only;guest-allowed;users;admins;writelist;comment
            "share;/smbshare/;yes;no;no;all;$(USERNAME1);;mainshare",
            "-p"
          ]
          env:
            - name: PERMISSIONS
              value: "0777"
            - name: USERNAME1
              valueFrom:
                secretKeyRef:
                  name: smbcredentials
                  key: username1
            - name: PASSWORD1
              valueFrom:
                secretKeyRef:
                  name: smbcredentials
                  key: password1
            - name: USERNAME2
              valueFrom:
                secretKeyRef:
                  name: smbcredentials
                  key: username2
            - name: PASSWORD2
              valueFrom:
                secretKeyRef:
                  name: smbcredentials
                  key: password2
          volumeMounts:
            - mountPath: /smbshare
              name: smb-volume
          ports:
            - containerPort: 445
              hostPort: 445

Обратите внимания, что здесь вместо PV и PVC мы используем hostPath. Устанавливаем его type как DirectoryOrCreate, чтобы каталог был создан в случае его отсутствия.


Мы используем docker-образ dperson/samba, который позволяет настраивать пользователей и общие ресурсы на лету. Здесь я указываю один общий ресурс с двумя пользователями (с USERNAME1 в качестве администратора общего ресурса). Пользователи и пароли берутся из простого файла env:


# stacks/samba/auth.env
username1=alice
password1=foo

username2=bob
password2=bar

Цель в Makefile:


samba:
  ${KUBECTL} apply -k stacks/samba

Ожидаемый результат:


> make samba
kubectl --kubeconfig ~/.kube/k3s-vm-config apply -k stacks/samba
secret/smbcredentials-59k7fh7dhm created
service/smb-server created
deployment.apps/smb-server created

Шаг 6 — BookStack


В качестве примера использования Kompose для преобразования docker-compose.yaml в файлы K8s воспользуемся отличным wiki-приложением BookStack.


Это мой оригинальный docker-compose файл для BookStack:


version: '2'
services:
  mysql:
    image: mysql:5.7.33
    environment:
    - MYSQL_ROOT_PASSWORD=secret
    - MYSQL_DATABASE=bookstack
    - MYSQL_USER=bookstack
    - MYSQL_PASSWORD=secret
    volumes:
    - mysql-data:/var/lib/mysql
    ports:
    - 3306:3306

  bookstack:
    image: solidnerd/bookstack:21.05.2
    depends_on:
    - mysql
    environment:
    - DB_HOST=mysql:3306
    - DB_DATABASE=bookstack
    - DB_USERNAME=bookstack
    - DB_PASSWORD=secret
    volumes:
    - uploads:/var/www/bookstack/public/uploads
    - storage-uploads:/var/www/bookstack/storage/uploads
    ports:
    - "8080:8080"

volumes:
 mysql-data:
 uploads:
 storage-uploads:

Использовать Kompose просто:


> kompose convert -f bookstack-original-compose.yaml
WARN Unsupported root level volumes key - ignoring
WARN Unsupported depends_on key - ignoring
INFO Kubernetes file "bookstack-service.yaml" created
INFO Kubernetes file "mysql-service.yaml" created
INFO Kubernetes file "bookstack-deployment.yaml" created
INFO Kubernetes file "uploads-persistentvolumeclaim.yaml" created
INFO Kubernetes file "storage-uploads-persistentvolumeclaim.yaml" created
INFO Kubernetes file "mysql-deployment.yaml" created
INFO Kubernetes file "mysql-data-persistentvolumeclaim.yaml" created

Облом, нам сразу же говорят, что наши тома и использование depends_on не поддерживаются. Но их достаточно легко исправить. В интересах краткости и не делая эту статью длиннее, я просто опубликую окончательный результат с некоторыми примечаниями.


# stacks/bookstack/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - bookstack-build.yaml

# stacks/bookstack/bookstack-build.yaml
apiVersion: v1
kind: Service
metadata:
  labels:
    io.kompose.service: bookstack
  name: bookstack
spec:
  ports:
  - name: bookstack-port
    port: 10000
    targetPort: 8080
  - name: bookstack-db-port
    port: 10001
    targetPort: 3306
  selector:
    io.kompose.service: bookstack
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: bookstack-storage-uploads-pv
spec:
  capacity:
    storage: 5Gi
  hostPath:
    path: >-
      /zpool/volumes/bookstack/storage-uploads
    type: DirectoryOrCreate
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-path
  volumeMode: Filesystem
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    io.kompose.service: bookstack-storage-uploads-pvc
  name: bookstack-storage-uploads-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-path
  volumeName: bookstack-storage-uploads-pv
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: bookstack-uploads-pv
spec:
  capacity:
    storage: 5Gi
  hostPath:
    path: >-
      /zpool/volumes/bookstack/uploads
    type: DirectoryOrCreate
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-path
  volumeMode: Filesystem
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    io.kompose.service: bookstack-uploads-pvc
  name: bookstack-uploads-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-path
  volumeName: bookstack-uploads-pv
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: bookstack-mysql-data-pv
spec:
  capacity:
    storage: 5Gi
  hostPath:
    path: >-
      /zpool/volumes/bookstack/mysql-data
    type: DirectoryOrCreate
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-path
  volumeMode: Filesystem
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    io.kompose.service: bookstack-mysql-data-pvc
  name: bookstack-mysql-data-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-path
  volumeName: bookstack-mysql-data-pv
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: bookstack-config
  namespace: default
data:
  DB_DATABASE: bookstack
  DB_HOST: bookstack:10001
  DB_PASSWORD: secret
  DB_USERNAME: bookstack
  APP_URL: https://bookstack.domain.tld
  MAIL_DRIVER: smtp
  MAIL_ENCRYPTION: SSL
  MAIL_FROM: user@domain.tld
  MAIL_HOST: smtp.domain.tld
  MAIL_PASSWORD: vewyvewysecretpassword
  MAIL_PORT: "465"
  MAIL_USERNAME: user@domain.tld
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: bookstack-mysql-config
  namespace: default
data:
  MYSQL_DATABASE: bookstack
  MYSQL_PASSWORD: secret
  MYSQL_ROOT_PASSWORD: secret
  MYSQL_USER: bookstack
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    io.kompose.service: bookstack
  name: bookstack
spec:
  replicas: 1
  selector:
    matchLabels:
      io.kompose.service: bookstack
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        io.kompose.service: bookstack
    spec:
      containers:
      - name: bookstack
        image: reddexx/bookstack:21112
        securityContext:
          allowPrivilegeEscalation: false
        envFrom:
        - configMapRef:
            name: bookstack-config
        ports:
        - containerPort: 8080
        volumeMounts:
        - name: bookstack-uploads-pv
          mountPath: /var/www/bookstack/public/uploads
        - name: bookstack-storage-uploads-pv
          mountPath: /var/www/bookstack/storage/uploads
      - name: mysql
        image: mysql:5.7.33
        envFrom:
        - configMapRef:
            name: bookstack-mysql-config
        ports:
        - containerPort: 3306
        volumeMounts:
        - mountPath: /var/lib/mysql
          name: bookstack-mysql-data-pv
      volumes:
      - name: bookstack-uploads-pv
        persistentVolumeClaim:
          claimName: bookstack-uploads-pvc
      - name: bookstack-storage-uploads-pv
        persistentVolumeClaim:
          claimName: bookstack-storage-uploads-pvc
      - name: bookstack-mysql-data-pv
        persistentVolumeClaim:
          claimName: bookstack-mysql-data-pvc
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: bookstack-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
  rules:
    - host: bookstack.domain.tld
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: bookstack
                port:
                  name: bookstack-port
  tls:
    - hosts:
      - bookstack.domain.tld
      secretName: bookstack-staging-secret-tls

Kompose преобразует оба контейнера внутри файла docker-compose в сервисы, однако я превратил их в один сервис.


Обратите внимание, как config map содержит всю конфигурацию приложения, а затем вводится в конфигурацию развертывание с помощью:


envFrom:
- configMapRef:
    name: bookstack-config

Сегмент chown в Makefile связан с особенностью установки docker-образа BookStack. У большинства образов этой проблемы нет, однако PHP-образы ею славятся. Без надлежащих прав для директории на сервере загрузка в wiki не будет работать. Но в нашем Makefile это учитывается:


bookstack:
  ${KUBECTL} apply -k stacks/bookstack
  @echo
  @echo "waiting for deployments to be ready... "
  @${KUBECTL} wait --namespace=default --for=condition=available deployments/bookstack --timeout=60s
  @echo
  ssh ${HOST} chmod 777 /zpool/volumes/bookstack/storage-uploads/
  ssh ${HOST} chmod 777 /zpool/volumes/bookstack/uploads/

Здесь мы применяем kustomization и затем ждем, пока оба деплоймента будут готовы, что случится, когда их смонтированные тома будут либо привязаны, либо созданы на сервере. Затем мы подключаемся к серверу по SSH, чтобы изменить владельца томов на правильные идентификаторы пользователей и групп. Не идеально, но работает. Образ MySQL при развертывании в этом не нуждается.


Также внимание, как легко преобразовать директиву depends_on из файла docker-compose, поскольку поды схожим образом имеют доступ друг к другу по имени.


Шаг 8 — Все готово!


Полный код доступен здесь. Приведем весь Makefile для завершения картины:


# set your host IP and name
HOST_IP=192.168.1.60
HOST=k3s
#### don't change anything below this line!
KUBECTL=kubectl --kubeconfig ~/.kube/k3s-vm-config

.PHONY: k3s_install base bookstack portainer samba

k3s_install:
  ssh ${HOST} 'export INSTALL_K3S_EXEC=" --no-deploy servicelb --no-deploy traefik"; \
    curl -sfL https://get.k3s.io | sh -'
  scp ${HOST}:/etc/rancher/k3s/k3s.yaml .
  sed -r 's/(\b[0-9]{1,3}\.){3}[0-9]{1,3}\b'/"${HOST_IP}"/ k3s.yaml > ~/.kube/k3s-vm-config && rm k3s.yaml

base:
  ${KUBECTL} apply -f k8s/ingress-nginx-v1.0.4.yml
  ${KUBECTL} wait --namespace ingress-nginx \
            --for=condition=ready pod \
            --selector=app.kubernetes.io/component=controller \
            --timeout=60s
  ${KUBECTL} apply -f k8s/cert-manager-v1.0.4.yaml
  @echo
  @echo "waiting for cert-manager pods to be ready... "
  ${KUBECTL} wait --namespace=cert-manager --for=condition=ready pod --all --timeout=60s
  ${KUBECTL} apply -f k8s/lets-encrypt-staging.yml
  ${KUBECTL} apply -f k8s/lets-encrypt-prod.yml

bookstack:
  ${KUBECTL} apply -k stacks/bookstack
  @echo
  @echo "waiting for deployments to be ready... "
  @${KUBECTL} wait --namespace=default --for=condition=available deployments/bookstack --timeout=60s
  @echo
  ssh ${HOST} chmod 777 /zpool/volumes/bookstack/storage-uploads/
  ssh ${HOST} chmod 777 /zpool/volumes/bookstack/uploads/

portainer:
  ${KUBECTL} apply -k stacks/portainer

samba:
  ${KUBECTL} apply -k stacks/samba

Заключение


Итак, зачем все это было нужно? Мне потребовалось несколько дней, чтобы все заработало, и я несколько раз бился головой об монитор, однако процесс дал лучшее понимание того, как Kubernetes работает под капотом, как его отлаживать, и теперь с этим Makefile мне требуется всего 4 минуты, чтобы воссоздать конфигурацию NAS для 3 приложений. У меня есть еще дюжина старых docker-compose приложений, которые нужно преобразовать, но с каждым разом это все проще.


Комментарии (7)


  1. pythoned195
    09.12.2021 18:31
    +1

    Зачем такие велосипеды , если у вас одна нода ?
    Чем minikube не угодил ?


    1. DirectX
      09.12.2021 20:07

      Я вижу пару плюсов:

      1. Можно довольно просто развернуть дополнительные виртуальные ноды на этой же машине для тестирования работы в условиях, приближенных к реальному кластеру.

      2. Использование k3s максимально похоже на установку кубернетеса через скрипты k3d на реальном железе.


    1. jreznot
      10.12.2021 00:23

      Если дома, то лучше вообще microk8s от Canonical, там не нужна виртуальная машина.


  1. Exclipt
    09.12.2021 22:47
    +4

    Расскажите, почему swarm уже мертв? Про сложные проекты не говорю, интересно с точки зрения применения на малых и средне-малых проектах, до 20-и сервисов и кластерах по 2-5 машин. Я не настоящий сварщик, но как минимум на дев сервере и дома сварма хватает. Что я не учитываю?


    1. vrangel
      10.12.2021 03:33
      +1

      Присоединяюсь к вопросу. Не первый раз слышу, что swarm мертв, хотелось бы узнать подробности. В swarm порог входа, конечно, заметно ниже. Для мини проектов выглядить более привлекательно.


    1. Ilya_Kuz
      10.12.2021 09:40

      Использовали Swarm в одном из небольших рабочих проектов (кластер из 3 машин) - и удивлялись, насколько он был ненадежен. Обновление сервиса может упасть просто так, несколько раз окружение просто отказывалось подниматься (все сервисы висели со статусом "ожидает деплоя"), сам Swarm часто шедулил почти все сервисы на один хост, пока 2 других простаивали.


    1. slonopotamus
      10.12.2021 09:53
      +3

      почему swarm уже мертв?

      Ну хотя бы потому что его объявили мёртвым: https://www.mirantis.com/blog/mirantis-acquires-docker-enterprise-platform-business/

      What About Docker Swarm?

      The primary orchestrator going forward is Kubernetes. Mirantis is committed to providing an excellent experience to all Docker Enterprise platform customers and currently expects to support Swarm for at least two years, depending on customer input into the roadmap.