From a82c1ca02536f27300838c302b8ff4752bb693a5 Mon Sep 17 00:00:00 2001 From: "a.kazantsev" Date: Wed, 15 Jul 2026 11:14:21 +0300 Subject: [PATCH] initial commit from gigacoms --- .ansible-lint | 39 + .gitignore | 13 + .gitlab-ci.yml | 308 ++++ README.md | 625 ++++++- ansible.cfg | 17 + inventory/prod/group_vars/all.yml | 5 + inventory/prod/group_vars/control_plane.yml | 4 + inventory/prod/group_vars/gpu_workers.yml | 26 + inventory/prod/group_vars/k8s_cluster.yml | 7 + inventory/prod/group_vars/manager_nodes.yml | 32 + inventory/prod/group_vars/traefik.yml | 146 ++ inventory/prod/group_vars/workers.yml | 12 + inventory/prod/hosts.yml | 26 + playbooks/setup_control_plane.yml | 6 + playbooks/setup_flux.yml | 5 + playbooks/setup_gbm_dev_access.yml | 6 + playbooks/setup_gitlab_agent.yml | 5 + playbooks/setup_gpu.yml | 13 + playbooks/setup_gpu_model_storage.yml | 10 + playbooks/setup_logging.yml | 23 + playbooks/setup_longhorn.yml | 11 + playbooks/setup_manager.yml | 6 + playbooks/setup_minio.yml | 6 + playbooks/setup_sealed_secrets.yml | 6 + playbooks/setup_traefik.yml | 29 + playbooks/setup_worker_plane.yml | 6 + requirements.yml | 8 + research/apps/hd-portal.md | 496 ++++++ research/apps/rkn-list-poller.md | 361 ++++ research/flux.md | 1152 +++++++++++++ research/logging/plg.md | 1275 ++++++++++++++ research/longhorn.md | 453 +++++ research/minio.md | 788 +++++++++ research/traefik-forwardauth.md | 173 ++ research/traefik.md | 494 ++++++ roles/flux/README.md | 1501 +++++++++++++++++ roles/flux/defaults/main.yml | 19 + roles/flux/handlers/main.yml | 1 + roles/flux/meta/main.yml | 11 + roles/flux/tasks/bootstrap.yml | 25 + roles/flux/tasks/install.yml | 48 + roles/flux/tasks/main.yml | 6 + roles/gitlab_agent/defaults/main.yml | 31 + roles/gitlab_agent/handlers/main.yml | 1 + roles/gitlab_agent/meta/main.yml | 11 + roles/gitlab_agent/tasks/config.yml | 87 + roles/gitlab_agent/tasks/helm.yml | 36 + roles/gitlab_agent/tasks/main.yml | 6 + roles/gpu_device_plugin/defaults/main.yml | 6 + roles/gpu_device_plugin/meta/main.yml | 11 + roles/gpu_device_plugin/tasks/deploy.yml | 22 + roles/gpu_device_plugin/tasks/label.yml | 19 + roles/gpu_device_plugin/tasks/main.yml | 6 + roles/gpu_model_storage/README.md | 129 ++ roles/gpu_model_storage/defaults/main.yml | 10 + roles/gpu_model_storage/meta/main.yml | 11 + roles/gpu_model_storage/tasks/filesystem.yml | 5 + roles/gpu_model_storage/tasks/main.yml | 16 + roles/gpu_model_storage/tasks/mount.yml | 19 + roles/gpu_model_storage/tasks/partition.yml | 17 + roles/gpu_model_storage/tasks/raid.yml | 28 + roles/gpu_prereqs/defaults/main.yml | 3 + roles/gpu_prereqs/handlers/main.yml | 6 + roles/gpu_prereqs/meta/main.yml | 11 + roles/gpu_prereqs/tasks/containerd.yml | 19 + roles/gpu_prereqs/tasks/driver_check.yml | 15 + roles/gpu_prereqs/tasks/main.yml | 9 + roles/gpu_prereqs/tasks/toolkit.yml | 12 + roles/k8s_control_plane/defaults/main.yml | 24 + roles/k8s_control_plane/handlers/main.yml | 17 + roles/k8s_control_plane/meta/main.yml | 11 + roles/k8s_control_plane/tasks/containerd.yml | 57 + .../k8s_control_plane/tasks/kubeadm_init.yml | 133 ++ roles/k8s_control_plane/tasks/kubernetes.yml | 34 + roles/k8s_control_plane/tasks/main.yml | 12 + .../k8s_control_plane/tasks/prerequisites.yml | 104 ++ .../templates/kubeadm_config.yml.j2 | 20 + roles/k8s_gbm_dev/defaults/main.yml | 18 + roles/k8s_gbm_dev/tasks/main.yml | 16 + roles/k8s_gbm_dev/tasks/rbac.yml | 57 + roles/k8s_gbm_dev/tasks/token.yml | 54 + .../templates/dev-clusterrole.yml.j2 | 11 + .../templates/dev-clusterrolebinding.yml.j2 | 13 + roles/k8s_gbm_dev/templates/dev-role.yml.j2 | 63 + .../templates/dev-rolebinding.yml.j2 | 14 + .../templates/dev-serviceaccount.yml.j2 | 6 + .../templates/dev-token-secret.yml.j2 | 10 + roles/k8s_manager/defaults/main.yml | 32 + roles/k8s_manager/handlers/main.yml | 6 + roles/k8s_manager/meta/main.yml | 11 + roles/k8s_manager/tasks/dashboard.yml | 91 + roles/k8s_manager/tasks/main.yml | 133 ++ .../templates/dashboard_admin.yml.j2 | 19 + roles/k8s_worker/defaults/main.yml | 6 + roles/k8s_worker/handlers/main.yml | 12 + roles/k8s_worker/meta/main.yml | 11 + roles/k8s_worker/tasks/containerd.yml | 57 + roles/k8s_worker/tasks/kubeadm_join.yml | 19 + roles/k8s_worker/tasks/kubernetes.yml | 33 + roles/k8s_worker/tasks/main.yml | 12 + roles/k8s_worker/tasks/prerequisites.yml | 94 ++ roles/logging/README.md | 357 ++++ roles/logging/defaults/main.yml | 26 + roles/logging/tasks/alloy.yml | 20 + roles/logging/tasks/grafana.yml | 42 + roles/logging/tasks/loki.yml | 37 + roles/logging/tasks/main.yml | 18 + roles/logging/tasks/minio-user.yml | 152 ++ roles/logging/tasks/namespace.yml | 18 + roles/logging/tasks/prometheus.yml | 36 + roles/logging/templates/alloy-values.yml.j2 | 105 ++ roles/logging/templates/grafana-values.yml.j2 | 49 + roles/logging/templates/loki-values.yml.j2 | 64 + .../templates/prometheus-values.yml.j2 | 69 + roles/longhorn/README.md | 832 +++++++++ roles/longhorn/defaults/main.yml | 14 + roles/longhorn/handlers/main.yml | 1 + roles/longhorn/meta/main.yml | 11 + roles/longhorn/tasks/annotate.yml | 28 + roles/longhorn/tasks/disks.yml | 145 ++ roles/longhorn/tasks/helm.yml | 35 + roles/longhorn/tasks/main.yml | 9 + roles/longhorn_prereqs/defaults/main.yml | 6 + roles/longhorn_prereqs/handlers/main.yml | 1 + roles/longhorn_prereqs/meta/main.yml | 11 + roles/longhorn_prereqs/tasks/disks.yml | 37 + roles/longhorn_prereqs/tasks/firewall.yml | 11 + roles/longhorn_prereqs/tasks/kernel.yml | 15 + roles/longhorn_prereqs/tasks/main.yml | 16 + roles/longhorn_prereqs/tasks/packages.yml | 20 + roles/longhorn_prereqs/tasks/selinux.yml | 20 + roles/minio/README.md | 347 ++++ roles/minio/defaults/main.yml | 23 + roles/minio/tasks/helm.yml | 37 + roles/minio/tasks/main.yml | 9 + roles/minio/tasks/namespace.yml | 18 + roles/minio/tasks/storageclass.yml | 14 + roles/minio/templates/minio-values.yml.j2 | 68 + roles/minio/templates/storageclass.yml.j2 | 12 + roles/sealed_secrets/defaults/main.yml | 11 + roles/sealed_secrets/tasks/backup.yml | 33 + roles/sealed_secrets/tasks/cli.yml | 44 + roles/sealed_secrets/tasks/helm.yml | 31 + roles/sealed_secrets/tasks/main.yml | 9 + roles/traefik/README.md | 485 ++++++ roles/traefik/defaults/main.yml | 5 + roles/traefik/tasks/firewall.yml | 21 + roles/traefik/tasks/helm.yml | 51 + roles/traefik/tasks/main.yml | 9 + roles/traefik/tasks/middleware.yml | 21 + roles/traefik/tasks/routes.yml | 42 + .../templates/basicauth-middleware.yml.j2 | 9 + .../traefik/templates/ingressroute-tcp.yml.j2 | 14 + roles/traefik/templates/ingressroute.yml.j2 | 21 + roles/traefik/templates/traefik-values.yml.j2 | 52 + 155 files changed, 13549 insertions(+), 1 deletion(-) create mode 100644 .ansible-lint create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 ansible.cfg create mode 100644 inventory/prod/group_vars/all.yml create mode 100644 inventory/prod/group_vars/control_plane.yml create mode 100644 inventory/prod/group_vars/gpu_workers.yml create mode 100644 inventory/prod/group_vars/k8s_cluster.yml create mode 100644 inventory/prod/group_vars/manager_nodes.yml create mode 100644 inventory/prod/group_vars/traefik.yml create mode 100644 inventory/prod/group_vars/workers.yml create mode 100644 inventory/prod/hosts.yml create mode 100644 playbooks/setup_control_plane.yml create mode 100644 playbooks/setup_flux.yml create mode 100644 playbooks/setup_gbm_dev_access.yml create mode 100644 playbooks/setup_gitlab_agent.yml create mode 100644 playbooks/setup_gpu.yml create mode 100644 playbooks/setup_gpu_model_storage.yml create mode 100644 playbooks/setup_logging.yml create mode 100644 playbooks/setup_longhorn.yml create mode 100644 playbooks/setup_manager.yml create mode 100644 playbooks/setup_minio.yml create mode 100644 playbooks/setup_sealed_secrets.yml create mode 100644 playbooks/setup_traefik.yml create mode 100644 playbooks/setup_worker_plane.yml create mode 100644 requirements.yml create mode 100644 research/apps/hd-portal.md create mode 100644 research/apps/rkn-list-poller.md create mode 100644 research/flux.md create mode 100644 research/logging/plg.md create mode 100644 research/longhorn.md create mode 100644 research/minio.md create mode 100644 research/traefik-forwardauth.md create mode 100644 research/traefik.md create mode 100644 roles/flux/README.md create mode 100644 roles/flux/defaults/main.yml create mode 100644 roles/flux/handlers/main.yml create mode 100644 roles/flux/meta/main.yml create mode 100644 roles/flux/tasks/bootstrap.yml create mode 100644 roles/flux/tasks/install.yml create mode 100644 roles/flux/tasks/main.yml create mode 100644 roles/gitlab_agent/defaults/main.yml create mode 100644 roles/gitlab_agent/handlers/main.yml create mode 100644 roles/gitlab_agent/meta/main.yml create mode 100644 roles/gitlab_agent/tasks/config.yml create mode 100644 roles/gitlab_agent/tasks/helm.yml create mode 100644 roles/gitlab_agent/tasks/main.yml create mode 100644 roles/gpu_device_plugin/defaults/main.yml create mode 100644 roles/gpu_device_plugin/meta/main.yml create mode 100644 roles/gpu_device_plugin/tasks/deploy.yml create mode 100644 roles/gpu_device_plugin/tasks/label.yml create mode 100644 roles/gpu_device_plugin/tasks/main.yml create mode 100644 roles/gpu_model_storage/README.md create mode 100644 roles/gpu_model_storage/defaults/main.yml create mode 100644 roles/gpu_model_storage/meta/main.yml create mode 100644 roles/gpu_model_storage/tasks/filesystem.yml create mode 100644 roles/gpu_model_storage/tasks/main.yml create mode 100644 roles/gpu_model_storage/tasks/mount.yml create mode 100644 roles/gpu_model_storage/tasks/partition.yml create mode 100644 roles/gpu_model_storage/tasks/raid.yml create mode 100644 roles/gpu_prereqs/defaults/main.yml create mode 100644 roles/gpu_prereqs/handlers/main.yml create mode 100644 roles/gpu_prereqs/meta/main.yml create mode 100644 roles/gpu_prereqs/tasks/containerd.yml create mode 100644 roles/gpu_prereqs/tasks/driver_check.yml create mode 100644 roles/gpu_prereqs/tasks/main.yml create mode 100644 roles/gpu_prereqs/tasks/toolkit.yml create mode 100644 roles/k8s_control_plane/defaults/main.yml create mode 100644 roles/k8s_control_plane/handlers/main.yml create mode 100644 roles/k8s_control_plane/meta/main.yml create mode 100644 roles/k8s_control_plane/tasks/containerd.yml create mode 100644 roles/k8s_control_plane/tasks/kubeadm_init.yml create mode 100644 roles/k8s_control_plane/tasks/kubernetes.yml create mode 100644 roles/k8s_control_plane/tasks/main.yml create mode 100644 roles/k8s_control_plane/tasks/prerequisites.yml create mode 100644 roles/k8s_control_plane/templates/kubeadm_config.yml.j2 create mode 100644 roles/k8s_gbm_dev/defaults/main.yml create mode 100644 roles/k8s_gbm_dev/tasks/main.yml create mode 100644 roles/k8s_gbm_dev/tasks/rbac.yml create mode 100644 roles/k8s_gbm_dev/tasks/token.yml create mode 100644 roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2 create mode 100644 roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2 create mode 100644 roles/k8s_gbm_dev/templates/dev-role.yml.j2 create mode 100644 roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2 create mode 100644 roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2 create mode 100644 roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2 create mode 100644 roles/k8s_manager/defaults/main.yml create mode 100644 roles/k8s_manager/handlers/main.yml create mode 100644 roles/k8s_manager/meta/main.yml create mode 100644 roles/k8s_manager/tasks/dashboard.yml create mode 100644 roles/k8s_manager/tasks/main.yml create mode 100644 roles/k8s_manager/templates/dashboard_admin.yml.j2 create mode 100644 roles/k8s_worker/defaults/main.yml create mode 100644 roles/k8s_worker/handlers/main.yml create mode 100644 roles/k8s_worker/meta/main.yml create mode 100644 roles/k8s_worker/tasks/containerd.yml create mode 100644 roles/k8s_worker/tasks/kubeadm_join.yml create mode 100644 roles/k8s_worker/tasks/kubernetes.yml create mode 100644 roles/k8s_worker/tasks/main.yml create mode 100644 roles/k8s_worker/tasks/prerequisites.yml create mode 100644 roles/logging/README.md create mode 100644 roles/logging/defaults/main.yml create mode 100644 roles/logging/tasks/alloy.yml create mode 100644 roles/logging/tasks/grafana.yml create mode 100644 roles/logging/tasks/loki.yml create mode 100644 roles/logging/tasks/main.yml create mode 100644 roles/logging/tasks/minio-user.yml create mode 100644 roles/logging/tasks/namespace.yml create mode 100644 roles/logging/tasks/prometheus.yml create mode 100644 roles/logging/templates/alloy-values.yml.j2 create mode 100644 roles/logging/templates/grafana-values.yml.j2 create mode 100644 roles/logging/templates/loki-values.yml.j2 create mode 100644 roles/logging/templates/prometheus-values.yml.j2 create mode 100644 roles/longhorn/README.md create mode 100644 roles/longhorn/defaults/main.yml create mode 100644 roles/longhorn/handlers/main.yml create mode 100644 roles/longhorn/meta/main.yml create mode 100644 roles/longhorn/tasks/annotate.yml create mode 100644 roles/longhorn/tasks/disks.yml create mode 100644 roles/longhorn/tasks/helm.yml create mode 100644 roles/longhorn/tasks/main.yml create mode 100644 roles/longhorn_prereqs/defaults/main.yml create mode 100644 roles/longhorn_prereqs/handlers/main.yml create mode 100644 roles/longhorn_prereqs/meta/main.yml create mode 100644 roles/longhorn_prereqs/tasks/disks.yml create mode 100644 roles/longhorn_prereqs/tasks/firewall.yml create mode 100644 roles/longhorn_prereqs/tasks/kernel.yml create mode 100644 roles/longhorn_prereqs/tasks/main.yml create mode 100644 roles/longhorn_prereqs/tasks/packages.yml create mode 100644 roles/longhorn_prereqs/tasks/selinux.yml create mode 100644 roles/minio/README.md create mode 100644 roles/minio/defaults/main.yml create mode 100644 roles/minio/tasks/helm.yml create mode 100644 roles/minio/tasks/main.yml create mode 100644 roles/minio/tasks/namespace.yml create mode 100644 roles/minio/tasks/storageclass.yml create mode 100644 roles/minio/templates/minio-values.yml.j2 create mode 100644 roles/minio/templates/storageclass.yml.j2 create mode 100644 roles/sealed_secrets/defaults/main.yml create mode 100644 roles/sealed_secrets/tasks/backup.yml create mode 100644 roles/sealed_secrets/tasks/cli.yml create mode 100644 roles/sealed_secrets/tasks/helm.yml create mode 100644 roles/sealed_secrets/tasks/main.yml create mode 100644 roles/traefik/README.md create mode 100644 roles/traefik/defaults/main.yml create mode 100644 roles/traefik/tasks/firewall.yml create mode 100644 roles/traefik/tasks/helm.yml create mode 100644 roles/traefik/tasks/main.yml create mode 100644 roles/traefik/tasks/middleware.yml create mode 100644 roles/traefik/tasks/routes.yml create mode 100644 roles/traefik/templates/basicauth-middleware.yml.j2 create mode 100644 roles/traefik/templates/ingressroute-tcp.yml.j2 create mode 100644 roles/traefik/templates/ingressroute.yml.j2 create mode 100644 roles/traefik/templates/traefik-values.yml.j2 diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 0000000..04f2830 --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,39 @@ +--- +profile: min + +exclude_paths: + - collections/ + - .ansible/ + +skip_list: + # Task names follow the project's documented "Category | Action" convention + # (see CLAUDE.md), which intentionally starts with a lowercase category (e.g. + # "kubectl | Download binary"). Renaming ~50 tasks repo-wide would break that + # convention for no functional benefit. + - name[casing] + + # Long template expressions in JSON Patch paths are more readable without + # extra spaces. The ansible-lint var-spacing rule is overly strict for this + # domain-specific syntax. + - var-spacing + + # Vars are named after their role's domain concept (e.g. k8s_version, + # longhorn_disks) and are shared/read across roles, defaults, group_vars and + # templates. Enforcing a role-name prefix here would require a repo-wide, + # high-risk rename touching every layer that references these variables. + - var-naming[no-role-prefix] + + # Several tasks intentionally run shell/command modules whose "changed" + # state is either always true (idempotent by design, e.g. helm upgrade + # --install) or checked via a separate registered/failed_when guard rather + # than changed_when. + - no-changed-when + + # A few changed-triggered tasks (e.g. Traefik rollout restart, GitLab fleet + # repo commit/push) are deliberately inline rather than handlers, since they + # need to run mid-playbook with immediate ordering guarantees. + - no-handler + + # Some Helm values/service description lines exceed 160 chars and are not + # worth artificially wrapping. + - yaml[line-length] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca3ebfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +LOCAL_RUN.md +collections/ +*.retry +/tmp/ +*.log +host_facts/ +public/ +.ansible/ +.claude/ +.mcp.json +/tmp/ansible_facts_cache/ +*.pyc +__pycache__/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..24682a7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,308 @@ +# GitLab CI pipeline for Kubernetes infrastructure (Ansible) +# +# Required GitLab CI/CD Variables (Settings → CI/CD → Variables): +# ANSIBLE_SSH_USER — SSH username for target servers +# ANSIBLE_SSHKEY_ID_RSA — private SSH key (тип: File или Variable, содержимое id_rsa) +# ANSIBLE_BECOME_PASS — sudo password +# GITLAB_FLUX_TOKEN — GitLab PAT (scope: api) для Flux bootstrap и клонирования fleet repo +# GITLAB_AGENT_TOKEN — токен GitLab Agent (получить в GitLab → Operate → Kubernetes clusters) +# LOKI_MINIO_PASSWORD — пароль пользователя loki в MinIO (создаётся при запуске setup_logging) +# GRAFANA_ADMIN_PASSWORD — пароль администратора Grafana (задаётся в Secret вручную) + +stages: + - validate + - setup + +# ── Shared configuration ─────────────────────────────────────────────────────── +default: + image: python:3.11-slim + before_script: + - apt-get update -qq + - apt-get install -y -qq --no-install-recommends openssh-client git + - pip install --quiet ansible ansible-lint netaddr + - > + for i in 1 2 3; do + ansible-galaxy collection install -r requirements.yml -p collections/ --force && exit 0; + echo "ansible-galaxy collection install failed (attempt $i/3), retrying in 5s..."; + sleep 5; + done; + exit 1 + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - printf '%b\n' "$ANSIBLE_SSHKEY_ID_RSA" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval $(ssh-agent -s) + - ssh-add ~/.ssh/id_rsa + interruptible: true + +variables: + ANSIBLE_FORCE_COLOR: "1" + ANSIBLE_HOST_KEY_CHECKING: "False" + ANSIBLE_ROLES_PATH: roles + ANSIBLE_COLLECTIONS_PATH: collections + ANSIBLE_FILTER_PLUGINS: filter_plugins + PIP_NO_CACHE_DIR: "1" + INVENTORY: inventory/prod + +# ── Stage: validate ──────────────────────────────────────────────────────────── +lint: + stage: validate + script: + - ansible-lint playbooks/ roles/ + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH + +syntax-check: + stage: validate + script: + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_manager.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_control_plane.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_worker_plane.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_longhorn.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_traefik.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_flux.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_gitlab_agent.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_minio.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_logging.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_sealed_secrets.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_dev_access.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_gpu.yml + - > + ansible-playbook + --syntax-check + -i $INVENTORY + playbooks/setup_gpu_model_storage.yml + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH + +# ── Stage: setup ─────────────────────────────────────────────────────────────── +setup:manager_nodes: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_manager.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:control_plane: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_control_plane.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:workers: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_worker_plane.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:longhorn: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_longhorn.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:traefik: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_traefik.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:flux: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_flux.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:gitlab_agent: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_gitlab_agent.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:minio: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_minio.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:logging: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_logging.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:sealed_secrets: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_sealed_secrets.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:dev_access: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_dev_access.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:gpu_workers: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_gpu.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production + +setup:gpu_model_storage: + stage: setup + script: + - > + ansible-playbook + -i $INVENTORY + playbooks/setup_gpu_model_storage.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production diff --git a/README.md b/README.md index 0be2f40..4614523 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,625 @@ -# k8s +# Kubernetes Infrastructure +> **Корпоративный стандарт.** Ansible-развёртывание кластера Kubernetes на Rocky Linux 9. +> Локальный запуск через CLI и автоматизированный запуск через GitLab CI/CD. + +--- + +## Содержание + +- [Инфраструктура](#инфраструктура) +- [Стек технологий](#стек-технологий) +- [Структура репозитория](#структура-репозитория) +- [Роли и компоненты](#роли-и-компоненты) +- - **Стандарт работы с хранилищем** [`roles/longhorn/README.md`](roles/longhorn/README.md) +- - **Стандарт публикации сервисов** [`roles/traefik/README.md`](roles/traefik/README.md) +- - **Стандарт GitOps-деплоя приложений** [`roles/flux/README.md`](roles/flux/README.md) +- - **Стандарт объектного хранилища S3** [`roles/minio/README.md`](roles/minio/README.md) +- - **Стандарт централизованного сбора логов** [`roles/logging/README.md`](roles/logging/README.md) +- - Sealed Secrets (kubeseal) +- [CI/CD-пайплайн](#cicd-пайплайн) +- [Порядок развёртывания](#порядок-развёртывания) +- [Архитектурные решения](#архитектурные-решения) +- [Локальная разработка](#локальная-разработка) +- [Добавление нового компонента](#добавление-нового-компонента) + +--- + +## Инфраструктура + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Operator workstation (manager_nodes) │ +│ k8s-manager-01 10.203.0.92 │ +│ kubectl · helm · k9s · kubectx/kubens │ +│ Kubernetes Dashboard token → ~/.kube/dashboard-token │ +└────────────────────────┬────────────────────────────────────────┘ + │ kubectl / helm (kubeconfig) +┌────────────────────────▼────────────────────────────────────────┐ +│ Kubernetes cluster (k8s_cluster) │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Control plane k8s-master-01 10.203.0.97 │ │ +│ │ kube-apiserver · etcd · controller-manager · scheduler │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Worker k8s-worker-01 10.203.0.96 │ │ +│ │ kubelet · kube-proxy · Longhorn disk agent │ │ +│ │ Traefik DaemonSet │ │ +│ │ :10001 → kubernetes-dashboard (ClusterIP :443) │ │ +│ │ :10002 → longhorn-frontend (ClusterIP :80) [auth] │ │ +│ │ :10003 → grafana (ClusterIP :80) │ │ +│ │ :10005 → minio (ClusterIP :9000) │ │ +│ │ :10006 → minio-console (ClusterIP :9001) │ │ +│ │ :10103 → db/bgbilling-dev (TCP :3306) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ CNI: Flannel v0.26.1 │ Storage: Longhorn 1.7.2 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Узлы кластера + +| Hostname | IP | Группа инвентаря | Роль | +|---|---|---|---| +| k8s-manager-01 | 10.203.0.92 | `manager_nodes` | Рабочая станция оператора | +| k8s-master-01 | 10.203.0.97 | `control_plane` | API server, etcd, scheduler | +| k8s-worker-01 | 10.203.0.96 | `workers` | Вычислительный узел | +| k8s-worker-02 | 10.203.0.212 | `workers` → `gpu_workers` | GPU-узел (NVIDIA), Ollama-стек | +| k8s-worker-NN | TBD | `workers` | Дополнительные вычислительные узлы | + +> `manager_nodes` — **не** Kubernetes-узел. Это рабочая станция оператора, с которой запускаются `kubectl` и `helm`. + +### Сервисы и точки доступа + +| Сервис | URL | Аутентификация | +|---|---|---| +| Kubernetes Dashboard | `http://10.203.0.96:10001` | токен из `~/.kube/dashboard-token` | +| Longhorn UI | `http://10.203.0.96:10002` | BasicAuth (Secret `traefik-auth-longhorn` в ns `traefik`) | +| Grafana | `http://10.203.0.96:10003` | логин Grafana (admin, пароль из Secret `grafana-admin-secret`) | +| MinIO S3 API | `http://10.203.0.96:10005` | AWS Signature v4 (встроенная аутентификация MinIO) | +| MinIO Console | `http://10.203.0.96:10006` | логин MinIO (rootUser из Secret `minio-root-credentials`) | +| MariaDB BGBilling | `10.203.0.96:10103` | TCP-проброс (без HTTP); аутентификация средствами MariaDB | +| Ollama Proxy (k8s_ai) | `http://10.203.0.96:10200` | своя аутентификация приложения | +| Open WebUI (k8s_ai) | `http://10.203.0.96:10201` | своя аутентификация приложения | + +--- + +## Стек технологий + +| Компонент | Версия | Управляется | +|---|---|---| +| ОС | Rocky Linux 9 | Ansible | +| Kubernetes | 1.33 (pkgs.k8s.io) | Ansible → `k8s_control_plane`, `k8s_worker` | +| Container runtime | containerd (Docker CE repo) | Ansible | +| CNI | Flannel v0.26.1 / Calico v3.27.0 | Ansible → `k8s_control_plane` | +| Постоянное хранилище | Longhorn 1.7.2 | Ansible → `longhorn_prereqs`, `longhorn` | +| Ingress / proxy | Traefik 32.1.0 | Ansible → `traefik` | +| Kubernetes Dashboard | v2.7.0 | Ansible → `k8s_manager` | +| Объектное хранилище | MinIO 5.4.0 (chart) | Ansible → `minio` | +| Централизованные логи | Loki 7.0.0 + Grafana Alloy 1.8.2 + Grafana 10.5.15 | Ansible → `logging` | +| Шифрование Secret'ов | Sealed Secrets 2.16.1 | Ansible → `sealed_secrets` | +| GitOps | Flux CD (latest stable) | Ansible → `flux` | +| GitLab Agent | agentk (latest stable) | Ansible → `gitlab_agent` | +| Автоматизация | Ansible (посм. requirements.yml) | — | +| CI/CD | GitLab CI/CD | `.gitlab-ci.yml` | + +--- + +## Структура репозитория + +``` +.gitlab-ci.yml — CI/CD-пайплайн (стадии validate → setup) +ansible.cfg — кэширование фактов, pipelining, YAML-вывод +requirements.yml — коллекции Ansible Galaxy + +inventory/prod/ + hosts.yml — статический список хостов (3 группы) + group_vars/ + all.yml — SSH-учётные данные из переменных окружения + k8s_cluster.yml — общие переменные для control_plane + workers + control_plane.yml — k8s_version, CIDR, CNI + workers.yml — конфигурация дисков Longhorn + manager_nodes.yml — версии инструментов, часовой пояс + traefik.yml — traefik_port_map: таблица портов и backend-сервисов + gpu_workers.yml — node_labels, override дисков Longhorn для GPU-узлов + +playbooks/ — один плейбук = одна CI/CD-задача + setup_manager.yml + setup_control_plane.yml + setup_worker_plane.yml + setup_longhorn.yml + setup_traefik.yml + setup_flux.yml + setup_gitlab_agent.yml + setup_minio.yml + setup_logging.yml + setup_sealed_secrets.yml + setup_gpu.yml + +roles/ + k8s_manager/ + k8s_control_plane/ + k8s_worker/ + longhorn_prereqs/ + longhorn/ README.md ← стандарт работы с хранилищем + traefik/ README.md ← стандарт публикации сервисов + flux/ README.md ← стандарт GitOps-деплоя приложений + gitlab_agent/ + minio/ README.md ← стандарт объектного хранилища S3 + logging/ README.md ← стандарт централизованного сбора логов + sealed_secrets/ + gpu_prereqs/ — nvidia-container-toolkit + containerd runtime на GPU-узле + gpu_device_plugin/ — node label + NVIDIA k8s-device-plugin с manager-ноды + gpu_model_storage/ README.md ← стандарт локального RAID1-хранилища под модели +``` + +--- + +## Роли и компоненты + +### `k8s_control_plane` · `k8s_worker` · `k8s_manager` + +Базовые роли развёртывания кластера. Не имеют отдельного role-README — полное описание в разделе [Архитектурные решения](#архитектурные-решения). + +| Роль | Что делает | Плейбук | +|---|---|---| +| `k8s_control_plane` | containerd → kubelet → kubeadm init → CNI → kubeconfig | `setup_control_plane.yml` | +| `k8s_worker` | containerd → kubelet → kubeadm join | `setup_worker_plane.yml` | +| `k8s_manager` | kubectl / helm / k9s, Kubernetes Dashboard | `setup_manager.yml` | + +
+Переменные k8s_control_plane + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `k8s_version` | `1.33` | Версия Kubernetes | +| `pod_network_cidr` | `10.244.0.0/16` | Сеть подов (Flannel) | +| `service_cidr` | `10.96.0.0/12` | Сеть сервисов | +| `cni_plugin` | `flannel` | `flannel` или `calico` | +| `flannel_version` | `v0.26.1` | Версия манифеста Flannel | +| `calico_version` | `v3.27.0` | Версия манифеста Calico | +| `kubeconfig_fetch_to_managers` | `true` | Копировать kubeconfig на manager-узел | + +
+ +
+Переменные k8s_worker + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `k8s_version` | `1.33` | Должна совпадать с control plane | +| `worker_join_source_host` | первый хост `control_plane` | Откуда читать join-команду | + +
+ +
+Переменные k8s_manager + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `dashboard_insecure` | `false` | HTTP-режим Dashboard (переопределяется в `true` в group_vars) | +| `dashboard_manifest_url` | GitHub raw v2.7.0 | Без зависимости от CDN | +| `k8s_manager_upgrade_packages` | `false` | Полное обновление ОС (opt-in) | + +
+ +--- + +### `gpu_prereqs` · `gpu_device_plugin` + +Подключение GPU-узла к кластеру. Драйвер NVIDIA ставится вручную заранее — роль его не устанавливает, только проверяет наличие (`nvidia-smi`). Запускается **после** `setup_worker_plane.yml`, отдельным плейбуком `setup_gpu.yml`, только для узлов группы `gpu_workers` (подгруппа `workers`). + +| Роль | Запускается на | Что делает | +|---|---|---| +| `gpu_prereqs` | `gpu_workers` | Проверка драйвера, установка `nvidia-container-toolkit`, `nvidia-ctk runtime configure` для containerd (default runtime) | +| `gpu_device_plugin` | `manager_nodes` | `kubectl label node gpu=nvidia` для каждого узла `gpu_workers`; `kubectl apply` манифеста NVIDIA k8s-device-plugin | + +
+Переменные gpu_prereqs / gpu_device_plugin + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `nvidia_container_toolkit_version` | `""` (последняя) | Версия пакета `nvidia-container-toolkit` | +| `node_labels` | `{gpu: nvidia}` (в `gpu_workers.yml`) | Лейблы, навешиваемые на узел после join | +| `gpu_device_plugin_version` | `v0.17.0` | Тег релиза NVIDIA k8s-device-plugin | +| `gpu_device_plugin_manifest_url` | GitHub raw для указанной версии | Статический манифест DaemonSet | + +
+ +> Порядок обязателен: `setup_worker_plane.yml` перезаписывает `/etc/containerd/config.toml` с нуля, поэтому `gpu_prereqs` (донастройка containerd под nvidia runtime) должна выполняться **после**, отдельным прогоном. + +--- + +### `gpu_model_storage` + +Выделенный локальный RAID1-раздел под файлы моделей (Ollama) на GPU-узлах. `gpu_workers` исключены из Longhorn (`longhorn_disks: []`), а корневая ФС слишком маленькая (~30 ГБ) для хранения моделей — поэтому под них нарезается отдельный раздел из неразмеченного места на тех же зеркальных дисках, что и ОС. Запускается плейбуком `setup_gpu_model_storage.yml`, независимо от порядка `kubeadm join`. + +
+Переменные gpu_model_storage + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `gpu_model_storage_devices` | `[]` | Диски-члены RAID1 (пусто = роль выключена); задаётся в `gpu_workers.yml` | +| `gpu_model_storage_partition_size_gb` | `1000` | Размер новой партиции на каждом диске, ГБ | +| `gpu_model_storage_raid_device` | `/dev/md1` | Имя нового mdadm-массива | +| `gpu_model_storage_mountpoint` | `/mnt/ollama-models` | Точка монтирования | +| `gpu_model_storage_fstype` | `xfs` | Файловая система | + +
+ +> **Standard**: [roles/gpu_model_storage/README.md](roles/gpu_model_storage/README.md) + +--- + +### `longhorn_prereqs` · `longhorn` + +Двухэтапное развёртывание распределённого блочного хранилища. + +| Роль | Запускается на | Что делает | +|---|---|---| +| `longhorn_prereqs` | `workers` | iSCSI / NFS пакеты, модули ядра, SELinux CIL-политика, форматирование и монтирование дисков XFS | +| `longhorn` | `manager_nodes` | Аннотации узлов с disk-config JSON, `helm upgrade --install longhorn` | + +После установки доступен StorageClass `longhorn` (default). Приложения используют его через `storageClassName: longhorn` в PVC. + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `longhorn_chart_version` | `1.7.2` | Версия Helm-чарта | +| `longhorn_namespace` | `longhorn-system` | Namespace | +| `longhorn_default_replica_count` | `1` | Реплик на том; поднять до 2–3 при добавлении worker-нод | +| `longhorn_minimal_available_storage_percentage` | `10` | Минимальный свободный % хранилища | +| `longhorn_storage_over_provisioning_percentage` | `100` | Коэффициент over-provisioning | +| `longhorn_disks` | `[{device: /dev/sdb, mountpoint: /mnt/longhorn-disk1}, ...]` | Диски для форматирования; переопределяется в hosts.yml | + +
+ +> **Стандарт работы с хранилищем** (PVC, StatefulSet, реплики, бэкапы, диагностика): +> [`roles/longhorn/README.md`](roles/longhorn/README.md) + +--- + +### `traefik` + +HTTP/TCP port-proxy. DaemonSet на worker-нодах. Каждый сервис получает выделенный TCP-порт из диапазона 10001–10999. Вся топология определяется через `traefik_port_map` в `inventory/prod/group_vars/traefik.yml`. Поддерживаются два режима маршрутизации: HTTP (`IngressRoute`, поле `protocol` не задаётся) и raw TCP (`IngressRouteTCP` с `HostSNI("*")`, `protocol: tcp` в port_map). + +| Задача | Что делает | +|---|---| +| `firewall.yml` | Открывает 10000–10999/tcp; помещает `flannel.1`, `cni0` в зону `trusted` | +| `helm.yml` | `helm upgrade --install traefik` как DaemonSet с EntryPoint-ами из port_map | +| `middleware.yml` | Применяет `Middleware` CRD (BasicAuth) для записей с `basicauth.enabled: true` | +| `routes.yml` | Применяет `IngressRoute` (HTTP) или `IngressRouteTCP` (TCP) в зависимости от `protocol` в port_map | + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `traefik_chart_version` | `32.1.0` | Версия Helm-чарта | +| `traefik_namespace` | `traefik` | Namespace | +| `traefik_port_map` | `[]` | Список сервисов; переопределяется в `group_vars/traefik.yml` | + +
+ +> **Стандарт публикации сервисов** (добавление порта, BasicAuth, диагностика, таблица активных портов): +> [`roles/traefik/README.md`](roles/traefik/README.md) + +--- + +### `flux` + +Устанавливает Flux CD CLI и выполняет `flux bootstrap gitlab`, подключая кластер к fleet-репозиторию `k8s-fleet` на `gitlab.gigacoms.info`. + +| Задача | Что делает | +|---|---| +| `install.yml` | Flux CLI → `/usr/local/bin/flux`, bash-completion, `flux check --pre` | +| `bootstrap.yml` | `flux bootstrap gitlab` — deploy key, Flux-контроллеры в `flux-system`, манифесты в `clusters/production` | + +Fleet-репозиторий: `gitlab.gigacoms.info/k8s/k8s-fleet` · путь: `clusters/production` + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `flux_version` | `""` (latest stable) | Версия Flux CLI | +| `flux_gitlab_hostname` | `gitlab.gigacoms.info` | Хост GitLab | +| `flux_gitlab_owner` | `k8s` | GitLab-группа fleet-репозитория | +| `flux_gitlab_repository` | `k8s-fleet` | Имя fleet-репозитория | +| `flux_gitlab_branch` | `main` | Ветка | +| `flux_gitlab_path` | `clusters/production` | Путь внутри репозитория | +| `flux_gitlab_token` | из `$GITLAB_FLUX_TOKEN` | GitLab PAT (scope: api) | + +
+ +> **Стандарт GitOps-деплоя приложений** (структура fleet-repo, multi-branch, примеры с Longhorn и Traefik, CI/CD-шаблон): +> [`roles/flux/README.md`](roles/flux/README.md) + +--- + +### `minio` + +S3-совместимое объектное хранилище на базе MinIO. Запускается как standalone StatefulSet на worker-ноде, хранилище предоставляется отдельным StorageClass `longhorn-minio` (reclaimPolicy: Retain). Доступ — через Traefik на портах 10005 (S3 API) и 10006 (Console UI). + +| Задача | Что делает | +|---|---| +| `storageclass.yml` | Создаёт StorageClass `longhorn-minio` (Retain, numberOfReplicas=1) | +| `namespace.yml` | Создаёт namespace `minio` | +| `helm.yml` | `helm upgrade --install minio minio/minio` с values из шаблона | + +**Предварительное условие:** создать Secret `minio-root-credentials` вручную в namespace `minio`: +```bash +kubectl create secret generic minio-root-credentials \ + --from-literal=rootUser=minioadmin \ + --from-literal=rootPassword='СИЛЬНЫЙ_ПАРОЛЬ' \ + -n minio +``` + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `minio_chart_version` | `5.4.0` | Версия Helm-чарта `minio/minio` | +| `minio_image_tag` | `RELEASE.2025-04-22T22-12-26Z` | Тег образа MinIO | +| `minio_namespace` | `minio` | Namespace | +| `minio_storage_class` | `longhorn-minio` | StorageClass для PVC | +| `minio_storage_size` | `1Ti` | Размер PVC | +| `minio_console_url` | `http://10.203.0.96:10006` | URL Console (для MINIO_BROWSER_REDIRECT_URL) | +| `minio_root_secret` | `minio-root-credentials` | Имя Secret с rootUser/rootPassword | +| `minio_buckets` | `[backups, artifacts]` | Бакеты, создаваемые при старте | + +
+ +> **Стандарт объектного хранилища S3** (StorageClass, пользователи, lifecycle, интеграция с Loki, масштабирование): +> [`roles/minio/README.md`](roles/minio/README.md) + +--- + +### `logging` + +PLG-стек централизованного сбора логов: **Loki** (S3 backend → MinIO), **Grafana Alloy** (DaemonSet, замена EOL Promtail), **Grafana** (UI + алерты). Все три компонента разворачиваются в namespace `monitoring` из manager-узла через Helm. + +| Задача | Что делает | +|---|---| +| `namespace.yml` | Создаёт namespace `monitoring` | +| `minio-user.yml` | Создаёт bucket `loki-chunks`, пользователя `loki` с политикой только на этот bucket, Secret `loki-minio-secret` | +| `loki.yml` | `helm upgrade --install loki` в режиме single-binary с S3 backend (MinIO) | +| `alloy.yml` | `helm upgrade --install alloy` как DaemonSet — сбор логов подов + journald на всех нодах | +| `grafana.yml` | `helm upgrade --install grafana` с provisioning datasource Loki | + +**Предварительное условие:** создать Secret `grafana-admin-secret` вручную в namespace `monitoring`: +```bash +kubectl create namespace monitoring +kubectl create secret generic grafana-admin-secret \ + --from-literal=admin-user=admin \ + --from-literal=admin-password='СИЛЬНЫЙ_ПАРОЛЬ' \ + -n monitoring +``` + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `loki_chart_version` | `7.0.0` | Версия Helm-чарта `grafana/loki` | +| `alloy_chart_version` | `1.8.2` | Версия Helm-чарта `grafana/alloy` | +| `grafana_chart_version` | `10.5.15` | Версия Helm-чарта `grafana/grafana` | +| `loki_minio_endpoint` | `http://minio.minio.svc.cluster.local:9000` | S3 endpoint для Loki | +| `loki_minio_bucket` | `loki-chunks` | Bucket для хранения чанков и индексов | +| `loki_retention_days` | `31` | Срок хранения логов в днях | +| `loki_wal_storage_size` | `10Gi` | PVC для WAL/temp Loki (не логи — они в MinIO) | +| `grafana_storage_size` | `5Gi` | PVC для базы данных Grafana | +| `grafana_root_url` | `http://10.203.0.96:10003` | Внешний URL Grafana (Traefik) | +| `grafana_admin_secret` | `grafana-admin-secret` | Имя Secret с admin-user/admin-password | + +
+ +> **Стандарт централизованного сбора логов** (архитектура PLG, масштабирование, переход на Graylog, диагностика): +> [`roles/logging/README.md`](roles/logging/README.md) + +--- + +### `sealed_secrets` + +Разворачивает [Sealed Secrets](https://github.com/bitnami-labs/sealed-secrets) — контроллер для безопасного хранения зашифрованных Secret'ов в Git. Запускается из manager-узла: контроллер — в `kube-system` через Helm, CLI `kubeseal` — на manager-узле. + +| Задача | Что делает | +|---|---| +| `cli.yml` | Скачивает `kubeseal` с GitHub Releases (версия из переменной или latest), идемпотентно | +| `helm.yml` | `helm upgrade --install sealed-secrets` из `bitnami-labs/sealed-secrets` в `kube-system` | + +**Использование после установки:** +```bash +# Зашифровать Secret +kubectl create secret generic my-secret --from-literal=password=mysecret --dry-run=client -o yaml \ + | kubeseal --format yaml > my-sealedsecret.yaml + +# Применить (можно коммитить в Git — зашифрован публичным ключом контроллера) +kubectl apply -f my-sealedsecret.yaml +``` + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `sealed_secrets_chart_version` | `2.16.1` | Версия Helm-чарта | +| `sealed_secrets_namespace` | `kube-system` | Namespace контроллера | +| `sealed_secrets_cli_version` | `""` (latest) | Версия kubeseal CLI; пустая — берёт latest с GitHub | + +
+ +--- + +### `gitlab_agent` + +Устанавливает GitLab Agent for Kubernetes (agentk). Работает совместно с Flux: Flux деплоит, agentk даёт видимость в GitLab UI (Operate → Kubernetes clusters). + +Agentk инициирует исходящее WebSocket-соединение к KAS — входящих портов не требует. + +| Задача | Что делает | +|---|---| +| `config.yml` | Клонирует fleet-repo, создаёт `.gitlab/agents/production/config.yaml`, коммитит и пушит при изменении | +| `helm.yml` | `helm upgrade --install gitlab-agent` с токеном и KAS-адресом | + +**Предварительное условие:** зарегистрировать агент в GitLab UI → Operate → Kubernetes clusters → Connect a cluster → имя `production` → токен в CI/CD переменную `GITLAB_AGENT_TOKEN`. + +
+Переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `gitlab_agent_hostname` | `gitlab.gigacoms.info` | Хост GitLab | +| `gitlab_agent_name` | `production` | Имя агента (совпадает с именем в GitLab UI) | +| `gitlab_agent_namespace` | `gitlab-agent` | Namespace | +| `gitlab_agent_kas_address` | `wss://gitlab.gigacoms.info/-/kubernetes-agent/` | KAS WebSocket-адрес | +| `gitlab_agent_token` | из `$GITLAB_AGENT_TOKEN` | Токен агента | +| `gitlab_agent_ci_access_group` | `k8s` | GitLab-группа с доступом к кластеру через CI | + +
+ +--- + +## CI/CD-пайплайн + +Стадии: **validate** → **setup** + +| Задача | Триггер | Описание | +|---|---|---| +| `validate/lint` | каждый push / MR | `ansible-lint` всех плейбуков и ролей | +| `validate/syntax-check` | каждый push / MR | Синтаксическая проверка всех плейбуков | +| `setup/manager_nodes` | ручной, ветка `main` | `setup_manager.yml` | +| `setup/control_plane` | ручной, ветка `main` | `setup_control_plane.yml` | +| `setup/workers` | ручной, ветка `main` | `setup_worker_plane.yml` | +| `setup/longhorn` | ручной, ветка `main` | `setup_longhorn.yml` | +| `setup/traefik` | ручной, ветка `main` | `setup_traefik.yml` | +| `setup/flux` | ручной, ветка `main` | `setup_flux.yml` | +| `setup/gitlab_agent` | ручной, ветка `main` | `setup_gitlab_agent.yml` | +| `setup/minio` | ручной, ветка `main` | `setup_minio.yml` | +| `setup/logging` | ручной, ветка `main` | `setup_logging.yml` | +| `setup/sealed_secrets` | ручной, ветка `main` | `setup_sealed_secrets.yml` | +| `setup/gpu_workers` | ручной, ветка `main` | `setup_gpu.yml` | +| `setup/gpu_model_storage` | ручной, ветка `main` | `setup_gpu_model_storage.yml` | + +`resource_group: production` — все setup-задачи сериализованы, одновременно выполняется только одна. + +### Переменные GitLab CI/CD (Settings → CI/CD → Variables) + +| Переменная | Описание | +|---|---| +| `ANSIBLE_SSH_USER` | SSH-пользователь для целевых серверов | +| `ANSIBLE_SSHKEY_ID_RSA` | Содержимое приватного SSH-ключа (тип: File) | +| `ANSIBLE_BECOME_PASS` | Пароль sudo | +| `GITLAB_FLUX_TOKEN` | GitLab PAT (scope: api) для Flux bootstrap и клонирования fleet-repo | +| `GITLAB_AGENT_TOKEN` | Токен GitLab Agent (получить в GitLab → Operate → Kubernetes clusters) | +| `MINIO_ROOT_PASSWORD` | Пароль root-пользователя MinIO (используется при ручном создании Secret) | +| `LOKI_MINIO_PASSWORD` | Пароль пользователя `loki` в MinIO (создаётся Ansible при запуске setup_logging) | +| `GRAFANA_ADMIN_PASSWORD` | Пароль администратора Grafana (задаётся в Secret `grafana-admin-secret` вручную) | + +--- + +## Порядок развёртывания + +Для нового кластера — строго в следующем порядке: + +``` +1. setup:manager_nodes — инструменты оператора +2. setup:control_plane — Kubernetes, CNI, Dashboard +3. setup:workers — подключение worker-нод +4. setup:longhorn — хранилище (prereqs на workers → Helm с manager) +5. setup:traefik — порт-прокси (firewall на workers → Helm с manager) +6. setup:minio — объектное хранилище S3 (StorageClass + Helm с manager) +7. setup:flux — GitOps bootstrap +8. setup:gitlab_agent — видимость кластера в GitLab UI +9. setup:logging — PLG-стек (Loki + Alloy + Grafana) +10. setup:sealed_secrets — контроллер Sealed Secrets + kubeseal CLI +11. setup:gpu_workers — только для узлов gpu_workers, после их setup:workers +``` + +> **Перед запуском setup:minio** создать Secret вручную: `kubectl create secret generic minio-root-credentials --from-literal=rootUser=minioadmin --from-literal=rootPassword='...' -n minio` + +> **Перед запуском setup:logging** создать Secret вручную: `kubectl create namespace monitoring && kubectl create secret generic grafana-admin-secret --from-literal=admin-user=admin --from-literal=admin-password='...' -n monitoring` + +--- + +## Архитектурные решения + +### kubeadm: version derive at runtime +`kubernetesVersion` в конфиге kubeadm определяется из `kubeadm version -o short` в runtime. Исключает ошибки несоответствия версий при изменении пакета без изменения переменной. + +### single-node: снятие control-plane taint +Taint `node-role.kubernetes.io/control-plane:NoSchedule` снимается автоматически. Dashboard и прочие workload'ы размещаются на master-узле при single-node-конфигурации. + +### kubeadm join: ежезапускная генерация токена +Join-токены истекают через 24 часа. Роль `k8s_worker` генерирует свежий токен на control plane при каждом запуске; шаг join идемпотентен — пропускается, если узел уже в кластере. + +### Longhorn: монтирование до аннотации +Диски форматируются как XFS и монтируются по UUID (`nofail`) **до** установки Longhorn. Longhorn обнаруживает диски через аннотации узлов — не через автоопределение. Это предотвращает переформатирование при повторном запуске плейбука. + +### Traefik: нет hostname, нет TLS +Каждый сервис получает выделенный порт (10001–10999). Нет hostname-routing, нет TLS — клиент подключается по `http://:`. TLS терминируется на уровне Traefik при необходимости. Dashboard работает в HTTP-режиме (`dashboard_insecure: true`). + +### Flux + agentk: разделение ответственности +Ansible управляет **ОС и кластером** (установка, конфигурация нод). Flux управляет **приложениями внутри кластера** (деплой, обновление образов). agentk обеспечивает **видимость** состояния кластера в GitLab UI. + +### GPU-узлы: отдельный плейбук, вручную ставится только драйвер +`gpu_workers` — подгруппа `workers`. NVIDIA-драйвер ставится на сервер вручную и не управляется Ansible (роль `gpu_prereqs` лишь проверяет `nvidia-smi` и падает, если драйвера нет). `setup_gpu.yml` запускается отдельно и **после** `setup_worker_plane.yml`, так как роль `k8s_worker` перегенерирует `/etc/containerd/config.toml` с нуля и стёрла бы настройку nvidia runtime, если бы порядок был обратным. GPU-узлы исключены из дефолтной конфигурации Longhorn (`longhorn_disks: []` в `gpu_workers.yml`) — это compute-нода, не storage-нода. + +### Firewalld и CNI-интерфейсы + +> **Критично.** `flannel.1` и `cni0` должны быть в зоне `trusted` на каждом узле кластера. + +Без этого pod-to-pod-трафик от workers, приходящий на master, обрабатывается зоной `internal` — она пропускает только явно перечисленные порты. Любой порт нового сервиса молча отбрасывается, даже если порт открыт на уровне хоста. + +При добавлении любой роли с `firewalld` — обязательно добавлять: + +```yaml +- name: Firewall | Trust CNI interfaces + ansible.posix.firewalld: + zone: trusted + interface: "{{ item }}" + permanent: true + state: enabled + loop: + - flannel.1 + - cni0 +``` + +--- + +## Локальная разработка + +```bash +# Установка коллекций Ansible Galaxy +ansible-galaxy collection install -r requirements.yml -p collections/ --force + +# Проверка синтаксиса +ansible-playbook --syntax-check -i inventory/prod playbooks/setup_manager.yml + +# Линтинг +ansible-lint playbooks/ roles/ + +# Dry-run (режим check + diff) +ansible-playbook -i inventory/prod playbooks/setup_control_plane.yml --check --diff + +# Запуск только на одном хосте +ansible-playbook -i inventory/prod playbooks/setup_worker_plane.yml --limit k8s-worker-01 +``` + +--- + +## Добавление нового компонента + +1. Добавить хост в `inventory/prod/hosts.yml` (если новая нода) +2. Создать `inventory/prod/group_vars/.yml` (если новая группа) +3. Создать роль `roles//` со стандартной структурой +4. Создать `roles//README.md` — подробный корп. стандарт использования компонента +5. Создать `playbooks/.yml` +6. Добавить CI/CD-задачу в `.gitlab-ci.yml` (`stage: setup`, `resource_group: production`) +7. Обновить **этот README**: краткое описание роли + ссылка на `roles//README.md` +8. Обновить `CLAUDE.md`: добавить роль в структуру репозитория и ключевые решения diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..d734ebf --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,17 @@ +[defaults] +inventory = inventory/prod +roles_path = roles +collections_path = collections +filter_plugins = filter_plugins +host_key_checking = False +stdout_callback = ansible.builtin.default +result_format = yaml +gathering = smart +fact_caching = jsonfile +fact_caching_connection = /tmp/ansible_facts_cache +fact_caching_timeout = 86400 +retry_files_enabled = False + +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=300s diff --git a/inventory/prod/group_vars/all.yml b/inventory/prod/group_vars/all.yml new file mode 100644 index 0000000..e392191 --- /dev/null +++ b/inventory/prod/group_vars/all.yml @@ -0,0 +1,5 @@ +--- +ansible_user: "{{ lookup('env', 'ANSIBLE_SSH_USER') | default('ansible') }}" +ansible_become: true +ansible_become_method: sudo +ansible_become_pass: "{{ lookup('env', 'ANSIBLE_BECOME_PASS') | default(omit) }}" diff --git a/inventory/prod/group_vars/control_plane.yml b/inventory/prod/group_vars/control_plane.yml new file mode 100644 index 0000000..80c70ae --- /dev/null +++ b/inventory/prod/group_vars/control_plane.yml @@ -0,0 +1,4 @@ +--- +k8s_version: "1.33" +cni_plugin: "flannel" +kubeconfig_fetch_to_managers: true diff --git a/inventory/prod/group_vars/gpu_workers.yml b/inventory/prod/group_vars/gpu_workers.yml new file mode 100644 index 0000000..282e886 --- /dev/null +++ b/inventory/prod/group_vars/gpu_workers.yml @@ -0,0 +1,26 @@ +--- +# Settings specific to workers with an attached NVIDIA GPU. +# This group is a child of `workers` — it inherits workers.yml and only +# overrides/adds what differs for a GPU node. + +# GPU nodes are not Longhorn storage nodes — override the workers.yml default +# (which assumes /dev/sdb + /dev/sdc are present) so setup_longhorn.yml does +# not try to format disks that don't exist in that layout. +longhorn_disks: [] + +# Applied to the node object in Kubernetes by roles/gpu_device_plugin after +# kubeadm join, so ollama/other GPU workloads can target it via nodeSelector. +node_labels: + gpu: nvidia + +# nvidia-container-toolkit package version (empty = latest) +nvidia_container_toolkit_version: "" + +# Dedicated local RAID1 storage for Ollama model files (see roles/gpu_model_storage). +# Carved out of otherwise-unpartitioned space on the same mirrored disks used +# for the OS (sda/sdb have ~3.6TB free beyond the ~30GB OS RAID1 partition) — +# model data must NOT go on the root filesystem, it's far too small. +gpu_model_storage_devices: + - /dev/sda + - /dev/sdb +gpu_model_storage_partition_size_gb: 1000 diff --git a/inventory/prod/group_vars/k8s_cluster.yml b/inventory/prod/group_vars/k8s_cluster.yml new file mode 100644 index 0000000..8cd2710 --- /dev/null +++ b/inventory/prod/group_vars/k8s_cluster.yml @@ -0,0 +1,7 @@ +--- +# Variables shared across all cluster nodes (control_plane + workers) + +# Pod and service CIDRs — must match kubeadm config in control_plane role. +# Defined here so both control_plane and worker firewall rules can reference them. +pod_network_cidr: "10.244.0.0/16" +service_cidr: "10.96.0.0/12" diff --git a/inventory/prod/group_vars/manager_nodes.yml b/inventory/prod/group_vars/manager_nodes.yml new file mode 100644 index 0000000..2a5ec43 --- /dev/null +++ b/inventory/prod/group_vars/manager_nodes.yml @@ -0,0 +1,32 @@ +--- +# Kubernetes management node settings + +# GitLab Agent: additional projects allowed to use the agent via CI/CD +gitlab_agent_ci_access_projects: + - it-dept/billing-mobile +dashboard_insecure: true + +k8s_manager_timezone: "Europe/Moscow" + +# kubectl version (empty = latest stable) +kubectl_version: "" + +# Helm version (empty = latest stable) +helm_version: "" + +# k9s version (empty = latest stable) +k9s_version: "" + +# Additional tools to install +k8s_manager_extra_packages: + - bash-completion + - curl + - wget + - git + - vim + - htop + - net-tools + - bind-utils + - jq + - python3 + - python3-pip diff --git a/inventory/prod/group_vars/traefik.yml b/inventory/prod/group_vars/traefik.yml new file mode 100644 index 0000000..9901b25 --- /dev/null +++ b/inventory/prod/group_vars/traefik.yml @@ -0,0 +1,146 @@ +--- + +traefik_port_map: + + - name: k8s-dashboard + description: "Kubernetes Dashboard" + port: 10001 + backend: + namespace: kubernetes-dashboard + service: kubernetes-dashboard + port: 443 + scheme: http + basicauth: + enabled: false + + - name: grafana + description: "Grafana (Logs & Metrics)" + port: 10003 + backend: + namespace: monitoring + service: grafana + port: 80 + scheme: http + basicauth: + enabled: false + + - name: longhorn-ui + description: "Longhorn Storage UI" + port: 10002 + backend: + namespace: longhorn-system + service: longhorn-frontend + port: 80 + scheme: http + basicauth: + enabled: true + secret_name: traefik-auth-longhorn + + - name: minio-api + description: "MinIO S3 API" + port: 10005 + backend: + namespace: minio + service: minio + port: 9000 + scheme: http + basicauth: + enabled: false + + - name: minio-console + description: "MinIO Console (Web UI)" + port: 10006 + backend: + namespace: minio + service: minio-console + port: 9001 + scheme: http + basicauth: + enabled: false + + - name: billing-backend + description: "Mobile Billing Backend API" + port: 10100 + backend: + namespace: gigacom-billing-mobile + service: billing-backend + port: 8080 + scheme: http + basicauth: + enabled: false + + - name: bgbilling + description: "BGBillingServer HTTP (web UI, API)" + port: 10101 + backend: + namespace: bgbilling-dev + service: bgbilling + port: 8080 + scheme: http + basicauth: + enabled: false + + - name: bgbilling-https + description: "BGBillingServer HTTPS (Tomcat TLS, .keystore)" + port: 10102 + backend: + namespace: bgbilling-dev + service: bgbilling + port: 8443 + scheme: https + basicauth: + enabled: false + + - name: bgbilling-db + description: "MariaDB for BGBilling (TCP)" + protocol: tcp + port: 10103 + backend: + namespace: bgbilling-dev + service: db + port: 3306 + + - name: activemq-web + description: "ActiveMQ Web Console" + port: 10104 + backend: + namespace: bgbilling-dev + service: activemq + port: 8161 + scheme: http + basicauth: + enabled: false + + - name: ollama-proxy + description: "Ollama Proxy API (k8s_ai)" + port: 10200 + backend: + namespace: ai + service: ollama-proxy + port: 8080 + scheme: http + basicauth: + enabled: false + + - name: open-webui + description: "Open WebUI (k8s_ai)" + port: 10201 + backend: + namespace: ai + service: open-webui + port: 8080 + scheme: http + basicauth: + enabled: false + + # Template for future services: + # - name: my-service + # description: "Human-readable description" + # port: 10007 + # backend: + # namespace: my-namespace + # service: my-service-name + # port: 8080 + # scheme: http + # basicauth: + # enabled: false diff --git a/inventory/prod/group_vars/workers.yml b/inventory/prod/group_vars/workers.yml new file mode 100644 index 0000000..3fb3258 --- /dev/null +++ b/inventory/prod/group_vars/workers.yml @@ -0,0 +1,12 @@ +--- +# Kubernetes worker (compute) node settings + +# node_labels: {} +# node_taints: [] + +# Disks dedicated to Longhorn storage (override per host in hosts.yml if needed) +longhorn_disks: + - device: /dev/sdb + mountpoint: /mnt/longhorn-disk1 + - device: /dev/sdc + mountpoint: /mnt/longhorn-disk2 diff --git a/inventory/prod/hosts.yml b/inventory/prod/hosts.yml new file mode 100644 index 0000000..0793a39 --- /dev/null +++ b/inventory/prod/hosts.yml @@ -0,0 +1,26 @@ +--- +all: + children: + # Operator workstation: kubectl, helm, k9s + manager_nodes: + hosts: + k8s-manager-01: + ansible_host: 10.203.0.92 + + # Kubernetes cluster nodes + k8s_cluster: + children: + control_plane: + hosts: + k8s-master-01: + ansible_host: 10.203.0.97 + workers: + hosts: + k8s-worker-01: + ansible_host: 10.203.0.96 + children: + # Workers with an NVIDIA GPU attached (device plugin + node label) + gpu_workers: + hosts: + k8s-worker-02: + ansible_host: 10.203.0.212 diff --git a/playbooks/setup_control_plane.yml b/playbooks/setup_control_plane.yml new file mode 100644 index 0000000..5e2a36a --- /dev/null +++ b/playbooks/setup_control_plane.yml @@ -0,0 +1,6 @@ +--- +- name: Setup Kubernetes control plane + hosts: control_plane + gather_facts: true + roles: + - k8s_control_plane diff --git a/playbooks/setup_flux.yml b/playbooks/setup_flux.yml new file mode 100644 index 0000000..29a5079 --- /dev/null +++ b/playbooks/setup_flux.yml @@ -0,0 +1,5 @@ +--- +- name: Flux | Install CLI and bootstrap with GitLab + hosts: manager_nodes + roles: + - flux diff --git a/playbooks/setup_gbm_dev_access.yml b/playbooks/setup_gbm_dev_access.yml new file mode 100644 index 0000000..8ca2a68 --- /dev/null +++ b/playbooks/setup_gbm_dev_access.yml @@ -0,0 +1,6 @@ +--- +- name: Setup developer access token for namespace + hosts: manager_nodes + gather_facts: false + roles: + - role: k8s_gbm_dev diff --git a/playbooks/setup_gitlab_agent.yml b/playbooks/setup_gitlab_agent.yml new file mode 100644 index 0000000..1d123cb --- /dev/null +++ b/playbooks/setup_gitlab_agent.yml @@ -0,0 +1,5 @@ +--- +- name: GitLab Agent | Push agent config to fleet repo and install agentk + hosts: manager_nodes + roles: + - gitlab_agent diff --git a/playbooks/setup_gpu.yml b/playbooks/setup_gpu.yml new file mode 100644 index 0000000..41fc3b8 --- /dev/null +++ b/playbooks/setup_gpu.yml @@ -0,0 +1,13 @@ +--- +# Run AFTER setup_worker_plane.yml (needs kubeadm join + containerd already done) +# and after the target node has joined the cluster. +- name: GPU | Configure container runtime on GPU worker nodes + hosts: gpu_workers + become: true + roles: + - gpu_prereqs + +- name: GPU | Label nodes and install NVIDIA device plugin + hosts: manager_nodes + roles: + - gpu_device_plugin diff --git a/playbooks/setup_gpu_model_storage.yml b/playbooks/setup_gpu_model_storage.yml new file mode 100644 index 0000000..c26cb37 --- /dev/null +++ b/playbooks/setup_gpu_model_storage.yml @@ -0,0 +1,10 @@ +--- +# Provisions a dedicated local RAID1 partition for large model files (Ollama) +# on GPU worker nodes, carved out of unpartitioned space on the same mirrored +# OS disks. Run any time after the node's disks are in place; independent of +# kubeadm join order. +- name: GPU | Provision dedicated RAID1 storage for model files + hosts: gpu_workers + become: true + roles: + - gpu_model_storage diff --git a/playbooks/setup_logging.yml b/playbooks/setup_logging.yml new file mode 100644 index 0000000..6c6c6bb --- /dev/null +++ b/playbooks/setup_logging.yml @@ -0,0 +1,23 @@ +--- +# Prerequisite (create manually before running): +# kubectl create namespace monitoring +# kubectl create secret generic grafana-admin-secret \ +# --from-literal=admin-user=admin \ +# --from-literal=admin-password='YOUR_PASSWORD' \ +# -n monitoring +# +# Required CI/CD variables: +# LOKI_MINIO_PASSWORD — password for the loki MinIO user (created by this playbook) +# GRAFANA_ADMIN_PASSWORD — set in grafana-admin-secret above (not read by Ansible) +- name: Deploy PLG logging stack (Loki + Alloy + Grafana) + hosts: manager_nodes + become: false + vars: + loki_minio_password: "{{ lookup('env', 'LOKI_MINIO_PASSWORD') }}" + pre_tasks: + - name: Preflight | Assert LOKI_MINIO_PASSWORD is set + ansible.builtin.assert: + that: loki_minio_password | length > 0 + fail_msg: "LOKI_MINIO_PASSWORD env var must be set" + roles: + - logging diff --git a/playbooks/setup_longhorn.yml b/playbooks/setup_longhorn.yml new file mode 100644 index 0000000..839e59d --- /dev/null +++ b/playbooks/setup_longhorn.yml @@ -0,0 +1,11 @@ +--- +- name: Longhorn | Prepare cluster nodes (OS-level) + hosts: k8s_cluster + become: true + roles: + - longhorn_prereqs + +- name: Longhorn | Annotate nodes and install via Helm + hosts: manager_nodes + roles: + - longhorn diff --git a/playbooks/setup_manager.yml b/playbooks/setup_manager.yml new file mode 100644 index 0000000..3c944d0 --- /dev/null +++ b/playbooks/setup_manager.yml @@ -0,0 +1,6 @@ +--- +- name: Setup Kubernetes manager nodes + hosts: manager_nodes + gather_facts: true + roles: + - k8s_manager diff --git a/playbooks/setup_minio.yml b/playbooks/setup_minio.yml new file mode 100644 index 0000000..a131d8c --- /dev/null +++ b/playbooks/setup_minio.yml @@ -0,0 +1,6 @@ +--- +- name: MinIO | Deploy object storage + hosts: manager_nodes + become: false + roles: + - minio diff --git a/playbooks/setup_sealed_secrets.yml b/playbooks/setup_sealed_secrets.yml new file mode 100644 index 0000000..3c861a6 --- /dev/null +++ b/playbooks/setup_sealed_secrets.yml @@ -0,0 +1,6 @@ +--- +- name: Deploy Sealed Secrets (kubeseal controller + CLI) + hosts: manager_nodes + become: true + roles: + - sealed_secrets diff --git a/playbooks/setup_traefik.yml b/playbooks/setup_traefik.yml new file mode 100644 index 0000000..86ab73e --- /dev/null +++ b/playbooks/setup_traefik.yml @@ -0,0 +1,29 @@ +--- +- name: Traefik | Open firewall ports on workers + hosts: workers + become: true + tasks: + - name: Firewall | Open Traefik service port range + ansible.posix.firewalld: + port: "10000-10999/tcp" + permanent: true + state: enabled + immediate: true + + - name: Firewall | Trust CNI interfaces + ansible.posix.firewalld: + zone: trusted + interface: "{{ item }}" + permanent: true + state: enabled + immediate: true + loop: + - flannel.1 + - cni0 + +- name: Traefik | Install via Helm and configure routes + hosts: manager_nodes + vars_files: + - "{{ playbook_dir }}/../inventory/prod/group_vars/traefik.yml" + roles: + - traefik diff --git a/playbooks/setup_worker_plane.yml b/playbooks/setup_worker_plane.yml new file mode 100644 index 0000000..bc8610a --- /dev/null +++ b/playbooks/setup_worker_plane.yml @@ -0,0 +1,6 @@ +--- +- name: Setup Kubernetes worker nodes + hosts: workers + gather_facts: true + roles: + - k8s_worker diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..97b4bbf --- /dev/null +++ b/requirements.yml @@ -0,0 +1,8 @@ +--- +collections: + - name: ansible.posix + version: ">=1.5.0" + - name: community.general + version: ">=9.0.0" + - name: community.crypto + version: ">=2.10.0" diff --git a/research/apps/hd-portal.md b/research/apps/hd-portal.md new file mode 100644 index 0000000..7e651bb --- /dev/null +++ b/research/apps/hd-portal.md @@ -0,0 +1,496 @@ +# Перенос HD Portal (device-diagnostic) в Kubernetes + +**Репозиторий:** `it-dept/device-diagnostic` на `gitlab.gigacoms.info` +**Публичное название:** HD Portal +**Текущая платформа:** Docker Swarm (нода `sandbox`) +**Целевая платформа:** Kubernetes (кластер `k8s_infrastructure`) + +--- + +## Что делает приложение + +HD Portal — внутренний веб-портал для инженеров техподдержки. Позволяет: + +1. **Найти абонента** по номеру договора через REST API BGBilling (`bill.gigacoms.ru`) +2. **Подключиться к сетевому оборудованию** абонента по SSH или Telnet и выполнить диагностические команды +3. **Сохранить результаты** диагностики в PostgreSQL +4. **Создать заявку** в BGERP/BGCRM (`hd.gigacoms.ru`) с описанием проблемы +5. Веб-интерфейс (Thymeleaf + Bootstrap) + REST API с API-Key аутентификацией (Swagger UI) + +### Поддерживаемое оборудование + +| Тип | Коды из биллинга | Протокол | +|---|---|---| +| BDCom | 14, 16, 119, 122 | SSH/Telnet | +| DLink | 9, 49 | SSH/Telnet | +| SNR | 17, 25 | SSH/Telnet | +| TPLink | 65, 70 | SSH/Telnet | +| ZTE EPON | 32 | SSH/Telnet | +| ZTE GPON | 107 | SSH/Telnet | +| СКАТ (BRAS) | 0 | SSH/Telnet | + +--- + +## Технический стек + +| Компонент | Версия | +|---|---| +| Java | 17 | +| Spring Boot | 3.2.4 | +| Spring Security | form login + API key filter | +| Thymeleaf | шаблонизатор UI | +| Flyway | миграции схемы БД (9 миграций) | +| PostgreSQL | 15 (своя БД приложения) | +| MariaDB 10.2 | внешняя БД BGERP (read-only: пользователи, группы) | +| sshj 0.35.0 | SSH-подключения к оборудованию | +| commons-net 3.9.0 | Telnet-подключения | +| springdoc-openapi 2.5.0 | Swagger UI (`/swagger-ui.html`) | +| образ | `eclipse-temurin:17-jre-alpine` | + +--- + +## Текущая архитектура (Docker Swarm) + +``` +Swarm (нода: sandbox, 10.203.0.211) +│ +├── Service: diagnostic-app (replicas: 1) +│ ├── Image: reg.gitlab.gigacoms.info/it-dept/device-diagnostic:latest +│ ├── Port: 8181 → 8080 (ingress mode) +│ ├── Network: diagnostic-network + nginx-proxy_nginx-network +│ ├── JVM: -Xmx512m -Xms256m +│ └── Env: APP_DATASOURCE_*, BGERP_DATASOURCE_*, BGERP_API_* +│ +└── Service: diagnostic-db (replicas: 1) + ├── Image: postgres:15-alpine + ├── Volume: /mnt/swarm_quorum/diagnostic/postgresql/data → /var/lib/postgresql/data + └── Network: diagnostic-network +``` + +**Внешние зависимости приложения:** + +| Система | Адрес | Тип | Назначение | +|---|---|---|---| +| BGBilling REST API | `https://bill.gigacoms.ru/bgbilling/...` | HTTPS | Данные по договору абонента | +| BGERP REST API | `https://hd.gigacoms.ru/` | HTTPS | Создание заявок, сообщений | +| BGERP MariaDB | `10.203.0.221:3306/bgcrm` | TCP | Аутентификация пользователей | +| Сетевые устройства | LAN `10.x.x.x` | SSH/Telnet | Диагностические команды | + +--- + +## Ключевые наблюдения для миграции + +### 1. Приложение stateless — подходит для Deployment + +Само приложение не хранит состояния между запросами. HTTP-сессии in-memory (стандарт Spring). +Единственное состояние — PostgreSQL. Подходит для `Deployment` с 1 репликой (масштабирование возможно при наличии shared session store, но не нужно сейчас). + +### 2. PostgreSQL — StatefulSet с Longhorn PVC + +Текущий bind-mount `/mnt/swarm_quorum/diagnostic/postgresql/data` → `PersistentVolumeClaim` на Longhorn. Данные переносятся стандартным `pg_dump` / `pg_restore`. + +### 3. Credentials hardcoded в коде — блокер + +В `ContractInfoService.java` жёстко прописаны: +```java +private String apiUrl = "https://bill.gigacoms.ru/bgbilling/aiDmUnxl6KC8R1ELG4QJDPDm3IPn6V"; +private String apiUser = "sbersalute"; +private String apiPassword = "ULW2KmPd6bqc"; +``` + +Перед миграцией **обязательно** перенести эти значения в env-переменные (`BGBILLING_API_URL`, `BGBILLING_API_USER`, `BGBILLING_API_PASSWORD`) через `@Value` и `application.properties`. Без этого K8s Secret не поможет. + +### 4. Пароли пользователей хранятся открытым текстом + +`SecurityConfig` использует `NoOpPasswordEncoder`. Пользователи логинятся с паролями из BGERP MariaDB. Это работает, но является техническим долгом. Не блокирует миграцию. + +### 5. Сетевой доступ к оборудованию LAN + +Приложение SSH/Telnet-ается к коммутаторам и BRAS во внутренней сети (`10.x.x.x`). K8s-воркер (`10.203.0.96`) находится в той же сети — дополнительных маршрутов не нужно. **Важно:** flannel-интерфейсы (`flannel.1`, `cni0`) должны быть в `trusted` зоне firewalld на мастере и воркере (это уже обеспечено существующей инфраструктурой). + +### 6. Nginx → Traefik + +Сейчас приложение за Nginx reverse proxy (Swarm network `nginx-proxy_nginx-network`). В K8s — Traefik IngressRoute. UI и API требуют аутентификации, поэтому BasicAuth на уровне Traefik не нужен (приложение само управляет доступом). + +### 7. CI: этап `mkdir` через SSH становится не нужным + +Шаг `mkdir` в CI создаёт директорию `/mnt/swarm_quorum/diagnostic` на Swarm-хосте по SSH. В K8s PVC создаётся декларативно. Этап `mkdir` из `.gitlab-ci.yml` нужно убрать или заменить на `kubectl apply`. + +--- + +## Целевая архитектура в Kubernetes + +``` +Namespace: hd-portal +│ +├── Secret: hd-portal-env +│ └── все переменные окружения приложения +│ +├── Secret: hd-portal-registry-pull (docker-registry) +│ +├── PersistentVolumeClaim: hd-portal-postgres-data +│ └── Longhorn, RWO, 5Gi +│ +├── StatefulSet: hd-portal-postgres +│ ├── Image: postgres:15-alpine +│ └── Volume: hd-portal-postgres-data → /var/lib/postgresql/data +│ +├── Service: hd-portal-postgres (ClusterIP, 5432) +│ +├── Deployment: hd-portal +│ ├── Image: reg.gitlab.gigacoms.info/it-dept/device-diagnostic:latest +│ ├── replicas: 1 +│ ├── resources: requests 256Mi/200m, limits 768Mi/1 +│ └── Env from Secret: hd-portal-env +│ +├── Service: hd-portal (ClusterIP, 8080) +│ +└── Traefik IngressRoute: порт 10003 → hd-portal:8080 + (без BasicAuth — приложение имеет собственную аутентификацию) +``` + +--- + +## Манифесты + +### Namespace + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: hd-portal +``` + +### Secret — переменные приложения + +Создаётся вручную (не хранится в Git): + +```bash +kubectl create secret generic hd-portal-env \ + --from-literal=APP_DATASOURCE_URL="jdbc:postgresql://hd-portal-postgres:5432/diagnostic" \ + --from-literal=APP_DATASOURCE_USERNAME="postgres" \ + --from-literal=APP_DATASOURCE_PASSWORD="<пароль>" \ + --from-literal=BGERP_DATASOURCE_URL="jdbc:mariadb://10.203.0.221/bgcrm?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Moscow" \ + --from-literal=BGERP_DATASOURCE_USERNAME="user.portal" \ + --from-literal=BGERP_DATASOURCE_PASSWORD="<пароль>" \ + --from-literal=BGERP_API_URL="https://hd.gigacoms.ru/" \ + --from-literal=BGERP_API_USERNAME="autobot" \ + --from-literal=BGERP_API_PASSWORD="<пароль>" \ + --from-literal=BGBILLING_API_URL="https://bill.gigacoms.ru/bgbilling/<токен>" \ + --from-literal=BGBILLING_API_USER="sbersalute" \ + --from-literal=BGBILLING_API_PASSWORD="<пароль>" \ + --from-literal=SPRING_PROFILES_ACTIVE="prod" \ + --from-literal=JAVA_OPTS="-Xmx512m -Xms256m -Duser.timezone=Europe/Moscow" \ + --from-literal=TZ="Europe/Moscow" \ + -n hd-portal +``` + +> Строка `BGBILLING_API_URL` и `BGBILLING_API_USER/PASSWORD` — только после исправления `ContractInfoService.java`. + +### Secret для pull из GitLab Registry + +```bash +kubectl create secret docker-registry hd-portal-registry-pull \ + --docker-server=reg.gitlab.gigacoms.info \ + --docker-username= \ + --docker-password= \ + -n hd-portal +``` + +### PVC для PostgreSQL + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: hd-portal-postgres-data + namespace: hd-portal +spec: + storageClassName: longhorn + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +``` + +### StatefulSet — PostgreSQL + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: hd-portal-postgres + namespace: hd-portal +spec: + serviceName: hd-portal-postgres + replicas: 1 + selector: + matchLabels: + app: hd-portal-postgres + template: + metadata: + labels: + app: hd-portal-postgres + spec: + containers: + - name: postgres + image: postgres:15-alpine + env: + - name: POSTGRES_DB + value: diagnostic + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: hd-portal-env + key: APP_DATASOURCE_USERNAME + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: hd-portal-env + key: APP_DATASOURCE_PASSWORD + - name: TZ + value: Europe/Moscow + ports: + - containerPort: 5432 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "postgres"] + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + volumes: + - name: data + persistentVolumeClaim: + claimName: hd-portal-postgres-data +``` + +### Service — PostgreSQL + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: hd-portal-postgres + namespace: hd-portal +spec: + selector: + app: hd-portal-postgres + ports: + - port: 5432 + targetPort: 5432 + clusterIP: None # headless для StatefulSet +``` + +### Deployment — приложение + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hd-portal + namespace: hd-portal +spec: + replicas: 1 + selector: + matchLabels: + app: hd-portal + template: + metadata: + labels: + app: hd-portal + spec: + imagePullSecrets: + - name: hd-portal-registry-pull + containers: + - name: app + image: reg.gitlab.gigacoms.info/it-dept/device-diagnostic:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: hd-portal-env + resources: + requests: + memory: 256Mi + cpu: 200m + limits: + memory: 768Mi + cpu: "1" + readinessProbe: + httpGet: + path: /actuator/health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 30 +``` + +### Service — приложение + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: hd-portal + namespace: hd-portal +spec: + selector: + app: hd-portal + ports: + - port: 8080 + targetPort: 8080 +``` + +### Traefik IngressRoute + +Добавить в `inventory/prod/group_vars/traefik.yml`: + +```yaml +traefik_port_map: + # ... существующие записи ... + - name: hd-portal # ≤15 символов + port: 10003 + namespace: hd-portal + service: hd-portal + servicePort: 8080 + basicauth: + enabled: false # приложение имеет собственную форму входа +``` + +--- + +## Необходимые изменения в коде (до миграции) + +### Обязательно: вынести BGBilling credentials из кода + +Файл: `src/main/java/ru/gigacoms/net/diagnostic/bgbilling/ContractInfoService.java` + +**Было:** +```java +private String apiUrl = "https://bill.gigacoms.ru/bgbilling/aiDmUnxl6KC8R1ELG4QJDPDm3IPn6V"; +private String apiUser = "sbersalute"; +private String apiPassword = "ULW2KmPd6bqc"; +``` + +**Стало:** +```java +@Value("${bgbilling.api.url}") +private String apiUrl; + +@Value("${bgbilling.api.user}") +private String apiUser; + +@Value("${bgbilling.api.password}") +private String apiPassword; +``` + +И в `application.properties`: +```properties +bgbilling.api.url=${BGBILLING_API_URL} +bgbilling.api.user=${BGBILLING_API_USER} +bgbilling.api.password=${BGBILLING_API_PASSWORD} +``` + +### Желательно: вынести API-ключи из application.properties в Secret + +Сейчас `app.api.keys` указаны прямо в `application.properties`. При K8s-деплое лучше передавать через env-переменную: +```properties +app.api.keys=${APP_API_KEYS} +``` + +--- + +## Перенос данных PostgreSQL + +```bash +# 1. Дамп с Swarm-ноды (sandbox) +ssh root@10.203.0.211 "docker exec \ + pg_dump -U postgres diagnostic" > diagnostic_dump.sql + +# 2. Создать PVC и запустить StatefulSet (применить манифесты) + +# 3. Восстановить дамп в K8s PostgreSQL +kubectl exec -n hd-portal hd-portal-postgres-0 -- \ + psql -U postgres -d diagnostic < diagnostic_dump.sql +``` + +--- + +## Изменения в CI/CD + +Убрать из `.gitlab-ci.yml` этап `mkdir` — он создавал директории на Swarm-хосте по SSH. В K8s PVC управляется декларативно. + +Опционально: добавить этап `deploy` для обновления образа в K8s после push: +```bash +kubectl set image deployment/hd-portal app=reg.gitlab.gigacoms.info/it-dept/device-diagnostic:$CI_COMMIT_SHA -n hd-portal +``` + +Или через Flux — после push в Registry Flux Image Automation обновит манифест автоматически (если настроен ImageRepository + ImagePolicy). + +--- + +## Интеграция с Flux + +Манифесты размещаются в `k8s/k8s-fleet`: + +``` +clusters/production/hd-portal/ +├── namespace.yaml +├── pvc.yaml +├── statefulset-postgres.yaml +├── service-postgres.yaml +├── deployment.yaml +└── service.yaml +``` + +Secrets (`hd-portal-env`, `hd-portal-registry-pull`) создаются вручную и **не хранятся в Git**. + +Traefik-маршрут добавляется через Ansible (`setup_traefik.yml`) при изменении `traefik_port_map`. + +--- + +## Риски и ограничения + +| Риск | Оценка | Митигация | +|---|---|---| +| BGBilling credentials в коде | **высокий** | Обязательно исправить до миграции (см. секцию выше) | +| `NoOpPasswordEncoder` (plaintext пароли) | средний | Технический долг, не блокирует миграцию | +| SSH/Telnet к сетевым устройствам из pod | низкий | Воркер в той же LAN, маршрутизация работает | +| Доступность BGERP MariaDB (`10.203.0.221`) | низкий | Прямой TCP, не зависит от K8s | +| Длительные SSH-сессии к оборудованию | низкий | Диагностика занимает секунды, не минуты | +| Flyway при старте — PostgreSQL может быть не готов | средний | `readinessProbe` на DB + `initialDelaySeconds: 30` на app | +| Потеря данных при переносе PostgreSQL | средний | Сделать дамп прямо перед переключением | + +--- + +## План миграции + +1. **Исправить `ContractInfoService.java`** — вынести credentials в env-переменные, собрать новый образ. +2. **Создать deploy token** для `it-dept/device-diagnostic` (scope: `read_registry`). +3. **Сделать дамп PostgreSQL** с ноды `sandbox`. +4. **Применить манифесты** через Flux или `kubectl apply`: + Namespace → PVC → StatefulSet postgres → Service postgres → Deployment → Service +5. **Создать секреты вручную**: + - `hd-portal-env` + - `hd-portal-registry-pull` +6. **Восстановить дамп** в K8s PostgreSQL. +7. **Проверить запуск** — убедиться в `/actuator/health` и доступности UI. +8. **Добавить Traefik-маршрут** (`traefik_port_map`, порт 10003), запустить `setup_traefik.yml`. +9. **Остановить Swarm-сервис** `diagnostic-app` и `diagnostic-db` после успешной проверки. +10. **Удалить этап `mkdir`** из `.gitlab-ci.yml`. diff --git a/research/apps/rkn-list-poller.md b/research/apps/rkn-list-poller.md new file mode 100644 index 0000000..9a2b317 --- /dev/null +++ b/research/apps/rkn-list-poller.md @@ -0,0 +1,361 @@ +# Перенос rkn-list-poller в Kubernetes + +**Репозиторий:** `docker/rkn-list-poller` на `gitlab.gigacoms.info` +**Текущая платформа:** Docker Swarm +**Целевая платформа:** Kubernetes (кластер `k8s_infrastructure`) + +--- + +## Что делает приложение + +`rkn-list-poller` — пакетный PHP-воркер для выгрузки реестров заблокированных ресурсов через SOAP-сервис Роскомнадзора (`vigruzki.rkn.gov.ru`). + +**Три скрипта, один алгоритм:** + +1. Читает `request.xml` и `request.xml.sig` из примонтированной директории. +2. Отправляет SOAP-запрос (`sendRequest`), получает `$request_code`. +3. Опрашивает сервис каждые 3 минуты (`getResult` / `getResultSocResources`) до готовности. +4. Записывает ZIP-архив дампа в ту же директорию. + +| Скрипт | SOAP-метод | Выходной файл | Расписание cron | +|---|---|---|---| +| `rkn_get_black_list.php` | `getResult` | `dump_black.zip` | `0 * * * *` (каждый час в :00) | +| `rkn_get_soc_list.php` | `getResultSocResources` | `dump_soc.zip` | `30 * * * *` (каждый час в :30) | +| `rkn_get_list.php` | `getResult` | `dump.zip` | не в активном cron | + +--- + +## Текущая архитектура (Docker Swarm) + +``` +Docker Swarm (manager node) +│ +└── Service: rkn-poller (replicas: 1) + ├── Image: reg.gitlab.gigacoms.info/docker/rkn-list-poller:main-latest + ├── Entrypoint: cron -f (PID 1, foreground) + │ ├── 0 */1 * * * → php /app/rkn_get_black_list.php + │ └── 30 */1 * * * → php /app/rkn_get_soc_list.php + └── Volume: /mnt/swarm_quorum/rkn-poller → /data/rezult + ├── request.xml (генерируется rknutils.jar, обновляется вручную) + ├── request.xml.sig (GOST-подпись, обновляется вручную) + ├── dump_black.zip (выход) + ├── dump_soc.zip (выход) + └── rkn.log (журнал) +``` + +**Особенности образа:** +- База: `rnix/openssl-gost:latest` (Debian Stretch, EOL) — нужен для GOST-криптографии при TLS с `vigruzki.rkn.gov.ru` +- PHP 7.0, php-soap, php-xml +- Российские корневые сертификаты Минцифры (5 штук в `certs/`) добавлены в хранилище системы и в `php.ini` +- Лимит памяти PHP: `-1` (без ограничений) — дамп может занять сотни МБ + +--- + +## Ключевые наблюдения для миграции + +### 1. Приложение — пакетный джоб, не сервис + +Контейнер сейчас держит внутри себя `cron` и работает постоянно. В Kubernetes это антипаттерн. +**PHP-скрипты — это одиночные запуски с выходом**: `php /app/rkn_get_black_list.php` запускается, ждёт ответа РКН, сохраняет файл, завершается. + +Это идеально соответствует `CronJob`. Нужды в Deployment нет. + +### 2. Время выполнения непредсказуемо + +Скрипт опрашивает РКН каждые 3 минуты в цикле. РКН может отвечать от 5 минут до нескольких часов. Нужно устанавливать `activeDeadlineSeconds` с большим запасом (6 часов). + +### 3. Конкурентный запуск опасен + +Если предыдущий джоб ещё работает (РКН медленно отвечает), новый запуск перезапишет тот же `dump_black.zip`. Нужен `concurrencyPolicy: Forbid`. + +### 4. Персистентное хранилище — единственная точка состояния + +Из Swarm-тома в директорию `/data/rezult` нужно перенести: +- `request.xml` и `request.xml.sig` — **входные файлы, которые нельзя потерять**. Обновляются вручную при смене сертификата оператора. Логично хранить как Secret. +- Выходные ZIP-архивы — в PVC. + +### 5. Образ менять не нужно + +Образ `rnix/openssl-gost` специфичен для GOST-криптографии и уже работает. CI/CD уже публикует его в GitLab Registry. **Менять образ не нужно** — только изменить способ запуска (убрать cron, запускать скрипт напрямую). + +> **Технический долг:** Debian Stretch достиг EOL в 2022. `rnix/openssl-gost` — заброшенный образ. Рекомендуется проверить наличие более свежих альтернатив (например, `openssl-gost` на базе Debian Bookworm), но это отдельная задача, не блокирующая миграцию. + +--- + +## Целевая архитектура в Kubernetes + +``` +Namespace: rkn-poller +│ +├── Secret: rkn-request-files +│ ├── request.xml (base64) +│ └── request.xml.sig (base64) +│ +├── PersistentVolumeClaim: rkn-data (Longhorn, RWO, 5Gi) +│ └── /data/rezult/ +│ ├── dump_black.zip +│ ├── dump_soc.zip +│ └── rkn.log +│ +├── CronJob: rkn-black-list +│ ├── schedule: "0 * * * *" +│ ├── concurrencyPolicy: Forbid +│ ├── activeDeadlineSeconds: 21600 +│ ├── command: php /app/rkn_get_black_list.php +│ └── volumes: Secret → /data/rezult (request файлы), PVC → /data/rezult (выход) +│ +└── CronJob: rkn-soc-list + ├── schedule: "30 * * * *" + ├── concurrencyPolicy: Forbid + ├── activeDeadlineSeconds: 21600 + ├── command: php /app/rkn_get_soc_list.php + └── volumes: то же самое +``` + +**Почему два тома в одну директорию?** +Оба пути монтирования (`/data/rezult`) можно разрешить через `subPath` в PVC — тогда Secret с файлами запроса проецируется поверх PVC не заменяя его содержимое. Либо использовать initContainer для копирования файлов из Secret в PVC перед запуском основного контейнера (более явный вариант). + +--- + +## Манифесты + +### Namespace + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: rkn-poller +``` + +### Secret с файлами запроса + +Создаётся **вручную** на manager-ноде (не хранится в Git): + +```bash +kubectl create secret generic rkn-request-files \ + --from-file=request.xml=/path/to/request.xml \ + --from-file=request.xml.sig=/path/to/request.xml.sig \ + -n rkn-poller +``` + +При смене сертификата оператора: + +```bash +kubectl create secret generic rkn-request-files \ + --from-file=request.xml=/path/to/new/request.xml \ + --from-file=request.xml.sig=/path/to/new/request.xml.sig \ + -n rkn-poller \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +### Secret для pull из GitLab Registry + +```bash +kubectl create secret docker-registry rkn-registry-pull \ + --docker-server=reg.gitlab.gigacoms.info \ + --docker-username= \ + --docker-password= \ + -n rkn-poller +``` + +### PersistentVolumeClaim + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: rkn-data + namespace: rkn-poller +spec: + storageClassName: longhorn + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +``` + +### CronJob: rkn-black-list + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: rkn-black-list + namespace: rkn-poller +spec: + schedule: "0 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 21600 # 6 часов: РКН может отвечать долго + template: + spec: + restartPolicy: OnFailure + imagePullSecrets: + - name: rkn-registry-pull + initContainers: + - name: copy-request-files + image: busybox:1.36 + command: + - sh + - -c + - cp /secrets/request.xml /data/rezult/request.xml && + cp /secrets/request.xml.sig /data/rezult/request.xml.sig + volumeMounts: + - name: request-secret + mountPath: /secrets + readOnly: true + - name: rkn-data + mountPath: /data/rezult + containers: + - name: poller + image: reg.gitlab.gigacoms.info/docker/rkn-list-poller:main-latest + command: ["php", "/app/rkn_get_black_list.php"] + env: + - name: RKN_DATA_DIR + value: /data/rezult + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 2Gi + cpu: 500m + volumeMounts: + - name: rkn-data + mountPath: /data/rezult + volumes: + - name: request-secret + secret: + secretName: rkn-request-files + - name: rkn-data + persistentVolumeClaim: + claimName: rkn-data +``` + +### CronJob: rkn-soc-list + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: rkn-soc-list + namespace: rkn-poller +spec: + schedule: "30 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 21600 + template: + spec: + restartPolicy: OnFailure + imagePullSecrets: + - name: rkn-registry-pull + initContainers: + - name: copy-request-files + image: busybox:1.36 + command: + - sh + - -c + - cp /secrets/request.xml /data/rezult/request.xml && + cp /secrets/request.xml.sig /data/rezult/request.xml.sig + volumeMounts: + - name: request-secret + mountPath: /secrets + readOnly: true + - name: rkn-data + mountPath: /data/rezult + containers: + - name: poller + image: reg.gitlab.gigacoms.info/docker/rkn-list-poller:main-latest + command: ["php", "/app/rkn_get_soc_list.php"] + env: + - name: RKN_DATA_DIR + value: /data/rezult + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 2Gi + cpu: 500m + volumeMounts: + - name: rkn-data + mountPath: /data/rezult + volumes: + - name: request-secret + secret: + secretName: rkn-request-files + - name: rkn-data + persistentVolumeClaim: + claimName: rkn-data +``` + +--- + +## Интеграция с Flux + +Манифесты размещаются в fleet-репозитории `k8s/k8s-fleet` (путь: `clusters/production/rkn-poller/`): + +``` +clusters/production/rkn-poller/ +├── namespace.yaml +├── pvc.yaml +├── cronjob-black-list.yaml +└── cronjob-soc-list.yaml +``` + +Secret-ресурсы (`rkn-request-files`, `rkn-registry-pull`) **не хранятся в Git** — создаются вручную один раз. + +Flux автоматически применит PVC и CronJob после push в fleet-репозиторий. + +--- + +## Замечания по firewall и сети + +- `vigruzki.rkn.gov.ru` — внешний HTTPS-хост. Pods в кластере имеют выход в интернет через worker-ноды — дополнительных правил не нужно. +- NetworkPolicy не требуется: поды не принимают входящих соединений. +- Для ГОСТ-TLS нужны российские корневые сертификаты — они уже вшиты в образ, ничего дополнительно настраивать не нужно. + +--- + +## Риски и ограничения + +| Риск | Оценка | Митигация | +|---|---|---| +| `rnix/openssl-gost` на Debian Stretch EOL | средний | Образ работает, обновление — отдельная задача. Изолирован в контейнере. | +| Долгий старт пода (pull большого образа) | низкий | Образ уже в локальном Registry, время pull минимально | +| РКН возвращает ошибку — pod упадёт, cron не перезапустит до следующего часа | средний | `restartPolicy: OnFailure` сделает retry. Можно добавить `backoffLimit: 3` в Job | +| PVC `ReadWriteOnce` — только одна нода | принимается | Оба CronJob работают последовательно и на одной ноде (single-worker кластер) | +| `request.xml.sig` истёк (сертификат оператора) — нет алертинга | средний | Добавить мониторинг по коду завершения Job (future work) | + +--- + +## План миграции + +1. **Создать deploy token** в GitLab (`docker/rkn-list-poller` → Settings → Repository → Deploy tokens, scope `read_registry`) +2. **Применить манифесты** через Flux или `kubectl apply`: + - Namespace → PVC → CronJob × 2 +3. **Создать секреты вручную** на manager-ноде: + - `rkn-registry-pull` (registry credentials) + - `rkn-request-files` (request.xml + request.xml.sig из текущего Swarm-тома) +4. **Запустить джобы вручную** для проверки: + ```bash + kubectl create job rkn-black-list-test \ + --from=cronjob/rkn-black-list -n rkn-poller + kubectl logs -f -n rkn-poller -l job-name=rkn-black-list-test + ``` +5. **Убедиться, что `dump_black.zip` обновился** в PVC: + ```bash + kubectl run check --rm -it --image=busybox \ + --overrides='{"spec":{"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"rkn-data"}}],"containers":[{"name":"c","image":"busybox","command":["ls","-lh","/data"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}]}}' \ + -n rkn-poller + ``` +6. **Остановить Swarm-сервис** `rkn` после подтверждения работы. diff --git a/research/flux.md b/research/flux.md new file mode 100644 index 0000000..4f75ff6 --- /dev/null +++ b/research/flux.md @@ -0,0 +1,1152 @@ +# Исследование: Flux CD для GitOps-развёртывания из GitLab + +## Задача + +Обеспечить автоматическое развёртывание приложений из проектов внешнего GitLab-сервера +(`https://gitlab.gigacoms.info`) в кластер Kubernetes (k8s-master-01 / k8s-worker-01). + +Flux работает совместно с уже установленными в кластере компонентами: +- **Longhorn** — постоянное блочное хранилище (StorageClass `longhorn`). Стандарт использования: `roles/longhorn/README.md` +- **Traefik** — HTTP-прокси для публикации сервисов наружу (порты 10001–10999). Стандарт использования: `roles/traefik/README.md` + +--- + +## Что такое Flux CD + +Flux CD — GitOps-инструмент для Kubernetes. Работает как набор контроллеров внутри кластера, +которые непрерывно синхронизируют состояние кластера с Git-репозиторием. Поддерживает GitLab +(включая self-hosted), GitHub, Bitbucket, S3-совместимые хранилища. + +Официальный сайт: https://fluxcd.io + +--- + +## Архитектура компонентов + +Flux состоит из нескольких независимых контроллеров, каждый управляет своим набором CRD. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GitLab: gitlab.gigacoms.info │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ fleet repo │ │ app repo A │ │ app repo B │ │ +│ │ (flux config)│ │ (Helm/k8s) │ │ (kustomize) │ │ +│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ +└─────────┼────────────────┼──────────────────-─┼────────────┘ + │ pull │ pull │ pull +┌─────────▼────────────────▼─────────────────────▼───────────┐ +│ Kubernetes cluster │ +│ │ +│ source-controller — следит за Git/Helm-источниками │ +│ kustomize-controller — применяет Kustomization манифесты │ +│ helm-controller — управляет HelmRelease │ +│ notification-controller— алерты, webhooks │ +│ image-reflector — сканирует container registry │ +│ image-automation — обновляет теги образов в Git │ +│ │ +│ ──── уже установлено Ansible ──────────────────────────── │ +│ Longhorn (longhorn-system) — StorageClass: longhorn │ +│ Traefik (traefik) — порт-прокси 10001–10999 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Основные CRD + +| CRD | Контроллер | Назначение | +|---|---|---| +| `GitRepository` | source-controller | Отслеживает Git-репозиторий, создаёт artifact | +| `HelmRepository` | source-controller | Отслеживает Helm chart repository | +| `HelmRelease` | helm-controller | Устанавливает/обновляет Helm chart | +| `Kustomization` | kustomize-controller | Применяет kustomize-манифесты из artifact | +| `ImageRepository` | image-reflector | Сканирует container registry | +| `ImagePolicy` | image-reflector | Выбирает тег по SemVer/паттерну | +| `ImageUpdateAutomation` | image-automation | Коммитит новый тег в Git | + +--- + +## Модели развёртывания + +### Модель 1: Один fleet-репозиторий (рекомендуется) + +Один специальный Git-репозиторий (`k8s-fleet`) хранит всю конфигурацию Flux. +Приложения описываются через `GitRepository` + `Kustomization` или `HelmRelease`. + +``` +k8s-fleet/ + clusters/ + production/ + flux-system/ ← bootstrap-манифесты (авто-генерируются) + apps.yaml ← Flux Kustomization → apps/production/ + apps/ + base/ + my-app/ + deployment.yaml + service.yaml + pvc.yaml ← storageClassName: longhorn + ingressroute.yaml ← IngressRoute для Traefik + kustomization.yaml + production/ + my-app/ + kustomization.yaml + patch-image.yaml + staging/ + my-app/ + kustomization.yaml + patch-image.yaml +``` + +### Модель 2: GitOps per-проект + +Каждый проект в GitLab хранит свои k8s-манифесты. Flux смотрит на каждый из них. +Подходит при большом количестве независимых команд. + +--- + +## Установка Flux CLI + +```bash +# На k8s-manager-01 (operator workstation) +curl -s https://fluxcd.io/install.sh | sudo bash + +# Проверка совместимости с кластером +flux check --pre +``` + +--- + +## Bootstrap: подключение к GitLab + +Bootstrap — однократная процедура, после которой Flux сам себя обслуживает через Git. + +### Требования + +- **GitLab Personal Access Token** со scope `api` (или `read_repository` + `write_repository`) +- Права **Owner** на проект или **Maintainer** на группу в GitLab +- `kubectl` с доступом к кластеру (kubeconfig на k8s-manager-01) +- Flux CLI установлен + +### Команда bootstrap для self-hosted GitLab + +```bash +export GITLAB_TOKEN= + +flux bootstrap gitlab \ + --hostname=gitlab.gigacoms.info \ + --owner= \ + --repository=k8s-fleet \ + --branch=main \ + --path=clusters/production \ + --personal # если owner — пользователь, не группа +``` + +Параметры: + +| Флаг | Описание | +|---|---| +| `--hostname` | Хост self-hosted GitLab | +| `--owner` | GitLab group или username | +| `--repository` | Имя репозитория (создастся если нет) | +| `--branch` | Ветка для Flux-конфига | +| `--path` | Путь внутри репо для этого кластера | +| `--personal` | Если owner — личный аккаунт, не группа | +| `--private` | Создать репо как приватный (по умолчанию `true`) | + +Bootstrap выполнит: +1. Создаст репозиторий `k8s-fleet` в GitLab (если не существует) +2. Сгенерирует Deploy Key и добавит в репозиторий +3. Установит Flux-контроллеры в namespace `flux-system` +4. Запушит начальные манифесты в `clusters/production/flux-system/` + +--- + +## Подключение приложения из GitLab-проекта + +После bootstrap, чтобы Flux следил за конкретным проектом: + +### Вариант A: Kustomize (plain YAML / kustomize) + +```yaml +# clusters/production/apps/my-app.yaml + +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: my-app + namespace: flux-system +spec: + interval: 1m + url: https://gitlab.gigacoms.info/mygroup/my-app.git + ref: + branch: main + secretRef: + name: my-app-gitlab-token # Secret с токеном доступа +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: my-app + namespace: flux-system +spec: + interval: 5m + path: ./deploy/k8s + prune: true + sourceRef: + kind: GitRepository + name: my-app + targetNamespace: my-app +``` + +### Вариант B: Helm chart из GitLab + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: mygroup-charts + namespace: flux-system +spec: + interval: 10m + url: https://gitlab.gigacoms.info/api/v4/projects//packages/helm/stable + secretRef: + name: gitlab-helm-token +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: my-app + namespace: my-app +spec: + interval: 5m + chart: + spec: + chart: my-app + version: ">=1.0.0" + sourceRef: + kind: HelmRepository + name: mygroup-charts + namespace: flux-system + values: + replicaCount: 2 +``` + +### Secret для доступа к приватному репозиторию + +```bash +# Создать Secret с GitLab токеном (HTTPS) +kubectl create secret generic my-app-gitlab-token \ + --namespace=flux-system \ + --from-literal=username=oauth2 \ + --from-literal=password= +``` + +Или через SSH deploy key: + +```bash +flux create secret git my-app-gitlab-ssh \ + --url=ssh://git@gitlab.gigacoms.info/mygroup/my-app.git \ + --namespace=flux-system +# Публичный ключ добавить в GitLab → Project → Settings → Repository → Deploy Keys +``` + +--- + +## Интеграция с Longhorn + +> Полный стандарт работы с Longhorn: `roles/longhorn/README.md` + +Longhorn установлен Ansible и предоставляет StorageClass `longhorn` (default). +Flux-управляемые приложения используют его напрямую через PVC — никаких дополнительных +настроек в fleet-repo не требуется. + +### Правила использования хранилища в fleet-repo + +- Всегда указывать `storageClassName: longhorn` явно — не полагаться на default +- Для одного Pod — `ReadWriteOnce`, `replicas: 1` в Deployment +- Для StatefulSet с несколькими репликами — `volumeClaimTemplates` (не `volumes + PVC`) +- Для PostgreSQL: `PGDATA` в подпапку, `fsGroup: 999` в `securityContext` +- `ReadWriteMany` — только если нескольким Pod на разных нодах нужен общий том + +### Пример A: простое приложение с постоянным хранилищем + +```yaml +# apps/base/file-processor/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: file-processor-data + namespace: file-processor +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 20Gi +--- +# apps/base/file-processor/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: file-processor + namespace: file-processor +spec: + replicas: 1 # RWO — только 1 реплика + selector: + matchLabels: + app: file-processor + template: + metadata: + labels: + app: file-processor + spec: + containers: + - name: file-processor + image: registry.gigacoms.info/myteam/file-processor:latest + volumeMounts: + - name: data + mountPath: /app/data + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: data + persistentVolumeClaim: + claimName: file-processor-data +``` + +### Пример B: StatefulSet с Longhorn (база данных PostgreSQL) + +```yaml +# apps/base/shop/postgres/statefulset.yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: shop-production +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: shop + component: postgres + template: + metadata: + labels: + app: shop + component: postgres + spec: + securityContext: + fsGroup: 999 # postgres GID + containers: + - name: postgres + image: postgres:16-alpine + env: + - name: POSTGRES_DB + value: shopdb + - name: POSTGRES_USER + value: shopuser + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: shop-postgres-secret + key: password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata # подпапка! не корень тома + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + readinessProbe: + exec: + command: [pg_isready, -U, shopuser, -d, shopdb] + initialDelaySeconds: 10 + periodSeconds: 5 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: longhorn # явно указывать всегда + resources: + requests: + storage: 50Gi +``` + +### Пример C: два компонента с общим томом (ReadWriteMany) + +Сценарий: CMS пишет медиафайлы, CDN-прокси их раздаёт с нескольких Pod. + +```yaml +# apps/base/cms/pvc-shared-media.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shared-media + namespace: cms +spec: + accessModes: + - ReadWriteMany # Longhorn NFS-шлюз, v1.5+ + storageClassName: longhorn + resources: + requests: + storage: 100Gi +--- +# apps/base/cms/deployment-cms.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cms + namespace: cms +spec: + replicas: 1 + template: + spec: + containers: + - name: cms + image: registry.gigacoms.info/myteam/cms:latest + volumeMounts: + - name: media + mountPath: /app/media + volumes: + - name: media + persistentVolumeClaim: + claimName: shared-media +--- +# apps/base/cms/deployment-cdn.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cdn-proxy + namespace: cms +spec: + replicas: 3 # несколько реплик читают один RWX-том + template: + spec: + containers: + - name: nginx + image: nginx:1.27-alpine + volumeMounts: + - name: media + mountPath: /usr/share/nginx/html/media + readOnly: true + volumes: + - name: media + persistentVolumeClaim: + claimName: shared-media +``` + +--- + +## Интеграция с Traefik + +> Полный стандарт конфигурации Traefik: `roles/traefik/README.md` + +Traefik установлен Ansible и слушает порты 10001–10999 как DaemonSet на worker-нодах. +Для публикации сервиса через Traefik есть **два подхода** — выбор зависит от характера сервиса. + +### Подход 1: IngressRoute в fleet-repo (рекомендуется для Flux-управляемых приложений) + +Flux-управляемое приложение само описывает свой маршрут в fleet-repo. +`allowCrossNamespace: true` уже включён в Traefik — IngressRoute из namespace `traefik` +может ссылаться на Service в любом другом namespace. + +Этот подход даёт полный GitOps-цикл: добавил приложение в fleet-repo — оно само появилось +и снаружи, без отдельного запуска Ansible. + +**Ограничение:** порт под IngressRoute должен быть заранее добавлен в Traefik через +`traefik_port_map` (Ansible). EntryPoint-ы в Traefik — это статическая конфигурация, +она не обновляется через CRD. Поэтому порядок действий: + +``` +1. Зарезервировать порт → добавить в traefik_port_map → запустить setup_traefik.yml +2. Закоммитить IngressRoute в fleet-repo → Flux применит его +``` + +Пример IngressRoute в fleet-repo (приложение `shop`, порт `10005` зарезервирован в traefik_port_map): + +```yaml +# apps/base/shop/ingressroute.yaml +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: shop-ui + namespace: traefik # всегда в namespace traefik +spec: + entryPoints: + - shop-ui # имя из traefik_port_map (≤15 символов) + routes: + - match: PathPrefix(`/`) + kind: Rule + services: + - name: frontend # Service в namespace shop-production + namespace: shop-production + port: 80 + scheme: http +``` + +Для BasicAuth: + +```yaml +# apps/base/shop/ingressroute-admin.yaml +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: shop-admin + namespace: traefik +spec: + entryPoints: + - shop-admin # порт 10006 в traefik_port_map + routes: + - match: PathPrefix(`/`) + kind: Rule + services: + - name: backend-admin + namespace: shop-production + port: 8080 + scheme: http + middlewares: + - name: basicauth-shop-admin + namespace: traefik +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: basicauth-shop-admin + namespace: traefik +spec: + basicAuth: + secret: traefik-auth-shop-admin # создать вручную перед деплоем + removeHeader: true +``` + +> BasicAuth Secret создаётся вручную (не в fleet-repo): +> ```bash +> kubectl create secret generic traefik-auth-shop-admin \ +> --from-literal=users="$(openssl passwd -apr1 'Password' | xargs -I{} echo 'admin:{}')" \ +> -n traefik +> ``` + +### Подход 2: traefik_port_map в Ansible (для инфраструктурных сервисов) + +Подходит для сервисов, которые не управляются Flux: мониторинг, дашборды, сервисы, +установленные Ansible. Добавить запись в `inventory/prod/group_vars/traefik.yml` и +запустить `setup_traefik.yml`. + +Подробно: `roles/traefik/README.md`, раздел «Единственный способ добавить сервис». + +### Полный пример приложения с Longhorn + Traefik в fleet-repo + +**Сценарий:** веб-приложение `shop` с PostgreSQL на Longhorn, UI опубликован через Traefik. + +Предварительные требования (выполнить один раз до первого деплоя через Flux): + +```bash +# 1. Зарезервировать порты в traefik_port_map и запустить Ansible: +# port 10005: name=shop-ui → frontend:80 (без BasicAuth) +# port 10006: name=shop-admin → backend:8080 (с BasicAuth) +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml + +# 2. Создать секреты вручную (не хранятся в Git) +kubectl create secret generic shop-postgres-secret \ + --from-literal=password='StrongDbPassword' \ + -n shop-production + +kubectl create secret generic traefik-auth-shop-admin \ + --from-literal=users="$(openssl passwd -apr1 'AdminPass' | xargs -I{} echo 'admin:{}')" \ + -n traefik +``` + +Структура fleet-repo для приложения: + +``` +apps/ +├── base/ +│ └── shop/ +│ ├── namespace.yaml +│ ├── configmap.yaml +│ ├── postgres/ +│ │ ├── statefulset.yaml ← volumeClaimTemplates: storageClassName: longhorn +│ │ └── service.yaml +│ ├── backend/ +│ │ ├── deployment.yaml +│ │ └── service.yaml +│ ├── frontend/ +│ │ ├── deployment.yaml +│ │ └── service.yaml +│ ├── ingressroute-ui.yaml ← IngressRoute: entryPoints: [shop-ui] +│ ├── ingressroute-admin.yaml ← IngressRoute: entryPoints: [shop-admin] + BasicAuth +│ ├── middleware-auth.yaml ← Middleware: basicAuth (ссылается на secret) +│ └── kustomization.yaml +└── production/ + └── shop/ + ├── kustomization.yaml + ├── patch-postgres.yaml ← образ postgres:16-alpine (стабильный) + ├── patch-frontend.yaml ← image: registry/shop-frontend:v2.1.0 + └── patch-backend.yaml ← image: registry/shop-backend:v2.1.0 +``` + +Ключевые манифесты: + +```yaml +# apps/base/shop/postgres/statefulset.yaml — Longhorn storage +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: shop-production +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: shop + component: postgres + template: + metadata: + labels: + app: shop + component: postgres + spec: + securityContext: + fsGroup: 999 + containers: + - name: postgres + image: postgres:16-alpine + env: + - name: POSTGRES_DB + value: shopdb + - name: POSTGRES_USER + value: shopuser + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: shop-postgres-secret + key: password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + readinessProbe: + exec: + command: [pg_isready, -U, shopuser, -d, shopdb] + initialDelaySeconds: 10 + periodSeconds: 5 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: longhorn + resources: + requests: + storage: 50Gi +--- +# apps/base/shop/ingressroute-ui.yaml — Traefik публикация +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: shop-ui + namespace: traefik +spec: + entryPoints: + - shop-ui + routes: + - match: PathPrefix(`/`) + kind: Rule + services: + - name: frontend + namespace: shop-production + port: 80 + scheme: http +--- +# apps/base/shop/ingressroute-admin.yaml — Traefik с BasicAuth +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: shop-admin + namespace: traefik +spec: + entryPoints: + - shop-admin + routes: + - match: PathPrefix(`/`) + kind: Rule + services: + - name: backend + namespace: shop-production + port: 8080 + scheme: http + middlewares: + - name: basicauth-shop-admin + namespace: traefik +--- +# apps/base/shop/middleware-auth.yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: basicauth-shop-admin + namespace: traefik +spec: + basicAuth: + secret: traefik-auth-shop-admin + removeHeader: true +``` + +Flux Kustomization с порядком деплоя (postgres → backend → frontend): + +```yaml +# clusters/production/apps.yaml +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: shop-postgres + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/shop/postgres + prune: true + healthChecks: + - apiVersion: apps/v1 + kind: StatefulSet + name: postgres + namespace: shop-production +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: shop-backend + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/shop/backend + prune: true + dependsOn: + - name: shop-postgres + healthChecks: + - apiVersion: apps/v1 + kind: Deployment + name: backend + namespace: shop-production +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: shop-frontend-and-routes + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/shop/frontend + prune: true + dependsOn: + - name: shop-backend + # IngressRoute и Middleware включены в этот же Kustomization: + # frontend/deployment.yaml, frontend/service.yaml, + # ../ingressroute-ui.yaml, ../ingressroute-admin.yaml, + # ../middleware-auth.yaml +``` + +--- + +## Image Automation (автообновление образов) + +Flux умеет самостоятельно обновлять тег образа в Git при появлении нового в registry. + +``` +GitLab CI билдит образ → пушит в GitLab Container Registry + ↓ +image-reflector замечает новый тег + ↓ +image-automation коммитит обновлённый тег в fleet-репозиторий + ↓ +kustomize/helm-controller деплоит обновлённую версию +``` + +Конфигурация: + +```yaml +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageRepository +metadata: + name: my-app + namespace: flux-system +spec: + image: registry.gitlab.gigacoms.info/mygroup/my-app + interval: 1m + secretRef: + name: gitlab-registry-token +--- +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImagePolicy +metadata: + name: my-app + namespace: flux-system +spec: + imageRepositoryRef: + name: my-app + policy: + semver: + range: ">=1.0.0" +--- +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageUpdateAutomation +metadata: + name: fleet + namespace: flux-system +spec: + interval: 1m + sourceRef: + kind: GitRepository + name: flux-system + git: + checkout: + ref: + branch: main + commit: + author: + email: fluxbot@gigacoms.info + name: FluxBot + push: + branch: main + update: + path: ./clusters/production + strategy: Setters +``` + +--- + +## Интеграция с существующим стеком + +### Что меняется в текущей инфраструктуре + +| Компонент | Сейчас | С Flux | +|---|---|---| +| Деплой инфраструктуры | Ansible playbooks (вручную/CI) | Без изменений — Ansible остаётся | +| Деплой приложений | Нет автоматизации | Flux следит за GitLab-проектами | +| Longhorn (хранилище) | Ansible устанавливает, `storageClassName: longhorn` — доступен всем | Приложения используют через PVC без изменений | +| Traefik (прокси) | Ansible управляет `traefik_port_map` и EntryPoint-ами | EntryPoint — по-прежнему Ansible; IngressRoute — опционально Flux | +| Обновление образов | Нет | Image Automation или CI-скрипт в fleet-repo | + +Ansible и Flux не конкурируют: Ansible управляет **OS и кластером**, Flux управляет +**приложениями внутри кластера**. + +### Разделение ответственности: Ansible vs Flux + +``` +Ansible (roles/longhorn, roles/traefik): + ├── Устанавливает Longhorn, создаёт StorageClass longhorn + ├── Устанавливает Traefik, настраивает EntryPoint-ы (traefik_port_map) + └── Открывает firewalld-порты + +Flux (fleet-repo): + ├── Деплоит приложения (Deployment, StatefulSet, Service) + ├── Создаёт PVC с storageClassName: longhorn + ├── Создаёт IngressRoute/Middleware в namespace traefik + └── Управляет тегами образов +``` + +### Сетевые требования + +Flux-контроллеры работают внутри кластера и сами инициируют соединения наружу: + +- `source-controller` → `gitlab.gigacoms.info:443` (HTTPS или SSH) +- `image-reflector` → `registry.gitlab.gigacoms.info:443` (если используется) +- Firewalld на узлах не требует изменений (egress трафик не блокируется) + +--- + +## Ansible-роль для установки Flux + +Роль `roles/flux/` уже реализована в проекте. Стандарт GitOps-структуры fleet-repo: `roles/flux/README.md`. + +``` +roles/flux/ + defaults/main.yml — flux_version, fleet_repo, gitlab_hostname, gitlab_owner + tasks/main.yml — include_tasks: install → bootstrap + tasks/install.yml — скачать flux CLI на k8s-manager-01 + tasks/bootstrap.yml — запустить flux bootstrap gitlab +``` + +Playbook `playbooks/setup_flux.yml` запускается на `manager_nodes`. + +GitLab CI job: + +```yaml +setup:flux: + stage: setup + script: + - ansible-playbook -i $INVENTORY playbooks/setup_flux.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production +``` + +Дополнительная CI/CD-переменная: `GITLAB_FLUX_TOKEN` — GitLab PAT для bootstrap. + +--- + +## Мониторинг и отладка + +```bash +# Статус всех Flux-объектов +flux get all -A + +# Принудительная синхронизация +flux reconcile source git flux-system +flux reconcile kustomization flux-system + +# Конкретное приложение +flux reconcile kustomization shop-backend --with-source + +# Логи контроллеров +kubectl logs -n flux-system deploy/source-controller +kubectl logs -n flux-system deploy/kustomize-controller + +# Подробности о конкретном ресурсе +flux get kustomization shop-backend --watch + +# Проверить что IngressRoute применился +kubectl get ingressroute -n traefik + +# Проверить что PVC создан и привязан +kubectl get pvc -n shop-production + +# Проверить том в Longhorn +kubectl get volumes.longhorn.io -n longhorn-system +``` + +--- + +## GitLab Agent для Kubernetes (agentk) + +GitLab Agent (agentk) — официальный способ подключить кластер Kubernetes к GitLab. +GitLab рекомендует использовать **Flux + agentk вместе**: Flux синхронизирует состояние +кластера с Git, agentk даёт видимость в GitLab UI и управление доступом. + +``` +┌─────────────────────────────────────────────────────┐ +│ GitLab: gitlab.gigacoms.info │ +│ GitLab UI → Operate → Kubernetes clusters │ +│ GitLab UI → Operate → Environments (k8s dashboard) │ +└──────────────────────┬──────────────────────────────┘ + │ исходящее соединение (WebSocket) + │ агент сам подключается к GitLab KAS +┌──────────────────────▼──────────────────────────────┐ +│ Kubernetes cluster │ +│ namespace: gitlab-agent │ +│ agentk pod ─────────────────────────────────→ │ +│ kubectl / Flux API │ +└─────────────────────────────────────────────────────┘ +``` + +> **Важно:** agentk сам инициирует соединение **наружу** к GitLab KAS +> (Kubernetes Agent Server) по WebSocket/gRPC. Входящих портов открывать не нужно. +> Работает за NAT и firewall — подходит для текущей инфраструктуры. + +### Что даёт agentk + +| Возможность | Описание | +|---|---| +| Kubernetes dashboard в GitLab UI | Обзор подов, деплойментов, namespace-ов прямо в GitLab | +| Статус Flux-объектов | Видимость `Kustomization` и `HelmRelease` в окружениях | +| Ручная синхронизация | Suspend/resume Flux reconciliation из GitLab UI | +| Логи подов | Просмотр логов контейнеров в GitLab → Environments | +| CI/CD-доступ к кластеру | `kubectl` в GitLab CI без прямого доступа к kubeconfig | +| RBAC-управление | Ограничение доступа к namespace-ам для разных проектов | + +### Установка + +#### Шаг 1 — Создать конфигурацию агента в GitLab + +В fleet-репозитории создать файл: + +``` +.gitlab/agents/production/config.yaml +``` + +Минимальная конфигурация для Flux + видимости в UI: + +```yaml +# .gitlab/agents/production/config.yaml + +gitops: + reconcile_timeout: 3600s + +observability: + logging: + level: info + +ci_access: + # Разрешить GitLab CI-пайплайнам этого проекта использовать агент + projects: + - id: mygroup/my-app + - id: mygroup/shop + # Или разрешить всей группе: + # groups: + # - id: mygroup +``` + +#### Шаг 2 — Зарегистрировать агент и получить токен + +В GitLab: **Infrastructure → Kubernetes clusters → Connect a cluster** (или через `glab`): + +```bash +# Через GitLab CLI +glab cluster agent bootstrap production \ + --repo mygroup/k8s-fleet +``` + +Или вручную: GitLab → проект → **Operate → Kubernetes clusters → Connect a cluster** → +ввести имя `production` → скопировать токен. + +#### Шаг 3 — Установить agentk в кластер через Helm + +```bash +helm repo add gitlab https://charts.gitlab.io +helm repo update + +helm upgrade --install gitlab-agent gitlab/gitlab-agent \ + --namespace gitlab-agent \ + --create-namespace \ + --set config.token= \ + --set config.kasAddress=wss://gitlab.gigacoms.info/-/kubernetes-agent/ +``` + +Параметры: + +| Параметр | Значение для вашего стека | +|---|---| +| `config.token` | Токен из GitLab (шаг 2) | +| `config.kasAddress` | `wss://gitlab.gigacoms.info/-/kubernetes-agent/` | +| `namespace` | `gitlab-agent` | + +> KAS (Kubernetes Agent Server) уже встроен в GitLab начиная с версии 14.4. +> Для self-hosted GitLab он доступен по пути `/-/kubernetes-agent/`. + +#### Установка через Ansible-роль + +Роль `roles/gitlab_agent/` уже реализована в проекте. +Playbook: `playbooks/setup_gitlab_agent.yml`. +CI-переменная: `GITLAB_AGENT_TOKEN`. + +### Просмотр кластера в GitLab UI + +После установки agentk в GitLab появится: + +- **Operate → Kubernetes clusters** — список подключённых кластеров +- **Operate → Environments** → выбрать окружение → вкладка **Kubernetes** — статус подов +- Flux-объекты (`Kustomization`, `HelmRelease`) — в окружении после настройки namespace + +Для отображения Flux-объектов в окружении указать namespace в настройках environment: + +```yaml +# .gitlab-ci.yml — окружение с привязкой к namespace +deploy: + environment: + name: production + kubernetes: + namespace: flux-system +``` + +### Связка Flux + agentk + +Рекомендованная GitLab архитектура: + +``` +Разработчик пушит в GitLab-проект + ↓ +GitLab CI: сборка образа → пуш в GitLab Registry + ↓ +CI обновляет тег образа в fleet-repo (patch-image.yaml) + ↓ +Flux kustomize-controller применяет изменение: + - StatefulSet postgres → том из Longhorn (storageClassName: longhorn) + - Deployment frontend/backend → контейнеры обновлены + - IngressRoute → маршрут в Traefik (порт 10005) + ↓ +agentk транслирует статус обратно в GitLab UI +``` + +Flux отвечает за **деплой**, agentk — за **видимость и доступ**. + +### Сетевые требования + +Agentk устанавливает исходящее соединение, дополнительных firewalld-правил не требуется: + +- Кластер → `gitlab.gigacoms.info:443` (WebSocket upgrade) — уже открыто + +--- + +## Альтернативы + +| Инструмент | Плюсы | Минусы | +|---|---|---| +| **Flux CD** | Нативный GitOps, много CRD, image automation | Сложнее начать, несколько контроллеров | +| **ArgoCD** | Удобный UI, проще для начала | Один монолитный процесс, нет image automation | +| **GitLab Agent (agentk)** | Нативная интеграция с GitLab UI | Требует постоянного соединения с GitLab | + +GitLab официально рекомендует связку **Flux + agentk**: Flux синхронизирует состояние, +agentk обеспечивает видимость в GitLab UI и управление доступом. + +--- + +## Рекомендуемый план внедрения + +1. **Создать fleet-репозиторий** `k8s-fleet` в GitLab-группе на `gitlab.gigacoms.info` +2. **Ansible-роль `flux`** уже готова — запустить `playbooks/setup_flux.yml` +3. **Добавить первое приложение** через `GitRepository` + `Kustomization`: + - PVC с `storageClassName: longhorn` если нужно хранилище + - IngressRoute в namespace `traefik` если нужен внешний доступ (порт предварительно зарезервировать в `traefik_port_map`) +4. **Ознакомиться со стандартами** компонентов: + - `roles/flux/README.md` — структура fleet-repo, многоветочный деплой, все примеры + - `roles/longhorn/README.md` — PVC, StatefulSet, реплики, диагностика + - `roles/traefik/README.md` — port_map, IngressRoute, BasicAuth, таблица портов +5. **Опционально**: настроить Image Automation для автодеплоя по новому тегу образа + +--- + +## Источники + +- [Flux — официальный сайт](https://fluxcd.io/) +- [Flux bootstrap для GitLab](https://fluxcd.io/flux/installation/bootstrap/gitlab/) +- [flux bootstrap gitlab — справка по команде](https://fluxcd.io/flux/cmd/flux_bootstrap_gitlab/) +- [GitOps компоненты Flux](https://fluxcd.io/flux/components/) +- [HelmRelease CRD](https://fluxcd.io/flux/components/helm/helmreleases/) +- [Kustomization CRD](https://fluxcd.io/flux/components/kustomize/kustomizations/) +- [GitLab: Using GitOps with a Kubernetes cluster](https://docs.gitlab.com/user/clusters/agent/gitops/) +- [GitLab: интеграция с Flux CD](https://about.gitlab.com/blog/why-did-we-choose-to-integrate-fluxcd-with-gitlab/) +- [Пример flux2-kustomize-helm](https://github.com/fluxcd/flux2-kustomize-helm-example) +- Стандарт хранилища кластера: `roles/longhorn/README.md` +- Стандарт публикации сервисов: `roles/traefik/README.md` +- Стандарт GitOps-структуры fleet-repo: `roles/flux/README.md` diff --git a/research/logging/plg.md b/research/logging/plg.md new file mode 100644 index 0000000..6eb6abd --- /dev/null +++ b/research/logging/plg.md @@ -0,0 +1,1275 @@ +# Сбор логов в Kubernetes: PLG-стек и переезд на Graylog + +**Кластер:** Rocky Linux 9 · Kubernetes 1.33 · Flannel CNI +**Текущая топология:** k8s-manager-01 (10.203.0.92) · k8s-master-01 (10.203.0.97) · k8s-worker-01 (10.203.0.96) +**Дата исследования:** 2026-05-28 +**Проверено на кластере:** 2026-05-28 + +> **⚠️ ВАЖНО — EOL-статус компонентов (проверено 2026-05-28):** +> +> | Компонент | Статус | Детали | +> |---|---|---| +> | **Promtail** | **EOL с 2 марта 2026** | [Официальное объявление Grafana Labs](https://community.grafana.com/t/promtail-end-of-life-eol-march-2026-how-to-migrate-to-grafana-alloy-for-existing-loki-server-deployments/159636). Баги и CVE не исправляются. Замена: **Grafana Alloy** | +> | **Loki** | Активно развивается | Последняя: v3.6.7. Helm chart 7.0.0 актуален. EOL не объявлен | +> | **Grafana** | Активно развивается | EOL не объявлен | +> +> **Все разделы про Promtail (5, 9, 11) обновлены на Grafana Alloy.** + +--- + +## Состояние кластера на дату исследования + +``` +MinIO (namespace: minio) + pod/minio-696468d6b6-wnm7c 1/1 Running + service/minio ClusterIP 10.101.211.198 9000/TCP + service/minio-console ClusterIP 10.111.84.4 9001/TCP + pvc/minio Bound 1Ti longhorn-minio (Retain) + IngressRoutes: route-minio-api (10005), route-minio-console (10006) ✓ + Buckets: loki-chunks ✓ backups ✓ artifacts ✓ (все пустые) + Пользователь loki: не создан ← нужно создать перед деплоем Loki + +Namespace monitoring: не существует ← PLG не развёрнут +StorageClasses: longhorn (default), longhorn-minio (Retain), longhorn-static +``` + +**Вывод:** MinIO полностью готов к роли S3 backend для Loki. Filesystem-режим не нужен — Loki деплоится сразу с S3/MinIO. + +--- + +## Содержание + +1. [Обзор проблемы](#1-обзор-проблемы) +2. [PLG-стек: архитектура](#2-plg-стек-архитектура) +3. [Подготовка MinIO для Loki](#3-подготовка-minio-для-loki) +4. [Loki: хранение в MinIO (S3)](#4-loki-хранение-в-minio-s3) +5. [Grafana Alloy: сбор логов с нод](#5-grafana-alloy-сбор-логов-с-нод) *(Promtail EOL 2026-03-02)* +6. [Grafana: визуализация и алерты](#6-grafana-визуализация-и-алерты) +6а. [Мониторинг ресурсов кластера: Prometheus stack](#6а-мониторинг-ресурсов-кластера-prometheus-stack) +7. [Grafana as Code: хранение настроек в GitLab](#7-grafana-as-code-хранение-настроек-в-gitlab) +8. [Интеграция с Traefik](#8-интеграция-с-traefik) +9. [Ansible-роли и плейбуки](#9-ansible-роли-и-плейбуки) +10. [Масштабирование до 10 нод / 100 сервисов](#10-масштабирование-до-10-нод--100-сервисов) +11. [Переезд на Graylog](#11-переезд-на-graylog) +12. [Итоговое сравнение и рекомендации](#12-итоговое-сравнение-и-рекомендации) + +--- + +## 1. Обзор проблемы + +В кластере Kubernetes логи существуют на трёх уровнях: + +| Уровень | Источник | Путь на ноде | +|---|---|---| +| Приложения | stdout/stderr контейнеров | `/var/log/pods/__//N.log` | +| Control plane | apiserver, etcd, scheduler, controller-manager | `journald` (Rocky Linux 9) | +| Нода | kubelet, kube-proxy, containerd | `journald` | + +containerd по умолчанию ротирует файлы логов (10 MB × 5 файлов), что означает потерю истории при интенсивной нагрузке. Без централизованного сбора: + +- Логи недоступны после рестарта пода +- Нет единого поиска по неймспейсам +- Нет алертов на паттерны ошибок +- Корреляция событий между сервисами невозможна + +--- + +## 2. PLG-стек: архитектура + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ k8s-worker-01 / k8s-master-01 │ +│ │ +│ [Pod stdout] → containerd → /var/log/pods/ │ +│ ↓ │ +│ [Grafana Alloy DaemonSet] (файлы + journald) │ +└───────────────────────┬──────────────────────────────────────────┘ + │ HTTP push (loki.write) + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Loki (namespace: monitoring) │ +│ single-binary pod │ +│ ├── Ingester — буферизует и пишет чанки │ +│ ├── Querier — выполняет LogQL запросы │ +│ └── Compactor — уплотняет, применяет retention │ +└───────────────────────┬──────────────────────────────────────────┘ + │ S3 API (chunks + index) + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ MinIO (namespace: minio) — УЖЕ ЗАПУЩЕН ✓ │ +│ pod/minio-696468d6b6-wnm7c Running │ +│ pvc/minio 1Ti longhorn-minio │ +│ bucket: loki-chunks ✓ (пустой, готов) │ +│ http://minio.minio.svc.cluster.local:9000 │ +└──────────────────────────────────────────────────────────────────┘ + │ datasource + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Grafana (namespace: monitoring) │ +│ ├── Loki datasource — LogQL explore + dashboards │ +│ └── Alerting — правила на паттерны ошибок │ +│ Traefik → port 10003 → http://10.203.0.96:10003 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Выбор компонентов + +| Компонент | Helm chart | Версия chart | Статус | +|---|---|---|---| +| Loki | `grafana/loki` | 7.0.0 | ✅ Актуален | +| ~~Promtail~~ → **Grafana Alloy** | `grafana/alloy` | 1.8.2 | ⚠️ Promtail EOL 2026-03-02; Alloy — официальная замена | +| Grafana | `grafana/grafana` | 10.5.15 | ✅ Актуален | + +--- + +## 3. Подготовка MinIO для Loki + +MinIO уже запущен. Требуется только создать выделенного пользователя с доступом исключительно к бакету `loki-chunks`. + +### Создание пользователя loki + +```bash +# Выполнить на k8s-manager-01 или через kubectl exec +kubectl exec -n minio deployment/minio -- \ + mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD + +# Создать пользователя +kubectl exec -n minio deployment/minio -- \ + mc admin user add local loki ПАРОЛЬ_LOKI + +# Создать политику — только бакет loki-chunks +kubectl exec -n minio deployment/minio -- \ + mc admin policy create local loki-policy /dev/stdin <<'EOF' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:ListBucket","s3:GetBucketLocation"], + "Resource": [ + "arn:aws:s3:::loki-chunks", + "arn:aws:s3:::loki-chunks/*" + ] + } + ] +} +EOF + +# Привязать политику +kubectl exec -n minio deployment/minio -- \ + mc admin policy attach local loki-policy --user loki +``` + +### Secret для Loki в namespace monitoring + +```bash +kubectl create namespace monitoring + +kubectl create secret generic loki-minio-secret \ + --from-literal=AWS_ACCESS_KEY_ID=loki \ + --from-literal=AWS_SECRET_ACCESS_KEY='ПАРОЛЬ_LOKI' \ + -n monitoring +``` + +### Проверка доступа + +```bash +# Проверить что loki-user видит только свой бакет +kubectl exec -n minio deployment/minio -- \ + mc alias set loki-test http://localhost:9000 loki ПАРОЛЬ_LOKI + +kubectl exec -n minio deployment/minio -- \ + mc ls loki-test +# Ожидаем: [дата] 0B loki-chunks/ + +kubectl exec -n minio deployment/minio -- \ + mc ls loki-test/loki-chunks +# Ожидаем: пустой список (бакет пустой и готов) +``` + +--- + +## 4. Loki: хранение в MinIO (S3) + +Loki деплоится сразу в режиме **single-binary с S3 backend** — MinIO уже в кластере, filesystem не нужен. + +### Helm values: `loki-values.yml` + +```yaml +loki: + auth_enabled: false + + commonConfig: + replication_factor: 1 + + # S3 backend — MinIO в namespace minio + storage: + type: s3 + s3: + endpoint: http://minio.minio.svc.cluster.local:9000 + region: us-east-1 # MinIO игнорирует region, поле обязательно + bucketnames: loki-chunks + access_key_id: loki + secret_access_key: "${LOKI_MINIO_SECRET_KEY}" # из Secret через envFrom + insecure: true # http, не https + s3forcepathstyle: true # обязательно для MinIO + + schemaConfig: + configs: + - from: "2024-01-01" + store: tsdb + object_store: s3 + schema: v13 + index: + prefix: loki_index_ + period: 24h + + limits_config: + retention_period: 744h # 31 день + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + max_streams_per_user: 10000 + max_chunks_per_query: 2000000 + + compactor: + working_directory: /loki/compactor + retention_enabled: true + delete_request_store: s3 # ← s3, не filesystem + +singleBinary: + replicas: 1 + # Нет PVC — данные в MinIO, только небольшой volume для WAL + persistence: + enabled: true + storageClass: longhorn + size: 10Gi # только WAL/temp, не логи + + extraEnv: + - name: LOKI_MINIO_SECRET_KEY + valueFrom: + secretKeyRef: + name: loki-minio-secret + key: AWS_SECRET_ACCESS_KEY + +# Отключаем встроенные компоненты (используем Promtail отдельно) +gateway: + enabled: false +backend: + replicas: 0 +read: + replicas: 0 +write: + replicas: 0 +``` + +### Объём хранилища: расчёт для текущего кластера + +``` +2 ноды, ~20 подов, средний lograte 1 KB/s: + 20 pods × 1 KB/s × 86400 s/day ≈ 1.7 GB/day (raw) + Loki сжимает ~10:1 → ~170 MB/day + 31 день retention → ~5 GB в loki-chunks бакете MinIO + +MinIO PVC = 1 TiB → запас до ~100 нод (≈ 500 MB/day × 31 дней ≈ 15 GB) +``` + +### Проверка после деплоя + +```bash +# Статус пода +kubectl get pod -n monitoring -l app.kubernetes.io/name=loki + +# Готовность API +kubectl exec -n monitoring deployment/loki -- wget -qO- http://localhost:3100/ready + +# Появились ли объекты в MinIO +kubectl exec -n minio deployment/minio -- mc ls local/loki-chunks --recursive | head -20 +``` + +--- + +## 5. Grafana Alloy: сбор логов с нод + +> **Promtail EOL с 2 марта 2026.** Официальная замена — **Grafana Alloy** (`grafana/alloy`, chart 1.8.2). +> Alloy — дистрибутив OpenTelemetry Collector от Grafana Labs. Собирает логи, метрики и трейсы единым агентом. +> Конфиг Promtail конвертируется автоматически: `alloy convert --source-format=promtail --output=config.alloy promtail.yml` + +### Что собирает + +- `/var/log/pods/**/*.log` — логи всех контейнеров (через `loki.source.kubernetes`) +- `journald` — kubelet, kube-proxy, containerd, sshd (через `loki.source.journal`) + +### Как работает Alloy + +Alloy использует собственный язык конфигурации (River/Alloy syntax), а не YAML. Конфиг передаётся в Helm как строка в `alloy.configMap.content`. + +### Helm values: `alloy-values.yml` + +```yaml +# Развёртывание как DaemonSet — один под на каждую ноду +controller: + type: daemonset + +tolerations: + # Запускать и на control plane (k8s-master-01) + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule + +# Монтирование journald с хоста +alloy: + mounts: + varlog: true # /var/log (поды) + dockercontainers: false + +extraVolumes: + - name: journal + hostPath: + path: /var/log/journal + +extraVolumeMounts: + - name: journal + mountPath: /var/log/journal + readOnly: true + + configMap: + create: true + content: | + // ── Обнаружение подов Kubernetes ─────────────────────────────── + discovery.kubernetes "pods" { + role = "pod" + } + + // ── Relabeling: namespace, pod, container, app из метаданных ── + discovery.relabel "pod_logs" { + targets = discovery.kubernetes.pods.targets + + rule { + source_labels = ["__meta_kubernetes_namespace"] + target_label = "namespace" + } + rule { + source_labels = ["__meta_kubernetes_pod_name"] + target_label = "pod" + } + rule { + source_labels = ["__meta_kubernetes_pod_container_name"] + target_label = "container" + } + rule { + source_labels = ["__meta_kubernetes_pod_label_app"] + target_label = "app" + } + // Отброс debug-логов на уровне агента + rule { + source_labels = ["__meta_kubernetes_pod_annotation_filter_debug"] + regex = "true" + action = "drop" + } + } + + // ── Чтение файлов логов подов ────────────────────────────────── + loki.source.kubernetes "pods" { + targets = discovery.relabel.pod_logs.output + forward_to = [loki.process.parse.receiver] + } + + // ── Парсинг JSON-логов, отброс debug ────────────────────────── + loki.process "parse" { + // Попытка распарсить как JSON и извлечь level + stage.json { + expressions = {level = "level", msg = "message"} + } + stage.labels { + values = {level = ""} + } + // Отброс debug-записей + stage.drop { + expression = ".*level=\"debug\".*" + drop_counter_reason = "debug_dropped" + } + forward_to = [loki.write.local.receiver] + } + + // ── Сбор journald (kubelet, containerd, sshd) ───────────────── + loki.source.journal "systemd" { + path = "/var/log/journal" + max_age = "12h" + labels = {job = "systemd-journal"} + forward_to = [loki.write.local.receiver] + + relabel_rules = discovery.relabel.journal.rules + } + + discovery.relabel "journal" { + targets = [] + rule { + source_labels = ["__journal__systemd_unit"] + target_label = "unit" + } + rule { + source_labels = ["__journal__hostname"] + target_label = "node" + } + } + + // ── Отправка в Loki ──────────────────────────────────────────── + loki.write "local" { + endpoint { + url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push" + } + } +``` + +--- + +## 6. Grafana: визуализация и алерты + +### Helm values: `grafana-values.yml` + +```yaml +grafana.ini: + server: + root_url: http://10.203.0.96:10003 + security: + admin_user: admin + auth.anonymous: + enabled: false + unified_alerting: + enabled: true + alerting: + enabled: false # legacy alerting отключаем + +admin: + existingSecret: grafana-admin-secret + userKey: admin-user + passwordKey: admin-password + +persistence: + enabled: true + storageClassName: longhorn + size: 5Gi + +# Datasources — provisioning при старте пода (Loki + Prometheus) +datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: Loki + type: loki + url: http://loki.monitoring.svc.cluster.local:3100 + access: proxy + isDefault: true + jsonData: + maxLines: 5000 + derivedFields: + - name: TraceID + matcherRegex: '"trace_id":"(\w+)"' + url: '$${__value.raw}' + urlDisplayLabel: Open Trace + + # Prometheus datasource — появляется после деплоя kube-prometheus-stack (раздел 6а) + - name: Prometheus + type: prometheus + url: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090 + access: proxy + isDefault: false + jsonData: + timeInterval: 30s + +# Автоимпорт дашбордов из ConfigMap с label grafana_dashboard=1 +# Подхватывает: ручные дашборды + ConfigMaps от kube-prometheus-stack (раздел 6а) +sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + folder: /var/lib/grafana/dashboards/default + searchNamespace: ALL # искать ConfigMaps во всех namespace + datasources: + enabled: true + label: grafana_datasource + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi +``` + +### Создание Secret с паролем администратора + +```bash +kubectl create secret generic grafana-admin-secret \ + --from-literal=admin-user=admin \ + --from-literal=admin-password='ВАШ_ПАРОЛЬ' \ + -n monitoring +``` + +### Ключевые дашборды для Kubernetes + +| Дашборд | Grafana ID | Назначение | +|---|---|---| +| Kubernetes Cluster Logs | 15141 | Логи всех нод и неймспейсов | +| Loki Dashboard | 13639 | Состояние самого Loki | +| Pod Logs | 18748 | Логи конкретного пода с фильтрами | + +### Алерт на высокий уровень ошибок (provisioning) + +```yaml +# ConfigMap: grafana-alert-rules, label: grafana_dashboard=1 +apiVersion: 1 +groups: + - orgId: 1 + name: kubernetes-errors + folder: Kubernetes + interval: 1m + rules: + - uid: high-error-rate + title: High Error Rate + condition: C + data: + - refId: A + model: + expr: 'sum(rate({namespace=~".+"} |= "error" [5m])) by (namespace)' + - refId: C + type: classic_conditions + model: + conditions: + - evaluator: + type: gt + params: [10] # > 10 ошибок/сек + query: + params: [A] + for: 2m + annotations: + summary: "Высокий уровень ошибок в {{ $labels.namespace }}" +``` + +--- + +## 6а. Мониторинг ресурсов кластера: Prometheus stack + +### Что покрывает PLG — и чего не хватает + +Текущий PLG-стек собирает **только логи**. Метрики (CPU, RAM, диски, сеть, состояние объектов k8s) не собираются вообще. Без метрик невозможно: + +- Видеть загрузку нод в реальном времени +- Строить алерты на исчерпание RAM/диска **до** инцидента +- Отслеживать статус Deployments, PVC, ReplicaSet +- Мониторить latency и error rate API-сервера + +### Решение: kube-prometheus-stack + +`prometheus-community/kube-prometheus-stack` — стандартный Helm chart, который включает: + +| Компонент | Назначение | +|---|---| +| **Prometheus** | Сбор и хранение метрик (time-series DB, pull model) | +| **node-exporter** | Метрики нод: CPU, RAM, диски, сеть (DaemonSet) | +| **kube-state-metrics** | Метрики объектов k8s: pod count, deployment status, PVC | +| **ServiceMonitors** | Auto-discovery k8s компонентов: kubelet, apiserver, etcd | +| **Dashboard ConfigMaps** | Готовые дашборды Kubernetes для Grafana sidecar | + +Встроенную Grafana в chart отключаем (`grafana.enabled: false`) — используем уже развёрнутую. ConfigMaps с дашбордами создаём через `forceDeployDashboards: true` — Grafana sidecar подхватывает их автоматически. + +### Обновлённая архитектура + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ k8s-worker-01 / k8s-master-01 │ +│ │ +│ [Pod stdout] → /var/log/pods/ │ +│ [node-exporter DaemonSet] ← CPU/RAM/диск/сеть (метрики нод) │ +│ [Grafana Alloy DaemonSet] ← файлы + journald (логи) │ +└──────────┬────────────────────────┬─────────────────────────────┘ + │ HTTP push (logs) │ HTTP scrape (metrics) + ▼ ▼ +┌──────────────────┐ ┌────────────────────────────┐ +│ Loki │ │ Prometheus │ +│ (monitoring) │ │ (monitoring) │ +│ S3 → MinIO │ │ PVC 20Gi (longhorn) │ +└────────┬─────────┘ └────────────┬───────────────┘ + │ datasource │ datasource + └────────────┬─────────────┘ + ▼ + ┌────────────────────────────┐ + │ Grafana (monitoring) │ + │ ├── Loki datasource │ ← логи + │ ├── Prometheus datasource │ ← метрики + │ ├── Logs dashboards │ ← из ConfigMaps + │ └── K8s cluster dashboards│ ← из kube-prometheus-stack + │ Traefik → :10003 │ + └────────────────────────────┘ +``` + +### Helm values: `prometheus-values.yml` + +```yaml +# Встроенную Grafana отключаем — используем свою +grafana: + enabled: false + # Создать ConfigMaps с готовыми k8s дашбордами для нашего Grafana sidecar + forceDeployDashboards: true + sidecar: + dashboards: + label: grafana_dashboard + labelValue: "1" + +prometheus: + prometheusSpec: + # Хранение метрик на Longhorn PVC + storageSpec: + volumeClaimTemplate: + spec: + storageClassName: longhorn + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi + retention: 30d + scrapeInterval: 30s + evaluationInterval: 30s + + # Разрешить сбор метрик из всех namespace + ruleSelectorNilUsesHelmValues: false + serviceMonitorSelectorNilUsesHelmValues: false + podMonitorSelectorNilUsesHelmValues: false + +alertmanager: + enabled: false # алерты через Grafana Alerting + +nodeExporter: + enabled: true + +kubeStateMetrics: + enabled: true + +# Rocky Linux 9: явно указать IP control plane для etcd/scheduler/controller-manager +kubeControllerManager: + enabled: true + endpoints: + - 10.203.0.97 + service: + enabled: true + port: 10257 + targetPort: 10257 + +kubeScheduler: + enabled: true + endpoints: + - 10.203.0.97 + service: + enabled: true + port: 10259 + targetPort: 10259 + +kubeEtcd: + enabled: true + endpoints: + - 10.203.0.97 + service: + enabled: true + port: 2381 + targetPort: 2381 + +kubeProxy: + enabled: true + endpoints: + - 10.203.0.97 + - 10.203.0.96 +``` + +> **Rocky Linux 9 / kubeadm gotcha:** kubeadm по умолчанию настраивает `controller-manager` и `scheduler` слушать только `127.0.0.1`. Если метрики этих компонентов нужны — потребуется патч `/etc/kubernetes/manifests/kube-controller-manager.yaml` и `kube-scheduler.yaml`: заменить `--bind-address=127.0.0.1` на `--bind-address=0.0.0.0`. Остальные компоненты (kubelet, node-exporter, kube-state-metrics, etcd на порту 2381) работают без изменений. + +### Готовые дашборды после деплоя + +`forceDeployDashboards: true` создаёт ConfigMaps в namespace `monitoring`. Grafana sidecar подхватывает их автоматически при старте: + +| Дашборд | Что показывает | +|---|---| +| Kubernetes / Compute Resources / Cluster | CPU/RAM/сеть по неймспейсам | +| Kubernetes / Compute Resources / Node (Pods) | Ресурсы по подам на конкретной ноде | +| Kubernetes / Compute Resources / Workload | Потребление по Deployment/StatefulSet | +| Node Exporter / Nodes | CPU, RAM, диски, сеть по нодам | +| Kubernetes / Persistent Volumes | Статус и заполнение PVC | +| Kubernetes / API server | Latency, error rate, RPS API-сервера | +| Kubernetes / Kubelet | Состояние kubelet на каждой ноде | +| Kubernetes / etcd | Состояние etcd, latency, лидер | + +### Datasource Prometheus в Grafana + +Добавляется в `grafana-values.yml.j2` рядом с Loki: + +```yaml +datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: Loki + type: loki + url: http://loki.monitoring.svc.cluster.local:3100 + access: proxy + isDefault: true + jsonData: + maxLines: 5000 + + - name: Prometheus + type: prometheus + url: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090 + access: proxy + isDefault: false + jsonData: + timeInterval: 30s +``` + +### Проверка после деплоя + +```bash +# Статус подов Prometheus stack +kubectl get pod -n monitoring -l release=kube-prometheus-stack + +# Метрики node-exporter доступны +kubectl exec -n monitoring -l app.kubernetes.io/name=prometheus -- \ + wget -qO- 'http://localhost:9090/api/v1/query?query=up' | python3 -m json.tool + +# ConfigMaps с дашбордами созданы (должно быть ~20+ штук) +kubectl get configmap -n monitoring -l grafana_dashboard=1 + +# Prometheus видит все таргеты (все должны быть UP) +kubectl port-forward -n monitoring svc/kube-prometheus-stack-prometheus 9090:9090 +# → http://localhost:9090/targets +``` + +--- + +## 7. Grafana as Code: хранение настроек в GitLab + +### Проблема + +Дашборды и datasource, созданные через UI Grafana, хранятся в SQLite внутри пода. При пересоздании пода — настройки теряются. + +### Решение: отдельный GitLab-проект `k8s/grafana-config` + +``` +gitlab.gigacoms.info/k8s/grafana-config +├── dashboards/ +│ ├── kubernetes-logs.json # экспорт из UI: Share → Export JSON +│ ├── loki-overview.json +│ └── pod-errors.json +├── provisioning/ +│ ├── datasources/ +│ │ └── loki.yaml +│ ├── dashboards/ +│ │ └── provider.yaml +│ └── alerting/ +│ ├── rules.yaml +│ └── contact-points.yaml +└── README.md +``` + +> Секреты (пароли, токены) — **не хранить в Git**. Создаются через `kubectl create secret` или GitLab CI Variables. + +### Flux: автосинхронизация ConfigMap из grafana-config репо + +Flux уже запущен в кластере (`flux bootstrap gitlab`). Добавить синхронизацию grafana-config: + +```yaml +# clusters/production/monitoring/grafana-config-source.yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: grafana-config + namespace: flux-system +spec: + interval: 1m + url: https://gitlab.gigacoms.info/k8s/grafana-config.git + secretRef: + name: flux-system # тот же PAT что используется для fleet-repo + ref: + branch: main +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: grafana-config + namespace: flux-system +spec: + interval: 5m + path: ./ + prune: true + sourceRef: + kind: GitRepository + name: grafana-config + targetNamespace: monitoring +``` + +Grafana sidecar подхватывает любой ConfigMap с label `grafana_dashboard: "1"` — Flux обновляет ConfigMap, Grafana перечитывает дашборды без рестарта. + +### Рабочий процесс изменения дашборда + +``` +Grafana UI → изменил дашборд + ↓ +Share → Export → Save to file → dashboard.json + ↓ +git commit в grafana-config репо + ↓ +Flux (~1 мин) → ConfigMap обновляется + ↓ +Grafana sidecar (~30 сек) → дашборд применён +``` + +--- + +## 8. Интеграция с Traefik + +Добавить в `inventory/prod/group_vars/traefik.yml`: + +```yaml +traefik_port_map: + # ... существующие записи (10001 dashboard, 10002 longhorn, 10005 minio-api, 10006 minio-console) ... + + - name: grafana + description: "Grafana (Logs & Metrics)" + port: 10003 + backend: + namespace: monitoring + service: grafana + port: 80 # service port (targetPort: 3000 — внутренний порт контейнера) + scheme: http + basicauth: + enabled: false # Grafana имеет собственную аутентификацию +``` + +После добавления запустить `setup_traefik.yml`. + +**Grafana:** http://10.203.0.96:10003 + +> Порт 10003 выбран как следующий свободный после 10001 (dashboard), 10002 (longhorn), 10005 (minio-api), 10006 (minio-console). + +--- + +## 9. Ansible-роли и плейбуки + +### Структура роли `roles/logging/` + +``` +roles/logging/ +├── defaults/main.yml +├── tasks/ +│ ├── main.yml +│ ├── namespace.yml — создать namespace monitoring +│ ├── minio-user.yml — создать пользователя loki в MinIO + Secret +│ ├── loki.yml — helm install loki (S3 backend) +│ ├── alloy.yml — helm install grafana/alloy (DaemonSet) +│ ├── prometheus.yml — helm install kube-prometheus-stack (метрики + дашборды) +│ └── grafana.yml — helm install grafana (Loki + Prometheus datasources) +└── templates/ + ├── loki-values.yml.j2 + ├── alloy-values.yml.j2 + ├── prometheus-values.yml.j2 + └── grafana-values.yml.j2 +``` + +### `defaults/main.yml` + +```yaml +logging_namespace: monitoring + +loki_chart_version: "7.0.0" +alloy_chart_version: "1.8.2" +grafana_chart_version: "10.5.15" +prometheus_stack_chart_version: "68.4.4" # prometheus-community/kube-prometheus-stack + +# MinIO — уже в кластере, только реквизиты +loki_minio_endpoint: "http://minio.minio.svc.cluster.local:9000" +loki_minio_bucket: "loki-chunks" +loki_minio_user: "loki" +loki_minio_secret_name: "loki-minio-secret" + +loki_retention_days: 31 +loki_wal_storage_size: "10Gi" +loki_wal_storage_class: longhorn + +prometheus_storage_size: "20Gi" +prometheus_storage_class: longhorn +prometheus_retention: "30d" + +grafana_storage_size: "5Gi" +grafana_storage_class: longhorn +grafana_admin_secret: "grafana-admin-secret" +grafana_root_url: "http://10.203.0.96:10003" +``` + +### `tasks/minio-user.yml` + +```yaml +- name: Loki | Create MinIO user for loki + kubernetes.core.k8s_exec: + namespace: minio + pod: "{{ minio_pod.resources[0].metadata.name }}" + command: > + mc admin user add local {{ loki_minio_user }} {{ loki_minio_password }} + no_log: true + +- name: Loki | Attach loki-policy in MinIO + kubernetes.core.k8s_exec: + namespace: minio + pod: "{{ minio_pod.resources[0].metadata.name }}" + command: mc admin policy attach local loki-policy --user {{ loki_minio_user }} + +- name: Loki | Create Secret with MinIO credentials + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ loki_minio_secret_name }}" + namespace: "{{ logging_namespace }}" + stringData: + AWS_ACCESS_KEY_ID: "{{ loki_minio_user }}" + AWS_SECRET_ACCESS_KEY: "{{ loki_minio_password }}" + no_log: true +``` + +### `tasks/main.yml` + +```yaml +--- +- ansible.builtin.import_tasks: namespace.yml +- ansible.builtin.import_tasks: minio-user.yml +- ansible.builtin.import_tasks: loki.yml +- ansible.builtin.import_tasks: alloy.yml +- ansible.builtin.import_tasks: prometheus.yml # метрики + готовые дашборды кластера +- ansible.builtin.import_tasks: grafana.yml +``` + +### `tasks/prometheus.yml` + +```yaml +--- +- name: Prometheus | Add prometheus-community Helm repo + kubernetes.core.helm_repository: + name: prometheus-community + repo_url: https://prometheus-community.github.io/helm-charts + +- name: Prometheus | Render values + ansible.builtin.template: + src: prometheus-values.yml.j2 + dest: /tmp/prometheus-values.yml + mode: "0600" + +- name: Prometheus | Install kube-prometheus-stack + kubernetes.core.helm: + name: kube-prometheus-stack + chart_ref: prometheus-community/kube-prometheus-stack + chart_version: "{{ prometheus_stack_chart_version }}" + release_namespace: "{{ logging_namespace }}" + create_namespace: false + values_files: + - /tmp/prometheus-values.yml + wait: true + wait_timeout: 600s +``` + +### `templates/prometheus-values.yml.j2` + +```yaml +grafana: + enabled: false + forceDeployDashboards: true + sidecar: + dashboards: + label: grafana_dashboard + labelValue: "1" + +prometheus: + prometheusSpec: + storageSpec: + volumeClaimTemplate: + spec: + storageClassName: {{ prometheus_storage_class }} + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ prometheus_storage_size }} + retention: {{ prometheus_retention }} + scrapeInterval: 30s + evaluationInterval: 30s + ruleSelectorNilUsesHelmValues: false + serviceMonitorSelectorNilUsesHelmValues: false + podMonitorSelectorNilUsesHelmValues: false + +alertmanager: + enabled: false + +nodeExporter: + enabled: true + +kubeStateMetrics: + enabled: true + +kubeControllerManager: + enabled: true + endpoints: +{% for host in groups['control_plane'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} + service: + enabled: true + port: 10257 + targetPort: 10257 + +kubeScheduler: + enabled: true + endpoints: +{% for host in groups['control_plane'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} + service: + enabled: true + port: 10259 + targetPort: 10259 + +kubeEtcd: + enabled: true + endpoints: +{% for host in groups['control_plane'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} + service: + enabled: true + port: 2381 + targetPort: 2381 + +kubeProxy: + enabled: true + endpoints: +{% for host in groups['k8s_cluster'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} +``` + +### `playbooks/setup_logging.yml` + +```yaml +--- +- name: Deploy PLG + Prometheus monitoring stack + hosts: manager_nodes + become: false + roles: + - logging +``` + +### `.gitlab-ci.yml` — новый job + +```yaml +setup/logging: + stage: setup + script: + - ansible-playbook -i inventory/prod playbooks/setup_logging.yml + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production +``` + +### GitLab CI/CD Variables (добавить) + +| Переменная | Описание | +|---|---| +| `LOKI_MINIO_PASSWORD` | Пароль пользователя `loki` в MinIO | +| `GRAFANA_ADMIN_PASSWORD` | Пароль администратора Grafana | + +--- + +## 10. Масштабирование до 10 нод / 100 сервисов + +### Promtail + +Не требует изменений — DaemonSet автоматически запустится на новых нодах. Убедиться, что `tolerations` включают control-plane taint (уже в конфиге выше). + +### Loki: переход на distributed-mode + +Триггер для перехода: объём > 50 GB/day или > 50 одновременных запросов Grafana. + +```yaml +# loki-values.yml — distributed режим +loki: + deploymentMode: Distributed + +ingester: + replicas: 3 +querier: + replicas: 2 +distributor: + replicas: 2 +compactor: + replicas: 1 +queryFrontend: + replicas: 2 + +# S3 backend остаётся тем же — только меняется deploymentMode +loki: + storage: + type: s3 + s3: + endpoint: http://minio.minio.svc.cluster.local:9000 + # ... те же параметры MinIO +``` + +**Преимущество текущей архитектуры:** переход single-binary → distributed не требует миграции данных — бакет `loki-chunks` в MinIO остаётся тем же, меняется только Helm release Loki. + +### Расчёт ресурсов при 10 нодах / 100 сервисах + +``` +100 сервисов × средний lograte 2 KB/s = 17 GB/day (raw) +Сжатие Loki ~10:1 → 1.7 GB/day +31 день → ~53 GB в loki-chunks бакете MinIO + +MinIO PVC = 1 TiB → запас ~19x от текущей нагрузки +``` + +--- + +## 11. Переезд на Graylog + +### Когда переезд оправдан + +| Триггер | Описание | +|---|---| +| Нужен поиск по содержимому | Loki: grep по чанкам; Graylog: полнотекстовый индекс OpenSearch | +| Нужны встроенные Pipelines | Нормализация, маскирование PII без Logstash | +| Встроенные алерты без Grafana | Graylog имеет собственный alerting | +| Объём > 100 GB/day | Loki становится менее эффективным для full-text search | + +### Архитектура Graylog + +``` +[Grafana Alloy / Fluent Bit] ← агент остаётся (Promtail EOL) + ↓ GELF HTTP (port 12202) +[Graylog Server] + ↓ ↓ +[OpenSearch] [MongoDB] +(хранит логи) (конфиг, метаданные) + ↓ +Traefik → port 10004 → http://10.203.0.96:10004 +``` + +### MinIO при переезде на Graylog + +MinIO продолжает использоваться — OpenSearch поддерживает S3 snapshot repository. Бэкапы индексов OpenSearch можно хранить в бакете `backups` MinIO: + +```bash +# Настройка S3 snapshot repository в OpenSearch +curl -X PUT http://opensearch:9200/_snapshot/minio-backup \ + -H 'Content-Type: application/json' -d '{ + "type": "s3", + "settings": { + "bucket": "backups", + "endpoint": "minio.minio.svc.cluster.local:9000", + "protocol": "http", + "path_style_access": "true" + } + }' +``` + +### Стратегия миграции: Dual-write + +Alloy поддерживает несколько `loki.write` и `otelcol.exporter` — dual-write настраивается в его конфиге: + +```alloy +// alloy config — dual-write период (2 недели) +loki.write "loki_backend" { + endpoint { + url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push" + } +} + +// Экспорт в Graylog через OTLP или loki.write с GELF-форматом +otelcol.exporter.otlphttp "graylog" { + client { + endpoint = "http://graylog.monitoring.svc.cluster.local:4318" + } +} +``` + +### Порядок действий при переезде + +``` +1. Развернуть OpenSearch + MongoDB + Graylog (параллельно с PLG) +2. Настроить Graylog inputs (OTLP HTTP port 4318 или GELF HTTP port 12202) +3. Переключить Grafana Alloy на dual-write (loki.write + otelcol.exporter.otlphttp) +4. Настроить Streams, Pipelines, алерты в Graylog +5. Добавить порт 10004 в traefik_port_map → http://10.203.0.96:10004 +6. Переключить команду на Graylog UI +7. Через 2 недели: убрать loki.write из конфига Alloy +8. Удалить Loki Helm release (бакет loki-chunks оставить для истории) +9. Grafana остаётся для метрик Prometheus; Alloy продолжает работать как агент Graylog +``` + +--- + +## 12. Итоговое сравнение и рекомендации + +| Критерий | ALG (Alloy + Loki + Grafana) | Graylog | +|---|---|---| +| RAM стека | ~1 GB | ~6-8 GB (JVM: Graylog + OpenSearch + MongoDB) | +| Агент сбора | **Grafana Alloy** (активен, замена EOL Promtail) | Grafana Alloy / Fluent Bit | +| Поиск по тексту | LogQL grep по чанкам (медленнее) | Полнотекстовый индекс OpenSearch (быстрее) | +| Обработка логов | Alloy pipeline stages (River syntax) | Встроенные Pipelines (мощнее) | +| Алерты | Grafana Alerting | Встроены в Graylog | +| Единый UI с метриками | Да (Prometheus + Loki + трейсы в Grafana) | Нет (отдельный UI) | +| Переход S3 → distributed | Без миграции данных | — | +| Сложность эксплуатации | Низкая | Высокая | +| EOL-риски | Loki ✅, Alloy ✅ (Promtail заменён) | Нет | + +### Рекомендуемый путь + +``` +Сейчас (1-2 ноды) +└── Loki (логи) + Prometheus/kube-prometheus-stack (метрики) + Grafana (единый UI) + └── Alloy DaemonSet собирает логи; node-exporter + kube-state-metrics собирают метрики + └── Grafana: Loki + Prometheus datasources provisioned, ~20 готовых k8s дашбордов + └── Promtail НЕ использовать — EOL с 2026-03-02 + +При росте до 5+ нод +└── Loki: distributed (только смена deploymentMode, данные в MinIO без миграции) +└── Prometheus: увеличить PVC или перейти на Thanos для long-term storage +└── Alloy / node-exporter: DaemonSet автоматически разворачивается на новых нодах + +При требовании глубокого поиска или 10+ нод +└── Параллельный запуск Graylog (dual-write 2 недели через Alloy) +└── Grafana остаётся для метрик Prometheus +└── MinIO — snapshot repository для OpenSearch +└── Alloy продолжает работать как единый агент (Loki → Graylog смена в конфиге) +``` + +### Следующие шаги + +**Подготовка (ручные действия перед запуском playbook):** +- [ ] Создать Secret `grafana-admin-secret` в namespace `monitoring` +- [ ] Создать GitLab-проект `k8s/grafana-config` на `gitlab.gigacoms.info` (для хранения дашбордов — раздел 7) + +**Ansible-роль `roles/logging/` (по структуре из раздела 9):** +- [ ] Написать `tasks/namespace.yml` +- [ ] Написать `tasks/minio-user.yml` + создать пользователя `loki` в MinIO +- [ ] Написать `tasks/loki.yml` + шаблон `loki-values.yml.j2` +- [ ] Написать `tasks/alloy.yml` + шаблон `alloy-values.yml.j2` (**Alloy, не Promtail**) +- [ ] Написать `tasks/prometheus.yml` + шаблон `prometheus-values.yml.j2` (метрики + дашборды кластера) +- [ ] Написать `tasks/grafana.yml` + шаблон `grafana-values.yml.j2` (Loki + Prometheus datasources) + +**CI/CD:** +- [ ] Добавить `grafana` в `traefik_port_map` (порт 10003) и запустить `setup_traefik.yml` +- [ ] Добавить переменные `LOKI_MINIO_PASSWORD`, `GRAFANA_ADMIN_PASSWORD` в GitLab CI/CD Variables +- [ ] Добавить job `setup/logging` в `.gitlab-ci.yml` с `resource_group: production` + +**Проверка после деплоя:** +- [ ] Grafana открывается на http://10.203.0.96:10003 +- [ ] В Grafana → Connections → Data sources: видны Loki и Prometheus со статусом OK +- [ ] В Grafana → Dashboards: папка `kubernetes-mixin` с ~20 готовыми дашбордами кластера +- [ ] Prometheus Targets: все таргеты в состоянии UP (`kubectl port-forward -n monitoring svc/kube-prometheus-stack-prometheus 9090:9090`) diff --git a/research/longhorn.md b/research/longhorn.md new file mode 100644 index 0000000..c7f2b15 --- /dev/null +++ b/research/longhorn.md @@ -0,0 +1,453 @@ +# Longhorn — исследование для развёртывания в кластере + +## Что такое Longhorn + +Longhorn — распределённое блочное хранилище для Kubernetes (CNCF-проект, изначально Rancher). +Предоставляет PersistentVolume через CSI-драйвер с репликацией данных между нодами, инкрементальными снапшотами и встроенным бэкапом. + +--- + +## На каких нодах нужны диски + +Longhorn Manager запускается как **DaemonSet на нодах кластера**. Каждая нода, где работает Longhorn Manager, может хранить реплики томов. + +**Применительно к этому кластеру:** + +| Нода | Группа | Роль в Longhorn | +|---|---|---| +| k8s-master-01 (10.203.0.97) | control_plane | Может участвовать (тейнт уже снят), но нежелательно в production | +| k8s-worker-NN | workers | **Основные ноды для хранилища** — диски должны быть здесь | +| k8s-manager-01 (10.203.0.92) | manager_nodes | Не входит в кластер, диски не нужны | + +**Вывод:** диски нужны на **worker-нодах**. Желательно не смешивать роль control plane и хранилища. + +--- + +## Минимальное количество нод + +| Режим | Нод с дисками | Репликация | Отказоустойчивость | +|---|---|---|---| +| Dev / тест | 1 | 1 | Нет | +| Minimum HA | **3** | 2 | Выдерживает отказ 1 ноды | +| Production HA | **3** | 3 | Выдерживает отказ 2 нод | +| Extended HA | 5+ | 3 | Рекомендуется для критичных данных | + +### Почему именно 3 ноды — минимум для стабильной работы + +Longhorn по умолчанию размещает реплики тома на **разных нодах** (replica node anti-affinity). +- При `replication=3` требуются 3 разные ноды, иначе Longhorn деградирует том или откажет в создании. +- При `replication=2` достаточно 2 нод, но потеря любой из них оставит данные без резерва — том продолжит работу, но уязвим. +- 3 ноды + `replication=3` — классическая кворум-схема: можно потерять 1 ноду и данные остаются защищёнными. + +**Практические варианты для этого кластера:** +- **Рекомендуется:** 3 worker-ноды + control plane (без хранилища) +- **Допустимо (временно):** 2 worker-ноды + control plane с Longhorn — итого 3 ноды с дисками, но смешение ролей нежелательно + +--- + +## Минимальное количество дисков на ноде + +- **Минимум: 1 диск на ноду.** Longhorn работает с одним диском. +- **Рекомендуется: 1 выделенный диск** (отдельно от ОС-диска `/dev/sda`). +- Longhorn поддерживает **несколько дисков на одной ноде** — реплики балансируются между ними автоматически. + +### ОС-диск vs выделенный диск + +| Параметр | ОС-диск | Выделенный диск | +|---|---|---| +| Изоляция от системы | Нет | Да | +| `minimalAvailableStoragePercentage` | ≥25% | 10% | +| Риск DiskPressure | Высокий | Низкий | +| Рекомендация | Только dev/тест | Production | + +**Минимальная жизнеспособная production-конфигурация:** +``` +3 worker-ноды × 1 выделенный диск = 3 диска суммарно +``` + +--- + +## Принципы репликации + +Longhorn реплицирует данные **посинхронно** (synchronous replication) между репликами при записи. + +``` +Pod (write) → Longhorn Engine → Replica 1 (node-1 /dev/sdb) + → Replica 2 (node-2 /dev/sdb) + → Replica 3 (node-3 /dev/sdb) +``` + +- Запись подтверждается только когда все реплики записали данные. +- При отказе реплики Longhorn автоматически перестраивает её на другой ноде (Rebuild). +- Значение `replication` задаётся на уровне StorageClass или отдельного тома. + +### Рекомендуемые настройки anti-affinity + +| Настройка | Значение | Смысл | +|---|---|---| +| `replicaNodeLevelSoftAntiAffinity` | `false` | Запрет размещения 2 реплик на одной ноде (hard rule) | +| `replicaDiskLevelSoftAntiAffinity` | `true` | Разрешить несколько реплик на одной ноде, но на разных дисках | + +--- + +## Prerequisites — что нужно установить на все ноды кластера + +### Пакеты (Rocky Linux 9) + +```bash +dnf install -y iscsi-initiator-utils nfs-utils cryptsetup +``` + +| Пакет | Зачем | +|---|---| +| `iscsi-initiator-utils` | iSCSI-стек для подключения томов | +| `nfs-utils` | NFS-поддержка для RWX-томов и бэкапов | +| `cryptsetup` | LUKS2-шифрование томов (опционально) | + +### Сервисы + +```bash +systemctl enable --now iscsid +``` + +### Kernel modules + +``` +iscsi_tcp — iSCSI over TCP +dm_crypt — Device-mapper шифрование +``` + +Добавить в `/etc/modules-load.d/longhorn.conf`: +``` +iscsi_tcp +dm_crypt +``` + +### Утилиты (должны быть в PATH) + +`bash`, `curl`, `findmnt`, `grep`, `awk`, `blkid`, `lsblk` — как правило, уже присутствуют на Rocky Linux 9. + +--- + +## SELinux workaround (Rocky Linux 9 — обязательно) + +Rocky Linux 9 с `container-selinux > 2.189.0` блокирует `iscsiadm` через SELinux, что приводит к бесконечному циклу attach/detach томов. + +### Ручной патч (на каждой ноде) + +```bash +echo '(allow iscsid_t self (capability (dac_override)))' > /tmp/local_longhorn.cil +semodule -vi /tmp/local_longhorn.cil +``` + +### Автоматически через DaemonSet (после установки Longhorn) + +```bash +kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/master/deploy/prerequisite/longhorn-iscsi-selinux-workaround.yaml +``` + +--- + +## Firewall (firewalld) + +Добавить на ноды кластера: + +| Порт | Протокол | Зачем | +|---|---|---| +| 2049 | tcp | NFS (для RWX томов и бэкапов) | +| 111 | tcp/udp | portmapper (NFS) | + +Порт 10250/tcp (kubelet API) уже открыт в существующей роли `k8s_control_plane`. + +--- + +## Установка — Helm chart (рекомендуемый метод) + +```bash +helm repo add longhorn https://charts.longhorn.io +helm repo update + +helm install longhorn longhorn/longhorn \ + --namespace longhorn-system \ + --create-namespace \ + --version 1.7.2 \ + --set defaultSettings.defaultReplicaCount=3 \ + --set defaultSettings.replicaNodeLevelSoftAntiAffinity=false \ + --set defaultSettings.minimalAvailableStoragePercentage=10 +``` + +### Ключевые Helm-параметры + +| Параметр | Рекомендуемое значение | Описание | +|---|---|---| +| `defaultSettings.defaultReplicaCount` | `3` | Реплик по умолчанию на новый том | +| `defaultSettings.replicaNodeLevelSoftAntiAffinity` | `false` | Запрет реплик на одной ноде | +| `defaultSettings.minimalAvailableStoragePercentage` | `10` (dedicated) / `25` (OS disk) | Резерв свободного места | +| `defaultSettings.storageOverProvisioningPercentage` | `100` | Overprovisioning (200 = двойной запас) | + +--- + +## Проверка готовности нод (preflight) + +Longhorn предоставляет утилиту `longhornctl`: + +```bash +# Скачать +curl -sSfL -o longhornctl https://github.com/longhorn/cli/releases/latest/download/longhornctl-linux-amd64 +chmod +x longhornctl + +# Проверить prerequisites +./longhornctl check preflight + +# Автоматически установить зависимости +./longhornctl install preflight +``` + +--- + +## StorageClass после установки + +Longhorn создаёт StorageClass `longhorn` автоматически: + +```yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: longhorn +provisioner: driver.longhorn.io +parameters: + numberOfReplicas: "3" + staleReplicaTimeout: "2880" + fromBackup: "" +reclaimPolicy: Delete +volumeBindingMode: Immediate +``` + +--- + +## Итоговые минимумы для production + +| Параметр | Минимум (stable) | Рекомендуется | +|---|---|---| +| Нод с дисками | **3** | 3+ | +| Дисков на ноду | **1** (выделенный) | 1–2 | +| Репликация | **2** | 3 | +| Пакеты | open-iscsi + nfs-utils | + cryptsetup | +| SELinux патч | **Обязательно** на Rocky Linux 9 | — | +| Отдельный диск от ОС | **Рекомендуется** | — | + +--- + +--- + +## Масштабирование: добавление дисков и нод + +### Сценарий A: добавление диска к существующей ноде + +#### Как это работает + +Longhorn не отслеживает диски автоматически — каждый новый диск нужно **явно зарегистрировать** через Longhorn UI или kubectl. После регистрации диск мгновенно становится доступен для новых томов. + +#### Подготовка диска (на уровне ОС) + +```bash +# 1. Отформатировать (ext4 или xfs — обязательно extent-based) +mkfs.xfs /dev/sdb + +# 2. Создать точку монтирования +mkdir -p /mnt/longhorn-disk2 + +# 3. Получить UUID (надёжнее device-name) +blkid /dev/sdb + +# 4. Добавить в /etc/fstab (UUID, nofail — обязательно) +echo "UUID= /mnt/longhorn-disk2 xfs defaults,nofail 0 2" >> /etc/fstab + +# 5. Смонтировать +mount -a +``` + +> **Важно:** не использовать symlink в пути — Longhorn-поды не разрешают символические ссылки корректно. + +#### Регистрация диска в Longhorn + +**Через kubectl (рекомендуется для Ansible):** +```bash +kubectl patch nodes.longhorn.io -n longhorn-system \ + --type merge \ + -p '{"spec":{"disks":{"disk2":{"path":"/mnt/longhorn-disk2","allowScheduling":true,"diskType":"filesystem"}}}}' +``` + +**Через UI:** Nodes → выбрать ноду → Edit node and disks → Add Disk. + +#### Автоматический ребалансинг реплик на новый диск? + +**Нет.** Существующие реплики **не переезжают** автоматически на новый диск. + +- Новый диск начинает использоваться только для **новых томов**. +- Если включена функция `Replica Auto Balance` (режим `best-effort`), Longhorn постепенно перемещает часть реплик для выравнивания нагрузки между дисками. +- Настройка: Longhorn UI → Settings → Replica Auto Balance → `best-effort`. + +#### Сложности при добавлении диска + +| Сложность | Причина | Решение | +|---|---|---| +| Диск не виден Longhorn | Не смонтирован до регистрации | `mount \| grep /mnt/longhorn-disk2` | +| Ошибка дублирования | Тот же filesystem UUID уже в кластере | Проверить `lsblk -f` перед добавлением | +| Disk не используется | `allowScheduling: false` | Включить через UI или patch | +| Резерв места слишком велик | `storageReserved` ≥ объёму диска | Понизить до 10% от объёма | + +#### Теги дисков (routing для специфичных воркеров) + +Можно пометить диск тегом (например `ssd`, `fast`) и указывать его в StorageClass: +```yaml +# StorageClass +parameters: + diskSelector: "ssd" +``` +Тогда реплики создаются только на дисках с нужными тегами. + +--- + +### Сценарий B: добавление ноды к кластеру + +#### Автоматическое обнаружение + +Когда новая нода присоединяется к Kubernetes, **Longhorn обнаруживает её автоматически** через DaemonSet — никаких ручных действий не требуется. Longhorn-manager pod поднимается на новой ноде и создаёт Longhorn Node CR. + +По умолчанию Longhorn сразу создаёт диск по пути из настройки `Default Data Path` (`/var/lib/longhorn`). + +#### Проверка после появления ноды + +```bash +# Убедиться, что нода появилась в Longhorn +kubectl get node.longhorn.io -n longhorn-system + +# Проверить диск на ноде +kubectl get node.longhorn.io -n longhorn-system -o yaml +# Ищем: spec.disks, status.diskStatus — должны быть schedulable: true +``` + +#### Ребалансинг существующих реплик на новую ноду? + +**По умолчанию — нет.** Существующие реплики не переезжают на новую ноду автоматически. + +Чтобы включить ребалансинг: +``` +Longhorn UI → Settings → Replica Auto Balance → best-effort +``` + +| Режим | Поведение | +|---|---| +| `disabled` (по умолчанию) | Реплики не перемещаются | +| `least-effort` | Минимальное перемещение — только для восстановления fault-tolerance | +| `best-effort` | Равномерное распределение по всем нодам | + +#### Ноды с Выделенными дисками (не `/var/lib/longhorn`) + +Если диск подключён как `/dev/sdb` и смонтирован в `/mnt/longhorn`, нужно: +1. Подготовить и смонтировать диск на новой ноде (аналогично Сценарию A). +2. Зарегистрировать диск в Longhorn вручную или через аннотацию ноды (до или после присоединения к кластеру): + +```bash +kubectl annotate node \ + node.longhorn.io/default-disks-config='[{"path":"/mnt/longhorn","allowScheduling":true}]' +``` + +#### Сложности при добавлении ноды + +| Сложность | Причина | Решение | +|---|---|---| +| Нода есть в K8s, но не в Longhorn | DaemonSet не запустился (taint, ресурсы) | Проверить `kubectl get pods -n longhorn-system -o wide` | +| Нода есть в Longhorn, но не планирует реплики | `allowScheduling: false` или нет свободного места | Включить scheduling, проверить space | +| Реплики не переехали | Auto Balance выключен | Включить `best-effort` | +| Existing volumes не используют новую ноду | Replication count не обновлён | Обновить replica count (см. ниже) | +| Prerequisites не установлены | Нет `iscsid`, `nfs-utils` | Запустить роль prereqs перед join | + +> **Критично:** перед добавлением ноды к кластеру на ней **обязаны быть установлены** `iscsi-initiator-utils`, `nfs-utils` и применён SELinux-патч (для Rocky Linux 9). Иначе Longhorn Manager pod запустится, но тома не смогут монтироваться на этой ноде. + +--- + +### Сценарий C: миграция 1 нода → 3 ноды (увеличение репликации) + +#### Ситуация + +Старт: 1 worker-нода, `replication=1` — данные есть, избыточности нет. +Цель: 3 ноды, `replication=3` — полная HA. + +#### Окно риска + +Пока replica count увеличивается с 1 до 3, том находится в состоянии **Degraded**: старая реплика работает, новые перестраиваются. В этот момент: + +- Том **доступен** для чтения и записи (без даунтайма). +- Если нода со старой единственной репликой упадёт **во время перестройки** → **данные потеряны**. + +Поэтому перед увеличением replica count — **сделать снапшот или бэкап**. + +#### Процедура + +```bash +# 1. Убедиться, что новые ноды видны Longhorn +kubectl get node.longhorn.io -n longhorn-system + +# 2. Включить Auto Balance (опционально, для автоматического выравнивания) +# Longhorn UI → Settings → Replica Auto Balance → best-effort + +# 3. Обновить replica count для существующих томов +kubectl patch volume -n longhorn-system \ + --type merge \ + -p '{"spec":{"numberOfReplicas":3}}' + +# 4. Мониторинг перестройки +kubectl get volume -n longhorn-system \ + -o jsonpath='{.status.state} {.status.replicaCount}' +# Ожидаем: "healthy 3" +``` + +#### Время перестройки (ориентир) + +| Объём тома | Время rebuild | +|---|---| +| 10 GiB | 2–5 минут | +| 100 GiB | 10–20 минут | +| 1 TiB | 1–4 часа | + +#### Типичные проблемы при переходе 1→3 + +| Проблема | Симптом | Решение | +|---|---|---| +| Том завис в Degraded | `replicaCount` не растёт > 1 часа | Проверить ноды: space, scheduling, taints | +| Replica count остался 1 | `spec.numberOfReplicas=3`, но `status.replicaCount=1` | Новые ноды `unschedulable`? `kubectl get node.longhorn.io` | +| Только 2 реплики создались | Третья нода без свободного места | Добавить диск или ноду с бо́льшим объёмом | +| `Data Locality: guaranteed` мешает | Том не перемещается | Сменить на `best-effort` на период миграции | + +#### Обновить StorageClass для новых томов + +Изменить `numberOfReplicas` в StorageClass, чтобы все **новые** PVC создавались сразу с нужной репликацией: +```bash +kubectl patch storageclass longhorn \ + -p '{"parameters":{"numberOfReplicas":"3"}}' +``` + +Это не влияет на уже существующие тома — только на новые. + +--- + +### Итоговая таблица: сводка по масштабированию + +| Действие | Автоматически? | Даунтайм? | Главный риск | Что сделать | +|---|---|---|---|---| +| Добавить диск на ноду | Нет (ручная регистрация) | Нет | Диск не смонтирован до регистрации | Смонтировать → зарегистрировать через kubectl/UI | +| Добавить ноду в кластер | Да (DaemonSet) | Нет | Prerequisites не установлены | Запустить prereqs-роль до join | +| Ребалансинг реплик | Нет (нужно включить) | Нет | Реплики остаются на старых нодах/дисках | Включить `Replica Auto Balance: best-effort` | +| Поднять replica count 1→3 | Нет (вручную) | Нет (том в Degraded) | Потеря данных при падении ноды во время rebuild | Снапшот перед изменением, мониторинг rebuild | + +--- + +## Источники + +- [Longhorn Official Documentation 1.11](https://longhorn.io/docs/1.11.2/) +- [Longhorn Installation Guide](https://longhorn.io/docs/1.11.2/deploy/install/) +- [Longhorn Best Practices](https://longhorn.io/docs/1.11.2/best-practices/) +- [Longhorn SELinux Troubleshooting](https://longhorn.io/kb/troubleshooting-volume-attachment-fails-due-to-selinux-denials/) +- [Longhorn Multiple Disk Support](https://longhorn.io/docs/1.10.1/nodes-and-volumes/nodes/multidisk/) diff --git a/research/minio.md b/research/minio.md new file mode 100644 index 0000000..460fc15 --- /dev/null +++ b/research/minio.md @@ -0,0 +1,788 @@ +# MinIO в Kubernetes: объектное хранилище 1 ТБ с веб-интерфейсом + +**Кластер:** Rocky Linux 9 · Kubernetes 1.33 · Flannel CNI +**Текущая топология:** k8s-manager-01 (10.203.0.92) · k8s-master-01 (10.203.0.97) · k8s-worker-01 (10.203.0.96) +**Дата исследования:** 2026-05-28 +**Проверено на кластере:** 2026-05-28 (kubectl + Longhorn API) + +--- + +## Содержание + +1. [Обзор и назначение](#1-обзор-и-назначение) +2. [Режимы развёртывания](#2-режимы-развёртывания) +3. [Планирование хранилища 1 ТБ](#3-планирование-хранилища-1-тб) +4. [Helm-развёртывание (standalone)](#4-helm-развёртывание-standalone) +5. [MinIO Console: веб-интерфейс](#5-minio-console-веб-интерфейс) +6. [Интеграция с Traefik](#6-интеграция-с-traefik) +7. [Безопасность: пользователи и политики](#7-безопасность-пользователи-и-политики) +8. [Интеграция с Loki (S3 backend)](#8-интеграция-с-loki-s3-backend) +9. [Ansible-роль и плейбук](#9-ansible-роль-и-плейбук) +10. [Масштабирование до distributed-режима](#10-масштабирование-до-distributed-режима) +11. [Итоги и рекомендации](#11-итоги-и-рекомендации) + +--- + +## 1. Обзор и назначение + +MinIO — S3-совместимое объектное хранилище. Работает в Kubernetes как StatefulSet или Deployment, предоставляет: + +- **S3 API** (порт 9000) — совместим с любым клиентом AWS SDK, boto3, mc, s3cmd +- **MinIO Console** (порт 9001) — встроенный веб-интерфейс: браузер объектов, управление пользователями, мониторинг + +### Сценарии использования в текущем кластере + +| Сценарий | Описание | +|---|---| +| Loki S3 backend | Хранилище chunks/index при переходе на distributed Loki | +| Бэкапы баз данных | S3-target для pg_dump, mysqldump, Velero | +| Артефакты CI/CD | GitLab Runner cache, артефакты сборки | +| Файловое хранилище | Статика, медиафайлы приложений | +| Резервные копии etcd | Автоматические снэпшоты control plane | + +--- + +## 2. Режимы развёртывания + +### Текущее состояние кластера (проверено) + +``` +Longhorn-диски на k8s-worker-01: + longhorn-disk1 /mnt/longhorn-disk1 ~4.09 TiB (свободно ~4.06 TiB) allowScheduling=true + longhorn-disk2 /mnt/longhorn-disk2 ~4.09 TiB (свободно ~4.06 TiB) allowScheduling=true + +k8s-master-01: диск из Longhorn удалён — нода без дисков, в планировании не участвует ✓ + +StorageClass longhorn (default): + numberOfReplicas: 1 ✓ + allowVolumeExpansion: true + createDefaultDiskLabeledNodes: true + +Traefik hostPort (занято): 10001 (k8s-dashboard), 10002 (longhorn-ui) +Traefik hostPort (свободно): 10003+ → MinIO: 10005 (API), 10006 (Console) +``` + +### Standalone (текущий кластер — 1 worker) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ k8s-worker-01 (10.203.0.96) │ +│ │ +│ [MinIO Pod — StatefulSet 1 replica] │ +│ ├── порт 9000 (S3 API) │ +│ └── порт 9001 (Console UI) │ +│ ↓ │ +│ [PVC 1 TiB → StorageClass longhorn-minio] │ +│ [Longhorn → longhorn-disk1 (/mnt/longhorn-disk1, 4 TiB)] │ +└─────────────────────────────────────────────────────────────┘ + ↓ ClusterIP + ┌─────────────────────┐ + │ Traefik │ + │ 10005 → API :9000 │ + │ 10006 → UI :9001 │ + └─────────────────────┘ +``` + +**Ограничения standalone:** нет erasure coding. Потеря диска = потеря данных. Приемлемо при наличии Longhorn-снэпшотов по расписанию. + +### SNMD — Single Node Multi-Drive (будущее) + +Worker уже имеет два диска (~4 TiB каждый). MinIO поддерживает SNMD начиная с 4 дисков. При добавлении ещё двух дисков к worker-ноде можно перейти на SNMD без новых нод: + +``` +MinIO SNMD, 4 диска по ~1 TiB (из доступных 4 TiB на каждом диске): + - usable storage ≈ 2 TiB (EC:2 — паритет 50%) + - допустимая потеря: 2 из 4 дисков +``` + +### Distributed (4+ нод) + +При добавлении worker-нод: 1 под MinIO на ноду, PVC через Longhorn на каждой. Требует минимум 4 пода (4 ноды) для полноценного erasure coding. + +**Для текущего кластера** (1 worker, 2 диска) — только standalone. + +--- + +## 3. Планирование хранилища 1 ТБ + +### Реальная конфигурация дисков (проверено) + +На `k8s-worker-01` уже настроены два Longhorn-диска: + +| Диск | Путь | Всего | Свободно | Статус | +|---|---|---|---|---| +| `longhorn-disk1` | `/mnt/longhorn-disk1` | ~4.09 TiB | ~4.06 TiB | Ready, Schedulable | +| `longhorn-disk2` | `/mnt/longhorn-disk2` | ~4.09 TiB | ~4.06 TiB | Ready, Schedulable | + +Оба диска полностью свободны. PVC 1 TiB займёт ~25% одного диска, оставляя ~3 TiB в резерве на том же диске. + +### Выделенный StorageClass для MinIO + +Дефолтный `longhorn` (numberOfReplicas: 1) технически подойдёт, но для MinIO рекомендуется отдельный StorageClass по двум причинам: + +- **reclaimPolicy: Retain** — при случайном удалении namespace или PVC данные на диске не уничтожаются (дефолтный Longhorn использует `Delete`) +- **diskSelector** — позволяет зафиксировать MinIO на `longhorn-disk1`, оставив `longhorn-disk2` для других workload (Loki, бэкапы и т.д.) + +```yaml +# применить через роль minio/tasks/storageclass.yml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: longhorn-minio +provisioner: driver.longhorn.io +reclaimPolicy: Retain # данные остаются при удалении PVC +allowVolumeExpansion: true +parameters: + numberOfReplicas: "1" + dataLocality: "best-effort" + diskSelector: "minio" # тег только на longhorn-disk1 + fsType: "ext4" + dataEngine: "v1" +``` + +### Disk-тег для изоляции MinIO на disk1 (опционально) + +Если нужно зафиксировать MinIO именно на `longhorn-disk1` (рекомендуется при нескольких workload): + +```bash +kubectl -n longhorn-system patch node.longhorn.io k8s-worker-01 --type=json -p='[ + {"op":"add","path":"/spec/disks/longhorn-disk1/tags","value":["minio"]} +]' +``` + +Без тега Longhorn выберет любой из двух дисков (disk1 или disk2) — это тоже корректно, просто менее предсказуемо. + +### Репликация на уровне Longhorn + +Сейчас: `numberOfReplicas: 1` — без репликации. При добавлении второго worker с Longhorn-дисками поднять до `2` — Longhorn начнёт реплицировать PVC MinIO между нодами без остановки пода. + +> **Важно:** репликация Longhorn защищает от потери ноды, но **не заменяет** MinIO distributed mode. Longhorn реплицирует блочное устройство целиком, erasure coding MinIO работает на уровне объектов. + +### Расчёт полезной ёмкости + +Физический диск (`longhorn-disk1`) — ~4.09 TiB. MinIO получает PVC 1 TiB — остаток диска доступен для других PVC. + +| Параметр | Значение | +|---|---| +| Физический диск | ~4.09 TiB (`longhorn-disk1`) | +| PVC для MinIO | 1 TiB | +| Overhead Longhorn + ext4 | ~2% | +| Overhead MinIO (метаданные) | ~1% | +| **Доступно для объектов** | **~990 Gi** | +| Рекомендуемый порог заполнения | 80% → ~790 Gi | +| Остаток на диске для других PVC | ~3.09 TiB | + +MinIO по умолчанию отказывается принимать данные при заполнении > 95% (настраивается через `MINIO_STORAGE_CLASS_STANDARD`). + +--- + +## 4. Helm-развёртывание (standalone) + +### Helm chart + +Используется официальный chart `minio/minio` (не bitnami — он добавляет лишние зависимости). + +```bash +helm repo add minio https://charts.min.io/ +helm repo update +``` + +### `minio-values.yml` + +```yaml +# roles/minio/files/minio-values.yml + +## Режим: standalone +mode: standalone + +## Образ +image: + repository: quay.io/minio/minio + tag: RELEASE.2025-04-22T22-12-26Z # фиксированная версия + pullPolicy: IfNotPresent + +## Корневые учётные данные (переопределяются через Secret) +existingSecret: minio-root-credentials + +## Хранилище +persistence: + enabled: true + storageClass: longhorn-minio # выделенный SC: numberOfReplicas=1, только worker-диски + accessMode: ReadWriteOnce + size: 1Ti + +## Ресурсы пода +resources: + requests: + memory: 512Mi + cpu: 250m + limits: + memory: 2Gi + cpu: 1000m + +## Привязка к worker-ноде (не запускать на control plane) +nodeSelector: + node-role.kubernetes.io/control-plane: "" # исключить +affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: node-role.kubernetes.io/control-plane + operator: DoesNotExist + +## Сервисы +service: + type: ClusterIP + port: 9000 + +consoleService: + type: ClusterIP + port: 9001 + +## Buckets создаются автоматически при старте +buckets: + - name: loki-chunks + policy: none + purge: false + - name: backups + policy: none + purge: false + - name: artifacts + policy: none + purge: false + +## Пользователи (пароли — через Secret, задаются ниже) +users: + - accessKey: loki + existingSecret: minio-user-loki + existingSecretKey: secretKey + policy: readwrite + - accessKey: backup + existingSecret: minio-user-backup + existingSecretKey: secretKey + policy: readwrite + +## Политики для бакетов +policies: + - name: loki-policy + statements: + - resources: + - "arn:aws:s3:::loki-chunks" + - "arn:aws:s3:::loki-chunks/*" + actions: + - "s3:GetObject" + - "s3:PutObject" + - "s3:DeleteObject" + - "s3:ListBucket" + +## Метрики (если Prometheus есть) +metrics: + serviceMonitor: + enabled: false # включить когда появится Prometheus + +## MinIO Console — веб-интерфейс +consoleIngress: + enabled: false # используем Traefik напрямую через ClusterIP + +## Окружение MinIO +environment: + MINIO_BROWSER_REDIRECT_URL: "http://10.203.0.96:10006" # URL Console через Traefik + MINIO_STORAGE_CLASS_STANDARD: "EC:0" # standalone: без erasure coding + MINIO_UPDATE: "off" # отключить автообновление +``` + +### Secret с корневыми учётными данными + +```bash +kubectl create secret generic minio-root-credentials \ + --from-literal=rootUser=minioadmin \ + --from-literal=rootPassword='СИЛЬНЫЙ_ПАРОЛЬ_МИНИМУМ_8_СИМВОЛОВ' \ + -n minio +``` + +### Установка + +```bash +kubectl create namespace minio + +helm upgrade --install minio minio/minio \ + --namespace minio \ + --version 5.4.0 \ + --values roles/minio/files/minio-values.yml \ + --wait +``` + +### Проверка + +```bash +# Статус пода +kubectl get pod -n minio + +# Логи +kubectl logs -n minio -l app=minio + +# Проброс порта для быстрой проверки +kubectl port-forward -n minio svc/minio 9000:9000 & +kubectl port-forward -n minio svc/minio-console 9001:9001 & + +# Проверка S3 API через mc (MinIO Client) +mc alias set local http://localhost:9000 minioadmin ПАРОЛЬ +mc ls local +mc admin info local +``` + +--- + +## 5. MinIO Console: веб-интерфейс + +MinIO Console — встроенный React-интерфейс, запускается в том же поде на порту 9001. Отдельная установка не нужна начиная с MinIO RELEASE.2021-07-08. + +### Возможности Console + +| Раздел | Функции | +|---|---| +| Object Browser | Навигация по бакетам, загрузка/скачивание файлов, просмотр метаданных | +| Buckets | Создание бакетов, версионирование, lifecycle policies, replication | +| Identity → Users | Создание пользователей, назначение политик | +| Identity → Groups | Группировка пользователей | +| Identity → Policies | Редактор IAM-политик (JSON) | +| Monitoring | Дашборд загрузки, IOPS, throughput в реальном времени | +| Logs | Потоковый просмотр логов MinIO в браузере | +| Audit | Журнал операций (включается через `MINIO_AUDIT_WEBHOOK_*`) | + +### Настройка адреса Console + +MinIO Console проверяет `MINIO_BROWSER_REDIRECT_URL` при формировании redirect после логина. Без правильного значения Console вернёт 401 или redirect на неверный URL. + +```yaml +# В minio-values.yml — уже задано выше +environment: + MINIO_BROWSER_REDIRECT_URL: "http://10.203.0.96:10006" +``` + +--- + +## 6. Интеграция с Traefik + +Два порта в `traefik_port_map`: +- `10005` — MinIO S3 API (для клиентов, boto3, mc) +- `10006` — MinIO Console (веб-интерфейс) + +### Добавить в `inventory/prod/group_vars/traefik.yml` + +```yaml +traefik_port_map: + # ... существующие записи ... + + - name: minio-api + description: "MinIO S3 API" + port: 10005 + backend: + namespace: minio + service: minio + port: 9000 + scheme: http + basicauth: + enabled: false # MinIO использует собственную аутентификацию (AWS Signature v4) + + - name: minio-console + description: "MinIO Console (Web UI)" + port: 10006 + backend: + namespace: minio + service: minio-console + port: 9001 + scheme: http + basicauth: + enabled: false # MinIO Console имеет собственный логин +``` + +> **Важно:** BasicAuth от Traefik + MinIO Console не совместимы — браузер не может пройти двойную аутентификацию. MinIO Console защищён своим логином, этого достаточно. + +После добавления запустить `setup_traefik.yml`. + +### Адреса после развёртывания + +| Сервис | URL | Назначение | +|---|---|---| +| MinIO S3 API | `http://10.203.0.96:10005` | Внешний доступ клиентов | +| MinIO Console | `http://10.203.0.96:10006` | Веб-интерфейс администратора | +| MinIO API (внутри кластера) | `http://minio.minio.svc.cluster.local:9000` | Для подов кластера | +| MinIO Console (внутри кластера) | `http://minio-console.minio.svc.cluster.local:9001` | — | + +### Настройка mc (MinIO Client) для внешнего доступа + +```bash +# На manager-ноде или локально +mc alias set prod http://10.203.0.96:10005 minioadmin ПАРОЛЬ + +# Проверка +mc ls prod +mc admin info prod +mc du prod/loki-chunks +``` + +--- + +## 7. Безопасность: пользователи и политики + +### Принцип минимальных привилегий + +Не используйте root-учётные данные в приложениях. Для каждого сервиса — отдельный пользователь с ограниченной политикой. + +### Создание пользователей через mc + +```bash +# Пользователь для Loki (только свой бакет) +mc admin user add prod loki ПАРОЛЬ_LOKI + +mc admin policy create prod loki-policy /dev/stdin <<'EOF' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": [ + "arn:aws:s3:::loki-chunks", + "arn:aws:s3:::loki-chunks/*" + ] + } + ] +} +EOF + +mc admin policy attach prod loki-policy --user loki + +# Пользователь для бэкапов (только запись в backups) +mc admin user add prod backup ПАРОЛЬ_BACKUP + +mc admin policy create prod backup-policy /dev/stdin <<'EOF' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"], + "Resource": [ + "arn:aws:s3:::backups", + "arn:aws:s3:::backups/*" + ] + } + ] +} +EOF + +mc admin policy attach prod backup-policy --user backup +``` + +### Lifecycle policy: автоудаление старых объектов + +```bash +# Удалять объекты в backups старше 30 дней +mc ilm rule add --expire-days 30 prod/backups + +# Удалять незавершённые multipart uploads старше 7 дней +mc ilm rule add --expire-days 7 --noncurrent-expire-days 7 prod/backups +``` + +### Версионирование бакетов (защита от случайного удаления) + +```bash +# Включить версионирование +mc version enable prod/backups + +# Посмотреть все версии объекта +mc ls --versions prod/backups/db-dump.sql.gz +``` + +--- + +## 8. Интеграция с Loki (S3 backend) + +При переходе Loki на distributed-режим (см. [PLG исследование](../logging/plg.md)) MinIO становится S3 backend для хранения chunks и index. + +### Loki values для S3 backend + +```yaml +# roles/logging/templates/loki-values.yml.j2 (distributed режим) +loki: + storage: + type: s3 + s3: + endpoint: http://minio.minio.svc.cluster.local:9000 + region: us-east-1 # MinIO игнорирует region, но поле обязательно + bucketnames: loki-chunks + access_key_id: loki + secret_access_key: "{{ loki_minio_password }}" + insecure: true # http (не https) + s3forcepathstyle: true # обязательно для MinIO + + schemaConfig: + configs: + - from: "2024-01-01" + store: tsdb + object_store: s3 # ← было filesystem + schema: v13 + index: + prefix: loki_index_ + period: 24h +``` + +### Secret для Loki в namespace monitoring + +```bash +kubectl create secret generic loki-minio-secret \ + --from-literal=AWS_ACCESS_KEY_ID=loki \ + --from-literal=AWS_SECRET_ACCESS_KEY='ПАРОЛЬ_LOKI' \ + -n monitoring +``` + +--- + +## 9. Ansible-роль и плейбук + +### Структура роли `roles/minio/` + +``` +roles/minio/ +├── defaults/main.yml +├── tasks/ +│ ├── main.yml +│ ├── namespace.yml — создать namespace minio +│ ├── secrets.yml — создать Secrets из vault/переменных +│ ├── helm.yml — helm upgrade --install minio +│ └── mc.yml — настройка mc alias, пользователей, политик, lifecycle +├── files/ +│ └── minio-values.yml +└── templates/ + └── minio-values.yml.j2 +``` + +### `defaults/main.yml` + +```yaml +minio_namespace: minio +minio_chart_version: "5.4.0" # minio/minio chart +minio_image_tag: "RELEASE.2025-04-22T22-12-26Z" + +minio_storage_class: longhorn-minio # выделенный SC, не дефолтный longhorn (numberOfReplicas=3) +minio_storage_size: 1Ti + +minio_console_url: "http://10.203.0.96:10006" +minio_api_external_url: "http://10.203.0.96:10005" + +# Имена Secrets (значения создаются вручную через kubectl) +minio_root_secret: minio-root-credentials + +minio_buckets: + - loki-chunks + - backups + - artifacts + +minio_resources_requests_memory: "512Mi" +minio_resources_limits_memory: "2Gi" +``` + +### `tasks/helm.yml` + +```yaml +- name: MinIO | Add Helm repo + kubernetes.core.helm_repository: + name: minio + repo_url: https://charts.min.io/ + +- name: MinIO | Deploy via Helm + kubernetes.core.helm: + name: minio + chart_ref: minio/minio + chart_version: "{{ minio_chart_version }}" + release_namespace: "{{ minio_namespace }}" + create_namespace: true + values: "{{ lookup('template', 'minio-values.yml.j2') | from_yaml }}" + wait: true + wait_condition: + type: Ready + status: "True" + timeout: "10m0s" +``` + +### `playbooks/setup_minio.yml` + +```yaml +--- +- name: Deploy MinIO object storage + hosts: manager_nodes + become: false + roles: + - minio +``` + +### `.gitlab-ci.yml` — новый job + +```yaml +setup/minio: + stage: setup + script: + - ansible-playbook -i inventory/prod playbooks/setup_minio.yml + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production +``` + +### Переменные GitLab CI/CD (добавить) + +| Переменная | Описание | +|---|---| +| `MINIO_ROOT_PASSWORD` | Пароль root-пользователя MinIO | +| `MINIO_LOKI_PASSWORD` | Пароль пользователя loki | +| `MINIO_BACKUP_PASSWORD` | Пароль пользователя backup | + +--- + +## 10. Масштабирование + +### Этапы роста: Longhorn и MinIO вместе + +Реальное текущее состояние (проверено): worker имеет 2 диска по ~4 TiB, оба пустые. Это даёт несколько путей масштабирования без добавления нод: + +| Этап | Кластер | Longhorn | MinIO | +|---|---|---|---| +| **Сейчас** | 1 worker, 2 диска по 4 TiB | `longhorn` SC: `numberOfReplicas: 1`, master без дисков ✓ | standalone, PVC 1 TiB на disk1 | +| +2 диска к worker | 1 worker, 4 диска | 4 PVC по 1 TiB, `numberOfReplicas: 1` | **SNMD**: 4 drives, EC:2, ~2 TiB usable | +| +1 worker с дисками | 2 workers | Поднять `numberOfReplicas: 2` в `longhorn-minio` | standalone + Longhorn-репликация между нодами | +| +3 workers (4 total) | 4 workers | `numberOfReplicas: 2` | **Distributed**: 4 пода, EC:2 | + +### Шаг 1: добавление второго worker (без пересоздания MinIO) + +После добавления `k8s-worker-02` в кластер: + +```bash +# Убедиться что Longhorn видит новую ноду и диск +kubectl get nodes -o wide +kubectl get nodes.longhorn.io -n longhorn-system + +# Обновить numberOfReplicas у StorageClass (или через Longhorn UI) +kubectl edit storageclass longhorn +# numberOfReplicas: "1" → "2" + +# Обновить replicas у существующего PVC MinIO +kubectl -n longhorn-system edit volume +# spec.numberOfReplicas: 1 → 2 +# Longhorn начнёт ребалансировку в фоне, MinIO продолжает работать +``` + +### Шаг 2: SNMD — 4 диска на одной ноде (реалистичный следующий шаг) + +Worker уже имеет 2 диска (~4 TiB каждый). При добавлении ещё 2 дисков можно перейти на SNMD **без новых нод**: + +```yaml +# minio-values.yml — SNMD режим (4 диска на k8s-worker-01) +mode: distributed # в MinIO SNMD тоже использует mode: distributed +replicas: 1 # 1 нода +drivesPerNode: 4 # 4 PVC на под + +persistence: + storageClass: longhorn-minio + size: 1Ti # 4 × 1 TiB = 4 TiB raw → ~2 TiB usable (EC:2) +``` + +При SNMD Longhorn создаёт 4 отдельных PVC (по одному на каждый логический диск MinIO). `numberOfReplicas: 1` в StorageClass — MinIO EC уже обеспечивает отказоустойчивость. + +### Шаг 3: distributed (4+ workers) + +```yaml +# minio-values.yml при 4 worker-нодах +mode: distributed +replicas: 4 # 4 пода на 4 нодах +drivesPerNode: 1 # 1 Longhorn PVC на под + +persistence: + storageClass: longhorn-minio + size: 1Ti # 4 × 1 TiB raw → ~2 TiB usable (EC:2) + +affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app: minio + topologyKey: kubernetes.io/hostname +``` + +В distributed-режиме Longhorn предоставляет отдельный PVC каждому поду MinIO. Репликацию Longhorn для distributed оставить на `numberOfReplicas: 1` — MinIO EC уже обеспечивает отказоустойчивость. + +### Миграция standalone → distributed + +MinIO **не поддерживает** in-place миграцию. Процедура: + +``` +1. Запустить distributed MinIO рядом (namespace minio-dist) +2. mc mirror minio-standalone minio-distributed --preserve --watch +3. Дождаться синхронизации всех объектов (mc du для сверки объёмов) +4. Переключить Traefik (изменить service в IngressRoute) на новый сервис +5. Обновить endpoint в configs Loki, бэкапов и других клиентов +6. Подождать 24-48 часов, убедиться что клиенты работают корректно +7. Удалить standalone namespace и PVC +``` + +### Расширение объёма без миграции (только standalone) + +Longhorn поддерживает расширение PVC онлайн — без остановки MinIO: + +```bash +kubectl patch pvc minio -n minio \ + -p '{"spec":{"resources":{"requests":{"storage":"2Ti"}}}}' + +# Longhorn расширит том; MinIO увидит новое пространство автоматически +mc admin info prod # проверить новый объём в разделе capacity +``` + +--- + +## 11. Итоги и рекомендации + +### Почему Longhorn — правильный выбор для этого кластера + +Longhorn уже является стандартом хранилища в кластере. Использование его для MinIO даёт: + +- **Единая точка управления** — диски, снэпшоты, репликация управляются через Longhorn UI, без отдельной операционной нагрузки +- **Онлайн-расширение** — PVC MinIO расширяется без остановки пода (`kubectl patch pvc`) +- **Путь к отказоустойчивости** — при добавлении второго worker достаточно поднять `numberOfReplicas: 2`, MinIO продолжает работать без изменений +- **Снэпшоты как бэкап** — Longhorn умеет делать снэпшоты PVC по расписанию; для MinIO standalone это основной механизм защиты данных до перехода на distributed + +### Порядок развёртывания + +``` +1. Применить StorageClass longhorn-minio (см. раздел 3) + (опц.) Longhorn UI → k8s-worker-01 → добавить тег "minio" на longhorn-disk1 для изоляции +2. kubectl create namespace minio +3. Создать Secret minio-root-credentials вручную +4. Запустить setup_minio.yml (Helm install) +5. Добавить minio-api и minio-console в traefik_port_map (порты 10005, 10006 — свободны) +6. Запустить setup_traefik.yml +7. Проверить Console: http://10.203.0.96:10006 +8. Создать пользователей через mc (loki, backup) +9. Обновить loki-values.yml если Loki уже работает +``` + +### Итоговые адреса + +| Сервис | URL | +|---|---| +| MinIO Console (UI) | `http://10.203.0.96:10006` | +| MinIO S3 API | `http://10.203.0.96:10005` | +| MinIO API (внутри кластера) | `http://minio.minio.svc.cluster.local:9000` | + +### Следующие шаги + +- [ ] Применить StorageClass `longhorn-minio` (раздел 3) +- [ ] (опц.) Longhorn UI: добавить тег `minio` на `longhorn-disk1` для изоляции диска +- [ ] Создать Secret `minio-root-credentials` вручную +- [ ] Написать роль `roles/minio/` по структуре из раздела 9 +- [ ] Добавить job `setup/minio` в `.gitlab-ci.yml` +- [ ] Добавить `minio-api` и `minio-console` в `traefik_port_map` (порты 10005, 10006) +- [ ] Добавить переменные `MINIO_ROOT_PASSWORD`, `MINIO_LOKI_PASSWORD` в GitLab CI/CD Variables +- [ ] Добавить firewalld-правило в `roles/minio/tasks/firewall.yml` (порты 9000, 9001 в `trusted` zone для flannel.1/cni0) diff --git a/research/traefik-forwardauth.md b/research/traefik-forwardauth.md new file mode 100644 index 0000000..f201c09 --- /dev/null +++ b/research/traefik-forwardauth.md @@ -0,0 +1,173 @@ +# Traefik ForwardAuth: HTML-логин для внутренних сервисов + +## 1. Контекст + +Текущая схема использует BasicAuth — браузерный нативный диалог, не кастомизируемый. +ForwardAuth позволяет заменить его на HTML-страницу входа с сессиями. + +**Что меняется:** вместо `Middleware basicAuth` → `Middleware forwardAuth` + отдельный auth-pod. + +--- + +## 2. Как работает ForwardAuth + +``` +Клиент → Traefik :10002 → ForwardAuth Middleware + │ + ▼ + auth-сервис /auth + │ + ┌───────────┴───────────┐ + │ 200 OK │ 401 → redirect на /login + │ (сессия валидна) │ (HTML-страница логина) + ▼ ▼ + longhorn-frontend форма → POST /login + │ + проверка пароля + │ + Set-Cookie session + │ + redirect обратно +``` + +Traefik делает subrequest на `authResponseHeaders` — если auth-сервис вернул 200, пропускает запрос дальше. Если 401/302 — возвращает клиенту ответ auth-сервиса (redirect на страницу логина). + +--- + +## 3. Варианты реализации + +### 3.1. oauth2-proxy (рекомендуется) + +**Что это:** легковесный reverse-proxy с поддержкой htpasswd, GitHub, Google, OIDC. +Для внутреннего использования подходит режим `--htpasswd-file` без внешнего провайдера. + +**Плюсы:** +- Один pod, ~50MB RAM +- Htpasswd-файл через Kubernetes Secret — никаких внешних зависимостей +- HTML-форма из коробки, можно переопределить шаблоны +- Cookie-сессии, configurable TTL + +**Минусы:** +- Один экземпляр oauth2-proxy на весь Traefik (или по одному на сервис) +- Нет 2FA + +**Схема деплоя:** +``` +roles/oauth2-proxy/ + tasks/main.yml — Kubernetes Deployment + Service + tasks/secret.yml — инструкция по созданию htpasswd Secret вручную + tasks/middleware.yml — ForwardAuth Middleware CRD + +inventory/prod/group_vars/oauth2proxy.yml — cookie_secret, upstream, htpasswd ref +``` + +**Ключевые параметры:** +```yaml +# Helm values / env vars +--provider=htpasswd +--htpasswd-file=/etc/oauth2-proxy/htpasswd +--cookie-secret=<32-byte-random> # генерируется вручную +--cookie-secure=false # HTTP, без TLS +--email-domain=* +--upstream=static://200 # ForwardAuth: upstream не используется +``` + +**Middleware CRD:** +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: forwardauth-oauth2proxy + namespace: traefik +spec: + forwardAuth: + address: http://oauth2-proxy.traefik.svc:4180/oauth2/auth + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-User + - X-Auth-Request-Email +``` + +--- + +### 3.2. Authelia + +**Что это:** полноценный self-hosted identity provider. HTML-логин, 2FA (TOTP, WebAuthn), LDAP, правила доступа по пользователям/группам. + +**Плюсы:** +- Красивая HTML-форма с брендингом +- 2FA из коробки +- Гранулярный контроль: разные пользователи для разных сервисов + +**Минусы:** +- Требует Redis (сессии) + хранилище пользователей (файл или LDAP) +- ~150MB RAM минимум +- Сложнее в настройке (YAML-конфиг, несколько секретов) + +**Оправдано если:** нужен 2FA или несколько пользователей с разными правами. + +--- + +### 3.3. thomseddon/traefik-forward-auth + +**Что это:** минималистичный ForwardAuth-сервис только для Google/OIDC OAuth. +Для htpasswd **не подходит** — требует внешний провайдер. + +--- + +## 4. Рекомендация + +Для текущего стека (внутренняя сеть, один оператор, нет SSO) — **oauth2-proxy с htpasswd**. + +| Критерий | oauth2-proxy | Authelia | +|---|---|---| +| RAM | ~50MB | ~150MB | +| Внешние зависимости | нет | Redis | +| 2FA | нет | да | +| Сложность настройки | низкая | средняя | +| HTML-логин | да (кастомизируемый) | да (красивый) | + +--- + +## 5. Интеграция с текущим port_map + +Схема остаётся прежней — меняется только тип Middleware: + +```yaml +# inventory/prod/group_vars/traefik.yml +traefik_port_map: + - name: longhorn-ui + port: 10002 + backend: + namespace: longhorn-system + service: longhorn-frontend + port: 80 + scheme: http + auth: + type: forwardauth # вместо basicauth + middleware: forwardauth-oauth2proxy # имя Middleware +``` + +При `type: forwardauth` шаблон `ingressroute.yml.j2` подставляет ForwardAuth Middleware вместо BasicAuth. +При `type: basicauth` — как сейчас. + +--- + +## 6. Порядок развёртывания (если решим внедрить) + +1. Добавить роль `oauth2-proxy` в `roles/` +2. Добавить `playbooks/setup_oauth2proxy.yml` +3. Создать Secret с htpasswd вручную: + ```bash + kubectl create secret generic oauth2proxy-htpasswd \ + --from-literal=htpasswd="$(htpasswd -nb admin 'pass')" \ + -n traefik + ``` +4. Создать Secret с cookie_secret: + ```bash + kubectl create secret generic oauth2proxy-cookie \ + --from-literal=cookie-secret="$(openssl rand -base64 32)" \ + -n traefik + ``` +5. Заменить `basicAuth` Middleware на `forwardAuth` в IngressRoute для нужных сервисов +6. Удалить старые BasicAuth Middleware и Secret `traefik-auth-longhorn` diff --git a/research/traefik.md b/research/traefik.md new file mode 100644 index 0000000..f2d93a1 --- /dev/null +++ b/research/traefik.md @@ -0,0 +1,494 @@ +# Traefik: port-proxy режим для внутренних сервисов + +## 1. Контекст и цель + +**Режим работы:** Traefik как HTTP-прокси на отдельном порту для каждого внутреннего сервиса. + +Вместо маршрутизации по доменному имени каждый сервис получает выделенный порт на worker-ноде. Клиент обращается `http://10.203.0.96:` и попадает напрямую на нужный сервис. + +**Что не используем:** HTTPS, Let's Encrypt, hostname-роутинг, Ingress. + +**Что используем:** EntryPoint per service, HTTP Router с catch-all правилом, BasicAuth Middleware для сервисов без авторизации. + +--- + +## 2. Концепция Port Map + +Port Map — единственный файл, описывающий всю топологию проксирования. Из него генерируется вся конфигурация Traefik: entrypoints, routers, services, middleware CRD. + +**Расположение:** `inventory/prod/group_vars/traefik.yml` + +Это inventory-файл, а не часть роли — привязка порта к сервису это инфраструктурное решение, а не логика установки. + +### Формат файла + +```yaml +# inventory/prod/group_vars/traefik.yml +--- + +traefik_port_map: + + - name: kubernetes-dashboard + description: "Kubernetes Dashboard" + port: 10001 + backend: + namespace: kubernetes-dashboard + service: kubernetes-dashboard + port: 443 # порт Service; K8s перенаправит на pod:9090 (insecure HTTP) + scheme: http # dashboard работает без TLS (--insecure-port=9090 --port=0) + basicauth: + enabled: false # Dashboard имеет собственную авторизацию по токену — BasicAuth не нужен + + - name: longhorn-ui + description: "Longhorn Storage UI" + port: 10002 + backend: + namespace: longhorn-system + service: longhorn-frontend + port: 80 + scheme: http + basicauth: + enabled: true + secret_name: traefik-auth-longhorn # Longhorn UI без авторизации — защищаем BasicAuth + + # Шаблон для будущих сервисов: + # - name: my-service + # description: "Human-readable описание" + # port: 10003 # уникальный порт в диапазоне 10000-10999 + # backend: + # namespace: my-namespace + # service: my-service-name + # port: 8080 + # scheme: http + # basicauth: + # enabled: false # если сервис имеет собственную авторизацию +``` + +### Поля Port Map + +| Поле | Обязательное | Описание | +|---|---|---| +| `name` | да | Уникальный идентификатор (используется в именах K8s-ресурсов) | +| `description` | нет | Человекочитаемое описание | +| `port` | да | Порт на Traefik-ноде, диапазон `10000–10999` | +| `backend.namespace` | да | Kubernetes namespace сервиса | +| `backend.service` | да | Имя Kubernetes Service | +| `backend.port` | да | Порт Service (не pod-а) | +| `backend.scheme` | да | `http` или `https` (протокол, которым Traefik достучится до Service) | +| `basicauth.enabled` | нет | `true` — прикрепить BasicAuth; дефолт `false` | +| `basicauth.secret_name` | если enabled | Имя Secret в namespace `traefik`; **создаётся вручную** | + +--- + +## 3. Управление credentials: только вручную, не через Ansible + +**Принцип разделения ответственности:** + +- **Ansible управляет инфраструктурой:** Middleware CRD, Helm-чарт, firewall, IngressRoute. +- **Оператор управляет секретами:** Kubernetes Secret с htpasswd-строками создаётся вручную через Dashboard или kubectl. + +Ansible не знает о содержимом секрета — только о его имени (`secret_name` в port_map). Если Secret не существует, Traefik вернёт 401 для всех запросов к этому сервису (безопасный дефолт). + +### Создание Secret вручную + +**Через kubectl (рекомендуется для первого раза):** + +```bash +# 1. Сгенерировать htpasswd-строку (требует htpasswd или openssl) +htpasswd -nb admin 'yourpassword' +# Вывод: admin:$apr1$xyz... + +# 2. Создать Secret в namespace traefik +kubectl create secret generic traefik-auth-dashboard \ + --from-literal=users='admin:$apr1$xyz...' \ + -n traefik + +# Для второго сервиса — отдельный Secret: +kubectl create secret generic traefik-auth-longhorn \ + --from-literal=users='admin:$apr1$abc...' \ + -n traefik +``` + +**Через Kubernetes Dashboard:** + +1. Открыть Dashboard → Namespace: `traefik` → Secrets → Create +2. Тип: `Opaque` +3. Имя: `traefik-auth-dashboard` (или как указано в `secret_name`) +4. Ключ: `users` +5. Значение: htpasswd-строка, например `admin:$apr1$xyz...` + +**Генерация htpasswd без утилиты htpasswd:** + +```bash +# Через openssl (доступен везде) +openssl passwd -apr1 'yourpassword' + +# Собрать строку вручную: +echo "admin:$(openssl passwd -apr1 'yourpassword')" +``` + +### Обновление пароля + +```bash +# Пересоздать Secret с новым паролем +kubectl create secret generic traefik-auth-dashboard \ + --from-literal=users='admin:$apr1$newvalue...' \ + -n traefik \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +### Разные пользователи для разных сервисов + +Каждый Secret независим. Для одного сервиса можно дать доступ нескольким пользователям — htpasswd-формат поддерживает несколько строк: + +```bash +kubectl create secret generic traefik-auth-longhorn \ + --from-literal=users='admin:$apr1$...'$'\n''devops:$apr1$...' \ + -n traefply +``` + +--- + +## 4. Архитектура + +### 4.1. Схема потока + +``` +Клиент + │ http://10.203.0.96:10001 → kubernetes-dashboard (BasicAuth: traefik-auth-dashboard) + │ http://10.203.0.96:10002 → longhorn-ui (BasicAuth: traefik-auth-longhorn) + │ http://10.203.0.96:10003 → future-service (без BasicAuth) + ▼ +┌────────────────────────────────────┐ +│ k8s-worker-01 │ +│ 10.203.0.96 │ +│ │ +│ ┌────────────────────────────┐ │ +│ │ Traefik DaemonSet │ │ +│ │ │ │ +│ │ EP :10001 → Middleware ──────► svc kubernetes-dashboard:443 (http→pod:9090) +│ │ basicauth-dash │ │ +│ │ │ │ +│ │ EP :10002 → Middleware ──────► svc longhorn-frontend:80 +│ │ basicauth-lhorn │ │ +│ │ │ │ +│ │ EP :10003 ───────────────────► svc future:8080 (без auth) +│ └────────────────────────────┘ │ +└────────────────────────────────────┘ + │ pod network (flannel 10.244.0.0/16) + ┌───────────┼───────────┐ + ▼ ▼ ▼ + dashboard longhorn future + pod:9090 pod:80 pod:8080 +``` + +### 4.2. Соответствие port_map → Traefik-ресурсы + +Каждая запись в `traefik_port_map` генерирует: + +``` +"kubernetes-dashboard" (port: 10001, basicauth.enabled: true) + │ + ├── EntryPoint "kubernetes-dashboard" port 10001, hostPort 10001 + ├── Middleware "basicauth-kubernetes-dashboard" + │ └── basicAuth.secret: traefik-auth-dashboard (Secret создан вручную) + ├── HTTP Router "router-kubernetes-dashboard" + │ entryPoints: [kubernetes-dashboard] + │ rule: PathPrefix(`/`) + │ middlewares: [basicauth-kubernetes-dashboard] + └── HTTP Service "svc-kubernetes-dashboard" + url: http://kubernetes-dashboard.kubernetes-dashboard.svc:443 +``` + +### 4.3. Почему Dashboard: scheme: http, port: 443 + +В текущей конфигурации Dashboard пропатчен с `--insecure-port=9090 --port=0`: + +``` +Service spec: port 443 → targetPort 9090 +Pod: слушает на :9090 по HTTP (без TLS) +``` + +Traefik подключается к `ClusterIP:443` по протоколу HTTP. Kubernetes направляет трафик на `pod:9090`. TLS нет на всём пути. + +--- + +## 5. Структура роли + +``` +roles/traefik/ + defaults/main.yml — chart version, namespace, kubeconfig path + tasks/main.yml — include_tasks по шагам + tasks/helm.yml — helm upgrade --install + tasks/middleware.yml — Middleware CRD (один на сервис, если basicauth.enabled) + tasks/routes.yml — IngressRoute CRD (один на сервис) + tasks/firewall.yml — открыть диапазон 10000-10999 на workers (delegate_to) + templates/ + traefik-values.yml.j2 — Helm values, генерируется из traefik_port_map + basicauth-middleware.yml.j2 — Middleware CRD (один экземпляр, рендерится в цикле) + ingressroute.yml.j2 — IngressRoute CRD (один экземпляр, рендерится в цикле) + +playbooks/ + setup_traefik.yml +``` + +--- + +## 6. Ключевые конфигурационные артефакты + +### 6.1. `roles/traefik/defaults/main.yml` + +```yaml +--- +traefik_chart_version: "32.1.0" +traefik_namespace: traefik +traefik_kubeconfig: "/home/{{ ansible_user }}/.kube/config" +traefik_port_map: [] # переопределяется в inventory/prod/group_vars/traefik.yml +``` + +### 6.2. `roles/traefik/templates/traefik-values.yml.j2` + +```yaml +deployment: + kind: DaemonSet + +nodeSelector: + kubernetes.io/os: linux + +ports: +{% for svc in traefik_port_map %} + {{ svc.name }}: + port: {{ svc.port }} + hostPort: {{ svc.port }} + expose: + default: true + exposedPort: {{ svc.port }} + protocol: TCP +{% endfor %} + web: + expose: + default: false + websecure: + expose: + default: false + +ingressRoute: + dashboard: + enabled: false + +providers: + kubernetesCRD: + enabled: true + kubernetesIngress: + enabled: false + +persistence: + enabled: false + +logs: + general: + level: INFO + access: + enabled: true +``` + +### 6.3. `roles/traefik/templates/basicauth-middleware.yml.j2` + +Рендерится в цикле для каждого сервиса с `basicauth.enabled: true`: + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: basicauth-{{ item.name }} + namespace: {{ traefik_namespace }} +spec: + basicAuth: + secret: {{ item.basicauth.secret_name }} + removeHeader: true +``` + +### 6.4. `roles/traefik/templates/ingressroute.yml.j2` + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: route-{{ item.name }} + namespace: {{ traefik_namespace }} +spec: + entryPoints: + - {{ item.name }} + routes: + - match: PathPrefix(`/`) + kind: Rule + services: + - name: {{ item.backend.service }} + namespace: {{ item.backend.namespace }} + port: {{ item.backend.port }} + scheme: {{ item.backend.scheme }} +{% if item.basicauth.enabled | default(false) %} + middlewares: + - name: basicauth-{{ item.name }} + namespace: {{ traefik_namespace }} +{% endif %} +``` + +### 6.5. `roles/traefik/tasks/middleware.yml` + +```yaml +--- +- name: Middleware | Render BasicAuth CRD manifests + ansible.builtin.template: + src: basicauth-middleware.yml.j2 + dest: "/tmp/traefik-middleware-{{ item.name }}.yml" + mode: "0600" + loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}" + loop_control: + label: "{{ item.name }}" + +- name: Middleware | Apply BasicAuth CRD manifests + ansible.builtin.command: > + kubectl apply -f /tmp/traefik-middleware-{{ item.name }}.yml + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}" + loop_control: + label: "{{ item.name }}" + register: _mw_apply + changed_when: "'configured' in _mw_apply.stdout or 'created' in _mw_apply.stdout" +``` + +### 6.6. `roles/traefik/tasks/firewall.yml` + +```yaml +--- +- name: Firewall | Open Traefik service port range on workers + ansible.posix.firewalld: + port: "10000-10999/tcp" + permanent: true + state: enabled + immediate: true + delegate_to: "{{ item }}" + loop: "{{ groups['workers'] }}" + +- name: Firewall | Trust CNI interfaces on workers + ansible.posix.firewalld: + zone: trusted + interface: "{{ iface }}" + permanent: true + state: enabled + immediate: true + loop: "{{ groups['workers'] }}" + loop_control: + loop_var: worker_host + vars: + _cni_ifaces: + - flannel.1 + - cni0 + delegate_to: "{{ worker_host }}" + with_nested: + - "{{ groups['workers'] }}" + - [flannel.1, cni0] +``` + +--- + +## 7. Сводная таблица портов + +Поддерживать синхронизированной с `inventory/prod/group_vars/traefik.yml`: + +| Порт | Сервис | BasicAuth | Secret | Описание | +|---|---|---|---|---| +| 10001 | kubernetes-dashboard | нет | — | K8s Dashboard (своя авторизация по токену) | +| 10002 | longhorn-frontend | да | `traefik-auth-longhorn` | Longhorn UI | +| 10003–10999 | (резерв) | — | — | Будущие сервисы | + +--- + +## 8. Порядок развёртывания + +### Шаг 1: Развернуть Traefik через Ansible + +```bash +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml +``` + +Ansible создаёт: DaemonSet, Middleware CRD (ссылается на несуществующие пока Secret), IngressRoute, firewall-правила. + +На данном этапе Traefik вернёт **500** для сервисов с BasicAuth — Secret ещё не создан. + +### Шаг 2: Создать Secrets вручную (только для сервисов с basicauth.enabled: true) + +```bash +# Longhorn — BasicAuth включён +kubectl create secret generic traefik-auth-longhorn \ + --from-literal=users="$(htpasswd -nb admin 'yourpassword')" \ + -n traefik + +# Dashboard — Secret не нужен, сервис защищён собственной авторизацией по токену +``` + +После создания Secret Traefik подхватит его автоматически (без рестарта). + +### Шаг 3: Проверить доступ + +```bash +# Dashboard — открывается без BasicAuth, но потребует токен внутри UI +curl http://10.203.0.96:10001/ + +# Longhorn — потребует BasicAuth +curl -u admin:yourpassword http://10.203.0.96:10002/ +``` + +--- + +## 9. Добавление нового сервиса (runbook) + +1. Добавить запись в `inventory/prod/group_vars/traefik.yml` +2. Перезапустить `setup_traefik.yml` +3. Создать Secret, если `basicauth.enabled: true`: + ```bash + kubectl create secret generic traefik-auth- \ + --from-literal=users="$(htpasswd -nb user 'pass')" \ + -n traefik + ``` +4. Обновить таблицу портов в разделе 7 этого документа + +--- + +## 10. Интеграция с CI/CD + +```yaml +# .gitlab-ci.yml + +# Добавить в syntax-check: +- ansible-playbook --syntax-check -i $INVENTORY playbooks/setup_traefik.yml + +# Новый job: +setup:traefik: + stage: setup + script: + - ansible-playbook -i $INVENTORY playbooks/setup_traefik.yml + environment: + name: production + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + resource_group: production +``` + +Secrets с паролями **не передаются** через GitLab CI/CD variables — они создаются вручную напрямую в кластере. + +--- + +## 11. Риски и gotchas + +| Риск | Митигация | +|---|---| +| Secret не создан — Traefik возвращает 500 | Ожидаемое поведение; создать Secret и Traefik подхватит без рестарта | +| `flannel.1`/`cni0` не в trusted zone | Явная задача в `tasks/firewall.yml` | +| hostPort занят | Диапазон 10000–10999 специфический; проверить `ss -tlnp` перед деплоем | +| DaemonSet пересоздаётся при добавлении нового порта в Helm values | Краткий downtime ~10с; все существующие соединения рвутся | +| Dashboard Service имеет `port: 443` но трафик HTTP | `scheme: http` обязателен; иначе Traefik попытается установить TLS и получит ошибку рукопожатия | diff --git a/roles/flux/README.md b/roles/flux/README.md new file mode 100644 index 0000000..43f2625 --- /dev/null +++ b/roles/flux/README.md @@ -0,0 +1,1501 @@ +# Flux CD — GitOps-эталон для команды разработчиков + +Этот документ описывает стандарт организации GitOps-деплоя приложений в Kubernetes через Flux CD. +Является эталоном для всех команд, разворачивающих сервисы в кластере `gitlab.gigacoms.info / k8s`. + +--- + +## Содержание + +1. [Концепция и термины](#1-концепция-и-термины) +2. [Двухрепозиторная модель: app-repo и fleet-repo](#2-двухрепозиторная-модель-app-repo-и-fleet-repo) +3. [Структура fleet-репозитория](#3-структура-fleet-репозитория) +4. [Многоветочный деплой: main → production, dev → staging](#4-многоветочный-деплой-main--production-dev--staging) +5. [Пример 1: Одно приложение, один Pod](#5-пример-1-одно-приложение-один-pod) +6. [Пример 2: Одно приложение, несколько Pod (Deployment + HPA)](#6-пример-2-одно-приложение-несколько-pod-deployment--hpa) +7. [Пример 3: Несколько компонентов одного приложения (frontend + backend + БД)](#7-пример-3-несколько-компонентов-одного-приложения-frontend--backend--бд) +8. [Пример 4: Взаимодействующие независимые приложения (микросервисы)](#8-пример-4-взаимодействующие-независимые-приложения-микросервисы) +9. [CI/CD в app-repo: автоматическое обновление образа](#9-cicd-в-app-repo-автоматическое-обновление-образа) +10. [Secrets: безопасная передача секретов через Flux](#10-secrets-безопасная-передача-секретов-через-flux) +11. [Диагностика и типичные ошибки](#11-диагностика-и-типичные-ошибки) +12. [Чеклист перед деплоем нового приложения](#12-чеклист-перед-деплоем-нового-приложения) + +--- + +## 1. Концепция и термины + +**GitOps** — практика, при которой Git-репозиторий является единственным источником истины о состоянии кластера. Flux CD периодически (по умолчанию каждые 1–5 минут) сравнивает желаемое состояние (Git) с фактическим (кластер) и приводит их в соответствие. + +| Термин | Описание | +|---|---| +| **fleet-repo** | Git-репозиторий с манифестами Kubernetes. Flux следит за ним и применяет изменения в кластер. В нашем случае: `gitlab.gigacoms.info/k8s/k8s-fleet` | +| **app-repo** | Репозиторий с исходным кодом приложения. Содержит `Dockerfile` и CI/CD пайплайн, который собирает образ и обновляет тег в fleet-repo | +| **Kustomization** | CRD Flux, описывающий: откуда брать манифесты, в каком namespace применять, от чего зависеть | +| **GitRepository** | CRD Flux, описывающий: какой Git-репозиторий наблюдать, какую ветку/тег/коммит | +| **HelmRelease** | CRD Flux для деплоя Helm-чарта с управлением версией и values | +| **ImageRepository** | CRD Flux для наблюдения за образами в Container Registry | +| **ImageUpdateAutomation** | CRD Flux для автоматического обновления тега образа в fleet-repo | +| **Overlay** | Kustomize-слой поверх базовой конфигурации — позволяет менять значения для разных окружений без дублирования | + +### Принцип работы + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Developer │ +│ │ │ +│ ├─ git push → app-repo (main или dev) │ +│ │ │ +│ │ CI/CD в app-repo: │ +│ │ 1. docker build + push → registry (тег: sha/version) │ +│ │ 2. git commit в fleet-repo → обновить тег образа │ +│ │ │ +│ └─ fleet-repo (k8s-fleet) │ +│ │ │ +│ │ Flux (в кластере) каждые N минут: │ +│ │ 1. git pull fleet-repo │ +│ │ 2. сравнить с кластером │ +│ │ 3. kubectl apply изменений │ +│ ▼ │ +│ Kubernetes Cluster │ +│ ├── namespace: production ← из ветки main fleet-repo │ +│ └── namespace: staging ← из ветки main fleet-repo │ +│ (другой path в том же repo) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +> **Важно:** Flux читает **один fleet-repo**, но может следить за разными **путями** внутри него для разных окружений. Ветки `main` и `dev` app-repo влияют на разные директории fleet-repo — staging и production. + +--- + +## 2. Двухрепозиторная модель: app-repo и fleet-repo + +### app-repo (репозиторий приложения) + +Содержит: +- Исходный код приложения +- `Dockerfile` +- `.gitlab-ci.yml` — сборка образа и **обновление fleet-repo** +- (опционально) базовые Kustomize-манифесты, которые копируются в fleet-repo при инициализации + +**Не содержит:** +- Финальных Kubernetes-манифестов с тегами образов +- Секретов + +### fleet-repo (репозиторий конфигурации кластера) + +Единственный источник истины для Flux. Содержит: +- Манифесты всех приложений и инфраструктурных компонентов +- Kustomize-оверлеи для каждого окружения +- HelmRelease-объекты +- Ссылки на секреты (но не сами секреты в открытом виде) + +**Не содержит:** +- Исходного кода приложений +- `Dockerfile` +- Секретов в открытом виде + +--- + +## 3. Структура fleet-репозитория + +Стандартная структура fleet-repo для нашего кластера: + +``` +k8s-fleet/ +├── clusters/ +│ └── production/ # flux bootstrap --path=clusters/production +│ ├── flux-system/ # авто-генерируется flux bootstrap, не трогать вручную +│ │ ├── gotk-components.yaml +│ │ ├── gotk-sync.yaml +│ │ └── kustomization.yaml +│ ├── apps.yaml # Flux Kustomization → apps/production/ +│ └── infrastructure.yaml # Flux Kustomization → infrastructure/production/ +│ +├── apps/ +│ ├── base/ # базовые манифесты (без env-специфики) +│ │ ├── myapp/ +│ │ │ ├── namespace.yaml +│ │ │ ├── deployment.yaml +│ │ │ ├── service.yaml +│ │ │ └── kustomization.yaml # Kustomize kustomization.yaml (не Flux CRD) +│ │ └── another-app/ +│ │ └── ... +│ ├── production/ # production оверлей +│ │ ├── kustomization.yaml # Flux Kustomization CRD +│ │ └── myapp/ +│ │ ├── kustomization.yaml # Kustomize: patch поверх base +│ │ └── patch-image.yaml # тег образа для production +│ └── staging/ # staging оверлей (из dev ветки app-repo) +│ ├── kustomization.yaml # Flux Kustomization CRD +│ └── myapp/ +│ ├── kustomization.yaml +│ └── patch-image.yaml # тег образа для staging +│ +└── infrastructure/ + ├── base/ + │ ├── traefik/ + │ └── longhorn/ + ├── production/ + │ └── kustomization.yaml + └── staging/ + └── kustomization.yaml +``` + +### Точка входа Flux: clusters/production/apps.yaml + +```yaml +# clusters/production/apps.yaml +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: apps + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system # fleet-repo, созданный flux bootstrap + path: ./apps/production # Flux следит за этой директорией + prune: true # удалять из кластера то, чего нет в Git + wait: true # ждать готовности перед следующим шагом + timeout: 5m +``` + +> `prune: true` — критически важный параметр. Без него удалённые из Git ресурсы останутся в кластере. + +--- + +## 4. Многоветочный деплой: main → production, dev → staging + +### Концепция + +Оба окружения (production и staging) существуют в **одном кластере** в **разных namespace**. +Flux следит за **одним fleet-repo** (ветка `main`), но за **разными путями** внутри него: + +- `apps/production/` → namespace `production` (образы из ветки `main` app-repo) +- `apps/staging/` → namespace `staging` (образы из ветки `dev` app-repo) + +CI/CD в app-repo при пуше в `main` обновляет тег в `apps/production/myapp/patch-image.yaml`. +CI/CD в app-repo при пуше в `dev` обновляет тег в `apps/staging/myapp/patch-image.yaml`. + +### Настройка двух окружений в clusters/production/ + +```yaml +# clusters/production/apps.yaml +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: apps-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production + prune: true + wait: true + timeout: 5m +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: apps-staging + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/staging + prune: true + wait: true + timeout: 5m +``` + +### Зависимости между окружениями (dependsOn) + +Если staging должен деплоиться только после успешного production (нетипично, но возможно): + +```yaml +spec: + dependsOn: + - name: apps-production +``` + +### Схема взаимодействия веток и окружений + +``` +app-repo fleet-repo (k8s-fleet, ветка main) +─────────────────────────────── ────────────────────────────────────── +branch: main apps/production/myapp/patch-image.yaml + │ image: registry/myapp:v1.5.0 + │ CI push → обновляет тег ──────► + │ +branch: dev apps/staging/myapp/patch-image.yaml + │ image: registry/myapp:dev-abc1234 + │ CI push → обновляет тег ──────► + │ + Flux (каждые 5 минут): + git pull fleet-repo + apply apps/production/ → ns: production + apply apps/staging/ → ns: staging +``` + +--- + +## 5. Пример 1: Одно приложение, один Pod + +**Сценарий:** простой HTTP-сервис (например, API на Go), одна реплика. + +### Структура в fleet-repo + +``` +apps/ +├── base/ +│ └── simple-api/ +│ ├── namespace.yaml +│ ├── deployment.yaml +│ ├── service.yaml +│ └── kustomization.yaml +├── production/ +│ ├── kustomization.yaml +│ └── simple-api/ +│ ├── kustomization.yaml +│ └── patch-image.yaml +└── staging/ + ├── kustomization.yaml + └── simple-api/ + ├── kustomization.yaml + └── patch-image.yaml +``` + +### apps/base/simple-api/namespace.yaml + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: production # для base используем production; staging переопределит через patch +``` + +### apps/base/simple-api/deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-api + namespace: production +spec: + replicas: 1 + selector: + matchLabels: + app: simple-api + template: + metadata: + labels: + app: simple-api + version: "1.0.0" + spec: + containers: + - name: simple-api + image: registry.gigacoms.info/myteam/simple-api:latest # тег заменяется патчем + ports: + - containerPort: 8080 + env: + - name: APP_ENV + value: production + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 +``` + +### apps/base/simple-api/service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: simple-api + namespace: production +spec: + selector: + app: simple-api + ports: + - port: 80 + targetPort: 8080 + type: ClusterIP +``` + +### apps/base/simple-api/kustomization.yaml + +```yaml +# Это Kustomize kustomization.yaml (не Flux CRD) +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - deployment.yaml + - service.yaml +``` + +### apps/production/simple-api/patch-image.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-api + namespace: production +spec: + template: + spec: + containers: + - name: simple-api + image: registry.gigacoms.info/myteam/simple-api:v1.5.2 # обновляет CI/CD +``` + +### apps/production/simple-api/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: production +resources: + - ../../base/simple-api +patches: + - path: patch-image.yaml +``` + +### apps/staging/simple-api/patch-image.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-api + namespace: staging +spec: + template: + spec: + containers: + - name: simple-api + image: registry.gigacoms.info/myteam/simple-api:dev-abc1234f + env: + - name: APP_ENV + value: staging +``` + +### apps/staging/simple-api/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: staging # переопределяет namespace для всех ресурсов из base +resources: + - ../../base/simple-api +patches: + - path: patch-image.yaml +``` + +### apps/production/kustomization.yaml (Flux Kustomization CRD) + +```yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: simple-api-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/simple-api + prune: true + targetNamespace: production +``` + +--- + +## 6. Пример 2: Одно приложение, несколько Pod (Deployment + HPA) + +**Сценарий:** нагруженный HTTP-сервис с горизонтальным масштабированием. + +### apps/base/web-service/deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web-service + namespace: production +spec: + replicas: 2 # минимальное количество; HPA управляет масштабом + selector: + matchLabels: + app: web-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 # zero-downtime deploy + template: + metadata: + labels: + app: web-service + spec: + affinity: + podAntiAffinity: # разносить Pod по разным нодам + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: web-service + topologyKey: kubernetes.io/hostname + containers: + - name: web-service + image: registry.gigacoms.info/myteam/web-service:latest + ports: + - containerPort: 8080 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 3 +``` + +### apps/base/web-service/hpa.yaml + +```yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: web-service + namespace: production +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: web-service + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 # не уменьшать быстрее чем раз в 5 минут + policies: + - type: Pods + value: 1 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 30 + policies: + - type: Pods + value: 2 + periodSeconds: 60 +``` + +### apps/base/web-service/pdb.yaml + +```yaml +# PodDisruptionBudget — не допускать недоступности при обновлении нод +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: web-service + namespace: production +spec: + minAvailable: 1 + selector: + matchLabels: + app: web-service +``` + +### apps/base/web-service/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - deployment.yaml + - service.yaml + - hpa.yaml + - pdb.yaml +``` + +### apps/staging/web-service/patch-replicas.yaml + +В staging HPA и PDB не нужны — экономим ресурсы: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web-service + namespace: staging +spec: + replicas: 1 # staging всегда 1 реплика +``` + +### apps/staging/web-service/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: staging +resources: + - ../../base/web-service +patches: + - path: patch-image.yaml + - path: patch-replicas.yaml +# исключаем HPA и PDB из staging +components: [] +``` + +Или через `kustomization.yaml` с явным списком resources (без hpa.yaml и pdb.yaml): + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: staging +resources: + - ../../base/web-service/namespace.yaml + - ../../base/web-service/deployment.yaml + - ../../base/web-service/service.yaml +patches: + - path: patch-image.yaml + - path: patch-replicas.yaml +``` + +--- + +## 7. Пример 3: Несколько компонентов одного приложения (frontend + backend + БД) + +**Сценарий:** веб-приложение из трёх компонентов в одном namespace. Все три деплоятся вместе, версионируются независимо. + +### Структура + +``` +apps/ +├── base/ +│ └── shop/ # одно логическое приложение "shop" +│ ├── namespace.yaml +│ ├── frontend/ +│ │ ├── deployment.yaml +│ │ └── service.yaml +│ ├── backend/ +│ │ ├── deployment.yaml +│ │ ├── service.yaml +│ │ └── configmap.yaml +│ ├── postgres/ +│ │ ├── statefulset.yaml +│ │ ├── service.yaml +│ │ └── pvc.yaml +│ └── kustomization.yaml +├── production/ +│ └── shop/ +│ ├── kustomization.yaml +│ ├── patch-frontend.yaml +│ ├── patch-backend.yaml +│ └── patch-postgres.yaml +└── staging/ + └── shop/ + ├── kustomization.yaml + ├── patch-frontend.yaml + ├── patch-backend.yaml + └── patch-postgres.yaml +``` + +### apps/base/shop/namespace.yaml + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: shop-production + labels: + environment: production + app.kubernetes.io/part-of: shop +``` + +### apps/base/shop/backend/configmap.yaml + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: backend-config + namespace: shop-production +data: + DB_HOST: postgres # имя Service внутри namespace + DB_PORT: "5432" + DB_NAME: shopdb + FRONTEND_URL: http://frontend + LOG_LEVEL: info +``` + +### apps/base/shop/backend/deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: shop-production +spec: + replicas: 2 + selector: + matchLabels: + app: shop + component: backend + template: + metadata: + labels: + app: shop + component: backend + spec: + initContainers: + - name: wait-for-postgres # ждём БД перед стартом + image: busybox:1.36 + command: + - sh + - -c + - | + until nc -z postgres 5432; do + echo "Waiting for postgres..." + sleep 2 + done + containers: + - name: backend + image: registry.gigacoms.info/myteam/shop-backend:latest + ports: + - containerPort: 3000 + envFrom: + - configMapRef: + name: backend-config + env: + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: shop-postgres-secret + key: password + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi +``` + +### apps/base/shop/backend/service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: shop-production +spec: + selector: + app: shop + component: backend + ports: + - port: 80 + targetPort: 3000 + type: ClusterIP +``` + +### apps/base/shop/frontend/deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: shop-production +spec: + replicas: 2 + selector: + matchLabels: + app: shop + component: frontend + template: + metadata: + labels: + app: shop + component: frontend + spec: + containers: + - name: frontend + image: registry.gigacoms.info/myteam/shop-frontend:latest + ports: + - containerPort: 80 + env: + - name: BACKEND_URL + value: http://backend # DNS-имя Service в том же namespace + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi +``` + +### apps/base/shop/postgres/statefulset.yaml + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: shop-production +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: shop + component: postgres + template: + metadata: + labels: + app: shop + component: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: shopdb + - name: POSTGRES_USER + value: shopuser + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: shop-postgres-secret + key: password + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: longhorn + resources: + requests: + storage: 10Gi +``` + +### apps/base/shop/postgres/service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: shop-production +spec: + selector: + app: shop + component: postgres + ports: + - port: 5432 + targetPort: 5432 + type: ClusterIP + clusterIP: None # Headless service для StatefulSet +``` + +### apps/base/shop/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - backend/configmap.yaml + - backend/deployment.yaml + - backend/service.yaml + - frontend/deployment.yaml + - frontend/service.yaml + - postgres/statefulset.yaml + - postgres/service.yaml +``` + +### apps/production/shop/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: shop-production +resources: + - ../../base/shop +patches: + - path: patch-frontend.yaml + - path: patch-backend.yaml + - path: patch-postgres.yaml +``` + +### apps/production/shop/patch-frontend.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: shop-production +spec: + template: + spec: + containers: + - name: frontend + image: registry.gigacoms.info/myteam/shop-frontend:v2.1.0 +``` + +### apps/staging/shop/kustomization.yaml + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: shop-staging # staging в отдельном namespace +resources: + - ../../base/shop +namePrefix: "" # не добавлять префикс +patches: + - path: patch-frontend.yaml + - path: patch-backend.yaml + - path: patch-postgres.yaml + - path: patch-staging-replicas.yaml # все компоненты = 1 реплика +``` + +### apps/staging/shop/patch-staging-replicas.yaml + +```yaml +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: shop-staging +spec: + replicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: shop-staging +spec: + replicas: 1 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: shop-staging +spec: + replicas: 1 +``` + +### Порядок деплоя (dependsOn в Flux Kustomization) + +Когда postgres должен быть готов до backend: + +```yaml +# clusters/production/apps.yaml +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: shop-postgres-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/shop/postgres + prune: true + healthChecks: + - apiVersion: apps/v1 + kind: StatefulSet + name: postgres + namespace: shop-production +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: shop-backend-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/shop/backend + prune: true + dependsOn: + - name: shop-postgres-production # backend стартует только после healthy postgres +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: shop-frontend-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/shop/frontend + prune: true + dependsOn: + - name: shop-backend-production +``` + +--- + +## 8. Пример 4: Взаимодействующие независимые приложения (микросервисы) + +**Сценарий:** несколько независимых сервисов в разных namespace, которые вызывают друг друга по HTTP. + +### Топология + +``` +namespace: auth-production + └── auth-service (порт 8080) + +namespace: orders-production + └── orders-service (порт 8080) → вызывает auth-service через cross-namespace DNS + +namespace: notifications-production + └── notifications-service (порт 8080) → вызывает orders-service +``` + +### DNS-имена для cross-namespace обращения + +Внутри Kubernetes полное DNS-имя Service: + +``` +..svc.cluster.local +``` + +Примеры: +- `auth-service.auth-production.svc.cluster.local:8080` +- `orders-service.orders-production.svc.cluster.local:8080` + +### Структура fleet-repo для микросервисов + +``` +apps/ +├── base/ +│ ├── auth-service/ +│ │ ├── namespace.yaml +│ │ ├── deployment.yaml +│ │ └── service.yaml +│ ├── orders-service/ +│ │ ├── namespace.yaml +│ │ ├── deployment.yaml +│ │ ├── service.yaml +│ │ └── configmap.yaml # URL других сервисов +│ └── notifications-service/ +│ ├── namespace.yaml +│ ├── deployment.yaml +│ ├── service.yaml +│ └── configmap.yaml +├── production/ +│ ├── kustomization.yaml # включает все три сервиса +│ ├── auth-service/ +│ │ ├── kustomization.yaml +│ │ └── patch-image.yaml +│ ├── orders-service/ +│ │ ├── kustomization.yaml +│ │ └── patch-image.yaml +│ └── notifications-service/ +│ ├── kustomization.yaml +│ └── patch-image.yaml +└── staging/ + └── ... # аналогично, namespace: *-staging +``` + +### apps/base/orders-service/configmap.yaml + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: orders-config + namespace: orders-production +data: + # cross-namespace DNS — полный FQDN обязателен + AUTH_SERVICE_URL: http://auth-service.auth-production.svc.cluster.local:80 + NOTIFICATIONS_SERVICE_URL: http://notifications-service.notifications-production.svc.cluster.local:80 + LOG_LEVEL: info +``` + +### apps/base/orders-service/deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: orders-service + namespace: orders-production +spec: + replicas: 2 + selector: + matchLabels: + app: orders-service + template: + metadata: + labels: + app: orders-service + spec: + containers: + - name: orders-service + image: registry.gigacoms.info/myteam/orders-service:latest + ports: + - containerPort: 8080 + envFrom: + - configMapRef: + name: orders-config + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 300m + memory: 256Mi + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 +``` + +### Staging: cross-namespace URL тоже меняется + +```yaml +# apps/staging/orders-service/patch-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: orders-config + namespace: orders-staging +data: + AUTH_SERVICE_URL: http://auth-service.auth-staging.svc.cluster.local:80 + NOTIFICATIONS_SERVICE_URL: http://notifications-service.notifications-staging.svc.cluster.local:80 + LOG_LEVEL: debug # staging = verbose logging +``` + +### NetworkPolicy: разрешить inter-namespace трафик явно + +По умолчанию в Kubernetes трафик между namespace разрешён. Если в кластере включён запретительный NetworkPolicy, нужно явно разрешить: + +```yaml +# apps/base/auth-service/networkpolicy.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-from-orders + namespace: auth-production +spec: + podSelector: + matchLabels: + app: auth-service + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: orders-production + podSelector: + matchLabels: + app: orders-service + ports: + - port: 8080 +``` + +### Порядок деплоя микросервисов (dependsOn) + +```yaml +# clusters/production/apps.yaml +--- +# auth деплоится первым (ни от чего не зависит) +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: auth-service-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/auth-service + prune: true + healthChecks: + - apiVersion: apps/v1 + kind: Deployment + name: auth-service + namespace: auth-production +--- +# orders ждёт auth +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: orders-service-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/orders-service + prune: true + dependsOn: + - name: auth-service-production + healthChecks: + - apiVersion: apps/v1 + kind: Deployment + name: orders-service + namespace: orders-production +--- +# notifications ждёт orders +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: notifications-service-production + namespace: flux-system +spec: + interval: 5m + sourceRef: + kind: GitRepository + name: flux-system + path: ./apps/production/notifications-service + prune: true + dependsOn: + - name: orders-service-production +``` + +--- + +## 9. CI/CD в app-repo: автоматическое обновление образа + +### Принцип работы + +1. Разработчик делает `git push` в ветку `main` (или `dev`) +2. CI собирает Docker-образ, тегирует его (`v1.5.2` или `dev-abc1234f`) +3. CI пушит образ в Container Registry +4. CI клонирует fleet-repo, обновляет тег образа в нужном оверлее, делает commit + push +5. Flux обнаруживает изменение в fleet-repo и применяет его в кластер + +### .gitlab-ci.yml в app-repo (стандартный шаблон) + +```yaml +variables: + REGISTRY: registry.gigacoms.info + IMAGE_NAME: $REGISTRY/myteam/simple-api + FLEET_REPO: https://oauth2:${GITLAB_FLEET_TOKEN}@gitlab.gigacoms.info/k8s/k8s-fleet.git + +stages: + - build + - deploy + +build: + stage: build + image: docker:27 + services: + - docker:27-dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $REGISTRY + - | + if [ "$CI_COMMIT_BRANCH" = "main" ]; then + TAG="v${CI_COMMIT_SHORT_SHA}" + OVERLAY="production" + else + TAG="dev-${CI_COMMIT_SHORT_SHA}" + OVERLAY="staging" + fi + - docker build -t $IMAGE_NAME:$TAG . + - docker push $IMAGE_NAME:$TAG + - echo "TAG=$TAG" >> build.env + - echo "OVERLAY=$OVERLAY" >> build.env + artifacts: + reports: + dotenv: build.env + rules: + - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "dev" + +deploy-to-fleet: + stage: deploy + image: alpine/git:latest + needs: + - job: build + artifacts: true + script: + - git config --global user.email "ci@gitlab.gigacoms.info" + - git config --global user.name "GitLab CI" + - git clone --depth=1 $FLEET_REPO /tmp/fleet + - | + PATCH_FILE="/tmp/fleet/apps/${OVERLAY}/simple-api/patch-image.yaml" + cat > $PATCH_FILE << EOF + apiVersion: apps/v1 + kind: Deployment + metadata: + name: simple-api + namespace: simple-api-${OVERLAY} + spec: + template: + spec: + containers: + - name: simple-api + image: ${IMAGE_NAME}:${TAG} + EOF + - cd /tmp/fleet + - git add apps/${OVERLAY}/simple-api/patch-image.yaml + - git diff --cached --quiet || git commit -m "ci: update simple-api ${OVERLAY} to ${TAG}" + - git push origin main + rules: + - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "dev" +``` + +> **Важно:** `GITLAB_FLEET_TOKEN` — отдельный токен с правами `write_repository` на fleet-repo. Хранить в CI/CD Variables проекта app-repo. Не использовать персональные токены. + +### Соглашения по тегам образов + +| Окружение | Ветка app-repo | Формат тега | Пример | +|---|---|---|---| +| production | `main` | `v` или semver | `v1.5.2`, `vabc1234f` | +| staging | `dev` | `dev-` | `dev-abc1234f` | + +Никогда не использовать тег `latest` в fleet-repo — он неиммутабелен и Flux не сможет отследить изменение. + +--- + +## 10. Secrets: безопасная передача секретов через Flux + +### Принцип + +Секреты **никогда не хранятся в Git в открытом виде**. Допустимые подходы: + +1. **Ручное создание** (используем сейчас) — `kubectl create secret` вручную один раз +2. **Sealed Secrets** — шифрование секрета публичным ключом кластера, зашифрованный yaml коммитится в Git +3. **External Secrets Operator** — секрет хранится во внешнем хранилище (Vault, AWS SM), ESO синхронизирует в кластер + +### Подход 1: ручное создание (текущий стандарт) + +```bash +# Создать секрет вручную — один раз, не через Ansible и не через Git +kubectl create secret generic shop-postgres-secret \ + --from-literal=password='StrongPassword123!' \ + -n shop-production + +# Для staging отдельно +kubectl create secret generic shop-postgres-secret \ + --from-literal=password='StagingPassword456!' \ + -n shop-staging +``` + +В манифесте деплоя ссылаться на секрет через `secretKeyRef` (как в примерах выше). + +Если Flux удалит namespace (через `prune: true`), секрет исчезнет вместе с ним. Документировать секреты в `secrets/README.md` внутри fleet-repo (без значений!): + +```markdown +# Требуемые секреты (создавать вручную перед первым деплоем) + +## shop-production +kubectl create secret generic shop-postgres-secret \ + --from-literal=password='' \ + -n shop-production +``` + +### Подход 2: Sealed Secrets (рекомендуется при масштабировании) + +```bash +# Установить kubeseal +brew install kubeseal # или скачать бинарь + +# Зашифровать секрет публичным ключом кластера +kubectl create secret generic shop-postgres-secret \ + --from-literal=password='StrongPassword123!' \ + --dry-run=client -o yaml | \ + kubeseal --controller-namespace flux-system \ + --controller-name sealed-secrets \ + --format yaml > apps/base/shop/postgres/sealed-secret.yaml + +# Полученный sealed-secret.yaml безопасно коммитить в Git +git add apps/base/shop/postgres/sealed-secret.yaml +git commit -m "feat: add shop postgres sealed secret" +``` + +--- + +## 11. Диагностика и типичные ошибки + +### Основные команды + +```bash +# Состояние всех Flux-объектов +flux get all -A + +# Состояние конкретного Kustomization +flux get kustomization apps-production -n flux-system + +# Принудительная синхронизация (не ждать interval) +flux reconcile kustomization apps-production --with-source + +# Логи flux-контроллеров +kubectl logs -n flux-system -l app=kustomize-controller --tail=50 +kubectl logs -n flux-system -l app=source-controller --tail=50 + +# Просмотр событий в namespace +kubectl get events -n production --sort-by='.lastTimestamp' + +# Детали конкретного Kustomization +kubectl describe kustomization apps-production -n flux-system +``` + +### Типичные ошибки и решения + +| Ошибка | Причина | Решение | +|---|---|---| +| `Health check failed` | Pod не стал Ready в течение timeout | Проверить `kubectl describe pod`, `kubectl logs` | +| `kustomize build failed` | Синтаксическая ошибка в yaml | `kustomize build apps/production/myapp` локально | +| `object not found` | Ресурс удалён из Git но остался в кластере без `prune: true` | Добавить `prune: true` или удалить вручную | +| `no such file or directory` | Неверный path в Kustomization | Проверить path в CRD, путь относительный от корня repo | +| `resource conflict` | Два Kustomization управляют одним ресурсом | Разнести по разным namespace или убрать дублирование | +| Flux не видит изменений | Интервал не истёк | `flux reconcile kustomization --with-source` | +| Deployment завис на 0/2 | imagePullError | Проверить тег образа и доступность registry | + +### Проверка до деплоя (dry-run) + +```bash +# Локальная проверка Kustomize-сборки +kustomize build apps/production/myapp + +# Проверить что Flux увидит после изменения +flux diff kustomization apps-production --path ./apps/production + +# Синтаксическая проверка всех yaml +find apps/ -name '*.yaml' | xargs kubectl apply --dry-run=client -f +``` + +--- + +## 12. Чеклист перед деплоем нового приложения + +### В fleet-repo + +- [ ] Создана директория `apps/base//` с namespace, deployment, service, kustomization.yaml +- [ ] Создана директория `apps/production//` с patch-image.yaml и kustomization.yaml +- [ ] Создана директория `apps/staging//` с patch-image.yaml и kustomization.yaml +- [ ] Добавлен Flux Kustomization CRD в `clusters/production/apps.yaml` +- [ ] Настроены `dependsOn` если приложение зависит от других компонентов +- [ ] Настроены `healthChecks` если другие компоненты зависят от этого приложения +- [ ] `prune: true` установлен во всех Kustomization CRD +- [ ] Ресурсы `requests`/`limits` заданы для всех контейнеров +- [ ] `readinessProbe` настроен для всех контейнеров +- [ ] `kustomize build apps/production/` выполняется без ошибок локально + +### В app-repo + +- [ ] `.gitlab-ci.yml` содержит шаг `deploy-to-fleet` +- [ ] Используются иммутабельные теги образов (не `latest`) +- [ ] `GITLAB_FLEET_TOKEN` добавлен в CI/CD Variables проекта +- [ ] Образ успешно собирается и пушится в registry + +### В кластере + +- [ ] Secrets созданы вручную в нужных namespace перед первым деплоем (documented в secrets/README.md) +- [ ] `flux get kustomization ` показывает `Ready=True` +- [ ] Pod в состоянии `Running`, все контейнеры `Ready` +- [ ] Сервис доступен внутри кластера: `kubectl exec -it -- curl http://` +- [ ] (если нужен внешний доступ) IngressRoute добавлен в `inventory/prod/group_vars/traefik.yml` + +--- + +## Приложение: соглашения по именованию + +| Сущность | Формат | Пример | +|---|---|---| +| Namespace production | `-production` | `shop-production` | +| Namespace staging | `-staging` | `shop-staging` | +| Flux Kustomization | `-` | `shop-production` | +| Docker-образ production | `registry/team/:v` | `registry.gigacoms.info/myteam/shop-backend:vabc1234f` | +| Docker-образ staging | `registry/team/:dev-` | `registry.gigacoms.info/myteam/shop-backend:dev-abc1234f` | +| Secret | `--secret` | `shop-postgres-secret` | +| ConfigMap | `-config` | `backend-config` | +| Service (внутр.) | `` | `backend`, `postgres` | + +## Приложение: минимальный ресурсный профиль + +Использовать как отправную точку, корректировать под реальную нагрузку: + +```yaml +resources: + requests: + cpu: 50m # гарантированное CPU + memory: 64Mi # гарантированная память + limits: + cpu: 200m # максимальное CPU + memory: 256Mi # максимальная память (OOMKill при превышении) +``` + +> При выборе `limits.memory`: установить в 2–4x от `requests.memory`. Слишком маленький лимит приведёт к постоянным OOMKill. Слишком большой — к неэффективному использованию узла. diff --git a/roles/flux/defaults/main.yml b/roles/flux/defaults/main.yml new file mode 100644 index 0000000..90e911a --- /dev/null +++ b/roles/flux/defaults/main.yml @@ -0,0 +1,19 @@ +--- +# Flux CLI version (empty = latest stable from GitHub) +flux_version: "" + +# Path to kubeconfig on manager node +flux_kubeconfig: "/home/{{ ansible_user }}/.kube/config" + +# GitLab connection settings for bootstrap +flux_gitlab_hostname: "gitlab.gigacoms.info" +flux_gitlab_owner: "k8s" +flux_gitlab_repository: "k8s-fleet" +flux_gitlab_branch: "main" +flux_gitlab_path: "clusters/production" + +# Whether the owner is a personal account (true) or a group (false) +flux_gitlab_personal: false + +# GitLab token passed via env in CI — never hardcode here +flux_gitlab_token: "{{ lookup('env', 'GITLAB_FLUX_TOKEN') | default('') }}" diff --git a/roles/flux/handlers/main.yml b/roles/flux/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/flux/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/flux/meta/main.yml b/roles/flux/meta/main.yml new file mode 100644 index 0000000..1690725 --- /dev/null +++ b/roles/flux/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Install Flux CD CLI and bootstrap Flux in Kubernetes cluster via GitLab + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/flux/tasks/bootstrap.yml b/roles/flux/tasks/bootstrap.yml new file mode 100644 index 0000000..2d29cf6 --- /dev/null +++ b/roles/flux/tasks/bootstrap.yml @@ -0,0 +1,25 @@ +--- +- name: Bootstrap | Verify GITLAB_FLUX_TOKEN is set + ansible.builtin.assert: + that: + - flux_gitlab_token | length > 0 + fail_msg: "GITLAB_FLUX_TOKEN environment variable is not set — cannot bootstrap Flux" + success_msg: "GitLab token is present" + +- name: Bootstrap | Run flux bootstrap gitlab + ansible.builtin.command: + argv: >- + {{ ['flux', 'bootstrap', 'gitlab', + '--hostname=' + flux_gitlab_hostname, + '--owner=' + flux_gitlab_owner, + '--repository=' + flux_gitlab_repository, + '--branch=' + flux_gitlab_branch, + '--path=' + flux_gitlab_path, + '--private=true'] + + (['--personal'] if flux_gitlab_personal | bool else []) }} + environment: + KUBECONFIG: "{{ flux_kubeconfig }}" + GITLAB_TOKEN: "{{ flux_gitlab_token }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _flux_bootstrap + changed_when: "'already exists' not in _flux_bootstrap.stderr" diff --git a/roles/flux/tasks/install.yml b/roles/flux/tasks/install.yml new file mode 100644 index 0000000..0762b41 --- /dev/null +++ b/roles/flux/tasks/install.yml @@ -0,0 +1,48 @@ +--- +- name: Flux CLI | Get latest version from GitHub + ansible.builtin.uri: + url: https://api.github.com/repos/fluxcd/flux2/releases/latest + return_content: true + register: _flux_latest + when: not flux_version + check_mode: false + +- name: Flux CLI | Set version fact (latest) + ansible.builtin.set_fact: + _flux_version: "{{ _flux_latest.json.tag_name | regex_replace('^v', '') }}" + when: not flux_version + +- name: Flux CLI | Set version fact (pinned) + ansible.builtin.set_fact: + _flux_version: "{{ flux_version | regex_replace('^v', '') }}" + when: flux_version + +- name: Flux CLI | Download and unpack + ansible.builtin.unarchive: + src: "https://github.com/fluxcd/flux2/releases/download/v{{ _flux_version }}/flux_{{ _flux_version }}_linux_amd64.tar.gz" + dest: /usr/local/bin + remote_src: true + include: + - flux + creates: /usr/local/bin/flux + +- name: Flux CLI | Set permissions + ansible.builtin.file: + path: /usr/local/bin/flux + mode: "0755" + owner: root + group: root + +- name: Flux CLI | Enable bash completion + ansible.builtin.shell: /usr/local/bin/flux completion bash > /etc/bash_completion.d/flux + args: + creates: /etc/bash_completion.d/flux + +- name: Flux CLI | Verify pre-flight checks + ansible.builtin.command: flux check --pre + environment: + KUBECONFIG: "{{ flux_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _flux_check + changed_when: false + failed_when: _flux_check.rc != 0 diff --git a/roles/flux/tasks/main.yml b/roles/flux/tasks/main.yml new file mode 100644 index 0000000..5bd7fee --- /dev/null +++ b/roles/flux/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: Flux | Install CLI + ansible.builtin.include_tasks: install.yml + +- name: Flux | Bootstrap with GitLab + ansible.builtin.include_tasks: bootstrap.yml diff --git a/roles/gitlab_agent/defaults/main.yml b/roles/gitlab_agent/defaults/main.yml new file mode 100644 index 0000000..6039b28 --- /dev/null +++ b/roles/gitlab_agent/defaults/main.yml @@ -0,0 +1,31 @@ +--- +# GitLab hostname +gitlab_agent_hostname: "gitlab.gigacoms.info" + +# Agent name — must match the name registered in GitLab UI +# and the directory name in .gitlab/agents//config.yaml in the fleet repo +gitlab_agent_name: "production" + +# Kubernetes namespace for agentk +gitlab_agent_namespace: "gitlab-agent" + +# Agent token — passed via GITLAB_AGENT_TOKEN CI variable +gitlab_agent_token: "{{ lookup('env', 'GITLAB_AGENT_TOKEN') | default('') }}" + +# KAS WebSocket address (auto-derived from hostname) +gitlab_agent_kas_address: "wss://{{ gitlab_agent_hostname }}/-/kubernetes-agent/" + +# Path to kubeconfig on manager node +gitlab_agent_kubeconfig: "/home/{{ ansible_user }}/.kube/config" + +# Fleet repo settings (for creating agent config.yaml) +gitlab_agent_fleet_repo: "https://{{ gitlab_agent_hostname }}/k8s/k8s-fleet.git" +gitlab_agent_fleet_token: "{{ lookup('env', 'GITLAB_FLUX_TOKEN') | default('') }}" +gitlab_agent_fleet_branch: "main" + +# GitLab group to grant ci_access (all projects in this group can use the agent) +gitlab_agent_ci_access_group: "k8s" + +# Additional individual projects to grant ci_access (outside the main group) +# Format: list of GitLab project paths, e.g. ["it-dept/billing-mobile"] +gitlab_agent_ci_access_projects: [] diff --git a/roles/gitlab_agent/handlers/main.yml b/roles/gitlab_agent/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/gitlab_agent/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/gitlab_agent/meta/main.yml b/roles/gitlab_agent/meta/main.yml new file mode 100644 index 0000000..434e8b4 --- /dev/null +++ b/roles/gitlab_agent/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Install GitLab Agent (agentk) in Kubernetes cluster and register agent config in fleet repo + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/gitlab_agent/tasks/config.yml b/roles/gitlab_agent/tasks/config.yml new file mode 100644 index 0000000..64a67af --- /dev/null +++ b/roles/gitlab_agent/tasks/config.yml @@ -0,0 +1,87 @@ +--- +- name: Agent config | Verify GITLAB_FLUX_TOKEN is set + ansible.builtin.assert: + that: + - gitlab_agent_fleet_token | length > 0 + fail_msg: "GITLAB_FLUX_TOKEN environment variable is not set — cannot push agent config to fleet repo" + +- name: Agent config | Create temp directory for fleet repo clone + ansible.builtin.tempfile: + state: directory + suffix: fleet + register: _fleet_tmpdir + +- name: Agent config | Clone fleet repo + ansible.builtin.git: + repo: "https://oauth2:{{ gitlab_agent_fleet_token }}@{{ gitlab_agent_hostname }}/k8s/k8s-fleet.git" + dest: "{{ _fleet_tmpdir.path }}/k8s-fleet" + version: "{{ gitlab_agent_fleet_branch }}" + depth: 1 + no_log: true + changed_when: true + +- name: Agent config | Create agent config directory + ansible.builtin.file: + path: "{{ _fleet_tmpdir.path }}/k8s-fleet/.gitlab/agents/{{ gitlab_agent_name }}" + state: directory + mode: "0755" + +- name: Agent config | Write agent config.yaml + ansible.builtin.copy: + dest: "{{ _fleet_tmpdir.path }}/k8s-fleet/.gitlab/agents/{{ gitlab_agent_name }}/config.yaml" + mode: "0644" + content: | + gitops: + reconcile_timeout: 3600s + + observability: + logging: + level: info + + ci_access: + groups: + - id: {{ gitlab_agent_ci_access_group }} + {% if gitlab_agent_ci_access_projects | length > 0 %} + projects: + {% for project in gitlab_agent_ci_access_projects %} + - id: {{ project }} + {% endfor %} + {% endif %} + register: _agent_config + +- name: Agent config | Commit and push if changed + when: _agent_config.changed + block: + - name: Agent config | Configure git user + ansible.builtin.git: + repo: "{{ _fleet_tmpdir.path }}/k8s-fleet" + user_name: "Ansible" + user_email: "ansible@{{ gitlab_agent_hostname }}" + changed_when: false + + - name: Agent config | Stage agent config + ansible.builtin.git: + repo: "{{ _fleet_tmpdir.path }}/k8s-fleet" + add: + - ".gitlab/agents/{{ gitlab_agent_name }}/config.yaml" + changed_when: false + + - name: Agent config | Commit + ansible.builtin.git: + repo: "{{ _fleet_tmpdir.path }}/k8s-fleet" + commit: + msg: "feat: add GitLab agent config for {{ gitlab_agent_name }}" + changed_when: true + + - name: Agent config | Push + ansible.builtin.git: + repo: "{{ _fleet_tmpdir.path }}/k8s-fleet" + push: true + branch: "{{ gitlab_agent_fleet_branch }}" + no_log: true + changed_when: true + +- name: Agent config | Remove temp directory + ansible.builtin.file: + path: "{{ _fleet_tmpdir.path }}" + state: absent diff --git a/roles/gitlab_agent/tasks/helm.yml b/roles/gitlab_agent/tasks/helm.yml new file mode 100644 index 0000000..a22ea20 --- /dev/null +++ b/roles/gitlab_agent/tasks/helm.yml @@ -0,0 +1,36 @@ +--- +- name: Helm | Verify GITLAB_AGENT_TOKEN is set + ansible.builtin.assert: + that: + - gitlab_agent_token | length > 0 + fail_msg: "GITLAB_AGENT_TOKEN environment variable is not set — cannot install agentk" + +- name: Helm | Add GitLab chart repository + ansible.builtin.command: helm repo add gitlab https://charts.gitlab.io + environment: + KUBECONFIG: "{{ gitlab_agent_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Helm | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ gitlab_agent_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Helm | Install or upgrade gitlab-agent + ansible.builtin.command: > + helm upgrade --install gitlab-agent gitlab/gitlab-agent + --namespace {{ gitlab_agent_namespace }} + --create-namespace + --set config.token={{ gitlab_agent_token }} + --set config.kasAddress={{ gitlab_agent_kas_address }} + environment: + KUBECONFIG: "{{ gitlab_agent_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + no_log: true + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/gitlab_agent/tasks/main.yml b/roles/gitlab_agent/tasks/main.yml new file mode 100644 index 0000000..d1a4d90 --- /dev/null +++ b/roles/gitlab_agent/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: GitLab Agent | Push agent config to fleet repo + ansible.builtin.include_tasks: config.yml + +- name: GitLab Agent | Install agentk via Helm + ansible.builtin.include_tasks: helm.yml diff --git a/roles/gpu_device_plugin/defaults/main.yml b/roles/gpu_device_plugin/defaults/main.yml new file mode 100644 index 0000000..a6b186b --- /dev/null +++ b/roles/gpu_device_plugin/defaults/main.yml @@ -0,0 +1,6 @@ +--- +gpu_device_plugin_version: "v0.17.0" +gpu_device_plugin_manifest_url: "https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/{{ gpu_device_plugin_version }}/deployments/static/nvidia-device-plugin.yml" + +# Path to kubeconfig on the manager node +gpu_kubeconfig: "/home/{{ ansible_user }}/.kube/config" diff --git a/roles/gpu_device_plugin/meta/main.yml b/roles/gpu_device_plugin/meta/main.yml new file mode 100644 index 0000000..6e52d9e --- /dev/null +++ b/roles/gpu_device_plugin/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Labels GPU worker nodes and installs the NVIDIA k8s device plugin + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/gpu_device_plugin/tasks/deploy.yml b/roles/gpu_device_plugin/tasks/deploy.yml new file mode 100644 index 0000000..69257ae --- /dev/null +++ b/roles/gpu_device_plugin/tasks/deploy.yml @@ -0,0 +1,22 @@ +--- +- name: Deploy | Check kubeconfig exists + ansible.builtin.stat: + path: "{{ gpu_kubeconfig }}" + register: _kubeconfig_plugin + +- name: Deploy | Skip notice + ansible.builtin.debug: + msg: "Device plugin install skipped — kubeconfig not found at {{ gpu_kubeconfig }}." + when: not _kubeconfig_plugin.stat.exists + +- name: Deploy | Apply NVIDIA device plugin manifest + ansible.builtin.command: + cmd: "kubectl apply -f {{ gpu_device_plugin_manifest_url }}" + environment: + KUBECONFIG: "{{ gpu_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + when: + - _kubeconfig_plugin.stat.exists + - groups['gpu_workers'] | default([]) | length > 0 + register: _plugin_apply + changed_when: "'configured' in _plugin_apply.stdout or 'created' in _plugin_apply.stdout" diff --git a/roles/gpu_device_plugin/tasks/label.yml b/roles/gpu_device_plugin/tasks/label.yml new file mode 100644 index 0000000..af33e8f --- /dev/null +++ b/roles/gpu_device_plugin/tasks/label.yml @@ -0,0 +1,19 @@ +--- +- name: Label | Check kubeconfig exists + ansible.builtin.stat: + path: "{{ gpu_kubeconfig }}" + register: _kubeconfig_gpu + +- name: Label | Apply node_labels to each GPU worker + ansible.builtin.command: + argv: "{{ ['kubectl', 'label', 'node', item, '--overwrite'] + (hostvars[item].node_labels.items() | map('join', '=') | list) }}" + environment: + KUBECONFIG: "{{ gpu_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + loop: "{{ groups['gpu_workers'] | default([]) }}" + when: + - _kubeconfig_gpu.stat.exists + - hostvars[item].node_labels | default({}) | length > 0 + register: _label + changed_when: "'labeled' in _label.stdout" + failed_when: _label.rc != 0 diff --git a/roles/gpu_device_plugin/tasks/main.yml b/roles/gpu_device_plugin/tasks/main.yml new file mode 100644 index 0000000..c510c19 --- /dev/null +++ b/roles/gpu_device_plugin/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: Label nodes + ansible.builtin.include_tasks: label.yml + +- name: Install device plugin + ansible.builtin.include_tasks: deploy.yml diff --git a/roles/gpu_model_storage/README.md b/roles/gpu_model_storage/README.md new file mode 100644 index 0000000..9ef9701 --- /dev/null +++ b/roles/gpu_model_storage/README.md @@ -0,0 +1,129 @@ +# gpu_model_storage + +Выделенный локальный RAID1-раздел под большие файлы моделей (Ollama и т.п.) на GPU-узлах кластера. + +## Зачем это нужно + +`gpu_workers` намеренно исключены из Longhorn (`longhorn_disks: []` в `group_vars/gpu_workers.yml`) — +GPU-узлы являются compute-only, а не storage-нодами. + +При этом проект `k8s_ai` (Ollama + Ollama Proxy + Open WebUI) хранит файлы моделей через статический +`hostPath` PV (`k8s_ai/k8s/ollama/pvc-models.yaml`), привязанный к конкретному узлу через `nodeAffinity`. +Изначально этот `hostPath` указывал на `/root/ollama-models`, что физически находится на **корневой +файловой системе** узла. + +Корневой раздел на `k8s-worker-02` — это RAID1-массив `md126` размером всего **~30 ГБ** +(`sda4`/`sdb4`). Первая же загрузка модели среднего размера (обычно десятки гигабайт) заполнила бы +диск полностью и уронила бы узел — на `/` живут `systemd`, `containerd`, `kubelet` и вся остальная ОС. +Kubernetes при этом **не** проверяет заявленную ёмкость `hostPath` PV (`capacity.storage` — чисто +декларативное поле), так что переполнение диска не было бы предотвращено на уровне Kubernetes. + +При этом два зеркальных диска узла (`sda`/`sdb`, по 4 ТБ каждый) используют под ОС (`boot`, +`boot_efi`, `root`) только первые ~34 ГБ — оставшиеся ~3.6 ТБ на каждом диске были полностью +неразмеченными и простаивали. + +**Решение**: роль нарезает из этого свободного места отдельный RAID1-раздел, форматирует его и +монтирует в отдельную точку — модели больше не могут повлиять на работоспособность ОС. + +## Архитектура + +``` +sda, sdb (по 4 ТБ, зеркало) +├─ sda1/sdb1 → md127 (boot, ext4, ~1 ГБ) — существовало до роли +├─ sda2/sdb2 → md125 (boot_efi, vfat, ~0.6 ГБ) — существовало до роли +├─ sda3/sdb3 → LVM (swap) — существовало до роли +├─ sda4/sdb4 → md126 (root, ext4, ~30 ГБ) — существовало до роли +└─ sda5/sdb5 → md1 (модели, xfs, gpu_model_storage_partition_size_gb ГБ) ← создаёт эта роль + └─ смонтирован в gpu_model_storage_mountpoint (по умолчанию /mnt/ollama-models) +``` + +Партиция №5 создаётся сразу после существующих ОС-партиций (старт фиксирован на `34GB`, конец — +`34 + gpu_model_storage_partition_size_gb` ГБ), на каждом диске из `gpu_model_storage_devices`. +Из этих партиций собирается новый независимый `mdadm`-массив RAID1 (не имеет отношения к +`md126`/`md127`/`md125` — отдельный массив, отдельная точка монтирования). + +## Как включить для узла/группы + +Роль **выключена по умолчанию** (`gpu_model_storage_devices: []` в `defaults/main.yml`) — по тому +же паттерну, что и `longhorn_disks` в `longhorn_prereqs`. Чтобы включить, переопределите список +дисков в `group_vars/.yml` или `hosts.yml` для конкретного хоста: + +```yaml +# inventory/prod/group_vars/gpu_workers.yml +gpu_model_storage_devices: + - /dev/sda + - /dev/sdb +gpu_model_storage_partition_size_gb: 1000 +``` + +Сейчас это включено для всей группы `gpu_workers` (на данный момент единственный член — +`k8s-worker-02`). + +## Запуск + +```bash +ansible-playbook -i inventory/prod playbooks/setup_gpu_model_storage.yml +``` + +Плейбук нацелен на группу `gpu_workers`. Порядок относительно `setup_worker_plane.yml` / +`setup_gpu.yml` **не важен** — роль работает только с локальными дисками узла и не трогает +`containerd`/`kubelet`/сеть. Можно запускать в любой момент, включая до `kubeadm join`. + +Все задачи идемпотентны: +- `community.general.parted` не пересоздаёт партицию, если она уже существует с нужными параметрами. +- `raid.yml` проверяет `mdadm --detail ` перед созданием массива — повторный запуск не + пересоздаёт RAID. +- `filesystem.yml` (`community.general.filesystem`) не переформатирует уже отформатированный раздел. +- `mount.yml` использует `ansible.posix.mount` с `state: mounted` — идемпотентно монтирует по UUID. + +## Все переменные + +| Переменная | Значение по умолчанию | Описание | +|---|---|---| +| `gpu_model_storage_devices` | `[]` | Список блочных устройств — членов будущего RAID1 (например `/dev/sda`, `/dev/sdb`). Пустой список = роль ничего не делает. | +| `gpu_model_storage_partition_size_gb` | `1000` | Размер новой партиции (пятой) на каждом устройстве, в гигабайтах. Партиция начинается сразу после существующих ОС-партиций (`34GB`). | +| `gpu_model_storage_raid_device` | `/dev/md1` | Имя нового RAID1-массива. Не должно совпадать с уже существующими (`/dev/md125`, `/dev/md126`, `/dev/md127` на типовом узле). | +| `gpu_model_storage_mountpoint` | `/mnt/ollama-models` | Точка монтирования нового раздела. | +| `gpu_model_storage_fstype` | `xfs` | Файловая система (как в Longhorn-дисках — консистентность в репозитории). | + +## Ограничения и подводные камни + +- **Начало партиции захардкожено в `34GB`.** Это соответствует текущему размеру существующих + ОС-партиций (`boot` + `boot_efi` + `swap` + `root` ≈ 32.2 ГБ + небольшой запас) на узлах, + разворачиваемых по стандартному Rocky Linux 9 + RAID1 layout. Если разметка ОС на новом узле + отличается — проверьте фактическое свободное место (`parted /dev/sdX unit GB print free`) и + скорректируйте `part_start` в `roles/gpu_model_storage/tasks/partition.yml` перед запуском. +- **Имя RAID-устройства должно быть свободным.** Перед первым запуском на новом узле проверьте + `cat /proc/mdstat`, чтобы `gpu_model_storage_raid_device` не совпадал с уже занятым `/dev/mdN`. +- **Роль не выполняет `dracut -f`** — это осознанно: массив не является частью `root`/`boot`, не + участвует в загрузке системы, поэтому пересборка initramfs не требуется. Ядро подхватывает массив + через udev по суперблоку при каждой загрузке, а запись в `/etc/mdadm.conf` — для надёжности + (явное ARRAY-определение вместо авто-скана). +- **Диски используются только на ~1/4** (при `gpu_model_storage_partition_size_gb: 1000` из ~3967 ГБ + свободных) — сделано намеренно, с запасом на будущее (другие датасеты, второй RAID-раздел и т.д.). + Увеличить размер существующего раздела после создания массива штатными средствами Ansible-роли + нельзя — потребуется resize партиции, RAID и файловой системы вручную (`parted resizepart`, + `mdadm --grow`, `xfs_growfs`). +- **Реальная доступная ёмкость меньше заявленных 1000 ГБ** из-за разницы GB/GiB и служебных данных + файловой системы. В `k8s_ai/k8s/ollama/pvc-models.yaml` `hostPath`/`PVC` объявляют `900Gi` — + безопасный запас под это расхождение (это не enforced-лимит, просто декларативное число). + +## Диагностика + +```bash +# Статус RAID-массива +cat /proc/mdstat +mdadm --detail /dev/md1 + +# Точка монтирования +lsblk -f +df -h /mnt/ollama-models + +# Если массив не поднялся после перезагрузки — проверить запись в mdadm.conf +grep md1 /etc/mdadm.conf +``` + +Если `/mnt/ollama-models` не смонтирован после перезагрузки — массив собран (`mdadm --detail` +показывает `active`), но запись в `/etc/fstab` (созданная `ansible.posix.mount`) использует +`nofail`, поэтому загрузка ОС не блокируется — нужно смонтировать вручную (`mount -a`) и +разобраться, почему массив не успел подняться до попытки монтирования при следующем прогоне роли. diff --git a/roles/gpu_model_storage/defaults/main.yml b/roles/gpu_model_storage/defaults/main.yml new file mode 100644 index 0000000..2a0a9db --- /dev/null +++ b/roles/gpu_model_storage/defaults/main.yml @@ -0,0 +1,10 @@ +--- +# Dedicated local RAID1 partition for large model files (Ollama etc.) on GPU +# worker nodes. Carved out of otherwise-unpartitioned space on the same +# mirrored disks used for the OS. Empty by default — override per host/group +# (see group_vars/gpu_workers.yml) to enable. +gpu_model_storage_devices: [] +gpu_model_storage_partition_size_gb: 1000 +gpu_model_storage_raid_device: /dev/md1 +gpu_model_storage_mountpoint: /mnt/ollama-models +gpu_model_storage_fstype: xfs diff --git a/roles/gpu_model_storage/meta/main.yml b/roles/gpu_model_storage/meta/main.yml new file mode 100644 index 0000000..1932cca --- /dev/null +++ b/roles/gpu_model_storage/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Dedicated local RAID1 partition + filesystem for large model files (Ollama) on GPU worker nodes + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/gpu_model_storage/tasks/filesystem.yml b/roles/gpu_model_storage/tasks/filesystem.yml new file mode 100644 index 0000000..00c298f --- /dev/null +++ b/roles/gpu_model_storage/tasks/filesystem.yml @@ -0,0 +1,5 @@ +--- +- name: Filesystem | Format RAID array + community.general.filesystem: + fstype: "{{ gpu_model_storage_fstype }}" + dev: "{{ gpu_model_storage_raid_device }}" diff --git a/roles/gpu_model_storage/tasks/main.yml b/roles/gpu_model_storage/tasks/main.yml new file mode 100644 index 0000000..72ee896 --- /dev/null +++ b/roles/gpu_model_storage/tasks/main.yml @@ -0,0 +1,16 @@ +--- +- name: Partition + ansible.builtin.include_tasks: partition.yml + when: gpu_model_storage_devices | length > 0 + +- name: RAID + ansible.builtin.include_tasks: raid.yml + when: gpu_model_storage_devices | length > 0 + +- name: Filesystem + ansible.builtin.include_tasks: filesystem.yml + when: gpu_model_storage_devices | length > 0 + +- name: Mount + ansible.builtin.include_tasks: mount.yml + when: gpu_model_storage_devices | length > 0 diff --git a/roles/gpu_model_storage/tasks/mount.yml b/roles/gpu_model_storage/tasks/mount.yml new file mode 100644 index 0000000..71fc758 --- /dev/null +++ b/roles/gpu_model_storage/tasks/mount.yml @@ -0,0 +1,19 @@ +--- +- name: Mount | Create mountpoint directory + ansible.builtin.file: + path: "{{ gpu_model_storage_mountpoint }}" + state: directory + mode: "0755" + +- name: Mount | Get UUID of RAID array + ansible.builtin.command: "blkid -s UUID -o value {{ gpu_model_storage_raid_device }}" + register: _md_uuid + changed_when: false + +- name: Mount | Mount persistently (UUID, nofail) + ansible.posix.mount: + path: "{{ gpu_model_storage_mountpoint }}" + src: "UUID={{ _md_uuid.stdout }}" + fstype: "{{ gpu_model_storage_fstype }}" + opts: defaults,nofail + state: mounted diff --git a/roles/gpu_model_storage/tasks/partition.yml b/roles/gpu_model_storage/tasks/partition.yml new file mode 100644 index 0000000..8eeb3da --- /dev/null +++ b/roles/gpu_model_storage/tasks/partition.yml @@ -0,0 +1,17 @@ +--- +# The existing OS RAID1 partitions (boot, boot_efi, root) occupy the first +# ~34GB of each mirrored disk. Partition 5 uses the free space right after +# that, sized by gpu_model_storage_partition_size_gb, on every device listed +# in gpu_model_storage_devices — the partitions are then mirrored into a new +# mdadm RAID1 array in raid.yml. +- name: Partition | Create model storage partition (raid member) on {{ item }} + community.general.parted: + device: "{{ item }}" + number: 5 + state: present + part_start: "34GB" + part_end: "{{ 34 + gpu_model_storage_partition_size_gb }}GB" + flags: [raid] + loop: "{{ gpu_model_storage_devices }}" + loop_control: + label: "{{ item }}" diff --git a/roles/gpu_model_storage/tasks/raid.yml b/roles/gpu_model_storage/tasks/raid.yml new file mode 100644 index 0000000..3d7656d --- /dev/null +++ b/roles/gpu_model_storage/tasks/raid.yml @@ -0,0 +1,28 @@ +--- +- name: RAID | Check whether array already exists + ansible.builtin.command: "mdadm --detail {{ gpu_model_storage_raid_device }}" + register: _md_detail + changed_when: false + failed_when: false + +- name: RAID | Create RAID1 array from partition 5 on each device + ansible.builtin.command: > + mdadm --create {{ gpu_model_storage_raid_device }} + --level=1 --raid-devices={{ gpu_model_storage_devices | length }} + --metadata=1.2 --run + {{ gpu_model_storage_devices | map('regex_replace', '$', '5') | join(' ') }} + when: _md_detail.rc != 0 + +- name: RAID | Read current mdadm scan output + ansible.builtin.command: mdadm --detail --scan + register: _mdadm_scan + changed_when: false + +- name: RAID | Persist array definition in /etc/mdadm.conf + ansible.builtin.lineinfile: + path: /etc/mdadm.conf + line: "{{ item }}" + regexp: "^ARRAY {{ gpu_model_storage_raid_device }} " + loop: "{{ _mdadm_scan.stdout_lines | select('search', gpu_model_storage_raid_device) | list }}" + loop_control: + label: "{{ gpu_model_storage_raid_device }}" diff --git a/roles/gpu_prereqs/defaults/main.yml b/roles/gpu_prereqs/defaults/main.yml new file mode 100644 index 0000000..476b14d --- /dev/null +++ b/roles/gpu_prereqs/defaults/main.yml @@ -0,0 +1,3 @@ +--- +nvidia_container_toolkit_version: "" # empty = latest +nvidia_container_toolkit_repo_url: "https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo" diff --git a/roles/gpu_prereqs/handlers/main.yml b/roles/gpu_prereqs/handlers/main.yml new file mode 100644 index 0000000..be9142a --- /dev/null +++ b/roles/gpu_prereqs/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart containerd + ansible.builtin.systemd: + name: containerd + state: restarted + daemon_reload: true diff --git a/roles/gpu_prereqs/meta/main.yml b/roles/gpu_prereqs/meta/main.yml new file mode 100644 index 0000000..3fe9828 --- /dev/null +++ b/roles/gpu_prereqs/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: NVIDIA GPU prerequisites for a Kubernetes worker (Rocky Linux 9) + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/gpu_prereqs/tasks/containerd.yml b/roles/gpu_prereqs/tasks/containerd.yml new file mode 100644 index 0000000..52132f5 --- /dev/null +++ b/roles/gpu_prereqs/tasks/containerd.yml @@ -0,0 +1,19 @@ +--- +# Must run after roles/k8s_worker has generated /etc/containerd/config.toml — +# k8s_worker regenerates that file from scratch and would wipe this out if it +# ran afterwards. Run setup_gpu.yml only after setup_worker_plane.yml. +- name: Containerd | Check for nvidia runtime section + ansible.builtin.command: grep -q 'runtimes.nvidia' /etc/containerd/config.toml + register: _nvidia_runtime_present + changed_when: false + failed_when: false + +- name: Containerd | Configure nvidia runtime + ansible.builtin.command: nvidia-ctk runtime configure --runtime=containerd + when: _nvidia_runtime_present.rc != 0 + notify: Restart containerd + +- name: Containerd | Set nvidia as default runtime + ansible.builtin.command: nvidia-ctk runtime configure --runtime=containerd --set-as-default + when: _nvidia_runtime_present.rc != 0 + notify: Restart containerd diff --git a/roles/gpu_prereqs/tasks/driver_check.yml b/roles/gpu_prereqs/tasks/driver_check.yml new file mode 100644 index 0000000..6dcb0f6 --- /dev/null +++ b/roles/gpu_prereqs/tasks/driver_check.yml @@ -0,0 +1,15 @@ +--- +# Driver installation is out of scope for this role — Ansible only configures +# the container runtime on top of a driver that is already present on the host. +- name: Driver check | Look up nvidia-smi + ansible.builtin.command: which nvidia-smi + register: _nvidia_smi + changed_when: false + failed_when: false + +- name: Driver check | Fail if NVIDIA driver is not installed + ansible.builtin.fail: + msg: >- + nvidia-smi not found on {{ inventory_hostname }}. Install the NVIDIA driver + manually before running this role — it is not managed by Ansible. + when: _nvidia_smi.rc != 0 diff --git a/roles/gpu_prereqs/tasks/main.yml b/roles/gpu_prereqs/tasks/main.yml new file mode 100644 index 0000000..295862a --- /dev/null +++ b/roles/gpu_prereqs/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: Verify NVIDIA driver + ansible.builtin.include_tasks: driver_check.yml + +- name: NVIDIA Container Toolkit + ansible.builtin.include_tasks: toolkit.yml + +- name: Containerd GPU runtime + ansible.builtin.include_tasks: containerd.yml diff --git a/roles/gpu_prereqs/tasks/toolkit.yml b/roles/gpu_prereqs/tasks/toolkit.yml new file mode 100644 index 0000000..e17be4f --- /dev/null +++ b/roles/gpu_prereqs/tasks/toolkit.yml @@ -0,0 +1,12 @@ +--- +- name: Toolkit | Add NVIDIA Container Toolkit repo + ansible.builtin.get_url: + url: "{{ nvidia_container_toolkit_repo_url }}" + dest: /etc/yum.repos.d/nvidia-container-toolkit.repo + mode: "0644" + +- name: Toolkit | Install nvidia-container-toolkit + ansible.builtin.dnf: + name: "{{ 'nvidia-container-toolkit-' + nvidia_container_toolkit_version if nvidia_container_toolkit_version != '' else 'nvidia-container-toolkit' }}" + state: present + update_cache: true diff --git a/roles/k8s_control_plane/defaults/main.yml b/roles/k8s_control_plane/defaults/main.yml new file mode 100644 index 0000000..fc97abf --- /dev/null +++ b/roles/k8s_control_plane/defaults/main.yml @@ -0,0 +1,24 @@ +--- +# Kubernetes version (major.minor) — used for repo URL and kubeadm +k8s_version: "1.33" + +# Container runtime +containerd_version: "" # empty = latest from Docker CE repo + +# Network settings +pod_network_cidr: "10.244.0.0/16" # Flannel default +service_cidr: "10.96.0.0/12" # kubeadm default +apiserver_advertise_address: "{{ ansible_host }}" + +# CNI plugin: flannel | calico +cni_plugin: "flannel" + +# Fix #6: pinned CNI versions — bump explicitly when upgrading +flannel_version: "v0.26.1" +calico_version: "v3.27.0" + +# Path where kubeconfig is placed for the ansible_user on the control plane +kubeconfig_path: "/home/{{ ansible_user }}/.kube/config" + +# Copy kubeconfig to manager nodes after init +kubeconfig_fetch_to_managers: true diff --git a/roles/k8s_control_plane/handlers/main.yml b/roles/k8s_control_plane/handlers/main.yml new file mode 100644 index 0000000..a2bccca --- /dev/null +++ b/roles/k8s_control_plane/handlers/main.yml @@ -0,0 +1,17 @@ +--- +- name: restart containerd + ansible.builtin.systemd: + name: containerd + state: restarted + daemon_reload: true + +- name: restart kubelet + ansible.builtin.systemd: + name: kubelet + state: restarted + daemon_reload: true + +- name: reload firewalld + ansible.builtin.systemd: + name: firewalld + state: reloaded diff --git a/roles/k8s_control_plane/meta/main.yml b/roles/k8s_control_plane/meta/main.yml new file mode 100644 index 0000000..adf381b --- /dev/null +++ b/roles/k8s_control_plane/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Kubernetes control plane node (single master) on Rocky Linux 9 + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/k8s_control_plane/tasks/containerd.yml b/roles/k8s_control_plane/tasks/containerd.yml new file mode 100644 index 0000000..2d49e0c --- /dev/null +++ b/roles/k8s_control_plane/tasks/containerd.yml @@ -0,0 +1,57 @@ +--- +- name: Containerd | Add Docker CE repo + ansible.builtin.get_url: + url: https://download.docker.com/linux/rhel/docker-ce.repo + dest: /etc/yum.repos.d/docker-ce.repo + mode: "0644" + +- name: Containerd | Disable conflicting container-tools module + ansible.builtin.command: dnf module disable container-tools -y + changed_when: false + failed_when: false + +- name: Containerd | Install containerd.io + ansible.builtin.dnf: + name: "{{ 'containerd.io-' + containerd_version if containerd_version != '' else 'containerd.io' }}" + state: present + update_cache: true + disablerepo: kubernetes + +- name: Containerd | Ensure runc is available at path expected by containerd + ansible.builtin.shell: | + RUNC_BIN=$(which runc 2>/dev/null || echo "") + if [ -n "$RUNC_BIN" ] && [ ! -f /usr/local/bin/runc ]; then + ln -sf "$RUNC_BIN" /usr/local/bin/runc + fi + changed_when: false + +- name: Containerd | Remove default config + ansible.builtin.file: + dest: /etc/containerd/config.toml + state: absent + +- name: Containerd | Generate default config + ansible.builtin.shell: containerd config default > /etc/containerd/config.toml + args: + creates: /etc/containerd/config.toml + +- name: Containerd | Enable SystemdCgroup + ansible.builtin.replace: + path: /etc/containerd/config.toml + regexp: 'SystemdCgroup = false' + replace: 'SystemdCgroup = true' + notify: restart containerd + +- name: Containerd | Enable SystemdCgroup + ansible.builtin.replace: + path: /etc/containerd/config.toml + regexp: 'disabled_plugins = ["cri"]' + replace: 'disabled_plugins = []' + notify: restart containerd + +- name: Containerd | Enable and start service + ansible.builtin.systemd: + name: containerd + enabled: true + state: started + daemon_reload: true diff --git a/roles/k8s_control_plane/tasks/kubeadm_init.yml b/roles/k8s_control_plane/tasks/kubeadm_init.yml new file mode 100644 index 0000000..82aa1c5 --- /dev/null +++ b/roles/k8s_control_plane/tasks/kubeadm_init.yml @@ -0,0 +1,133 @@ +--- +- name: kubeadm init | Get full kubeadm version + ansible.builtin.command: kubeadm version -o short + register: _kubeadm_full_version + changed_when: false + +- name: kubeadm init | Check if cluster already initialized + ansible.builtin.stat: + path: /etc/kubernetes/admin.conf + register: _admin_conf + +- name: kubeadm init | Write kubeadm config + ansible.builtin.template: + src: kubeadm_config.yml.j2 + dest: /tmp/kubeadm_config.yml + mode: "0600" + when: not _admin_conf.stat.exists + +- name: kubeadm init | Initialize cluster + ansible.builtin.command: > + kubeadm init + --config /tmp/kubeadm_config.yml + --upload-certs + when: not _admin_conf.stat.exists + register: _kubeadm_init + changed_when: true + +- name: kubeadm init | Ensure .kube dir for root + ansible.builtin.file: + path: /root/.kube + state: directory + mode: "0700" + +- name: kubeadm init | Copy admin.conf for root + ansible.builtin.copy: + src: /etc/kubernetes/admin.conf + dest: /root/.kube/config + remote_src: true + mode: "0600" + +- name: kubeadm init | Ensure .kube dir for ansible_user + ansible.builtin.file: + path: "/home/{{ ansible_user }}/.kube" + state: directory + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0700" + +- name: kubeadm init | Copy admin.conf for ansible_user + ansible.builtin.copy: + src: /etc/kubernetes/admin.conf + dest: "{{ kubeconfig_path }}" + remote_src: true + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0600" + +# Fix #6: pinned Flannel version — change flannel_version in defaults to upgrade +- name: kubeadm init | Install Flannel CNI + ansible.builtin.command: > + kubectl apply -f + https://github.com/flannel-io/flannel/releases/download/{{ flannel_version }}/kube-flannel.yml + environment: + KUBECONFIG: /etc/kubernetes/admin.conf + when: + - cni_plugin == "flannel" + - not _admin_conf.stat.exists + changed_when: true + +- name: kubeadm init | Install Calico CNI + ansible.builtin.command: > + kubectl apply -f + https://raw.githubusercontent.com/projectcalico/calico/{{ calico_version }}/manifests/calico.yaml + environment: + KUBECONFIG: /etc/kubernetes/admin.conf + when: + - cni_plugin == "calico" + - not _admin_conf.stat.exists + changed_when: true + +# single-node cluster — remove taint so workloads can schedule on master +- name: kubeadm init | Remove control-plane taint (single-node) + ansible.builtin.command: > + kubectl taint nodes {{ inventory_hostname }} + node-role.kubernetes.io/control-plane:NoSchedule- + environment: + KUBECONFIG: /etc/kubernetes/admin.conf + when: not _admin_conf.stat.exists + failed_when: false + changed_when: true + +# Fix #5: save join command so worker nodes can be added later (tokens expire in 24h) +- name: kubeadm init | Save worker join command + ansible.builtin.shell: > + kubeadm token create --print-join-command + register: _join_command + changed_when: false + +- name: kubeadm init | Write join command to file + ansible.builtin.copy: + content: "{{ _join_command.stdout }}\n" + dest: /etc/kubernetes/worker_join_command.sh + mode: "0600" + +# Fix #3: create .kube dir on managers before copying kubeconfig (race condition) +- name: kubeadm init | Ensure .kube dir on manager nodes + ansible.builtin.file: + path: "/home/{{ ansible_user }}/.kube" + state: directory + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0700" + delegate_to: "{{ item }}" + loop: "{{ groups['manager_nodes'] }}" + when: kubeconfig_fetch_to_managers | bool + +- name: kubeadm init | Fetch kubeconfig to controller + ansible.builtin.fetch: + src: /etc/kubernetes/admin.conf + dest: "/tmp/k8s_admin_{{ inventory_hostname }}.conf" + flat: true + when: kubeconfig_fetch_to_managers | bool + +- name: kubeadm init | Distribute kubeconfig to manager nodes + ansible.builtin.copy: + src: "/tmp/k8s_admin_{{ inventory_hostname }}.conf" + dest: "/home/{{ ansible_user }}/.kube/config" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0600" + delegate_to: "{{ item }}" + loop: "{{ groups['manager_nodes'] }}" + when: kubeconfig_fetch_to_managers | bool diff --git a/roles/k8s_control_plane/tasks/kubernetes.yml b/roles/k8s_control_plane/tasks/kubernetes.yml new file mode 100644 index 0000000..70440ad --- /dev/null +++ b/roles/k8s_control_plane/tasks/kubernetes.yml @@ -0,0 +1,34 @@ +--- +- name: Kubernetes | Add repo + ansible.builtin.yum_repository: + name: kubernetes + description: Kubernetes + baseurl: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/" + enabled: true + gpgcheck: true + gpgkey: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/repodata/repomd.xml.key" + exclude: "kubelet kubeadm kubectl cri-tools kubernetes-cni" + timeout: "300" + +- name: Kubernetes | Set a low but nonzero minrate so a truly stalled CDN connection times out + community.general.ini_file: + path: /etc/yum.repos.d/kubernetes.repo + section: kubernetes + option: minrate + value: "1000" + mode: "0644" + +- name: Kubernetes | Install kubelet, kubeadm, kubectl + ansible.builtin.dnf: + name: + - kubelet + - kubeadm + - kubectl + state: present + disable_excludes: kubernetes + +- name: Kubernetes | Enable kubelet (kubeadm will start it) + ansible.builtin.systemd: + name: kubelet + enabled: true + daemon_reload: true diff --git a/roles/k8s_control_plane/tasks/main.yml b/roles/k8s_control_plane/tasks/main.yml new file mode 100644 index 0000000..57d08a1 --- /dev/null +++ b/roles/k8s_control_plane/tasks/main.yml @@ -0,0 +1,12 @@ +--- +- name: Prerequisites + ansible.builtin.include_tasks: prerequisites.yml + +- name: Containerd + ansible.builtin.include_tasks: containerd.yml + +- name: Kubernetes packages + ansible.builtin.include_tasks: kubernetes.yml + +- name: Cluster init + ansible.builtin.include_tasks: kubeadm_init.yml diff --git a/roles/k8s_control_plane/tasks/prerequisites.yml b/roles/k8s_control_plane/tasks/prerequisites.yml new file mode 100644 index 0000000..d3d10d0 --- /dev/null +++ b/roles/k8s_control_plane/tasks/prerequisites.yml @@ -0,0 +1,104 @@ +--- +- name: Prerequisites | Set hostname + ansible.builtin.hostname: + name: "{{ inventory_hostname }}" + +- name: Prerequisites | Add hostname to /etc/hosts + ansible.builtin.lineinfile: + path: /etc/hosts + line: "{{ ansible_host }} {{ inventory_hostname }}" + regexp: ".*{{ inventory_hostname }}$" + state: present + +- name: Prerequisites | Disable swap permanently + ansible.builtin.replace: + path: /etc/fstab + regexp: '^([^#].*\s+swap\s+.*)$' + replace: '# \1' + +- name: Prerequisites | Disable swap now + ansible.builtin.command: swapoff -a + changed_when: false + +- name: Prerequisites | Set SELinux to permissive + ansible.posix.selinux: + policy: targeted + state: permissive + +- name: Prerequisites | Load kernel modules (persistent) + ansible.builtin.copy: + dest: /etc/modules-load.d/k8s.conf + content: | + overlay + br_netfilter + mode: "0644" + +- name: Prerequisites | Load kernel modules now + community.general.modprobe: + name: "{{ item }}" + state: present + loop: + - overlay + - br_netfilter + +- name: Prerequisites | Set sysctl params + ansible.posix.sysctl: + name: "{{ item.key }}" + value: "{{ item.value }}" + sysctl_file: /etc/sysctl.d/k8s.conf + reload: true + loop: + - { key: "net.bridge.bridge-nf-call-iptables", value: "1" } + - { key: "net.bridge.bridge-nf-call-ip6tables", value: "1" } + - { key: "net.ipv4.ip_forward", value: "1" } + +# Fix #9: ensure firewalld is running before opening ports +- name: Prerequisites | Ensure firewalld is running + ansible.builtin.systemd: + name: firewalld + state: started + enabled: true + daemon_reload: true + +- name: Prerequisites | Open control plane firewall ports + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - 6443/tcp # kube-apiserver + - 2379-2380/tcp # etcd + - 10250/tcp # kubelet API + - 10251/tcp # kube-scheduler + - 10252/tcp # kube-controller-manager + - 10257/tcp # kube-controller-manager (secure) + - 10259/tcp # kube-scheduler (secure) + - 8472/udp # Flannel VXLAN (fix #4: required for pod-to-pod traffic across nodes) + +- name: Prerequisites | Trust overlay interfaces in firewalld + ansible.posix.firewalld: + interface: "{{ item }}" + zone: trusted + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - flannel.1 + - cni0 + +# Interface-based rules above only work when the interface already exists at +# firewalld reload time. Flannel creates flannel.1/cni0 dynamically (no +# NetworkManager integration), so they may not be in the trusted zone after +# a fresh boot. Source-based CIDR rules are reliable regardless of interface +# lifecycle and cover all pod-to-pod and service traffic. +- name: Prerequisites | Trust pod and service CIDRs in firewalld + ansible.posix.firewalld: + source: "{{ item }}" + zone: trusted + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - "{{ pod_network_cidr }}" + - "{{ service_cidr }}" diff --git a/roles/k8s_control_plane/templates/kubeadm_config.yml.j2 b/roles/k8s_control_plane/templates/kubeadm_config.yml.j2 new file mode 100644 index 0000000..f9af891 --- /dev/null +++ b/roles/k8s_control_plane/templates/kubeadm_config.yml.j2 @@ -0,0 +1,20 @@ +--- +apiVersion: kubeadm.k8s.io/v1beta4 +kind: InitConfiguration +localAPIEndpoint: + advertiseAddress: "{{ apiserver_advertise_address }}" + bindPort: 6443 +nodeRegistration: + criSocket: unix:///run/containerd/containerd.sock + name: "{{ inventory_hostname }}" +--- +apiVersion: kubeadm.k8s.io/v1beta4 +kind: ClusterConfiguration +kubernetesVersion: "{{ _kubeadm_full_version.stdout | trim }}" +networking: + podSubnet: "{{ pod_network_cidr }}" + serviceSubnet: "{{ service_cidr }}" +--- +apiVersion: kubelet.config.k8s.io/v1beta1 +kind: KubeletConfiguration +cgroupDriver: systemd diff --git a/roles/k8s_gbm_dev/defaults/main.yml b/roles/k8s_gbm_dev/defaults/main.yml new file mode 100644 index 0000000..e9b05f6 --- /dev/null +++ b/roles/k8s_gbm_dev/defaults/main.yml @@ -0,0 +1,18 @@ +--- +dev_access_namespace: gigacom-billing-mobile +dev_access_sa_name: developer +dev_access_role_name: developer-role +dev_access_rolebinding_name: developer-rolebinding +dev_access_token_secret_name: developer-token +dev_access_token_file: "/home/{{ ansible_user }}/.kube/dev-token-{{ dev_access_namespace }}" + +dev_access_kubeconfig: "/home/{{ ansible_user }}/.kube/config" + +# Verbs granted on namespace resources +dev_access_verbs: + - get + - list + - watch + +# Whether to also allow exec/portforward/logs (useful for debugging) +dev_access_allow_exec: true diff --git a/roles/k8s_gbm_dev/tasks/main.yml b/roles/k8s_gbm_dev/tasks/main.yml new file mode 100644 index 0000000..679e827 --- /dev/null +++ b/roles/k8s_gbm_dev/tasks/main.yml @@ -0,0 +1,16 @@ +--- +- name: DevAccess | Check kubeconfig exists + ansible.builtin.stat: + path: "{{ dev_access_kubeconfig }}" + register: _kubeconfig_dev + +- name: DevAccess | Fail if kubeconfig missing + ansible.builtin.fail: + msg: "kubeconfig not found at {{ dev_access_kubeconfig }}" + when: not _kubeconfig_dev.stat.exists + +- name: DevAccess | Apply RBAC + ansible.builtin.include_tasks: rbac.yml + +- name: DevAccess | Create token + ansible.builtin.include_tasks: token.yml diff --git a/roles/k8s_gbm_dev/tasks/rbac.yml b/roles/k8s_gbm_dev/tasks/rbac.yml new file mode 100644 index 0000000..94e6f82 --- /dev/null +++ b/roles/k8s_gbm_dev/tasks/rbac.yml @@ -0,0 +1,57 @@ +--- +- name: DevAccess | Render RBAC manifests + ansible.builtin.template: + src: "{{ item.src }}" + dest: "/tmp/{{ item.dest }}" + mode: "0600" + loop: + - { src: dev-serviceaccount.yml.j2, dest: dev-sa.yml } + - { src: dev-role.yml.j2, dest: dev-role.yml } + - { src: dev-rolebinding.yml.j2, dest: dev-rolebinding.yml } + - { src: dev-clusterrole.yml.j2, dest: dev-clusterrole.yml } + - { src: dev-clusterrolebinding.yml.j2, dest: dev-clusterrolebinding.yml } + +- name: DevAccess | Apply ServiceAccount + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dev-sa.yml + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _dev_sa + changed_when: "'created' in _dev_sa.stdout or 'configured' in _dev_sa.stdout" + +- name: DevAccess | Apply Role + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dev-role.yml + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _dev_role + changed_when: "'created' in _dev_role.stdout or 'configured' in _dev_role.stdout" + +- name: DevAccess | Apply RoleBinding + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dev-rolebinding.yml + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _dev_rb + changed_when: "'created' in _dev_rb.stdout or 'configured' in _dev_rb.stdout" + +- name: DevAccess | Apply ClusterRole (namespace reader) + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dev-clusterrole.yml + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _dev_cr + changed_when: "'created' in _dev_cr.stdout or 'configured' in _dev_cr.stdout" + +- name: DevAccess | Apply ClusterRoleBinding (namespace reader) + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dev-clusterrolebinding.yml + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _dev_crb + changed_when: "'created' in _dev_crb.stdout or 'configured' in _dev_crb.stdout" diff --git a/roles/k8s_gbm_dev/tasks/token.yml b/roles/k8s_gbm_dev/tasks/token.yml new file mode 100644 index 0000000..be530f7 --- /dev/null +++ b/roles/k8s_gbm_dev/tasks/token.yml @@ -0,0 +1,54 @@ +--- +- name: DevAccess | Render token Secret manifest + ansible.builtin.template: + src: dev-token-secret.yml.j2 + dest: /tmp/dev-token-secret.yml + mode: "0600" + +- name: DevAccess | Apply token Secret + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dev-token-secret.yml + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _dev_secret + changed_when: "'created' in _dev_secret.stdout or 'configured' in _dev_secret.stdout" + +- name: DevAccess | Wait for token to be populated + ansible.builtin.command: > + kubectl get secret {{ dev_access_token_secret_name }} + -n {{ dev_access_namespace }} + -o jsonpath='{.data.token}' + environment: + KUBECONFIG: "{{ dev_access_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _token_raw + retries: 10 + delay: 3 + until: _token_raw.stdout | length > 0 + changed_when: false + +- name: DevAccess | Decode token + ansible.builtin.set_fact: + _dev_token: "{{ _token_raw.stdout | b64decode }}" + +- name: DevAccess | Save token to file + ansible.builtin.copy: + content: "{{ _dev_token }}\n" + dest: "{{ dev_access_token_file }}" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0600" + +- name: DevAccess | Show token info + ansible.builtin.debug: + msg: | + ============================================================ + Developer token for namespace: {{ dev_access_namespace }} + ServiceAccount: {{ dev_access_sa_name }} + Token saved to: {{ dev_access_token_file }} + View token: cat {{ dev_access_token_file }} + + Dashboard URL: http://10.203.0.96:10001 + Login: paste token from the file above + ============================================================ diff --git a/roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2 b/roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2 new file mode 100644 index 0000000..1edb3fc --- /dev/null +++ b/roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2 @@ -0,0 +1,11 @@ +--- +# Minimal cluster-level read for Dashboard namespace selector. +# Without namespaces/list the Dashboard shows nothing after login. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ dev_access_sa_name }}-namespace-reader +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] diff --git a/roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2 b/roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2 new file mode 100644 index 0000000..82f0d09 --- /dev/null +++ b/roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ dev_access_sa_name }}-namespace-reader +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ dev_access_sa_name }}-namespace-reader +subjects: + - kind: ServiceAccount + name: {{ dev_access_sa_name }} + namespace: {{ dev_access_namespace }} diff --git a/roles/k8s_gbm_dev/templates/dev-role.yml.j2 b/roles/k8s_gbm_dev/templates/dev-role.yml.j2 new file mode 100644 index 0000000..d50fcd5 --- /dev/null +++ b/roles/k8s_gbm_dev/templates/dev-role.yml.j2 @@ -0,0 +1,63 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ dev_access_role_name }} + namespace: {{ dev_access_namespace }} +rules: + # Core workload resources — read + - apiGroups: [""] + resources: + - pods + - services + - endpoints + - configmaps + - persistentvolumeclaims + - events + - replicationcontrollers + verbs: {{ dev_access_verbs | to_json }} + # Logs — always readable + - apiGroups: [""] + resources: + - pods/log + verbs: ["get", "list", "watch"] +{% if dev_access_allow_exec | bool %} + # Exec and port-forward — for debugging + - apiGroups: [""] + resources: + - pods/exec + - pods/portforward + verbs: ["create"] +{% endif %} + # Secrets — read only (developers may need env inspection) + - apiGroups: [""] + resources: + - secrets + verbs: ["get", "list"] + # Apps + - apiGroups: ["apps"] + resources: + - deployments + - replicasets + - statefulsets + - daemonsets + verbs: {{ dev_access_verbs | to_json }} + # Batch + - apiGroups: ["batch"] + resources: + - jobs + - cronjobs + verbs: {{ dev_access_verbs | to_json }} + # Networking + - apiGroups: ["networking.k8s.io"] + resources: + - ingresses + - networkpolicies + verbs: {{ dev_access_verbs | to_json }} + # Traefik CRDs + - apiGroups: ["traefik.io"] + resources: + - ingressroutes + - ingressroutetcps + - middlewares + verbs: {{ dev_access_verbs | to_json }} diff --git a/roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2 b/roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2 new file mode 100644 index 0000000..cf776ee --- /dev/null +++ b/roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2 @@ -0,0 +1,14 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ dev_access_rolebinding_name }} + namespace: {{ dev_access_namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ dev_access_role_name }} +subjects: + - kind: ServiceAccount + name: {{ dev_access_sa_name }} + namespace: {{ dev_access_namespace }} diff --git a/roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2 b/roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2 new file mode 100644 index 0000000..5b9ee31 --- /dev/null +++ b/roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ dev_access_sa_name }} + namespace: {{ dev_access_namespace }} diff --git a/roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2 b/roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2 new file mode 100644 index 0000000..00f71a6 --- /dev/null +++ b/roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2 @@ -0,0 +1,10 @@ +--- +# Long-lived token for ServiceAccount (Kubernetes 1.24+ requires explicit Secret) +apiVersion: v1 +kind: Secret +metadata: + name: {{ dev_access_token_secret_name }} + namespace: {{ dev_access_namespace }} + annotations: + kubernetes.io/service-account.name: {{ dev_access_sa_name }} +type: kubernetes.io/service-account-token diff --git a/roles/k8s_manager/defaults/main.yml b/roles/k8s_manager/defaults/main.yml new file mode 100644 index 0000000..ea19950 --- /dev/null +++ b/roles/k8s_manager/defaults/main.yml @@ -0,0 +1,32 @@ +--- +k8s_manager_timezone: "Europe/Moscow" + +# Fix #8: opt-in full upgrade — set true only when intentionally patching the OS +k8s_manager_upgrade_packages: false + +kubectl_version: "" +helm_version: "" +k9s_version: "" + +k8s_manager_extra_packages: + - bash-completion + - curl + - wget + - git + - vim + - htop + - net-tools + - bind-utils + - jq + - python3 + - python3-pip + +# kubeconfig location on the manager node +kubeconfig_dir: "/home/{{ ansible_user }}/.kube" + +# Kubernetes Dashboard (manifest from GitHub raw — no CDN issues) +dashboard_enabled: true +dashboard_namespace: "kubernetes-dashboard" +dashboard_manifest_url: "https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml" +# Traefik terminates TLS — dashboard serves plain HTTP on port 9090 +dashboard_insecure: false diff --git a/roles/k8s_manager/handlers/main.yml b/roles/k8s_manager/handlers/main.yml new file mode 100644 index 0000000..f420619 --- /dev/null +++ b/roles/k8s_manager/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: reload bashrc + ansible.builtin.command: source /etc/bashrc + args: + executable: /bin/bash + become: false diff --git a/roles/k8s_manager/meta/main.yml b/roles/k8s_manager/meta/main.yml new file mode 100644 index 0000000..07aab0f --- /dev/null +++ b/roles/k8s_manager/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Setup Kubernetes management node on Rocky Linux 9 + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/k8s_manager/tasks/dashboard.yml b/roles/k8s_manager/tasks/dashboard.yml new file mode 100644 index 0000000..e94ff8e --- /dev/null +++ b/roles/k8s_manager/tasks/dashboard.yml @@ -0,0 +1,91 @@ +--- +- name: Dashboard | Check kubeconfig exists + ansible.builtin.stat: + path: "{{ kubeconfig_dir }}/config" + register: _kubeconfig_dash + +- name: Dashboard | Skip notice + ansible.builtin.debug: + msg: "Dashboard install skipped — kubeconfig not found at {{ kubeconfig_dir }}/config." + when: not _kubeconfig_dash.stat.exists + +- name: Dashboard | Deploy and configure + when: + - _kubeconfig_dash.stat.exists + - dashboard_enabled | bool + environment: + KUBECONFIG: "{{ kubeconfig_dir }}/config" + PATH: "/usr/local/bin:/usr/bin:/bin" + block: + - name: Dashboard | Apply manifest + ansible.builtin.command: + cmd: kubectl apply -f {{ dashboard_manifest_url }} + register: _dash_apply + changed_when: "'configured' in _dash_apply.stdout or 'created' in _dash_apply.stdout" + + - name: Dashboard | Patch deployment for insecure HTTP access + ansible.builtin.command: > + kubectl patch deployment kubernetes-dashboard + -n {{ dashboard_namespace }} + --type strategic + -p '{"spec":{"template":{"spec":{"containers":[{"name":"kubernetes-dashboard","args":["--namespace={{ dashboard_namespace }}","--enable-insecure-login","--insecure-port=9090","--port=0"],"ports":[{"containerPort":9090,"protocol":"TCP"}],"livenessProbe":{"httpGet":{"path":"/","port":9090,"scheme":"HTTP"}}}]}}}}' + register: _dash_deploy_patch + changed_when: "'patched' in _dash_deploy_patch.stdout" + failed_when: _dash_deploy_patch.rc != 0 and 'unchanged' not in _dash_deploy_patch.stdout + when: dashboard_insecure | bool + check_mode: false + + - name: Dashboard | Patch service to ClusterIP + ansible.builtin.command: > + kubectl patch svc kubernetes-dashboard + -n {{ dashboard_namespace }} + -p '{"spec":{"type":"ClusterIP","ports":[{"port":443,"targetPort":{{ 9090 if dashboard_insecure | bool else 8443 }},"protocol":"TCP"}]}}' + register: _dash_patch + changed_when: "'patched' in _dash_patch.stdout" + failed_when: _dash_patch.rc != 0 and 'not patched' not in _dash_patch.stdout + check_mode: false + + - name: Dashboard | Apply admin ServiceAccount + ansible.builtin.template: + src: dashboard_admin.yml.j2 + dest: /tmp/dashboard_admin.yml + mode: "0600" + + - name: Dashboard | Create admin user + ansible.builtin.command: + cmd: kubectl apply -f /tmp/dashboard_admin.yml + register: _dash_admin + changed_when: "'created' in _dash_admin.stdout" + + - name: Dashboard | Check if token file exists + ansible.builtin.stat: + path: "/home/{{ ansible_user }}/.kube/dashboard-token" + register: _token_file + + - name: Dashboard | Generate long-lived token + ansible.builtin.command: + cmd: kubectl -n {{ dashboard_namespace }} create token admin-user --duration=8760h + register: _dashboard_token + changed_when: true + when: not _token_file.stat.exists + + - name: Dashboard | Save token to file + ansible.builtin.copy: + content: "{{ _dashboard_token.stdout }}\n" + dest: "/home/{{ ansible_user }}/.kube/dashboard-token" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0600" + when: not _token_file.stat.exists + + - name: Dashboard | Show access info + ansible.builtin.debug: + msg: | + ============================================================ + Kubernetes Dashboard: accessible via Traefik proxy. + {% if dashboard_insecure | bool %} + Mode: HTTP (insecure) — TLS terminated by Traefik + {% endif %} + Token saved to: ~/.kube/dashboard-token + View token: cat ~/.kube/dashboard-token + ============================================================ diff --git a/roles/k8s_manager/tasks/main.yml b/roles/k8s_manager/tasks/main.yml new file mode 100644 index 0000000..85e8ff2 --- /dev/null +++ b/roles/k8s_manager/tasks/main.yml @@ -0,0 +1,133 @@ +--- +- name: System | Set hostname + ansible.builtin.hostname: + name: "{{ inventory_hostname }}" + +- name: System | Set timezone + community.general.timezone: + name: "{{ k8s_manager_timezone }}" + +# Fix #8: explicit full upgrade is opt-in (k8s_manager_upgrade_packages: true) +# to prevent unintended kernel/system updates on prod runs +- name: System | Update cache + ansible.builtin.dnf: + update_cache: true + +- name: System | Upgrade all packages # noqa: package-latest + ansible.builtin.dnf: + name: "*" + state: latest + when: k8s_manager_upgrade_packages | bool + +- name: System | Install extra packages + ansible.builtin.dnf: + name: "{{ k8s_manager_extra_packages }}" + state: present + +- name: kubectl | Get latest stable version + ansible.builtin.uri: + url: https://dl.k8s.io/release/stable.txt + return_content: true + register: _kubectl_latest + when: not kubectl_version + check_mode: false + +- name: kubectl | Set version fact (latest) + ansible.builtin.set_fact: + _kubectl_version: "{{ _kubectl_latest.content | trim }}" + when: not kubectl_version + +- name: kubectl | Set version fact (pinned) + ansible.builtin.set_fact: + _kubectl_version: "{{ kubectl_version }}" + when: kubectl_version + +- name: kubectl | Download binary + ansible.builtin.get_url: + url: "https://dl.k8s.io/release/{{ _kubectl_version }}/bin/linux/amd64/kubectl" + dest: /usr/local/bin/kubectl + mode: "0755" + owner: root + group: root + +- name: kubectl | Enable bash completion + ansible.builtin.shell: /usr/local/bin/kubectl completion bash > /etc/bash_completion.d/kubectl + args: + creates: /etc/bash_completion.d/kubectl + +- name: helm | Install EPEL repository + ansible.builtin.dnf: + name: epel-release + state: present + +- name: helm | Install via DNF + ansible.builtin.dnf: + name: helm + state: present + +- name: helm | Enable bash completion + ansible.builtin.shell: helm completion bash > /etc/bash_completion.d/helm + args: + creates: /etc/bash_completion.d/helm + +- name: k9s | Install via DNF + ansible.builtin.dnf: + name: "https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.rpm" + state: present + disable_gpg_check: true + + +- name: k9s | Set permissions + ansible.builtin.file: + path: /usr/local/bin/k9s + mode: "0755" + owner: root + group: root + +- name: kubectx/kubens | Download kubectx + ansible.builtin.get_url: + url: https://raw.githubusercontent.com/ahmetb/kubectx/master/kubectx + dest: '{{ role_path }}/files/kubectx' + mode: "0755" + delegate_to: localhost + run_once: true + +- name: kubectx/kubens | Download kubens + ansible.builtin.get_url: + url: https://raw.githubusercontent.com/ahmetb/kubectx/master/kubens + dest: '{{ role_path }}/files/kubens' + mode: "0755" + delegate_to: localhost + run_once: true + +- name: kubectx/kubens | Install binaries + ansible.builtin.copy: + src: "{{ role_path }}/files/{{ item }}" + dest: /usr/local/bin/{{ item }} + mode: "0755" + owner: root + group: root + loop: + - kubectx + - kubens + +- name: kubeconfig | Ensure .kube directory exists + ansible.builtin.file: + path: "{{ kubeconfig_dir }}" + state: directory + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0700" + +- name: dashboard | Install Kubernetes Dashboard + ansible.builtin.include_tasks: dashboard.yml + +- name: bash | Add kubectl alias and kubeconfig to profile + ansible.builtin.blockinfile: + path: "/home/{{ ansible_user }}/.bashrc" + marker: "# {mark} ANSIBLE MANAGED — k8s aliases" + block: | + export KUBECONFIG="{{ kubeconfig_dir }}/config" + source /usr/share/bash-completion/bash_completion + alias k=kubectl + complete -o default -F __start_kubectl k diff --git a/roles/k8s_manager/templates/dashboard_admin.yml.j2 b/roles/k8s_manager/templates/dashboard_admin.yml.j2 new file mode 100644 index 0000000..1c4660f --- /dev/null +++ b/roles/k8s_manager/templates/dashboard_admin.yml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: admin-user + namespace: {{ dashboard_namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: admin-user +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: admin-user + namespace: {{ dashboard_namespace }} diff --git a/roles/k8s_worker/defaults/main.yml b/roles/k8s_worker/defaults/main.yml new file mode 100644 index 0000000..e952bad --- /dev/null +++ b/roles/k8s_worker/defaults/main.yml @@ -0,0 +1,6 @@ +--- +k8s_version: "1.33" +containerd_version: "" # empty = latest from Docker CE repo + +# Control plane host to generate the join command from +worker_join_source_host: "{{ groups['control_plane'][0] }}" diff --git a/roles/k8s_worker/handlers/main.yml b/roles/k8s_worker/handlers/main.yml new file mode 100644 index 0000000..3e24ed6 --- /dev/null +++ b/roles/k8s_worker/handlers/main.yml @@ -0,0 +1,12 @@ +--- +- name: Restart containerd + ansible.builtin.systemd: + name: containerd + state: restarted + daemon_reload: true + +- name: Restart kubelet + ansible.builtin.systemd: + name: kubelet + state: restarted + daemon_reload: true diff --git a/roles/k8s_worker/meta/main.yml b/roles/k8s_worker/meta/main.yml new file mode 100644 index 0000000..f6dbd99 --- /dev/null +++ b/roles/k8s_worker/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: ops + description: Kubernetes worker node on Rocky Linux 9 + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" +dependencies: [] diff --git a/roles/k8s_worker/tasks/containerd.yml b/roles/k8s_worker/tasks/containerd.yml new file mode 100644 index 0000000..7ca51a7 --- /dev/null +++ b/roles/k8s_worker/tasks/containerd.yml @@ -0,0 +1,57 @@ +--- +- name: Containerd | Add Docker CE repo + ansible.builtin.get_url: + url: https://download.docker.com/linux/rhel/docker-ce.repo + dest: /etc/yum.repos.d/docker-ce.repo + mode: "0644" + +- name: Containerd | Disable conflicting container-tools module + ansible.builtin.command: dnf module disable container-tools -y + changed_when: false + failed_when: false + +- name: Containerd | Install containerd.io + ansible.builtin.dnf: + name: "{{ 'containerd.io-' + containerd_version if containerd_version != '' else 'containerd.io' }}" + state: present + update_cache: true + disablerepo: kubernetes + +- name: Containerd | Ensure runc is available at path expected by containerd + ansible.builtin.shell: | + RUNC_BIN=$(which runc 2>/dev/null || echo "") + if [ -n "$RUNC_BIN" ] && [ ! -f /usr/local/bin/runc ]; then + ln -sf "$RUNC_BIN" /usr/local/bin/runc + fi + changed_when: false + +- name: Containerd | Remove default config + ansible.builtin.file: + dest: /etc/containerd/config.toml + state: absent + +- name: Containerd | Generate default config + ansible.builtin.shell: containerd config default > /etc/containerd/config.toml + args: + creates: /etc/containerd/config.toml + +- name: Containerd | Enable SystemdCgroup + ansible.builtin.replace: + path: /etc/containerd/config.toml + regexp: 'SystemdCgroup = false' + replace: 'SystemdCgroup = true' + notify: Restart containerd + +- name: Containerd | Enable SystemdCgroup + ansible.builtin.replace: + path: /etc/containerd/config.toml + regexp: 'disabled_plugins = ["cri"]' + replace: 'disabled_plugins = []' + notify: restart containerd + +- name: Containerd | Enable and start service + ansible.builtin.systemd: + name: containerd + enabled: true + state: started + daemon_reload: true diff --git a/roles/k8s_worker/tasks/kubeadm_join.yml b/roles/k8s_worker/tasks/kubeadm_join.yml new file mode 100644 index 0000000..4562d50 --- /dev/null +++ b/roles/k8s_worker/tasks/kubeadm_join.yml @@ -0,0 +1,19 @@ +--- +- name: kubeadm join | Check if node already joined + ansible.builtin.stat: + path: /etc/kubernetes/kubelet.conf + register: _kubelet_conf + +- name: kubeadm join | Generate fresh join command on control plane + ansible.builtin.command: kubeadm token create --print-join-command + delegate_to: "{{ worker_join_source_host }}" + register: _join_command + changed_when: false + when: not _kubelet_conf.stat.exists + +- name: kubeadm join | Execute join command + ansible.builtin.command: "{{ _join_command.stdout | trim }}" + when: not _kubelet_conf.stat.exists + changed_when: true + async: 300 + poll: 10 diff --git a/roles/k8s_worker/tasks/kubernetes.yml b/roles/k8s_worker/tasks/kubernetes.yml new file mode 100644 index 0000000..2ece3df --- /dev/null +++ b/roles/k8s_worker/tasks/kubernetes.yml @@ -0,0 +1,33 @@ +--- +- name: Kubernetes | Add repo + ansible.builtin.yum_repository: + name: kubernetes + description: Kubernetes + baseurl: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/" + enabled: true + gpgcheck: true + gpgkey: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/repodata/repomd.xml.key" + exclude: "kubelet kubeadm kubectl cri-tools kubernetes-cni" + timeout: "300" + +- name: Kubernetes | Set a low but nonzero minrate so a truly stalled CDN connection times out + community.general.ini_file: + path: /etc/yum.repos.d/kubernetes.repo + section: kubernetes + option: minrate + value: "1000" + mode: "0644" + +- name: Kubernetes | Install kubelet and kubeadm + ansible.builtin.dnf: + name: + - kubelet + - kubeadm + state: present + disable_excludes: kubernetes + +- name: Kubernetes | Enable kubelet (kubeadm join will start it) + ansible.builtin.systemd: + name: kubelet + enabled: true + daemon_reload: true diff --git a/roles/k8s_worker/tasks/main.yml b/roles/k8s_worker/tasks/main.yml new file mode 100644 index 0000000..afb4349 --- /dev/null +++ b/roles/k8s_worker/tasks/main.yml @@ -0,0 +1,12 @@ +--- +- name: Prerequisites + ansible.builtin.include_tasks: prerequisites.yml + +- name: Containerd + ansible.builtin.include_tasks: containerd.yml + +- name: Kubernetes packages + ansible.builtin.include_tasks: kubernetes.yml + +- name: Join cluster + ansible.builtin.include_tasks: kubeadm_join.yml diff --git a/roles/k8s_worker/tasks/prerequisites.yml b/roles/k8s_worker/tasks/prerequisites.yml new file mode 100644 index 0000000..e5cc54d --- /dev/null +++ b/roles/k8s_worker/tasks/prerequisites.yml @@ -0,0 +1,94 @@ +--- +- name: Prerequisites | Set hostname + ansible.builtin.hostname: + name: "{{ inventory_hostname }}" + +- name: Prerequisites | Add hostname to /etc/hosts + ansible.builtin.lineinfile: + path: /etc/hosts + line: "{{ ansible_host }} {{ inventory_hostname }}" + regexp: ".*{{ inventory_hostname }}$" + state: present + +- name: Prerequisites | Disable swap permanently + ansible.builtin.replace: + path: /etc/fstab + regexp: '^([^#].*\s+swap\s+.*)$' + replace: '# \1' + +- name: Prerequisites | Disable swap now + ansible.builtin.command: swapoff -a + changed_when: false + +- name: Prerequisites | Set SELinux to permissive + ansible.posix.selinux: + policy: targeted + state: permissive + +- name: Prerequisites | Load kernel modules (persistent) + ansible.builtin.copy: + dest: /etc/modules-load.d/k8s.conf + content: | + overlay + br_netfilter + mode: "0644" + +- name: Prerequisites | Load kernel modules now + community.general.modprobe: + name: "{{ item }}" + state: present + loop: + - overlay + - br_netfilter + +- name: Prerequisites | Set sysctl params + ansible.posix.sysctl: + name: "{{ item.key }}" + value: "{{ item.value }}" + sysctl_file: /etc/sysctl.d/k8s.conf + reload: true + loop: + - { key: "net.bridge.bridge-nf-call-iptables", value: "1" } + - { key: "net.bridge.bridge-nf-call-ip6tables", value: "1" } + - { key: "net.ipv4.ip_forward", value: "1" } + +- name: Prerequisites | Ensure firewalld is running + ansible.builtin.systemd: + name: firewalld + state: started + enabled: true + daemon_reload: true + +- name: Prerequisites | Open worker firewall ports + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - 10250/tcp # kubelet API + - 10256/tcp # kube-proxy healthz + - 30000-32767/tcp # NodePort services + - 8472/udp # Flannel VXLAN + +- name: Prerequisites | Trust overlay interfaces in firewalld + ansible.posix.firewalld: + interface: "{{ item }}" + zone: trusted + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - flannel.1 + - cni0 + +- name: Prerequisites | Trust pod and service CIDRs in firewalld + ansible.posix.firewalld: + source: "{{ item }}" + zone: trusted + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - "{{ pod_network_cidr }}" + - "{{ service_cidr }}" diff --git a/roles/logging/README.md b/roles/logging/README.md new file mode 100644 index 0000000..9031191 --- /dev/null +++ b/roles/logging/README.md @@ -0,0 +1,357 @@ +# Стандарт централизованного сбора логов: PLG + Prometheus + +**Стек:** Loki · Grafana Alloy · Grafana · kube-prometheus-stack +**Namespace:** `monitoring` +**Доступ:** `http://10.203.0.96:10003` (Traefik port 10003) +**Исследование:** [`research/logging/plg.md`](../../research/logging/plg.md) + +--- + +## Архитектура + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ k8s-worker-01 / k8s-master-01 │ +│ [Pod stdout] → /var/log/pods/ ← Alloy: loki.source.k8s │ +│ [journald] → kubelet, containerd ← Alloy: loki.source.journal│ +│ [node-exporter DaemonSet] ← CPU / RAM / диск / сеть │ +│ │ +│ [Grafana Alloy DaemonSet] (namespace: monitoring) │ +└────────────┬──────────────────────────────────────────────────────┘ + │ HTTP push (loki.write) + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Loki single-binary (namespace: monitoring) │ +│ S3 backend: MinIO bucket loki-chunks │ +│ 10 GiB PVC longhorn — только WAL/temp (не логи) │ +│ service/loki:3100 │ +└─────────────────────┬────────────────────────────────────────────┘ + │ S3 API + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ MinIO (namespace: minio) — bucket: loki-chunks │ +│ StorageClass: longhorn-minio (Retain, 1 TiB) │ +│ Пользователь: loki (только bucket loki-chunks) │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ kube-prometheus-stack (namespace: monitoring) │ +│ Prometheus PVC 20 GiB · node-exporter · kube-state-metrics │ +│ ~27 готовых дашбордов кластера → ConfigMaps → Grafana sidecar │ +└─────────────────────┬────────────────────────────────────────────┘ + │ datasource + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Grafana (namespace: monitoring) │ +│ Datasource: Loki (isDefault) + Prometheus (provisioned) │ +│ Sidecar: подхватывает ConfigMaps grafana_dashboard=1 (ALL ns) │ +│ PVC: 5 GiB longhorn │ +│ Traefik → port 10003 → http://10.203.0.96:10003 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Компоненты + +| Компонент | Chart | Версия | Примечание | +|---|---|---|---| +| Loki | `grafana/loki` | 7.0.0 | single-binary, S3 backend, retention 31d | +| Grafana Alloy | `grafana/alloy` | 1.8.2 | DaemonSet; **Promtail EOL с 2026-03-02** | +| kube-prometheus-stack | `prometheus-community/kube-prometheus-stack` | 68.4.4 | Prometheus + node-exporter + kube-state-metrics | +| Grafana | `grafana/grafana` | 10.5.15 | provisioned Loki + Prometheus datasources, sidecar | + +> **Promtail не используется** — объявлен EOL 2 марта 2026. Grafana Alloy — официальная замена. + +--- + +## Предварительные условия + +### 1. Создать namespace и Secret с паролем Grafana + +```bash +kubectl create namespace monitoring + +kubectl create secret generic grafana-admin-secret \ + --from-literal=admin-user=admin \ + --from-literal=admin-password='СИЛЬНЫЙ_ПАРОЛЬ' \ + -n monitoring +``` + +Секрет создаётся **вручную** — пароль не хранится в Ansible-переменных и не попадает в Git. +Если секрет не создать до запуска playbook, задача `Grafana | Assert grafana-admin-secret exists` упадёт с подробным сообщением об ошибке. + +### 2. Задать переменную окружения + +| Переменная | Описание | +|---|---| +| `LOKI_MINIO_PASSWORD` | Пароль пользователя `loki` в MinIO — Ansible создаст пользователя с этим паролем | +| `GRAFANA_ADMIN_PASSWORD` | Не читается Ansible — только для справки при ручном создании Secret выше | + +Пароль `LOKI_MINIO_PASSWORD` можно посмотреть в любой момент: + +```bash +kubectl get secret loki-minio-secret -n monitoring \ + -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d +``` + +--- + +## Запуск + +```bash +# Локально (с заданной переменной окружения) +LOKI_MINIO_PASSWORD='ПАРОЛЬ_LOKI' \ + ansible-playbook -i inventory/prod playbooks/setup_logging.yml + +# Через GitLab CI/CD +# → Pipelines → setup:logging (ручной запуск, ветка main) +``` + +Playbook выполняет задачи в порядке: + +1. Создаёт namespace `monitoring` +2. Создаёт bucket `loki-chunks` в MinIO, пользователя `loki`, политику доступа, Secret `loki-minio-secret` +3. Устанавливает Loki (Helm) +4. Устанавливает Grafana Alloy (Helm) +5. Устанавливает kube-prometheus-stack (Helm) — Prometheus + node-exporter + kube-state-metrics + ~27 дашбордов +6. Устанавливает Grafana (Helm) — проверяет наличие `grafana-admin-secret` перед установкой + +Playbook идемпотентен: повторный запуск пропустит уже выполненные шаги. + +--- + +## Конфигурация + +### Loki: параметры хранения + +Loki работает в режиме **single-binary**. Логи хранятся в MinIO (`loki-chunks`), не на диске. +PVC 10 GiB используется только для WAL и временных файлов компактора. + +```yaml +# roles/logging/defaults/main.yml (переопределять в group_vars при необходимости) +loki_retention_days: 31 # срок хранения логов +loki_wal_storage_size: "10Gi" # PVC только для WAL +loki_wal_storage_class: longhorn +loki_minio_bucket: loki-chunks +``` + +Расчёт объёма для текущего кластера (2 ноды, ~20 подов): +- ~170 MB/day после сжатия Loki (~10:1) +- 31 дней → ~5 GB в bucket `loki-chunks` +- MinIO PVC 1 TiB → запас до ~100 нод + +### Alloy: сбор логов + +Alloy запускается как **DaemonSet** (по одному поду на каждую ноду, включая control plane). + +Что собирает: +- `/var/log/pods/**/*.log` — все контейнеры через `loki.source.kubernetes` +- `journald` — kubelet, containerd, sshd через `loki.source.journal` + +Фактические метки на каждой записи в Loki: + +| Метка | Источник | Описание | +|---|---|---| +| `namespace` | Kubernetes API | namespace пода | +| `pod` | Kubernetes API | имя пода | +| `container` | Kubernetes API | имя контейнера | +| `app` | label пода | значение `app` label | +| `level` | JSON-парсинг | уровень лога (info/warn/error) | +| `job` | константа | `systemd-journal` для journald-логов | +| `node` | journald | hostname ноды (только journald) | +| `unit` | journald | systemd unit (только journald) | +| `stream` | containerd | `stdout` или `stderr` | + +Debug-записи (label `level=debug`) отбрасываются на уровне агента — не попадают в Loki. + +### Prometheus: метрики кластера + +```yaml +# roles/logging/defaults/main.yml +prometheus_stack_chart_version: "68.4.4" +prometheus_storage_size: "20Gi" +prometheus_storage_class: longhorn +prometheus_retention: "30d" +``` + +kube-prometheus-stack устанавливается с отключённой встроенной Grafana (`grafana.enabled: false`) и `forceDeployDashboards: true` — создаёт ~27 ConfigMaps с дашбордами, которые Grafana sidecar подхватывает автоматически. + +> **Rocky Linux 9 / kubeadm gotcha:** `controller-manager` и `scheduler` по умолчанию слушают только `127.0.0.1` → их метрики в Prometheus будут недоступны (таргеты Down). Остальные таргеты работают без изменений. Для исправления нужен ручной патч `/etc/kubernetes/manifests/kube-controller-manager.yaml` и `kube-scheduler.yaml`: замена `--bind-address=127.0.0.1` на `--bind-address=0.0.0.0`. + +### Grafana: доступ и дашборды + +URL: `http://10.203.0.96:10003` +Логин: `admin` / пароль из Secret `grafana-admin-secret` + +Datasources provisioned при старте — не требуют ручной настройки: +- **Loki** — `http://loki.monitoring.svc.cluster.local:3100` (isDefault) +- **Prometheus** — `http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090` + +**Дашборды (загружаются автоматически через sidecar):** + +| Дашборд | Источник | Описание | +|---|---|---| +| **Kubernetes Logs** | ConfigMap `grafana-dashboard-kubernetes-logs` | Фильтрация по namespace / pod / container / level, график интенсивности + поток логов | +| Kubernetes / Compute Resources / Cluster | kube-prometheus-stack | CPU/RAM по namespace | +| Kubernetes / Compute Resources / Node (Pods) | kube-prometheus-stack | Ресурсы по подам на ноде | +| Node Exporter / Nodes | kube-prometheus-stack | CPU, RAM, диски, сеть нод | +| Kubernetes / Persistent Volumes | kube-prometheus-stack | Статус и заполнение PVC | +| Kubernetes / API server | kube-prometheus-stack | Latency, error rate, RPS | +| ...и ещё ~22 дашборда | kube-prometheus-stack | Kubelet, etcd, сеть, workloads | + +> **Дашборды Grafana.com с ID 15141, 13639, 18748 не работают** с Grafana 12.x — используют устаревший API плагина. Не импортировать. + +--- + +## Диагностика + +### Проверка после установки + +```bash +# Статус всех подов стека +kubectl get pod -n monitoring + +# Loki готов (запрос через временный под — в контейнере нет curl/wget) +kubectl run loki-check --image=curlimages/curl --restart=Never -n monitoring \ + -- curl -s http://loki:3100/ready +sleep 5 && kubectl logs -n monitoring loki-check +kubectl delete pod loki-check -n monitoring + +# Метки, которые реально есть в Loki +kubectl run loki-labels --image=curlimages/curl --restart=Never -n monitoring \ + -- curl -s 'http://loki:3100/loki/api/v1/labels' +sleep 5 && kubectl logs -n monitoring loki-labels +kubectl delete pod loki-labels -n monitoring + +# Alloy открыл потоки логов со всех подов (нет level=error) +kubectl logs -n monitoring -l app.kubernetes.io/name=alloy --tail=50 | grep -v "opened log stream" + +# Данные появились в MinIO +kubectl exec -n minio deployment/minio -- \ + mc ls local/loki-chunks --recursive | head -10 +``` + +### Loki CrashLoopBackOff при старте + +**Симптом 1:** `mkdir /loki/compactor: read-only file system` +Причина: неверный путь compactor. Должен быть `/var/loki/compactor`, не `/loki/compactor`. +Проверить в ConfigMap: `kubectl get configmap loki -n monitoring -o jsonpath='{.data.config\.yaml}' | grep working_directory` + +**Симптом 2:** `SignatureDoesNotMatch` или `InvalidAccessKeyId` +Причина: env var expansion `${VAR}` в Loki 3.x не работает для S3 credentials в config-файле. +Решение: пароль передаётся через `--set loki.storage.s3.secret_access_key=...` в helm-команде (реализовано в `tasks/loki.yml`). + +**Симптом 3:** `error running loki` при повторном запуске после CrashLoop + `another operation in progress` +Причина: helm upgrade завис в предыдущей попытке. +```bash +helm rollback loki -n monitoring +# затем повторить helm upgrade +``` + +**Предупреждение `loki-memberlist: no such host`** — некритично для single-binary, можно игнорировать. + +### Loki 500 при запросе через Grafana + +```bash +# Проверить пользователя loki в MinIO +MINIO_POD=$(kubectl get pod -n minio -l app=minio -o jsonpath='{.items[0].metadata.name}') +kubectl exec -n minio $MINIO_POD -- mc admin user info local loki + +# Проверить Secret и его содержимое +kubectl get secret loki-minio-secret -n monitoring +kubectl get secret loki-minio-secret -n monitoring \ + -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d + +# Тест доступа с теми же учётными данными +SECRET=$(kubectl get secret loki-minio-secret -n monitoring \ + -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d) +kubectl exec -n minio $MINIO_POD -- mc alias set lokitest http://localhost:9000 loki "$SECRET" +kubectl exec -n minio $MINIO_POD -- mc ls lokitest/ +``` + +### Alloy не отправляет логи + +```bash +# Ошибки в логах Alloy +kubectl logs -n monitoring -l app.kubernetes.io/name=alloy --tail=100 \ + | grep -i "error\|level=error" + +# Loki endpoint доступен из пода Alloy +kubectl exec -n monitoring -l app.kubernetes.io/name=alloy -c alloy -- \ + wget -qO- http://loki.monitoring.svc.cluster.local:3100/ready 2>&1 +``` + +### Grafana не видит данные (No Data) + +```bash +# Проверить что Loki datasource здоров после рестарта Grafana +kubectl rollout restart deployment/grafana -n monitoring +kubectl rollout status deployment/grafana -n monitoring + +# Проверить UID datasource (нужен для ручного исправления импортированных дашбордов) +GRAFANA_PASS=$(kubectl get secret grafana-admin-secret -n monitoring \ + -o jsonpath='{.data.admin-password}' | base64 -d) +kubectl run gf-check --image=curlimages/curl --restart=Never -n monitoring \ + -- curl -s -u "admin:${GRAFANA_PASS}" http://grafana/api/datasources +sleep 5 && kubectl logs -n monitoring gf-check | python3 -m json.tool | grep -E '"uid"|"name"' +kubectl delete pod gf-check -n monitoring +``` + +> Если Grafana была запущена в момент когда Loki находился в CrashLoop — datasource кэширует нерабочее состояние. Перезапуск пода исправляет. + +### Grafana не открывается + +```bash +# Состояние подов +kubectl get pod -n monitoring -l app.kubernetes.io/name=grafana + +# IngressRoute создан +kubectl get ingressroute route-grafana -n traefik + +# Если IngressRoute отсутствует — перезапустить setup_traefik.yml +# (порт 10003 уже есть в Traefik DaemonSet) +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml +``` + +### MinIO `kubectl cp` завершается ошибкой `tar not found` + +MinIO-контейнер — минимальный образ без `tar`. `kubectl cp` использует `tar` внутри. +Решение: использовать `kubectl exec -i ... -- mc ... /dev/stdin` для передачи файлов. +Уже реализовано в `tasks/minio-user.yml`. + +--- + +## Масштабирование + +### До 5+ нод: Loki distributed mode + +Триггер: объём > 50 GB/day или > 50 одновременных запросов. +Изменить в `loki-values.yml.j2`: `deploymentMode: Distributed` + задать реплики ingester/querier/distributor. +**Данные в MinIO не мигрируют** — bucket `loki-chunks` остаётся тем же. + +### Prometheus: Thanos для long-term storage + +Триггер: retention > 30d или несколько кластеров. +Thanos sidecar к Prometheus pod + remote_write в объектное хранилище. + +### Alloy и node-exporter + +Не требуют изменений — DaemonSet автоматически запускается на новых нодах. + +--- + +## Известные ограничения + +| Проблема | Описание | +|---|---| +| `deploymentMode` обязателен на верхнем уровне | В Loki chart 7.x `deploymentMode: SingleBinary` должен быть вне блока `loki:`, иначе деплоится distributed-режим с 0 репликами | +| Env var expansion не работает для S3 credentials | Loki 3.x не раскрывает `${VAR}` в полях `access_key_id` / `secret_access_key`. Пароль передаётся через `helm --set` | +| Controller-manager / Scheduler метрики недоступны | kubeadm привязывает их к `127.0.0.1`. Требует ручного патча static pod манифестов | +| Дашборды Grafana.com 15141 / 13639 / 18748 сломаны | Несовместимы с Grafana 12.x. Использовать встроенный дашборд **Kubernetes Logs** | + +--- + +## Переход на Graylog + +Alloy поддерживает dual-write: одновременная отправка в Loki и Graylog. Порядок переезда описан в [`research/logging/plg.md`](../../research/logging/plg.md) (раздел 11). + +MinIO остаётся востребованным при Graylog — как S3 snapshot repository для OpenSearch (bucket `backups`). diff --git a/roles/logging/defaults/main.yml b/roles/logging/defaults/main.yml new file mode 100644 index 0000000..6f736c6 --- /dev/null +++ b/roles/logging/defaults/main.yml @@ -0,0 +1,26 @@ +--- +logging_namespace: monitoring +logging_kubeconfig: "/home/{{ ansible_user }}/.kube/config" + +loki_chart_version: "7.0.0" +alloy_chart_version: "1.8.2" +grafana_chart_version: "10.5.15" + +loki_minio_endpoint: "http://minio.minio.svc.cluster.local:9000" +loki_minio_bucket: "loki-chunks" +loki_minio_user: "loki" +loki_minio_secret_name: "loki-minio-secret" + +loki_retention_days: 31 +loki_wal_storage_size: "10Gi" +loki_wal_storage_class: longhorn + +prometheus_stack_chart_version: "68.4.4" +prometheus_storage_size: "20Gi" +prometheus_storage_class: longhorn +prometheus_retention: "30d" + +grafana_storage_size: "5Gi" +grafana_storage_class: longhorn +grafana_admin_secret: "grafana-admin-secret" +grafana_root_url: "http://10.203.0.96:10003" diff --git a/roles/logging/tasks/alloy.yml b/roles/logging/tasks/alloy.yml new file mode 100644 index 0000000..aaf8070 --- /dev/null +++ b/roles/logging/tasks/alloy.yml @@ -0,0 +1,20 @@ +--- +- name: Alloy | Render Alloy values + ansible.builtin.template: + src: alloy-values.yml.j2 + dest: /tmp/alloy-values.yml + mode: "0600" + +- name: Alloy | Install or upgrade Grafana Alloy + ansible.builtin.command: > + helm upgrade --install alloy grafana/alloy + --namespace {{ logging_namespace }} + --version {{ alloy_chart_version }} + --values /tmp/alloy-values.yml + --timeout 5m0s + --wait + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/logging/tasks/grafana.yml b/roles/logging/tasks/grafana.yml new file mode 100644 index 0000000..1557b6c --- /dev/null +++ b/roles/logging/tasks/grafana.yml @@ -0,0 +1,42 @@ +--- +- name: Grafana | Check grafana-admin-secret exists + ansible.builtin.command: > + kubectl get secret {{ grafana_admin_secret }} + -n {{ logging_namespace }} + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + failed_when: false + register: _grafana_secret + +- name: Grafana | Assert grafana-admin-secret exists + ansible.builtin.assert: + that: _grafana_secret.rc == 0 + fail_msg: >- + Secret '{{ grafana_admin_secret }}' not found in namespace '{{ logging_namespace }}'. + Create it manually before running this playbook: + kubectl create namespace {{ logging_namespace }} + kubectl create secret generic {{ grafana_admin_secret }} + --from-literal=admin-user=admin --from-literal=admin-password='YOUR_PASSWORD' + -n {{ logging_namespace }} + +- name: Grafana | Render Grafana values + ansible.builtin.template: + src: grafana-values.yml.j2 + dest: /tmp/grafana-values.yml + mode: "0600" + +- name: Grafana | Install or upgrade Grafana + ansible.builtin.command: > + helm upgrade --install grafana grafana/grafana + --namespace {{ logging_namespace }} + --version {{ grafana_chart_version }} + --values /tmp/grafana-values.yml + --timeout 5m0s + --wait + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/logging/tasks/loki.yml b/roles/logging/tasks/loki.yml new file mode 100644 index 0000000..909c987 --- /dev/null +++ b/roles/logging/tasks/loki.yml @@ -0,0 +1,37 @@ +--- +- name: Helm | Add Grafana chart repository + ansible.builtin.command: helm repo add grafana https://grafana.github.io/helm-charts + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Helm | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Loki | Render Loki values + ansible.builtin.template: + src: loki-values.yml.j2 + dest: /tmp/loki-values.yml + mode: "0600" + +- name: Loki | Install or upgrade Loki + ansible.builtin.command: > + helm upgrade --install loki grafana/loki + --namespace {{ logging_namespace }} + --version {{ loki_chart_version }} + --values /tmp/loki-values.yml + --set loki.storage.s3.secret_access_key={{ loki_minio_password }} + --timeout 10m0s + --wait + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/logging/tasks/main.yml b/roles/logging/tasks/main.yml new file mode 100644 index 0000000..5535d6c --- /dev/null +++ b/roles/logging/tasks/main.yml @@ -0,0 +1,18 @@ +--- +- name: Create logging namespace + ansible.builtin.include_tasks: namespace.yml + +- name: Configure MinIO for Loki + ansible.builtin.include_tasks: minio-user.yml + +- name: Install Loki via Helm + ansible.builtin.include_tasks: loki.yml + +- name: Install Grafana Alloy via Helm + ansible.builtin.include_tasks: alloy.yml + +- name: Install kube-prometheus-stack via Helm + ansible.builtin.include_tasks: prometheus.yml + +- name: Install Grafana via Helm + ansible.builtin.include_tasks: grafana.yml diff --git a/roles/logging/tasks/minio-user.yml b/roles/logging/tasks/minio-user.yml new file mode 100644 index 0000000..515f7c5 --- /dev/null +++ b/roles/logging/tasks/minio-user.yml @@ -0,0 +1,152 @@ +--- +- name: MinIO | Get MinIO pod name + ansible.builtin.command: > + kubectl get pod -n minio -l app=minio + -o jsonpath='{.items[0].metadata.name}' + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _minio_pod + changed_when: false + +- name: MinIO | Get root user from secret + ansible.builtin.command: > + kubectl get secret minio-root-credentials -n minio + -o jsonpath='{.data.rootUser}' + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _minio_root_user_b64 + changed_when: false + no_log: true + +- name: MinIO | Get root password from secret + ansible.builtin.command: > + kubectl get secret minio-root-credentials -n minio + -o jsonpath='{.data.rootPassword}' + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _minio_root_pass_b64 + changed_when: false + no_log: true + +- name: MinIO | Set mc alias with root credentials + ansible.builtin.command: > + kubectl exec -n minio {{ _minio_pod.stdout }} -- + mc alias set local http://localhost:9000 + {{ _minio_root_user_b64.stdout | b64decode }} + {{ _minio_root_pass_b64.stdout | b64decode }} + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + no_log: true + +- name: MinIO | Create loki-chunks bucket + ansible.builtin.command: > + kubectl exec -n minio {{ _minio_pod.stdout }} -- + mc mb --ignore-existing local/{{ loki_minio_bucket }} + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _mb_result + changed_when: "'Bucket created' in _mb_result.stdout" + +- name: MinIO | Check if loki user exists + ansible.builtin.command: > + kubectl exec -n minio {{ _minio_pod.stdout }} -- + mc admin user info local {{ loki_minio_user }} + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _loki_user_check + failed_when: false + changed_when: false + +- name: MinIO | Create loki user + ansible.builtin.command: > + kubectl exec -n minio {{ _minio_pod.stdout }} -- + mc admin user add local {{ loki_minio_user }} {{ loki_minio_password }} + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + when: _loki_user_check.rc != 0 + no_log: true + +- name: MinIO | Check if loki policy exists + ansible.builtin.command: > + kubectl exec -n minio {{ _minio_pod.stdout }} -- + mc admin policy info local loki-policy + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _loki_policy_check + failed_when: false + changed_when: false + +- name: MinIO | Create loki policy + ansible.builtin.command: > + kubectl exec -i -n minio {{ _minio_pod.stdout }} -- + mc admin policy create local loki-policy /dev/stdin + args: + stdin: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", "s3:PutObject", "s3:DeleteObject", + "s3:ListBucket", "s3:GetBucketLocation" + ], + "Resource": [ + "arn:aws:s3:::{{ loki_minio_bucket }}", + "arn:aws:s3:::{{ loki_minio_bucket }}/*" + ] + } + ] + } + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + when: _loki_policy_check.rc != 0 + +- name: MinIO | Attach loki policy to loki user + ansible.builtin.command: > + kubectl exec -n minio {{ _minio_pod.stdout }} -- + mc admin policy attach local loki-policy --user {{ loki_minio_user }} + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _attach_result + failed_when: > + _attach_result.rc != 0 and + 'already attached' not in (_attach_result.stderr | lower) and + 'already attached' not in (_attach_result.stdout | lower) + changed_when: _attach_result.rc == 0 + +- name: MinIO | Build loki-minio-secret manifest + ansible.builtin.command: > + kubectl create secret generic {{ loki_minio_secret_name }} + --from-literal=AWS_ACCESS_KEY_ID={{ loki_minio_user }} + --from-literal=AWS_SECRET_ACCESS_KEY={{ loki_minio_password }} + --namespace {{ logging_namespace }} + --dry-run=client -o yaml + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _loki_secret_manifest + changed_when: false + no_log: true + +- name: MinIO | Apply loki-minio-secret + ansible.builtin.command: kubectl apply -f - + args: + stdin: "{{ _loki_secret_manifest.stdout }}" + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _secret_apply + changed_when: "'created' in _secret_apply.stdout or 'configured' in _secret_apply.stdout" + no_log: true diff --git a/roles/logging/tasks/namespace.yml b/roles/logging/tasks/namespace.yml new file mode 100644 index 0000000..5a18824 --- /dev/null +++ b/roles/logging/tasks/namespace.yml @@ -0,0 +1,18 @@ +--- +- name: Namespace | Create monitoring namespace + ansible.builtin.command: kubectl create namespace {{ logging_namespace }} --dry-run=client -o yaml + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _ns_manifest + changed_when: false + +- name: Namespace | Apply monitoring namespace + ansible.builtin.command: kubectl apply -f - + args: + stdin: "{{ _ns_manifest.stdout }}" + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _ns_apply + changed_when: "'created' in _ns_apply.stdout" diff --git a/roles/logging/tasks/prometheus.yml b/roles/logging/tasks/prometheus.yml new file mode 100644 index 0000000..3cb0747 --- /dev/null +++ b/roles/logging/tasks/prometheus.yml @@ -0,0 +1,36 @@ +--- +- name: Prometheus | Add prometheus-community Helm repo + ansible.builtin.command: helm repo add prometheus-community https://prometheus-community.github.io/helm-charts + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Prometheus | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Prometheus | Render values + ansible.builtin.template: + src: prometheus-values.yml.j2 + dest: /tmp/prometheus-values.yml + mode: "0600" + +- name: Prometheus | Install or upgrade kube-prometheus-stack + ansible.builtin.command: > + helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack + --namespace {{ logging_namespace }} + --version {{ prometheus_stack_chart_version }} + --values /tmp/prometheus-values.yml + --timeout 10m0s + --wait + environment: + KUBECONFIG: "{{ logging_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/logging/templates/alloy-values.yml.j2 b/roles/logging/templates/alloy-values.yml.j2 new file mode 100644 index 0000000..8cd9852 --- /dev/null +++ b/roles/logging/templates/alloy-values.yml.j2 @@ -0,0 +1,105 @@ +controller: + type: daemonset + tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule + +alloy: + configMap: + create: true + content: | + // ── Обнаружение подов Kubernetes ───────────────────────────── + discovery.kubernetes "pods" { + role = "pod" + } + + // ── Relabeling: namespace, pod, container, app ──────────────── + discovery.relabel "pod_logs" { + targets = discovery.kubernetes.pods.targets + + rule { + source_labels = ["__meta_kubernetes_namespace"] + target_label = "namespace" + } + rule { + source_labels = ["__meta_kubernetes_pod_name"] + target_label = "pod" + } + rule { + source_labels = ["__meta_kubernetes_pod_container_name"] + target_label = "container" + } + rule { + source_labels = ["__meta_kubernetes_pod_label_app"] + target_label = "app" + } + rule { + source_labels = ["__meta_kubernetes_pod_annotation_filter_debug"] + regex = "true" + action = "drop" + } + } + + // ── Чтение файлов логов подов ───────────────────────────────── + loki.source.kubernetes "pods" { + targets = discovery.relabel.pod_logs.output + forward_to = [loki.process.parse.receiver] + } + + // ── Парсинг JSON-логов, отброс debug ───────────────────────── + loki.process "parse" { + stage.json { + expressions = {level = "level", msg = "message"} + } + stage.labels { + values = {level = ""} + } + stage.drop { + expression = ".*level=\"debug\".*" + drop_counter_reason = "debug_dropped" + } + forward_to = [loki.write.local.receiver] + } + + // ── Сбор journald (kubelet, containerd, sshd) ───────────────── + discovery.relabel "journal" { + targets = [] + rule { + source_labels = ["__journal__systemd_unit"] + target_label = "unit" + } + rule { + source_labels = ["__journal__hostname"] + target_label = "node" + } + } + + loki.source.journal "systemd" { + path = "/var/log/journal" + max_age = "12h" + labels = {job = "systemd-journal"} + forward_to = [loki.write.local.receiver] + relabel_rules = discovery.relabel.journal.rules + } + + // ── Отправка в Loki ─────────────────────────────────────────── + loki.write "local" { + endpoint { + url = "http://loki.{{ logging_namespace }}.svc.cluster.local:3100/loki/api/v1/push" + } + } + + mounts: + varlog: true + dockercontainers: false + +extraVolumes: + - name: journal + hostPath: + path: /var/log/journal + +extraVolumeMounts: + - name: journal + mountPath: /var/log/journal + readOnly: true diff --git a/roles/logging/templates/grafana-values.yml.j2 b/roles/logging/templates/grafana-values.yml.j2 new file mode 100644 index 0000000..b604743 --- /dev/null +++ b/roles/logging/templates/grafana-values.yml.j2 @@ -0,0 +1,49 @@ +grafana.ini: + server: + root_url: {{ grafana_root_url }} + security: + admin_user: admin + auth.anonymous: + enabled: false + unified_alerting: + enabled: true + alerting: + enabled: false + +admin: + existingSecret: {{ grafana_admin_secret }} + userKey: admin-user + passwordKey: admin-password + +persistence: + enabled: true + storageClassName: {{ grafana_storage_class }} + size: {{ grafana_storage_size }} + +datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: Loki + type: loki + url: http://loki.{{ logging_namespace }}.svc.cluster.local:3100 + access: proxy + isDefault: true + jsonData: + maxLines: 5000 + + - name: Prometheus + type: prometheus + url: http://kube-prometheus-stack-prometheus.{{ logging_namespace }}.svc.cluster.local:9090 + access: proxy + isDefault: false + jsonData: + timeInterval: 30s + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/roles/logging/templates/loki-values.yml.j2 b/roles/logging/templates/loki-values.yml.j2 new file mode 100644 index 0000000..159ffd3 --- /dev/null +++ b/roles/logging/templates/loki-values.yml.j2 @@ -0,0 +1,64 @@ +deploymentMode: SingleBinary + +loki: + auth_enabled: false + + commonConfig: + replication_factor: 1 + + storage: + type: s3 + bucketNames: + chunks: {{ loki_minio_bucket }} + ruler: {{ loki_minio_bucket }} + admin: {{ loki_minio_bucket }} + s3: + endpoint: {{ loki_minio_endpoint }} + region: us-east-1 + bucketnames: {{ loki_minio_bucket }} + access_key_id: loki + insecure: true + s3forcepathstyle: true + + schemaConfig: + configs: + - from: "2024-01-01" + store: tsdb + object_store: s3 + schema: v13 + index: + prefix: loki_index_ + period: 24h + + limits_config: + retention_period: {{ loki_retention_days * 24 }}h + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + max_streams_per_user: 10000 + max_chunks_per_query: 2000000 + + compactor: + working_directory: /var/loki/compactor + retention_enabled: true + delete_request_store: s3 + +singleBinary: + replicas: 1 + persistence: + enabled: true + storageClass: {{ loki_wal_storage_class }} + size: {{ loki_wal_storage_size }} + + resources: {} + +gateway: + enabled: false + +backend: + replicas: 0 + +read: + replicas: 0 + +write: + replicas: 0 diff --git a/roles/logging/templates/prometheus-values.yml.j2 b/roles/logging/templates/prometheus-values.yml.j2 new file mode 100644 index 0000000..5a1568f --- /dev/null +++ b/roles/logging/templates/prometheus-values.yml.j2 @@ -0,0 +1,69 @@ +grafana: + enabled: false + forceDeployDashboards: false + +prometheus: + prometheusSpec: + storageSpec: + volumeClaimTemplate: + spec: + storageClassName: {{ prometheus_storage_class }} + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ prometheus_storage_size }} + retention: {{ prometheus_retention }} + scrapeInterval: 30s + evaluationInterval: 30s + ruleSelectorNilUsesHelmValues: false + serviceMonitorSelectorNilUsesHelmValues: false + podMonitorSelectorNilUsesHelmValues: false + +alertmanager: + enabled: false + +nodeExporter: + enabled: true + +kubeStateMetrics: + enabled: true + +kubeControllerManager: + enabled: true + endpoints: +{% for host in groups['control_plane'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} + service: + enabled: true + port: 10257 + targetPort: 10257 + +kubeScheduler: + enabled: true + endpoints: +{% for host in groups['control_plane'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} + service: + enabled: true + port: 10259 + targetPort: 10259 + +kubeEtcd: + enabled: true + endpoints: +{% for host in groups['control_plane'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} + service: + enabled: true + port: 2381 + targetPort: 2381 + +kubeProxy: + enabled: true + endpoints: +{% for host in groups['k8s_cluster'] %} + - {{ hostvars[host]['ansible_host'] }} +{% endfor %} diff --git a/roles/longhorn/README.md b/roles/longhorn/README.md new file mode 100644 index 0000000..a540d51 --- /dev/null +++ b/roles/longhorn/README.md @@ -0,0 +1,832 @@ +# Longhorn — стандарт постоянного хранилища в кластере + +Этот документ описывает стандарт работы с Longhorn в нашем кластере: +как подключать хранилище к приложениям, добавлять диски, управлять репликами и резервными копиями. +Является эталоном для разработчиков и DevOps. + +--- + +## Содержание + +1. [Архитектура: как Longhorn работает в кластере](#1-архитектура-как-longhorn-работает-в-кластере) +2. [Два этапа деплоя: longhorn_prereqs и longhorn](#2-два-этапа-деплоя-longhorn_prereqs-и-longhorn) +3. [Конфигурация дисков](#3-конфигурация-дисков) +4. [Пример 1: PVC для одного Pod (ReadWriteOnce)](#4-пример-1-pvc-для-одного-pod-readwriteonce) +5. [Пример 2: StatefulSet с несколькими репликами](#5-пример-2-statefulset-с-несколькими-репликами) +6. [Пример 3: база данных (PostgreSQL) с Longhorn](#6-пример-3-база-данных-postgresql-с-longhorn) +7. [Пример 4: несколько компонентов с общим хранилищем](#7-пример-4-несколько-компонентов-с-общим-хранилищем) +8. [Реплики: сколько и когда менять](#8-реплики-сколько-и-когда-менять) +9. [Резервное копирование и восстановление](#9-резервное-копирование-и-восстановление) +10. [Расширение тома](#10-расширение-тома) +11. [Добавление нового worker-узла с дисками](#11-добавление-нового-worker-узла-с-дисками) +12. [Диагностика и типичные ошибки](#12-диагностика-и-типичные-ошибки) +13. [Чеклист перед использованием хранилища](#13-чеклист-перед-использованием-хранилища) + +--- + +## 1. Архитектура: как Longhorn работает в кластере + +### Схема + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ k8s-worker-01 (10.203.0.96) │ +│ │ +│ ┌────────────────┐ ┌────────────────┐ │ +│ │ /dev/sdb │ │ /dev/sdc │ │ +│ │ XFS │ │ XFS │ │ +│ │ /mnt/longhorn-│ │ /mnt/longhorn-│ │ +│ │ disk1 │ │ disk2 │ │ +│ └───────┬────────┘ └───────┬────────┘ │ +│ │ │ │ +│ Longhorn Manager Pod ─────────┘ │ +│ Longhorn Engine Pod (управляет репликами) │ +│ Instance Manager Pod (iSCSI-таргет для kubelet) │ +│ │ +│ kubelet → iSCSI → Longhorn Engine → Longhorn Replica │ +│ (файлы на диске) │ +└─────────────────────────────────────────────────────────────────┘ + +PersistentVolumeClaim (ns: myapp) ──► StorageClass: longhorn + │ + ▼ + PersistentVolume + (Longhorn Volume) + │ + Replicas: 1 (сейчас) + → расположены на worker-нодах +``` + +### Компоненты + +| Компонент | Назначение | +|---|---| +| **longhorn-manager** | Оркестрирует тома, реплики, бэкапы; CRD-контроллер | +| **longhorn-engine** | Процесс ввода-вывода для каждого тома | +| **instance-manager** | Управляет engine и replica инстансами | +| **longhorn-ui** | Web-интерфейс (доступен через Traefik на порту 10002) | +| **CSI driver** | Kubernetes CSI-интерфейс; обрабатывает PVC → PV | + +### StorageClass + +Longhorn создаёт StorageClass `longhorn` при установке. Это **StorageClass по умолчанию** в кластере. + +```bash +kubectl get storageclass +# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ... +# longhorn (default) driver.longhorn.io Delete Immediate ... +``` + +`ReclaimPolicy: Delete` — при удалении PVC том удаляется. Если нужно сохранить данные — делайте снапшот или бэкап перед удалением PVC. + +--- + +## 2. Два этапа деплоя: longhorn_prereqs и longhorn + +Longhorn разворачивается через два плейбука, которые нужно запускать в порядке: + +``` +1. setup_longhorn.yml + ├── role: longhorn_prereqs (runs on: workers) + │ ├── packages.yml — iscsi-initiator-utils, nfs-utils, cryptsetup + │ ├── kernel.yml — iscsi_tcp, dm_crypt модули + │ ├── firewall.yml — NFS (2049/tcp), portmapper (111/tcp, 111/udp) + │ ├── selinux.yml — CIL-политика для iscsid (Rocky Linux 9) + │ └── disks.yml — форматирование XFS, монтирование по UUID + │ + └── role: longhorn (runs on: manager_nodes) + ├── annotate.yml — kubectl annotate node: disk config JSON + └── helm.yml — helm upgrade --install longhorn/longhorn +``` + +### Когда перезапускать плейбук + +| Ситуация | Что запускать | +|---|---| +| Первичная установка | `setup_longhorn.yml` полностью | +| Добавление нового worker-узла | `setup_longhorn.yml` с `--limit k8s-worker-NN` для prereqs, затем полностью для annotate | +| Добавление диска к существующей ноде | `setup_longhorn.yml` (prereqs: disks.yml пропустится для существующих; annotate обновит аннотацию) | +| Обновление версии Longhorn | Изменить `longhorn_chart_version`, запустить только `longhorn` role | +| Изменение параметров Helm | Изменить переменные, запустить только `longhorn` role | + +--- + +## 3. Конфигурация дисков + +### inventory/prod/group_vars/workers.yml — глобальный дефолт + +```yaml +longhorn_disks: + - device: /dev/sdb + mountpoint: /mnt/longhorn-disk1 + - device: /dev/sdc + mountpoint: /mnt/longhorn-disk2 +``` + +Это применяется ко **всем** worker-нодам. +Если у разных нод разные диски — переопределить в `hosts.yml` (host_vars). + +### inventory/prod/hosts.yml — переопределение для конкретной ноды + +```yaml +workers: + hosts: + k8s-worker-01: + ansible_host: 10.203.0.96 + longhorn_disks: # переопределяет group_vars для этой ноды + - device: /dev/sdb + mountpoint: /mnt/longhorn-disk1 + - device: /dev/nvme0n1 + mountpoint: /mnt/longhorn-nvme # SSD на этой ноде + + k8s-worker-02: + ansible_host: 10.203.0.X + longhorn_disks: + - device: /dev/sdb + mountpoint: /mnt/longhorn-disk1 # только один диск +``` + +### Что происходит с дисками при деплое + +1. `filesystem` форматирует устройство в XFS (идемпотентно — пропускает уже отформатированные) +2. Создаётся директория mountpoint +3. `blkid` получает UUID устройства +4. `/etc/fstab` добавляется запись `UUID=... /mnt/... xfs defaults,nofail` +5. Диск монтируется немедленно +6. Аннотация ноды: `node.longhorn.io/default-disks-config=[{"path":"/mnt/longhorn-disk1",...}]` +7. Longhorn читает аннотацию при старте и регистрирует диски + +> `nofail` в fstab критически важен: если диск недоступен при загрузке — нода загрузится, а не зависнет. + +### Проверка дисков в Longhorn + +```bash +# Посмотреть ноды и их диски в Longhorn +kubectl get nodes.longhorn.io -n longhorn-system + +# Детали ноды +kubectl describe node.longhorn.io k8s-worker-01 -n longhorn-system + +# Через UI: http://10.203.0.96:10002 → Node +``` + +--- + +## 4. Пример 1: PVC для одного Pod (ReadWriteOnce) + +**Сценарий:** одиночный Pod с постоянным хранилищем (файловый кэш, локальные данные). + +### PersistentVolumeClaim + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-data + namespace: my-app +spec: + accessModes: + - ReadWriteOnce # один узел в режиме чтения-записи + storageClassName: longhorn + resources: + requests: + storage: 5Gi +``` + +### Deployment с PVC + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app + namespace: my-app +spec: + replicas: 1 # ВАЖНО: ReadWriteOnce — только 1 реплика! + selector: + matchLabels: + app: my-app + template: + metadata: + labels: + app: my-app + spec: + containers: + - name: my-app + image: registry.gigacoms.info/myteam/my-app:v1.0.0 + volumeMounts: + - name: data + mountPath: /app/data + volumes: + - name: data + persistentVolumeClaim: + claimName: app-data +``` + +> **ReadWriteOnce** означает: том примонтирован к одному узлу одновременно. +> Несколько Pod могут использовать один RWO-том, **только если все они на одном узле**. +> С `replicas: >1` и DaemonSet это создаст проблемы при планировании на разные ноды. + +### Проверить что PVC создан и привязан + +```bash +kubectl get pvc -n my-app +# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +# app-data Bound pvc-abc12345 5Gi RWO longhorn 30s + +# Посмотреть на том в Longhorn +kubectl get volumes.longhorn.io -n longhorn-system | grep app-data +``` + +--- + +## 5. Пример 2: StatefulSet с несколькими репликами + +**Сценарий:** очередь сообщений или key-value хранилище (Redis, RabbitMQ) с несколькими репликами. +Каждая реплика StatefulSet получает **свой отдельный PVC**. + +### StatefulSet с volumeClaimTemplates + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: cache +spec: + serviceName: redis + replicas: 3 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + command: + - redis-server + - --appendonly yes + - --dir /data + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumeClaimTemplates: # Kubernetes создаёт PVC для каждой реплики + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] # каждая реплика = свой том + storageClassName: longhorn + resources: + requests: + storage: 10Gi +``` + +При `replicas: 3` Kubernetes создаст три PVC: +- `data-redis-0` → 10Gi +- `data-redis-1` → 10Gi +- `data-redis-2` → 10Gi + +```bash +kubectl get pvc -n cache +# NAME STATUS VOLUME CAPACITY ACCESS MODES +# data-redis-0 Bound pvc-aaa11111 10Gi RWO +# data-redis-1 Bound pvc-bbb22222 10Gi RWO +# data-redis-2 Bound pvc-ccc33333 10Gi RWO +``` + +> **Важно:** при уменьшении `replicas` PVC **не удаляются автоматически**. Это намеренное поведение Kubernetes — защита от потери данных. Удалять PVC вручную после проверки. + +--- + +## 6. Пример 3: база данных (PostgreSQL) с Longhorn + +**Сценарий:** продакшн PostgreSQL с выделенным Longhorn-томом. + +### Объекты + +```yaml +--- +# PVC отдельно от StatefulSet — для явного управления жизненным циклом +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-data + namespace: myapp + annotations: + # Документировать содержимое тома для оператора + longhorn.io/description: "PostgreSQL data for myapp — created 2026-01-01" +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 50Gi +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: myapp +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + securityContext: + fsGroup: 999 # postgres GID — Longhorn создаст том с нужными правами + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: myapp + - name: POSTGRES_USER + value: myuser + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata # подпапка, не корень тома + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + readinessProbe: + exec: + command: [pg_isready, -U, myuser, -d, myapp] + initialDelaySeconds: 10 + periodSeconds: 5 + volumes: + - name: data + persistentVolumeClaim: + claimName: postgres-data +``` + +> Переменная `PGDATA=/var/lib/postgresql/data/pgdata` — подпапка внутри mountPath. +> PostgreSQL требует пустую директорию при инициализации. Если смонтировать том прямо в `/var/lib/postgresql/data`, Longhorn создаёт там `lost+found` и PostgreSQL падает с ошибкой. + +### Резервное копирование PostgreSQL + +Два уровня резервирования: + +1. **Longhorn snapshot** — снапшот файловой системы (быстрый, crash-consistent) +2. **pg_dump** — логический дамп (переносимый, consistent) + +```bash +# Логический дамп из Pod +kubectl exec -n myapp postgres-0 -- \ + pg_dump -U myuser myapp | gzip > backup-$(date +%Y%m%d).sql.gz + +# Восстановление +kubectl exec -i -n myapp postgres-0 -- \ + psql -U myuser myapp < backup-20260101.sql +``` + +--- + +## 7. Пример 4: несколько компонентов с общим хранилищем + +**Сценарий:** несколько Pod должны читать один и тот же набор файлов (например, общий медиа-каталог между CMS и CDN-прокси). + +### Проблема: ReadWriteOnce не подходит + +`ReadWriteOnce` разрешает запись только с одного узла. При нескольких Pod на разных узлах — ошибка монтирования. + +### Решение A: все Pod на одном узле (nodeSelector) + +```yaml +# Принудительно разместить все Pod на одной ноде +spec: + nodeSelector: + kubernetes.io/hostname: k8s-worker-01 +``` + +Тогда PVC типа `ReadWriteOnce` работает с несколькими Pod на одном узле. + +### Решение B: NFS через Longhorn (ReadWriteMany) + +Longhorn поддерживает `ReadWriteMany` через встроенный NFS-шлюз начиная с версии 1.5. + +```yaml +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shared-media + namespace: cms +spec: + accessModes: + - ReadWriteMany # несколько узлов одновременно + storageClassName: longhorn + resources: + requests: + storage: 100Gi +``` + +```yaml +--- +# CMS Pod — пишет медиафайлы +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cms + namespace: cms +spec: + replicas: 1 + template: + spec: + containers: + - name: cms + image: registry.gigacoms.info/myteam/cms:v2.0.0 + volumeMounts: + - name: media + mountPath: /app/media + volumes: + - name: media + persistentVolumeClaim: + claimName: shared-media +--- +# CDN-прокси Pod — читает медиафайлы с того же тома +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cdn-proxy + namespace: cms +spec: + replicas: 3 # несколько реплик, разные ноды — RWX позволяет + template: + spec: + containers: + - name: cdn-proxy + image: nginx:1.27-alpine + volumeMounts: + - name: media + mountPath: /usr/share/nginx/html/media + readOnly: true # прокси только читает + volumes: + - name: media + persistentVolumeClaim: + claimName: shared-media +``` + +> **Ограничение RWX в Longhorn:** производительность ниже, чем RWO (идёт через NFS). Для баз данных использовать только RWO. RWX — для файлов с редкой записью и частым чтением. + +--- + +## 8. Реплики: сколько и когда менять + +### Текущая конфигурация кластера + +``` +Кластер: 1 worker-нода (k8s-worker-01) +Реплики: 1 (longhorn_default_replica_count: 1) +``` + +Одна реплика означает: **данные не реплицируются**. При потере ноды — данные недоступны до восстановления узла. + +### Когда увеличивать количество реплик + +| Число worker-нод | Рекомендуемые реплики | Причина | +|---|---|---| +| 1 | **1** | Некуда реплицировать | +| 2 | **2** | Реплика на каждой ноде; потеря одной ноды — нет прерывания | +| 3+ | **3** | Quorum; потеря одной ноды — данные доступны | + +### Как изменить количество реплик + +**Глобально** (для новых томов): + +```yaml +# inventory/prod/group_vars/longhorn.yml или group_vars/k8s_cluster.yml +longhorn_default_replica_count: 2 # изменить при добавлении worker-нод +``` + +```bash +ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml +# Применит только Helm-апгрейд, не затронет prereqs и диски +``` + +**Для конкретного тома** (через UI или kubectl): + +```bash +# Через Longhorn API +kubectl patch volume.longhorn.io pvc-abc12345 -n longhorn-system \ + --type=merge -p '{"spec":{"numberOfReplicas":2}}' +``` + +**Для конкретного PVC через StorageClass**: + +```yaml +# Создать отдельный StorageClass с другим количеством реплик +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: longhorn-replicated +provisioner: driver.longhorn.io +parameters: + numberOfReplicas: "2" + staleReplicaTimeout: "30" + dataLocality: "disabled" +reclaimPolicy: Delete +allowVolumeExpansion: true +volumeBindingMode: Immediate +``` + +```yaml +# PVC с кастомным StorageClass +spec: + storageClassName: longhorn-replicated # вместо longhorn +``` + +### soft anti-affinity (при числе нод < числа реплик) + +Если `numberOfReplicas: 2`, но доступна только 1 нода — Longhorn не создаст том (нет места для реплики). +Для обхода в dev-окружении включить `replicaNodeLevelSoftAntiAffinity`: + +```yaml +longhorn_replica_node_soft_anti_affinity: "true" # НЕ использовать в production +``` + +--- + +## 9. Резервное копирование и восстановление + +### Longhorn Snapshot (мгновенный снапшот тома) + +Снапшот — point-in-time копия тома на том же диске. Защищает от случайного удаления данных, но не от потери диска. + +```bash +# Создать снапшот через kubectl +kubectl create -f - < → Create Snapshot. + +### Backup в S3-совместимое хранилище + +Для полноценного backup необходимо настроить внешнее хранилище (S3, NFS): + +```yaml +# Настройка backup target через Longhorn Settings +# UI: http://10.203.0.96:10002 → Settings → Backup Target +# Значение: s3://bucket-name@region/prefix (для S3) +# nfs://server/path (для NFS) +``` + +```bash +# Создать backup существующего снапшота +kubectl create -f - < → Snapshots → Revert +# Или через kubectl (том должен быть detached) + +# 1. Масштабировать Deployment в 0 (отмонтировать том) +kubectl scale deployment my-app -n my-app --replicas=0 + +# 2. Revert через UI или API + +# 3. Вернуть Deployment +kubectl scale deployment my-app -n my-app --replicas=1 +``` + +--- + +## 10. Расширение тома + +Longhorn поддерживает расширение тома **без остановки Pod** (online expansion). + +### Расширить PVC + +```bash +# Отредактировать PVC — увеличить storage (уменьшение не поддерживается) +kubectl patch pvc app-data -n my-app \ + --type=merge -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}' + +# Проверить прогресс +kubectl get pvc app-data -n my-app +# STATUS: пока идёт расширение, будет Bound с условием FileSystemResizePending + +# После завершения +kubectl describe pvc app-data -n my-app | grep -A5 Conditions +``` + +Расширение на уровне файловой системы происходит автоматически при следующем монтировании Pod (или сразу, если `allowVolumeExpansion: true` в StorageClass — а у Longhorn это включено по умолчанию). + +### Проверить размер тома внутри Pod + +```bash +kubectl exec -n my-app $(kubectl get pod -n my-app -l app=my-app -o name) \ + -- df -h /app/data +``` + +--- + +## 11. Добавление нового worker-узла с дисками + +При добавлении `k8s-worker-02`: + +### Шаг 1: добавить в инвентарь + +```yaml +# inventory/prod/hosts.yml +workers: + hosts: + k8s-worker-01: + ansible_host: 10.203.0.96 + k8s-worker-02: # новый узел + ansible_host: 10.203.0.XX + longhorn_disks: # если диски отличаются от дефолта + - device: /dev/sdb + mountpoint: /mnt/longhorn-disk1 +``` + +### Шаг 2: запустить prereqs только для нового узла + +```bash +# Подготовка ОС только для новой ноды +ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml \ + --limit k8s-worker-02 \ + --tags longhorn_prereqs +``` + +### Шаг 3: запустить join нового воркера в кластер + +```bash +ansible-playbook -i inventory/prod playbooks/setup_worker_plane.yml \ + --limit k8s-worker-02 +``` + +### Шаг 4: аннотировать ноду и обновить Longhorn + +```bash +# Полный плейбук обновит аннотации для всех нод включая новую +ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml +``` + +### Шаг 5: увеличить количество реплик + +```yaml +# group_vars/k8s_cluster.yml или longhorn.yml +longhorn_default_replica_count: 2 # было 1 +``` + +```bash +ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml +``` + +--- + +## 12. Диагностика и типичные ошибки + +### Основные команды + +```bash +# Состояние всех томов +kubectl get volumes.longhorn.io -n longhorn-system + +# Детали тома +kubectl describe volume.longhorn.io -n longhorn-system + +# Состояние реплик +kubectl get replicas.longhorn.io -n longhorn-system | grep + +# Состояние нод Longhorn +kubectl get nodes.longhorn.io -n longhorn-system + +# Логи manager +kubectl logs -n longhorn-system -l app=longhorn-manager --tail=100 + +# Логи CSI driver +kubectl logs -n longhorn-system -l app=longhorn-csi-plugin --tail=50 + +# PVC не привязывается — посмотреть события +kubectl describe pvc -n +kubectl get events -n --sort-by='.lastTimestamp' + +# Посмотреть все PVC в кластере +kubectl get pvc -A +``` + +### Таблица ошибок + +| Симптом | Причина | Решение | +|---|---|---| +| PVC в статусе `Pending` | Нет доступных дисков / нода не готова | `kubectl get nodes.longhorn.io -n longhorn-system` — проверить schedulable | +| PVC `Pending`: `no schedulable node` | Включён soft anti-affinity=false, только 1 нода, replicas=2 | Уменьшить реплики до 1 или включить soft anti-affinity | +| Pod не стартует: `volume not found` | Том деградировал или удалён | Проверить `kubectl get volumes.longhorn.io -n longhorn-system` | +| Том в статусе `Degraded` | Нода с репликой недоступна | Подождать восстановления ноды или rebuild replica | +| Том в статусе `Faulted` | Все реплики потеряны | Восстановить из бэкапа | +| `iscsiadm` ошибки в логах | SELinux блокирует iscsid | Перепроверить CIL-политику: `semodule -l \| grep local_longhorn` | +| Медленная запись | Все реплики на одном диске | Разнести реплики по дискам (Disk Tags) | +| `lost+found` в PostgreSQL data dir | PGDATA смонтирован прямо на корень тома | Установить `PGDATA=/var/lib/postgresql/data/pgdata` (подпапка) | +| Диск не виден в Longhorn | Аннотация ноды устарела | `ansible-playbook setup_longhorn.yml` — обновит annotate | + +### Проверка iSCSI на ноде + +```bash +# Выполнить через SSH на worker-ноде или kubectl exec + +# Статус iscsid +systemctl status iscsid + +# Список активных iSCSI-сессий (примонтированные тома) +iscsiadm -m session + +# Загружены ли нужные модули ядра +lsmod | grep iscsi_tcp +lsmod | grep dm_crypt +``` + +### Принудительный detach застрявшего тома + +Если Pod удалён, но том остался в статусе attached: + +```bash +# Через UI: Longhorn → Volumes → → Detach + +# Или через kubectl +kubectl patch volume.longhorn.io -n longhorn-system \ + --type=merge -p '{"spec":{"nodeID":""}}' +``` + +--- + +## 13. Чеклист перед использованием хранилища + +### Первое использование Longhorn в кластере + +- [ ] `setup_longhorn.yml` выполнен успешно (prereqs + helm install) +- [ ] Все worker-ноды показывают статус `Ready` в UI Longhorn (`http://10.203.0.96:10002`) +- [ ] Диски видны в UI Longhorn: Node → Disks +- [ ] StorageClass `longhorn` существует: `kubectl get storageclass` +- [ ] Тестовый PVC создаётся и переходит в `Bound`: `kubectl apply -f test-pvc.yaml` + +### Перед каждым новым PVC / StatefulSet + +- [ ] Выбран правильный `accessMode`: + - `ReadWriteOnce` — один узел, обычные Pod и StatefulSet + - `ReadWriteMany` — несколько узлов, только через Longhorn NFS +- [ ] `storageClassName: longhorn` указан явно (не полагаться на default при нескольких StorageClass) +- [ ] Указан разумный размер тома (расширить можно, уменьшить — нет) +- [ ] Для PostgreSQL: `PGDATA` установлен в подпапку, `fsGroup: 999` в securityContext +- [ ] Для StatefulSet: использован `volumeClaimTemplates`, не `volumes + PVC` +- [ ] `resources.requests` и `limits` заданы для контейнеров + +### После деплоя + +- [ ] PVC в статусе `Bound`: `kubectl get pvc -n ` +- [ ] Pod в статусе `Running`: `kubectl get pods -n ` +- [ ] Том виден в Longhorn UI со статусом `Healthy` +- [ ] Данные доступны внутри Pod: `kubectl exec -n -- ls /mountpath` +- [ ] (для продакшн-данных) Настроен регулярный snapshot или backup diff --git a/roles/longhorn/defaults/main.yml b/roles/longhorn/defaults/main.yml new file mode 100644 index 0000000..4710569 --- /dev/null +++ b/roles/longhorn/defaults/main.yml @@ -0,0 +1,14 @@ +--- +longhorn_chart_version: "1.7.2" +longhorn_namespace: longhorn-system + +# Number of replicas per volume. Set to 1 while only 1 worker node exists. +# Raise to 2 or 3 after adding more worker nodes. +longhorn_default_replica_count: 1 + +longhorn_replica_node_soft_anti_affinity: "false" +longhorn_minimal_available_storage_percentage: "10" +longhorn_storage_over_provisioning_percentage: "100" + +# Path to kubeconfig on manager node +longhorn_kubeconfig: "/home/{{ ansible_user }}/.kube/config" diff --git a/roles/longhorn/handlers/main.yml b/roles/longhorn/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/longhorn/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/longhorn/meta/main.yml b/roles/longhorn/meta/main.yml new file mode 100644 index 0000000..b792d58 --- /dev/null +++ b/roles/longhorn/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + role_name: longhorn + author: ops + description: Installs Longhorn via Helm and registers worker node disks via node annotations + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" diff --git a/roles/longhorn/tasks/annotate.yml b/roles/longhorn/tasks/annotate.yml new file mode 100644 index 0000000..3a5ab79 --- /dev/null +++ b/roles/longhorn/tasks/annotate.yml @@ -0,0 +1,28 @@ +--- +# Annotate each worker node with its Longhorn disk config BEFORE helm install. +# Longhorn reads node.longhorn.io/default-disks-config on first node discovery +# and automatically registers the listed mountpoints as storage disks. +- name: Annotate | Set Longhorn disk config on worker nodes + ansible.builtin.command: + argv: + - kubectl + - annotate + - node + - "{{ item }}" + - node.longhorn.io/create-default-disk=config + - "node.longhorn.io/default-disks-config={{ _disk_config }}" + - --overwrite + vars: + _disk_config: >- + {{ '[' + (hostvars[item].longhorn_disks + | map(attribute='mountpoint') + | map('regex_replace', '(.+)', '{"path":"\1","allowScheduling":true,"diskType":"filesystem"}') + | list + | join(',')) + ']' }} + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + loop: "{{ groups['workers'] }}" + register: _annotate + changed_when: "'annotated' in _annotate.stdout" + failed_when: _annotate.rc != 0 diff --git a/roles/longhorn/tasks/disks.yml b/roles/longhorn/tasks/disks.yml new file mode 100644 index 0000000..b71f033 --- /dev/null +++ b/roles/longhorn/tasks/disks.yml @@ -0,0 +1,145 @@ +--- +# Idempotently registers dedicated disks on Longhorn worker nodes and removes +# the auto-created default disk at /var/lib/longhorn. +# Runs after helm.yml — Longhorn must already be installed. + +# ── Step 1: wait for Longhorn to discover all worker nodes ──────────────────── + +- name: Disks | Wait for worker nodes to appear in Longhorn + ansible.builtin.command: + argv: [kubectl, get, node.longhorn.io, "{{ item }}"] + loop: "{{ groups['workers'] }}" + register: _lh_node_wait + until: _lh_node_wait.rc == 0 + retries: 24 + delay: 5 + changed_when: false + failed_when: _lh_node_wait.rc != 0 and _lh_node_wait.attempts | default(0) >= 24 + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: /usr/local/bin:/usr/bin:/bin + +# ── Step 2: register dedicated disks via k8s_json_patch ── + +- name: Disks | Build flat worker-disk list + ansible.builtin.set_fact: + _lh_worker_disks: "{{ _lh_worker_disks | default([]) + [hostvars[item].longhorn_disks | default([]) | map('combine', {'node': item}) | list] | flatten }}" + loop: "{{ groups['workers'] }}" + +- name: Disks | Register dedicated disk on Longhorn node + kubernetes.core.k8s_json_patch: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item.node }}" + patches: + - op: add + path: "/spec/disks/{{ item.mountpoint | basename | replace('/', '~1') }}" + value: + path: "{{ item.mountpoint }}" + allowScheduling: "{{ item.allowScheduling | default(true) }}" + diskType: "{{ item.diskType | default('filesystem') }}" + storageReserved: "{{ item.storageReserved | default(0) }}" + loop: "{{ _lh_worker_disks | default([]) }}" + loop_control: + label: "{{ item.node }}/{{ item.mountpoint | basename }}" + register: _disk_patch + changed_when: false + +# ── Step 3: remove auto-created default disk at /var/lib/longhorn ───────────── + +- name: Disks | Get current disk spec for each worker node + kubernetes.core.k8s_info: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item }}" + loop: "{{ groups['workers'] }}" + register: _lh_node_info + changed_when: false + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: /usr/local/bin:/usr/bin:/bin + +- name: Disks | Disable auto-created default disk (required before removal) + kubernetes.core.k8s_json_patch: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item.item }}" + patches: + - op: add + path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({key: ''}) | key | replace('/', '~1') }}" + value: + path: "{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({value: {value: {}}}) | value | path | default('/var/lib/longhorn/') }}" + allowScheduling: false + diskType: "filesystem" + loop: "{{ _lh_node_info.results }}" + when: + - item.resources | length > 0 + - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | selectattr('value.allowScheduling', 'equalto', true) | list | length > 0 + register: _disable_default + changed_when: false + +- name: Disks | Remove auto-created default disk + kubernetes.core.k8s_json_patch: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item.item }}" + patches: + - op: remove + path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | map(attribute='key') | first | default('') | replace('/', '~1') }}" + loop: "{{ _lh_node_info.results }}" + when: + - item.resources | length > 0 + - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | list | length > 0 + register: _remove_default + changed_when: false + +# ── Step 4: remove auto-created default disk from control_plane nodes ───────── +# Control plane nodes have no dedicated storage disks; Longhorn must not +# schedule replicas on the small OS disk (/var/lib/longhorn/). + +- name: Disks | Get current disk spec for control_plane nodes + kubernetes.core.k8s_info: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item }}" + loop: "{{ groups['control_plane'] }}" + register: _lh_cp_node_info + changed_when: false + failed_when: false + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: /usr/local/bin:/usr/bin:/bin + +- name: Disks | Disable auto-created default disk on control_plane nodes + kubernetes.core.k8s_json_patch: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item.item }}" + patches: + - op: add + path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({key: ''}) | key | replace('/', '~1') }}" + value: + path: "{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({value: {}}) | value | path | default('/var/lib/longhorn/') }}" + allowScheduling: false + diskType: "filesystem" + loop: "{{ _lh_cp_node_info.results }}" + when: + - item.resources | length > 0 + - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | selectattr('value.allowScheduling', 'equalto', true) | list | length > 0 + register: _disable_cp_default + changed_when: false + +- name: Disks | Remove auto-created default disk from control_plane nodes + kubernetes.core.k8s_json_patch: + api_version: longhorn.io/v1beta1 + kind: Node + name: "{{ item.item }}" + patches: + - op: remove + path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | map(attribute='key') | first | default('') | replace('/', '~1') }}" + loop: "{{ _lh_cp_node_info.results }}" + when: + - item.resources | length > 0 + - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | list | length > 0 + register: _remove_cp_default + changed_when: false diff --git a/roles/longhorn/tasks/helm.yml b/roles/longhorn/tasks/helm.yml new file mode 100644 index 0000000..f9fc631 --- /dev/null +++ b/roles/longhorn/tasks/helm.yml @@ -0,0 +1,35 @@ +--- +- name: Helm | Add Longhorn chart repository + ansible.builtin.command: helm repo add longhorn https://charts.longhorn.io + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Helm | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Helm | Install or upgrade Longhorn + ansible.builtin.command: > + helm upgrade --install longhorn longhorn/longhorn + --namespace {{ longhorn_namespace }} + --create-namespace + --version {{ longhorn_chart_version }} + --set defaultSettings.defaultReplicaCount={{ longhorn_default_replica_count }} + --set defaultSettings.replicaNodeLevelSoftAntiAffinity={{ longhorn_replica_node_soft_anti_affinity }} + --set defaultSettings.minimalAvailableStoragePercentage={{ longhorn_minimal_available_storage_percentage }} + --set defaultSettings.storageOverProvisioningPercentage={{ longhorn_storage_over_provisioning_percentage }} + --set defaultSettings.createDefaultDiskLabeledNodes=true + --set persistence.defaultClassReplicaCount={{ longhorn_default_replica_count }} + --set service.ui.type=ClusterIP + environment: + KUBECONFIG: "{{ longhorn_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/longhorn/tasks/main.yml b/roles/longhorn/tasks/main.yml new file mode 100644 index 0000000..d1c7cb9 --- /dev/null +++ b/roles/longhorn/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: Annotate worker nodes + ansible.builtin.include_tasks: annotate.yml + +- name: Install Longhorn via Helm + ansible.builtin.include_tasks: helm.yml + +- name: Register disks and remove default disk + ansible.builtin.include_tasks: disks.yml diff --git a/roles/longhorn_prereqs/defaults/main.yml b/roles/longhorn_prereqs/defaults/main.yml new file mode 100644 index 0000000..6b5ce5e --- /dev/null +++ b/roles/longhorn_prereqs/defaults/main.yml @@ -0,0 +1,6 @@ +--- +longhorn_disks: [] + +longhorn_kernel_modules: + - iscsi_tcp + - dm_crypt diff --git a/roles/longhorn_prereqs/handlers/main.yml b/roles/longhorn_prereqs/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/longhorn_prereqs/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/longhorn_prereqs/meta/main.yml b/roles/longhorn_prereqs/meta/main.yml new file mode 100644 index 0000000..32eb01a --- /dev/null +++ b/roles/longhorn_prereqs/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + role_name: longhorn_prereqs + author: ops + description: Prepares worker nodes for Longhorn storage (packages, kernel modules, firewall, SELinux, disk format and mount) + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" diff --git a/roles/longhorn_prereqs/tasks/disks.yml b/roles/longhorn_prereqs/tasks/disks.yml new file mode 100644 index 0000000..858871b --- /dev/null +++ b/roles/longhorn_prereqs/tasks/disks.yml @@ -0,0 +1,37 @@ +--- +- name: Disks | Format disk as XFS (idempotent) + community.general.filesystem: + fstype: xfs + dev: "{{ item.device }}" + loop: "{{ longhorn_disks }}" + loop_control: + label: "{{ item.device }}" + +- name: Disks | Create mountpoint directories + ansible.builtin.file: + path: "{{ item.mountpoint }}" + state: directory + mode: "0755" + loop: "{{ longhorn_disks }}" + loop_control: + label: "{{ item.mountpoint }}" + +- name: Disks | Get UUID of each disk + ansible.builtin.command: blkid -s UUID -o value {{ item.device }} + register: _disk_uuids + changed_when: false + check_mode: false + loop: "{{ longhorn_disks }}" + loop_control: + label: "{{ item.device }}" + +- name: Disks | Mount disks persistently (UUID, nofail) + ansible.posix.mount: + path: "{{ item.item.mountpoint }}" + src: "UUID={{ item.stdout }}" + fstype: xfs + opts: defaults,nofail + state: mounted + loop: "{{ _disk_uuids.results }}" + loop_control: + label: "{{ item.item.mountpoint }}" diff --git a/roles/longhorn_prereqs/tasks/firewall.yml b/roles/longhorn_prereqs/tasks/firewall.yml new file mode 100644 index 0000000..16aed95 --- /dev/null +++ b/roles/longhorn_prereqs/tasks/firewall.yml @@ -0,0 +1,11 @@ +--- +- name: Firewall | Open NFS and portmapper ports for Longhorn + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: "{{ not ansible_check_mode }}" + loop: + - 2049/tcp # NFS (RWX volumes and backups) + - 111/tcp # portmapper (NFS) + - 111/udp # portmapper (NFS) diff --git a/roles/longhorn_prereqs/tasks/kernel.yml b/roles/longhorn_prereqs/tasks/kernel.yml new file mode 100644 index 0000000..11511b3 --- /dev/null +++ b/roles/longhorn_prereqs/tasks/kernel.yml @@ -0,0 +1,15 @@ +--- +- name: Kernel | Load Longhorn modules (persistent) + ansible.builtin.copy: + dest: /etc/modules-load.d/longhorn.conf + content: | + {% for mod in longhorn_kernel_modules %} + {{ mod }} + {% endfor %} + mode: "0644" + +- name: Kernel | Load Longhorn modules now + community.general.modprobe: + name: "{{ item }}" + state: present + loop: "{{ longhorn_kernel_modules }}" diff --git a/roles/longhorn_prereqs/tasks/main.yml b/roles/longhorn_prereqs/tasks/main.yml new file mode 100644 index 0000000..f6ef942 --- /dev/null +++ b/roles/longhorn_prereqs/tasks/main.yml @@ -0,0 +1,16 @@ +--- +- name: Packages + ansible.builtin.include_tasks: packages.yml + +- name: Kernel modules + ansible.builtin.include_tasks: kernel.yml + +- name: Firewall + ansible.builtin.include_tasks: firewall.yml + +- name: SELinux patch + ansible.builtin.include_tasks: selinux.yml + +- name: Disks + ansible.builtin.include_tasks: disks.yml + when: longhorn_disks | default([]) | length > 0 diff --git a/roles/longhorn_prereqs/tasks/packages.yml b/roles/longhorn_prereqs/tasks/packages.yml new file mode 100644 index 0000000..89c88cb --- /dev/null +++ b/roles/longhorn_prereqs/tasks/packages.yml @@ -0,0 +1,20 @@ +--- +- name: Packages | Install Longhorn dependencies + ansible.builtin.dnf: + name: + - iscsi-initiator-utils + - nfs-utils + - cryptsetup + state: present + +- name: Packages | Reload systemd after package install + ansible.builtin.systemd: + daemon_reload: true + when: not ansible_check_mode + +- name: Packages | Enable and start iscsid + ansible.builtin.systemd: + name: iscsid + state: started + enabled: true + when: not ansible_check_mode diff --git a/roles/longhorn_prereqs/tasks/selinux.yml b/roles/longhorn_prereqs/tasks/selinux.yml new file mode 100644 index 0000000..7a7bc06 --- /dev/null +++ b/roles/longhorn_prereqs/tasks/selinux.yml @@ -0,0 +1,20 @@ +--- +# Rocky Linux 9 with container-selinux > 2.189.0 blocks iscsiadm via SELinux, +# causing infinite attach/detach loops on Longhorn volumes. +- name: SELinux | Write iscsid CIL policy file + ansible.builtin.copy: + dest: /tmp/local_longhorn.cil + content: "(allow iscsid_t self (capability (dac_override)))\n" + mode: "0644" + +- name: SELinux | Check if Longhorn policy module is already loaded + ansible.builtin.command: semodule -l + register: _semodule_list + changed_when: false + check_mode: false + +- name: SELinux | Install iscsid CIL policy module + ansible.builtin.command: semodule -vi /tmp/local_longhorn.cil + when: "'local_longhorn' not in _semodule_list.stdout" + register: _semodule_install + changed_when: _semodule_install.rc == 0 diff --git a/roles/minio/README.md b/roles/minio/README.md new file mode 100644 index 0000000..851b4d8 --- /dev/null +++ b/roles/minio/README.md @@ -0,0 +1,347 @@ +# MinIO — стандарт объектного хранилища S3 в кластере + +MinIO — S3-совместимое объектное хранилище. Предоставляет S3 API (порт 9000) и веб-интерфейс Console (порт 9001). Запускается как standalone StatefulSet на worker-ноде, использует отдельный StorageClass `longhorn-minio` с политикой Retain. + +--- + +## Содержание + +1. [Архитектура](#1-архитектура) +2. [Предварительные условия](#2-предварительные-условия) +3. [Развёртывание](#3-развёртывание) +4. [StorageClass longhorn-minio](#4-storageclass-longhorn-minio) +5. [Traefik: порты 10005 и 10006](#5-traefik-порты-10005-и-10006) +6. [Пользователи и политики](#6-пользователи-и-политики) +7. [Бакеты и lifecycle](#7-бакеты-и-lifecycle) +8. [Интеграция с Loki](#8-интеграция-с-loki) +9. [Масштабирование](#9-масштабирование) +10. [Диагностика](#10-диагностика) + +--- + +## 1. Архитектура + +``` +k8s-worker-01 (10.203.0.96) +│ +├── MinIO Pod (StatefulSet, 1 replica, namespace: minio) +│ ├── порт 9000 — S3 API (aws s3, boto3, mc, s3cmd) +│ └── порт 9001 — Console UI (браузер) +│ +├── PVC 1 TiB → StorageClass longhorn-minio +│ └── Longhorn volume → longhorn-disk1 (/mnt/longhorn-disk1, ~4 TiB) +│ +└── Traefik DaemonSet + ├── :10005 → minio:9000 (S3 API, внешний доступ) + └── :10006 → minio-console:9001 (Console UI, внешний доступ) +``` + +### Адреса + +| Адрес | Назначение | +|---|---| +| `http://10.203.0.96:10005` | S3 API (внешний, через Traefik) | +| `http://10.203.0.96:10006` | Console UI (внешний, через Traefik) | +| `http://minio.minio.svc.cluster.local:9000` | S3 API (внутри кластера) | +| `http://minio-console.minio.svc.cluster.local:9001` | Console (внутри кластера) | + +--- + +## 2. Предварительные условия + +- Longhorn установлен и работает (`setup_longhorn.yml` выполнен) +- Traefik установлен (`setup_traefik.yml` выполнен; записи `minio-api` и `minio-console` добавлены в `traefik_port_map`) +- Secret `minio-root-credentials` создан вручную в namespace `minio`: + +```bash +kubectl create namespace minio + +kubectl create secret generic minio-root-credentials \ + --from-literal=rootUser=minioadmin \ + --from-literal=rootPassword='СИЛЬНЫЙ_ПАРОЛЬ_МИНИМУМ_8_СИМВОЛОВ' \ + -n minio +``` + +> Пароль — минимум 8 символов. MinIO не примет короткий пароль и не запустится. + +--- + +## 3. Развёртывание + +```bash +# Запуск через CI/CD (рекомендуется) +# GitLab → CI/CD → Pipelines → setup:minio → Run manually + +# Запуск локально +ansible-playbook -i inventory/prod playbooks/setup_minio.yml + +# После установки Traefik нужно перезапустить setup_traefik.yml, +# если записи minio-api и minio-console ещё не применены +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml +``` + +### Проверка после развёртывания + +```bash +# Статус пода +kubectl get pod -n minio + +# Логи +kubectl logs -n minio -l app=minio --tail=50 + +# Проброс порта для локальной проверки +kubectl port-forward -n minio svc/minio 9000:9000 & + +# Проверка S3 API через mc (MinIO Client) +mc alias set local http://localhost:9000 minioadmin ПАРОЛЬ +mc ls local +mc admin info local +``` + +--- + +## 4. StorageClass longhorn-minio + +Отдельный StorageClass с двумя ключевыми отличиями от дефолтного `longhorn`: + +| Параметр | `longhorn` (default) | `longhorn-minio` | +|---|---|---| +| `reclaimPolicy` | `Delete` | **`Retain`** | +| `numberOfReplicas` | `1` | `1` | +| `dataLocality` | `disabled` | `best-effort` | + +**Retain** — при удалении PVC или namespace `minio` данные на диске **не уничтожаются**. Для восстановления: пересоздать PVC с тем же `volumeName`, указав имя существующего Longhorn volume. + +### (Опционально) Изоляция MinIO на disk1 через тег + +Если нужно зафиксировать MinIO именно на `longhorn-disk1`, добавить тег через Longhorn API: + +```bash +kubectl -n longhorn-system patch node.longhorn.io k8s-worker-01 --type=json -p='[ + {"op":"add","path":"/spec/disks/longhorn-disk1/tags","value":["minio"]} +]' +``` + +Затем раскомментировать `diskSelector: "minio"` в `roles/minio/templates/storageclass.yml.j2` и перезапустить `setup_minio.yml`. + +Без тега Longhorn выбирает любой из доступных дисков — это тоже корректно. + +--- + +## 5. Traefik: порты 10005 и 10006 + +Записи в `inventory/prod/group_vars/traefik.yml`: + +```yaml +- name: minio-api + port: 10005 + backend: + namespace: minio + service: minio + port: 9000 + scheme: http + basicauth: + enabled: false # MinIO использует AWS Signature v4 + +- name: minio-console + port: 10006 + backend: + namespace: minio + service: minio-console + port: 9001 + scheme: http + basicauth: + enabled: false # Console защищён собственным логином +``` + +> BasicAuth от Traefik несовместим с MinIO Console — браузер не может пройти двойную аутентификацию. Console имеет свой логин. + +После изменения `traefik_port_map` перезапустить `setup_traefik.yml`. + +--- + +## 6. Пользователи и политики + +Не используйте root-учётные данные в приложениях. Для каждого сервиса — отдельный пользователь с ограниченной политикой. + +### Создание пользователей через mc + +```bash +# Настройка alias (через Traefik) +mc alias set prod http://10.203.0.96:10005 minioadmin ПАРОЛЬ + +# Пользователь для Loki (только бакет loki-chunks) +mc admin user add prod loki ПАРОЛЬ_LOKI + +mc admin policy create prod loki-policy /dev/stdin <<'EOF' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": [ + "arn:aws:s3:::loki-chunks", + "arn:aws:s3:::loki-chunks/*" + ] + } + ] +} +EOF + +mc admin policy attach prod loki-policy --user loki + +# Пользователь для бэкапов (только запись в backups) +mc admin user add prod backup ПАРОЛЬ_BACKUP + +mc admin policy create prod backup-policy /dev/stdin <<'EOF' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"], + "Resource": [ + "arn:aws:s3:::backups", + "arn:aws:s3:::backups/*" + ] + } + ] +} +EOF + +mc admin policy attach prod backup-policy --user backup +``` + +--- + +## 7. Бакеты и lifecycle + +Бакеты создаются автоматически при старте MinIO через `values.buckets`. Дефолтный список: `loki-chunks`, `backups`, `artifacts`. Изменить список — через переменную `minio_buckets` в `defaults/main.yml` или `group_vars`. + +### Lifecycle: автоудаление старых объектов + +```bash +# Удалять объекты в backups старше 30 дней +mc ilm rule add --expire-days 30 prod/backups + +# Включить версионирование бакета +mc version enable prod/backups + +# Посмотреть все версии объекта +mc ls --versions prod/backups/db-dump.sql.gz +``` + +--- + +## 8. Интеграция с Loki + +При переходе Loki на distributed-режим MinIO становится S3 backend. Внутрикластерный endpoint: + +```yaml +# В loki-values.yml (distributed режим) +loki: + storage: + type: s3 + s3: + endpoint: http://minio.minio.svc.cluster.local:9000 + region: us-east-1 # MinIO игнорирует region, но поле обязательно + bucketnames: loki-chunks + access_key_id: loki + secret_access_key: "ПАРОЛЬ_LOKI" + insecure: true # http, не https + s3forcepathstyle: true # обязательно для MinIO +``` + +Secret для Loki в namespace monitoring: + +```bash +kubectl create secret generic loki-minio-secret \ + --from-literal=AWS_ACCESS_KEY_ID=loki \ + --from-literal=AWS_SECRET_ACCESS_KEY='ПАРОЛЬ_LOKI' \ + -n monitoring +``` + +--- + +## 9. Масштабирование + +### Расширение PVC без остановки MinIO + +Longhorn поддерживает online-расширение PVC: + +```bash +kubectl patch pvc minio -n minio \ + -p '{"spec":{"resources":{"requests":{"storage":"2Ti"}}}}' + +# MinIO увидит новое пространство автоматически +mc admin info prod +``` + +### Переход на SNMD (4 диска на 1 ноде) + +При добавлении ещё 2 дисков к worker-ноде (итого 4 диска): + +```yaml +# minio-values.yml — SNMD (mode: distributed для 1 ноды с 4 дисками) +mode: distributed +replicas: 1 +drivesPerNode: 4 +persistence: + storageClass: longhorn-minio + size: 1Ti # 4 × 1 TiB raw → ~2 TiB usable (EC:2) +``` + +### Миграция standalone → distributed + +MinIO **не поддерживает** in-place миграцию. Процедура: + +``` +1. Запустить distributed MinIO рядом (namespace minio-dist) +2. mc mirror prod prod-dist --preserve --watch +3. Дождаться синхронизации (mc du для сверки объёмов) +4. Переключить Traefik (изменить service в traefik_port_map) +5. Обновить endpoint в Loki, бэкапах и других клиентах +6. Через 24–48 ч удалить standalone namespace и PVC +``` + +--- + +## 10. Диагностика + +```bash +# Статус пода и PVC +kubectl get pod,pvc -n minio + +# Логи MinIO +kubectl logs -n minio -l app=minio --tail=100 + +# Проверка S3 API напрямую +kubectl port-forward -n minio svc/minio 9000:9000 & +mc alias set local http://localhost:9000 minioadmin ПАРОЛЬ +mc admin info local + +# Проверка через Traefik +curl -I http://10.203.0.96:10005/minio/health/live + +# StorageClass +kubectl get storageclass longhorn-minio -o yaml + +# Longhorn volume для MinIO +kubectl get pvc -n minio +kubectl -n longhorn-system get volume + +# Console недоступна (redirect на неверный URL) +# → проверить MINIO_BROWSER_REDIRECT_URL в values +kubectl get pod -n minio -o yaml | grep MINIO_BROWSER_REDIRECT_URL +``` + +### Типичные ошибки + +| Симптом | Причина | Решение | +|---|---|---| +| Pod в состоянии `Pending` | PVC не создан (StorageClass не найден) | Проверить `kubectl get sc`, перезапустить `setup_minio.yml` | +| `Access Denied` при входе | Неверные данные в Secret | Пересоздать `minio-root-credentials` | +| Console → 401 после логина | Неверный `MINIO_BROWSER_REDIRECT_URL` | Исправить URL в values, перезапустить `setup_minio.yml` | +| Traefik → 404 на порту 10005/10006 | IngressRoute не применён | Перезапустить `setup_traefik.yml` | +| PVC завис в `Released` после удаления | reclaimPolicy: Retain (ожидаемо) | Удалить PV вручную или привязать к новому PVC | diff --git a/roles/minio/defaults/main.yml b/roles/minio/defaults/main.yml new file mode 100644 index 0000000..99a41c1 --- /dev/null +++ b/roles/minio/defaults/main.yml @@ -0,0 +1,23 @@ +--- +minio_namespace: minio +minio_chart_version: "5.4.0" +minio_image_tag: "RELEASE.2025-04-22T22-12-26Z" + +minio_storage_class: longhorn-minio +minio_storage_size: 1Ti + +minio_kubeconfig: "/home/{{ ansible_user }}/.kube/config" + +minio_console_url: "http://10.203.0.96:10006" +minio_api_external_url: "http://10.203.0.96:10005" + +minio_root_secret: minio-root-credentials + +minio_buckets: + - backups + - artifacts + +minio_resources_requests_memory: "512Mi" +minio_resources_requests_cpu: "250m" +minio_resources_limits_memory: "2Gi" +minio_resources_limits_cpu: "1000m" diff --git a/roles/minio/tasks/helm.yml b/roles/minio/tasks/helm.yml new file mode 100644 index 0000000..af62e1b --- /dev/null +++ b/roles/minio/tasks/helm.yml @@ -0,0 +1,37 @@ +--- +- name: Helm | Add MinIO chart repository + ansible.builtin.command: helm repo add minio https://charts.min.io/ + environment: + KUBECONFIG: "{{ minio_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Helm | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ minio_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Helm | Render MinIO values + ansible.builtin.template: + src: minio-values.yml.j2 + dest: /tmp/minio-values.yml + mode: "0600" + +- name: Helm | Install or upgrade MinIO + ansible.builtin.command: > + helm upgrade --install minio minio/minio + --namespace {{ minio_namespace }} + --create-namespace + --version {{ minio_chart_version }} + --values /tmp/minio-values.yml + --timeout 10m0s + --wait + environment: + KUBECONFIG: "{{ minio_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/minio/tasks/main.yml b/roles/minio/tasks/main.yml new file mode 100644 index 0000000..5d76e50 --- /dev/null +++ b/roles/minio/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: Apply StorageClass for MinIO + ansible.builtin.include_tasks: storageclass.yml + +- name: Create MinIO namespace + ansible.builtin.include_tasks: namespace.yml + +- name: Install MinIO via Helm + ansible.builtin.include_tasks: helm.yml diff --git a/roles/minio/tasks/namespace.yml b/roles/minio/tasks/namespace.yml new file mode 100644 index 0000000..a62510f --- /dev/null +++ b/roles/minio/tasks/namespace.yml @@ -0,0 +1,18 @@ +--- +- name: Namespace | Create minio namespace + ansible.builtin.command: kubectl create namespace {{ minio_namespace }} --dry-run=client -o yaml + environment: + KUBECONFIG: "{{ minio_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _ns_manifest + changed_when: false + +- name: Namespace | Apply minio namespace + ansible.builtin.command: kubectl apply -f - + args: + stdin: "{{ _ns_manifest.stdout }}" + environment: + KUBECONFIG: "{{ minio_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _ns_apply + changed_when: "'created' in _ns_apply.stdout" diff --git a/roles/minio/tasks/storageclass.yml b/roles/minio/tasks/storageclass.yml new file mode 100644 index 0000000..998d6e9 --- /dev/null +++ b/roles/minio/tasks/storageclass.yml @@ -0,0 +1,14 @@ +--- +- name: StorageClass | Render longhorn-minio manifest + ansible.builtin.template: + src: storageclass.yml.j2 + dest: /tmp/minio-storageclass.yml + mode: "0600" + +- name: StorageClass | Apply longhorn-minio + ansible.builtin.command: kubectl apply -f /tmp/minio-storageclass.yml + environment: + KUBECONFIG: "{{ minio_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _sc_apply + changed_when: "'configured' in _sc_apply.stdout or 'created' in _sc_apply.stdout" diff --git a/roles/minio/templates/minio-values.yml.j2 b/roles/minio/templates/minio-values.yml.j2 new file mode 100644 index 0000000..015f2c4 --- /dev/null +++ b/roles/minio/templates/minio-values.yml.j2 @@ -0,0 +1,68 @@ +## Режим: standalone (1 worker) +mode: standalone + +## Образ +image: + repository: quay.io/minio/minio + tag: {{ minio_image_tag }} + pullPolicy: IfNotPresent + +## Корневые учётные данные из Secret +existingSecret: {{ minio_root_secret }} + +## Хранилище +persistence: + enabled: true + storageClass: {{ minio_storage_class }} + accessMode: ReadWriteOnce + size: {{ minio_storage_size }} + +## Ресурсы пода +resources: + requests: + memory: {{ minio_resources_requests_memory }} + cpu: {{ minio_resources_requests_cpu }} + limits: + memory: {{ minio_resources_limits_memory }} + cpu: {{ minio_resources_limits_cpu }} + +## Запускать только на worker-нодах (не на control plane) +affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: node-role.kubernetes.io/control-plane + operator: DoesNotExist + +## Сервисы +service: + type: ClusterIP + port: 9000 + +consoleService: + type: ClusterIP + port: 9001 + +## Бакеты создаются автоматически при старте +buckets: +{% for bucket in minio_buckets %} + - name: {{ bucket }} + policy: none + purge: false +{% endfor %} + +## Метрики (включить когда появится Prometheus) +metrics: + serviceMonitor: + enabled: false + +## Console Ingress отключён — используем Traefik +consoleIngress: + enabled: false + +## Окружение MinIO +environment: + MINIO_BROWSER_REDIRECT_URL: "{{ minio_console_url }}" + MINIO_STORAGE_CLASS_STANDARD: "EC:0" + MINIO_UPDATE: "off" diff --git a/roles/minio/templates/storageclass.yml.j2 b/roles/minio/templates/storageclass.yml.j2 new file mode 100644 index 0000000..15620af --- /dev/null +++ b/roles/minio/templates/storageclass.yml.j2 @@ -0,0 +1,12 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: longhorn-minio +provisioner: driver.longhorn.io +reclaimPolicy: Retain +allowVolumeExpansion: true +parameters: + numberOfReplicas: "1" + dataLocality: "best-effort" + fsType: "ext4" + dataEngine: "v1" diff --git a/roles/sealed_secrets/defaults/main.yml b/roles/sealed_secrets/defaults/main.yml new file mode 100644 index 0000000..7358396 --- /dev/null +++ b/roles/sealed_secrets/defaults/main.yml @@ -0,0 +1,11 @@ +--- +sealed_secrets_namespace: kube-system +sealed_secrets_chart_version: "2.19.0" + +# kubeseal CLI version (empty = latest from GitHub) +sealed_secrets_cli_version: "" + +sealed_secrets_kubeconfig: "/home/{{ ansible_user }}/.kube/config" + +# Path on the manager node where the master key backup is saved (mode 0600) +sealed_secrets_backup_path: "/home/{{ ansible_user }}/.kube/sealed-secrets-key-backup.yaml" diff --git a/roles/sealed_secrets/tasks/backup.yml b/roles/sealed_secrets/tasks/backup.yml new file mode 100644 index 0000000..1840721 --- /dev/null +++ b/roles/sealed_secrets/tasks/backup.yml @@ -0,0 +1,33 @@ +--- +- name: Backup | Wait for sealed-secrets key Secret to be created + ansible.builtin.command: > + kubectl get secret + -n {{ sealed_secrets_namespace }} + -l sealedsecrets.bitnami.com/sealed-secrets-key + --no-headers + environment: + KUBECONFIG: "{{ sealed_secrets_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _key_check + until: _key_check.stdout != "" + retries: 12 + delay: 10 + changed_when: false + +- name: Backup | Export sealed-secrets master key + ansible.builtin.command: > + kubectl get secret + -n {{ sealed_secrets_namespace }} + -l sealedsecrets.bitnami.com/sealed-secrets-key + -o yaml + environment: + KUBECONFIG: "{{ sealed_secrets_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _key_yaml + changed_when: false + +- name: Backup | Save key to {{ sealed_secrets_backup_path }} + ansible.builtin.copy: + content: "{{ _key_yaml.stdout }}" + dest: "{{ sealed_secrets_backup_path }}" + mode: "0600" diff --git a/roles/sealed_secrets/tasks/cli.yml b/roles/sealed_secrets/tasks/cli.yml new file mode 100644 index 0000000..1b20d7d --- /dev/null +++ b/roles/sealed_secrets/tasks/cli.yml @@ -0,0 +1,44 @@ +--- +- name: kubeseal CLI | Get latest version from GitHub + ansible.builtin.uri: + url: https://api.github.com/repos/bitnami-labs/sealed-secrets/releases/latest + return_content: true + register: _kubeseal_latest + when: not sealed_secrets_cli_version + check_mode: false + +- name: kubeseal CLI | Set version fact (latest) + ansible.builtin.set_fact: + _kubeseal_version: "{{ _kubeseal_latest.json.tag_name | regex_replace('^v', '') }}" + when: not sealed_secrets_cli_version + +- name: kubeseal CLI | Set version fact (pinned) + ansible.builtin.set_fact: + _kubeseal_version: "{{ sealed_secrets_cli_version | regex_replace('^v', '') }}" + when: sealed_secrets_cli_version + +- name: kubeseal CLI | Check installed version + ansible.builtin.command: kubeseal --version + register: _kubeseal_installed + changed_when: false + failed_when: false + +- name: kubeseal CLI | Download and unpack + ansible.builtin.unarchive: + src: "https://github.com/bitnami-labs/sealed-secrets/releases/download/v{{ _kubeseal_version }}/kubeseal-{{ _kubeseal_version }}-linux-amd64.tar.gz" + dest: /usr/local/bin + remote_src: true + include: + - kubeseal + mode: "0755" + owner: root + group: root + when: > + _kubeseal_installed.rc != 0 or + _kubeseal_version not in _kubeseal_installed.stdout + +- name: kubeseal CLI | Enable bash completion + ansible.builtin.shell: /usr/local/bin/kubeseal --help > /dev/null && echo done + args: + creates: /etc/bash_completion.d/kubeseal + changed_when: false diff --git a/roles/sealed_secrets/tasks/helm.yml b/roles/sealed_secrets/tasks/helm.yml new file mode 100644 index 0000000..5822c05 --- /dev/null +++ b/roles/sealed_secrets/tasks/helm.yml @@ -0,0 +1,31 @@ +--- +- name: Helm | Add Sealed Secrets chart repository + ansible.builtin.command: > + helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets + environment: + KUBECONFIG: "{{ sealed_secrets_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Helm | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ sealed_secrets_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Helm | Install or upgrade Sealed Secrets controller + ansible.builtin.command: > + helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets + --namespace {{ sealed_secrets_namespace }} + --version {{ sealed_secrets_chart_version }} + --set fullnameOverride=sealed-secrets-controller + --timeout 5m0s + --wait + environment: + KUBECONFIG: "{{ sealed_secrets_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" diff --git a/roles/sealed_secrets/tasks/main.yml b/roles/sealed_secrets/tasks/main.yml new file mode 100644 index 0000000..6a056c0 --- /dev/null +++ b/roles/sealed_secrets/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: Install kubeseal CLI + ansible.builtin.include_tasks: cli.yml + +- name: Deploy Sealed Secrets controller via Helm + ansible.builtin.include_tasks: helm.yml + +- name: Backup sealed-secrets master key + ansible.builtin.include_tasks: backup.yml diff --git a/roles/traefik/README.md b/roles/traefik/README.md new file mode 100644 index 0000000..6a2cbef --- /dev/null +++ b/roles/traefik/README.md @@ -0,0 +1,485 @@ +# Traefik — стандарт публикации сервисов в кластере + +Этот документ описывает стандарт подключения новых HTTP-сервисов к Traefik в нашем кластере. +Является эталоном для разработчиков и DevOps при добавлении любого нового сервиса. + +--- + +## Содержание + +1. [Архитектура: как Traefik работает в кластере](#1-архитектура-как-traefik-работает-в-кластере) +2. [Единственный способ добавить сервис: traefik_port_map](#2-единственный-способ-добавить-сервис-traefik_port_map) +3. [Пример 1: сервис без аутентификации](#3-пример-1-сервис-без-аутентификации) +4. [Пример 2: сервис с BasicAuth](#4-пример-2-сервис-с-basicauth) +5. [Пример 3: сервис в том же namespace что и Traefik](#5-пример-3-сервис-в-том-же-namespace-что-и-traefik) +6. [Пример 4: несколько маршрутов для одного приложения](#6-пример-4-несколько-маршрутов-для-одного-приложения) +7. [Создание BasicAuth-секрета](#7-создание-basicauth-секрета) +8. [Добавление нового сервиса: пошаговая инструкция](#8-добавление-нового-сервиса-пошаговая-инструкция) +9. [Ограничения и правила именования](#9-ограничения-и-правила-именования) +10. [Диагностика и типичные ошибки](#10-диагностика-и-типичные-ошибки) +11. [Таблица активных сервисов](#11-таблица-активных-сервисов) + +--- + +## 1. Архитектура: как Traefik работает в кластере + +### Схема + +``` +Пользователь + │ + │ HTTP :10001, :10002, :10003, ... (порты 10000-10999) + ▼ +k8s-worker-01 (10.203.0.96) + │ + │ hostPort (DaemonSet Pod слушает на хосте) + ▼ +Traefik Pod (namespace: traefik) + │ + │ IngressRoute CRD → EntryPoint → Service + │ + ├──► kubernetes-dashboard.kubernetes-dashboard:443 + ├──► longhorn-frontend.longhorn-system:80 + └──► my-service.my-namespace:8080 +``` + +### Ключевые решения архитектуры + +| Параметр | Значение | Причина | +|---|---|---| +| Режим деплоя | **DaemonSet** | Pod на каждой worker-ноде; при добавлении нод — автоматически | +| Тип доступа | **hostPort** | Прямое связывание порта Pod с портом хоста; не нужен NodePort или LoadBalancer | +| Маршрутизация | **IngressRoute CRD** | Не Ingress; только CRD Traefik — полный контроль над entryPoint | +| Namespace | `traefik` | Изолированный namespace; IngressRoute живут здесь, сервисы — в своих | +| TLS | **выключен** | Внутренняя сеть, доверенные клиенты; TLS добавить при необходимости | +| hostname routing | **нет** | Каждый сервис на своём порту; нет нужды в DNS-именах | +| `allowCrossNamespace` | **true** | IngressRoute в `traefik` ссылается на Service в других namespace | + +### Диапазон портов + +``` +10000–10999 — зарезервировано для Traefik-сервисов + открыт на firewalld на всех worker-нодах +``` + +Порт `10000` — зарезервирован (не использовать). +Порты `10001–10999` — свободны для сервисов, назначаются последовательно. + +--- + +## 2. Единственный способ добавить сервис: traefik_port_map + +Вся топология Traefik определяется через одну переменную в `inventory/prod/group_vars/traefik.yml`. +**Не создавайте IngressRoute и Middleware вручную** — они будут перезаписаны при следующем запуске плейбука. + +### Структура записи в traefik_port_map + +```yaml +traefik_port_map: + - name: my-service # ≤15 символов! становится именем entryPoint и ресурсов + description: "Описание" # только для читаемости, не влияет на деплой + port: 10003 # уникальный порт из диапазона 10001-10999 + backend: + namespace: my-namespace # namespace, где живёт целевой Service + service: my-service-svc # имя Service (не Pod, не Deployment) + port: 8080 # порт Service (не containerPort) + scheme: http # http или https (если backend использует TLS) + basicauth: + enabled: false # true — включить BasicAuth для этого маршрута + # secret_name: traefik-auth-my-service # нужно только если enabled: true +``` + +### Что происходит после добавления записи + +При запуске `ansible-playbook playbooks/setup_traefik.yml`: + +1. **Helm** перерендеривает `traefik-values.yml` — добавляет новый entryPoint `my-service` на порт 10003 +2. **Middleware** (если `basicauth.enabled: true`) — создаёт объект `Middleware` в namespace `traefik` +3. **IngressRoute** — создаёт объект `IngressRoute` в namespace `traefik`, ссылающийся на backend Service +4. **Helm upgrade** применяет изменения; Traefik Pod перезапускается (DaemonSet rolling update) + +--- + +## 3. Пример 1: сервис без аутентификации + +**Сценарий:** развернули веб-приложение `my-app` в namespace `my-app`, хотим сделать его доступным на порту 10003. + +### Шаг 1: убедиться что Service существует + +```yaml +# Это должно быть в манифестах приложения (fleet-repo или kubectl apply) +apiVersion: v1 +kind: Service +metadata: + name: my-app + namespace: my-app +spec: + selector: + app: my-app + ports: + - port: 80 + targetPort: 8080 + type: ClusterIP # ClusterIP достаточно — Traefik обращается по внутренней сети +``` + +### Шаг 2: добавить запись в traefik_port_map + +```yaml +# inventory/prod/group_vars/traefik.yml +traefik_port_map: + # ... существующие записи ... + + - name: my-app # ≤15 символов + description: "My Application" + port: 10003 + backend: + namespace: my-app + service: my-app + port: 80 + scheme: http + basicauth: + enabled: false +``` + +### Шаг 3: запустить плейбук + +```bash +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml +``` + +### Шаг 4: проверить доступность + +```bash +# С любой машины в сети +curl http://10.203.0.96:10003/ + +# Проверить что IngressRoute создан +kubectl get ingressroute -n traefik +kubectl describe ingressroute route-my-app -n traefik +``` + +--- + +## 4. Пример 2: сервис с BasicAuth + +**Сценарий:** Grafana в namespace `monitoring`, доступ только с паролем. + +### Шаг 1: создать Secret с учётными данными + +```bash +# Сгенерировать хэш пароля и создать Secret вручную (один раз) +kubectl create secret generic traefik-auth-grafana \ + --from-literal=users="$(openssl passwd -apr1 'SecurePassword' | xargs -I{} echo 'admin:{}')" \ + -n traefik +``` + +> Secret создаётся **в namespace `traefik`**, а не в namespace приложения. + +### Шаг 2: добавить запись + +```yaml +# inventory/prod/group_vars/traefik.yml +traefik_port_map: + - name: grafana + description: "Grafana Monitoring Dashboard" + port: 10004 + backend: + namespace: monitoring + service: grafana + port: 3000 + scheme: http + basicauth: + enabled: true + secret_name: traefik-auth-grafana # имя Secret из шага 1 +``` + +### Шаг 3: запустить плейбук + +```bash +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml +``` + +Плейбук создаст `Middleware` объект: + +```yaml +# Автогенерируется из basicauth-middleware.yml.j2 +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: basicauth-grafana + namespace: traefik +spec: + basicAuth: + secret: traefik-auth-grafana + removeHeader: true # не передавать Authorization header в backend +``` + +### Шаг 4: проверить + +```bash +# Без пароля — должен вернуть 401 +curl -v http://10.203.0.96:10004/ + +# С паролем — должен вернуть 200 +curl -u admin:SecurePassword http://10.203.0.96:10004/ +``` + +--- + +## 5. Пример 3: сервис в том же namespace что и Traefik + +**Сценарий:** какое-то приложение развёрнуто прямо в namespace `traefik` (нетипично, но возможно). + +Для сервисов в том же namespace `traefik` параметр `allowCrossNamespace` не нужен — он уже есть. +Запись в `traefik_port_map` стандартная, просто `namespace: traefik`: + +```yaml + - name: whoami + description: "Debug: whoami service" + port: 10010 + backend: + namespace: traefik # тот же namespace + service: whoami + port: 80 + scheme: http + basicauth: + enabled: false +``` + +--- + +## 6. Пример 4: несколько маршрутов для одного приложения + +**Сценарий:** у приложения есть отдельный API и UI, их нужно опубликовать на разных портах независимо (например, для разных уровней доступа). + +```yaml +traefik_port_map: + # UI — с BasicAuth + - name: myapp-ui + description: "MyApp Frontend" + port: 10005 + backend: + namespace: myapp + service: myapp-frontend + port: 80 + scheme: http + basicauth: + enabled: true + secret_name: traefik-auth-myapp + + # API — без BasicAuth (аутентификация на уровне приложения) + - name: myapp-api + description: "MyApp Backend API" + port: 10006 + backend: + namespace: myapp + service: myapp-backend + port: 8080 + scheme: http + basicauth: + enabled: false +``` + +> **Почему не один порт с path-routing?** Traefik в нашей конфигурации использует `PathPrefix(/)` для всех маршрутов — это намеренное упрощение. Каждый сервис получает свой порт. Path-routing возможен, но усложняет конфигурацию и не соответствует нашему стандарту порт-прокси. + +--- + +## 7. Создание BasicAuth-секрета + +### Формат + +Secret должен содержать ключ `users` с htpasswd-строками (один пользователь на строку): + +``` +admin:$apr1$xyz$hashedpassword +``` + +### Один пользователь + +```bash +kubectl create secret generic traefik-auth- \ + --from-literal=users="$(openssl passwd -apr1 'PASSWORD' | xargs -I{} echo 'USERNAME:{}')" \ + -n traefik +``` + +### Несколько пользователей + +```bash +# Сгенерировать файл htpasswd +htpasswd -c /tmp/auth admin # первый пользователь (создаёт файл) +htpasswd /tmp/auth developer # второй пользователь (добавляет) + +# Создать Secret из файла +kubectl create secret generic traefik-auth-myapp \ + --from-file=users=/tmp/auth \ + -n traefik + +rm /tmp/auth +``` + +### Обновить пароль существующего пользователя + +```bash +# Удалить старый Secret и создать новый — Traefik подхватит без перезапуска +kubectl delete secret traefik-auth-myapp -n traefik + +kubectl create secret generic traefik-auth-myapp \ + --from-literal=users="$(openssl passwd -apr1 'NewPassword' | xargs -I{} echo 'admin:{}')" \ + -n traefik +``` + +> Traefik читает Secret динамически. После замены Secret новые пароли начинают работать в течение 30-60 секунд без перезапуска Pod. + +### Именование секретов + +Формат: `traefik-auth-`, где `` совпадает с полем `name` в `traefik_port_map`. +Пример: `name: grafana` → `secret_name: traefik-auth-grafana`. + +--- + +## 8. Добавление нового сервиса: пошаговая инструкция + +``` +1. Выбрать свободный порт из диапазона 10001-10999 + └─ Проверить таблицу активных сервисов в этом README + +2. Придумать name (≤15 символов, только строчные буквы, цифры, дефис) + └─ Пример: my-service, grafana, pgadmin, api-v2 + +3. Убедиться что Service существует в кластере + └─ kubectl get svc -n + +4. (если BasicAuth нужен) Создать Secret в namespace traefik + └─ kubectl create secret generic traefik-auth- ... + +5. Добавить запись в inventory/prod/group_vars/traefik.yml + +6. Обновить таблицу в этом README (раздел 11) + +7. Запустить плейбук: + └─ ansible-playbook -i inventory/prod playbooks/setup_traefik.yml + +8. Проверить: + └─ curl http://10.203.0.96:/ + └─ kubectl get ingressroute -n traefik + └─ kubectl describe ingressroute route- -n traefik +``` + +--- + +## 9. Ограничения и правила именования + +### Критические ограничения + +| Ограничение | Причина | Последствие нарушения | +|---|---|---| +| `name` ≤ 15 символов | Используется как имя Kubernetes container port | `helm upgrade` падает с ошибкой валидации | +| `name` только `[a-z0-9-]` | Kubernetes naming convention | Ошибка применения манифеста | +| Порты уникальны | hostPort — один порт = один Pod на ноде | Конфликт портов, Pod не стартует | +| Secret в namespace `traefik` | IngressRoute и Middleware в `traefik` | Middleware не найдёт Secret, маршрут недоступен (404) | +| Service тип ClusterIP | Достаточно для Traefik → Service связки | NodePort и LoadBalancer избыточны | + +### Что происходит при отсутствии BasicAuth-секрета + +Если Secret указан в `secret_name`, но не существует: +- Traefik **полностью отключает маршрут** (возвращает 404, не 401) +- Другие маршруты продолжают работать +- В логах Traefik: `middleware "traefik/basicauth-X" does not exist` + +**Всегда создавайте Secret до запуска плейбука.** + +### Rolling update DaemonSet: возможное зависание + +При обновлении Traefik (новый Helm values) DaemonSet пересоздаёт Pod на каждой ноде. +Если старый Pod не успел завершиться, новый не может занять hostPort: + +```bash +# Симптом: Pod застрял в Pending +kubectl get pods -n traefik -o wide + +# Решение: принудительно удалить старые Pod +kubectl delete pod -n traefik -l app.kubernetes.io/name=traefik +``` + +--- + +## 10. Диагностика и типичные ошибки + +### Основные команды + +```bash +# Список всех IngressRoute +kubectl get ingressroute -n traefik + +# Детали конкретного маршрута +kubectl describe ingressroute route-my-service -n traefik + +# Список Middleware +kubectl get middleware -n traefik + +# Логи Traefik (последние 100 строк) +kubectl logs -n traefik -l app.kubernetes.io/name=traefik --tail=100 + +# Access логи (каждый запрос) +kubectl logs -n traefik -l app.kubernetes.io/name=traefik --tail=50 | grep '"method"' + +# Traefik Dashboard (если включён — у нас выключен по умолчанию) +# kubectl port-forward -n traefik svc/traefik 9000:9000 +# open http://localhost:9000/dashboard/ + +# Проверить что порт слушается на хосте +# (выполнить через SSH на worker-ноде) +ss -tlnp | grep 10003 + +# Проверить firewalld +firewall-cmd --list-ports | grep 10000-10999 +``` + +### Таблица ошибок + +| Симптом | Причина | Решение | +|---|---|---| +| 404 на порту | IngressRoute не создан | Проверить `kubectl get ingressroute -n traefik` | +| 404 при включённом BasicAuth | Secret не существует | Создать Secret в namespace `traefik` | +| 401 без запроса пароля | BasicAuth не применился | Проверить `kubectl get middleware -n traefik` | +| Connection refused | Порт не открыт на firewall | `firewall-cmd --add-port=10003/tcp --permanent && firewall-cmd --reload` | +| Connection refused | Traefik Pod не запущен | `kubectl get pods -n traefik` | +| Bad Gateway (502) | Backend Service недоступен | `kubectl get svc -n `, проверить Endpoints | +| `helm upgrade` ошибка | Имя `name` > 15 символов | Сократить имя в `traefik_port_map` | +| Pod застрял в Pending | hostPort конфликт | Удалить старый Pod вручную | + +### Проверка backend: доступен ли Service из Pod Traefik + +```bash +# Запустить временный Pod и обратиться к backend +kubectl run -it --rm debug --image=curlimages/curl --restart=Never \ + -- curl -v http://my-service.my-namespace.svc.cluster.local:80/health + +# Или через exec в Pod Traefik +kubectl exec -n traefik \ + $(kubectl get pod -n traefik -l app.kubernetes.io/name=traefik -o name | head -1) \ + -- wget -qO- http://my-service.my-namespace.svc.cluster.local:80/health +``` + +### Проверка конфигурации без применения + +```bash +# Посмотреть что сгенерирует шаблон (dry-run Ansible) +ansible-playbook -i inventory/prod playbooks/setup_traefik.yml --check --diff + +# Просмотреть текущий Helm values в кластере +helm get values traefik -n traefik +``` + +--- + +## 11. Таблица активных сервисов + +Обновлять при каждом добавлении или удалении сервиса. + +| Порт | Name | Сервис | Namespace | BasicAuth | Secret | +|---|---|---|---|---|---| +| 10001 | `k8s-dashboard` | `kubernetes-dashboard:443` | `kubernetes-dashboard` | нет | — | +| 10002 | `longhorn-ui` | `longhorn-frontend:80` | `longhorn-system` | **да** | `traefik-auth-longhorn` | + +**Следующий свободный порт: 10003** diff --git a/roles/traefik/defaults/main.yml b/roles/traefik/defaults/main.yml new file mode 100644 index 0000000..74e04d5 --- /dev/null +++ b/roles/traefik/defaults/main.yml @@ -0,0 +1,5 @@ +--- +traefik_chart_version: "32.1.0" +traefik_namespace: traefik +traefik_kubeconfig: "/home/{{ ansible_user }}/.kube/config" +traefik_port_map: [] diff --git a/roles/traefik/tasks/firewall.yml b/roles/traefik/tasks/firewall.yml new file mode 100644 index 0000000..d8bb110 --- /dev/null +++ b/roles/traefik/tasks/firewall.yml @@ -0,0 +1,21 @@ +--- +- name: Firewall | Open Traefik service port range on workers + ansible.posix.firewalld: + port: "10000-10999/tcp" + permanent: true + state: enabled + immediate: true + delegate_to: "{{ item }}" + loop: "{{ groups['workers'] }}" + +- name: Firewall | Trust CNI interfaces on workers + ansible.posix.firewalld: + zone: trusted + interface: "{{ item.1 }}" + permanent: true + state: enabled + immediate: true + loop: "{{ groups['workers'] | product(['flannel.1', 'cni0']) | list }}" + loop_control: + label: "{{ item.0 }} {{ item.1 }}" + delegate_to: "{{ item.0 }}" diff --git a/roles/traefik/tasks/helm.yml b/roles/traefik/tasks/helm.yml new file mode 100644 index 0000000..37d6143 --- /dev/null +++ b/roles/traefik/tasks/helm.yml @@ -0,0 +1,51 @@ +--- +- name: Helm | Add Traefik chart repository + ansible.builtin.command: helm repo add traefik https://traefik.github.io/charts + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_repo_add + changed_when: "'already exists' not in _helm_repo_add.stdout" + failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout + +- name: Helm | Update chart repositories + ansible.builtin.command: helm repo update + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + changed_when: false + +- name: Helm | Render Traefik values + ansible.builtin.template: + src: traefik-values.yml.j2 + dest: /tmp/traefik-values.yml + mode: "0600" + +- name: Helm | Install or upgrade Traefik + ansible.builtin.command: > + helm upgrade --install traefik traefik/traefik + --namespace {{ traefik_namespace }} + --create-namespace + --version {{ traefik_chart_version }} + --values /tmp/traefik-values.yml + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + register: _helm_install + changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout" + +- name: Helm | Force rollout restart to apply new hostPorts + ansible.builtin.command: > + kubectl rollout restart daemonset/traefik -n {{ traefik_namespace }} + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + when: _helm_install.changed + +- name: Helm | Wait for Traefik rollout to complete + ansible.builtin.command: > + kubectl rollout status daemonset/traefik -n {{ traefik_namespace }} --timeout=120s + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + when: _helm_install.changed diff --git a/roles/traefik/tasks/main.yml b/roles/traefik/tasks/main.yml new file mode 100644 index 0000000..0ac1158 --- /dev/null +++ b/roles/traefik/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: Install Traefik via Helm + ansible.builtin.include_tasks: helm.yml + +- name: Apply BasicAuth Middleware CRDs + ansible.builtin.include_tasks: middleware.yml + +- name: Apply IngressRoute CRDs + ansible.builtin.include_tasks: routes.yml diff --git a/roles/traefik/tasks/middleware.yml b/roles/traefik/tasks/middleware.yml new file mode 100644 index 0000000..6f485f1 --- /dev/null +++ b/roles/traefik/tasks/middleware.yml @@ -0,0 +1,21 @@ +--- +- name: Middleware | Render BasicAuth CRD manifests + ansible.builtin.template: + src: basicauth-middleware.yml.j2 + dest: "/tmp/traefik-middleware-{{ item.name }}.yml" + mode: "0600" + loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}" + loop_control: + label: "{{ item.name }}" + +- name: Middleware | Apply BasicAuth CRD manifests + ansible.builtin.command: > + kubectl apply -f /tmp/traefik-middleware-{{ item.name }}.yml + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}" + loop_control: + label: "{{ item.name }}" + register: _mw_apply + changed_when: "'configured' in _mw_apply.stdout or 'created' in _mw_apply.stdout" diff --git a/roles/traefik/tasks/routes.yml b/roles/traefik/tasks/routes.yml new file mode 100644 index 0000000..1bf90eb --- /dev/null +++ b/roles/traefik/tasks/routes.yml @@ -0,0 +1,42 @@ +--- +- name: Routes | Render HTTP IngressRoute CRD manifests + ansible.builtin.template: + src: ingressroute.yml.j2 + dest: "/tmp/traefik-route-{{ item.name }}.yml" + mode: "0600" + loop: "{{ (traefik_port_map | rejectattr('protocol', 'defined') | list) + (traefik_port_map | selectattr('protocol', 'defined') | rejectattr('protocol', 'equalto', 'tcp') | list) }}" + loop_control: + label: "{{ item.name }}" + +- name: Routes | Apply HTTP IngressRoute CRD manifests + ansible.builtin.command: > + kubectl apply -f /tmp/traefik-route-{{ item.name }}.yml + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + loop: "{{ (traefik_port_map | rejectattr('protocol', 'defined') | list) + (traefik_port_map | selectattr('protocol', 'defined') | rejectattr('protocol', 'equalto', 'tcp') | list) }}" + loop_control: + label: "{{ item.name }}" + register: _route_apply + changed_when: "'configured' in _route_apply.stdout or 'created' in _route_apply.stdout" + +- name: Routes | Render TCP IngressRouteTCP CRD manifests + ansible.builtin.template: + src: ingressroute-tcp.yml.j2 + dest: "/tmp/traefik-route-{{ item.name }}.yml" + mode: "0600" + loop: "{{ traefik_port_map | selectattr('protocol', 'defined') | selectattr('protocol', 'equalto', 'tcp') | list }}" + loop_control: + label: "{{ item.name }}" + +- name: Routes | Apply TCP IngressRouteTCP CRD manifests + ansible.builtin.command: > + kubectl apply -f /tmp/traefik-route-{{ item.name }}.yml + environment: + KUBECONFIG: "{{ traefik_kubeconfig }}" + PATH: "/usr/local/bin:/usr/bin:/bin" + loop: "{{ traefik_port_map | selectattr('protocol', 'defined') | selectattr('protocol', 'equalto', 'tcp') | list }}" + loop_control: + label: "{{ item.name }}" + register: _route_tcp_apply + changed_when: "'configured' in _route_tcp_apply.stdout or 'created' in _route_tcp_apply.stdout" diff --git a/roles/traefik/templates/basicauth-middleware.yml.j2 b/roles/traefik/templates/basicauth-middleware.yml.j2 new file mode 100644 index 0000000..a541145 --- /dev/null +++ b/roles/traefik/templates/basicauth-middleware.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: basicauth-{{ item.name }} + namespace: {{ traefik_namespace }} +spec: + basicAuth: + secret: {{ item.basicauth.secret_name }} + removeHeader: true diff --git a/roles/traefik/templates/ingressroute-tcp.yml.j2 b/roles/traefik/templates/ingressroute-tcp.yml.j2 new file mode 100644 index 0000000..ff48b83 --- /dev/null +++ b/roles/traefik/templates/ingressroute-tcp.yml.j2 @@ -0,0 +1,14 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRouteTCP +metadata: + name: route-{{ item.name }} + namespace: {{ traefik_namespace }} +spec: + entryPoints: + - {{ item.name }} + routes: + - match: HostSNI(`*`) + services: + - name: {{ item.backend.service }} + namespace: {{ item.backend.namespace }} + port: {{ item.backend.port }} diff --git a/roles/traefik/templates/ingressroute.yml.j2 b/roles/traefik/templates/ingressroute.yml.j2 new file mode 100644 index 0000000..7e44193 --- /dev/null +++ b/roles/traefik/templates/ingressroute.yml.j2 @@ -0,0 +1,21 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: route-{{ item.name }} + namespace: {{ traefik_namespace }} +spec: + entryPoints: + - {{ item.name }} + routes: + - match: PathPrefix(`/`) + kind: Rule + services: + - name: {{ item.backend.service }} + namespace: {{ item.backend.namespace }} + port: {{ item.backend.port }} + scheme: {{ item.backend.scheme }} +{% if item.basicauth.enabled | default(false) %} + middlewares: + - name: basicauth-{{ item.name }} + namespace: {{ traefik_namespace }} +{% endif %} diff --git a/roles/traefik/templates/traefik-values.yml.j2 b/roles/traefik/templates/traefik-values.yml.j2 new file mode 100644 index 0000000..5708b54 --- /dev/null +++ b/roles/traefik/templates/traefik-values.yml.j2 @@ -0,0 +1,52 @@ +deployment: + kind: DaemonSet + podLabels: {} + +updateStrategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 0 + +nodeSelector: + kubernetes.io/os: linux + +ports: +{% for svc in traefik_port_map %} + {{ svc.name }}: + port: {{ svc.port }} + hostPort: {{ svc.port }} + expose: + default: true + exposedPort: {{ svc.port }} + protocol: TCP +{% endfor %} + web: + expose: + default: false + websecure: + expose: + default: false + +service: + enabled: false + +ingressRoute: + dashboard: + enabled: false + +providers: + kubernetesCRD: + enabled: true + allowCrossNamespace: true + kubernetesIngress: + enabled: false + +persistence: + enabled: false + +logs: + general: + level: INFO + access: + enabled: true