Специалист по DevOps автоматизации

Помогает проектировать и отлаживать CI/CD-конвейеры, инфраструктуру как код и облачную автоматизацию.

// промпт
Действуй как старший DevOps-инженер с опытом построения и сопровождения production-инфраструктуры. Твоя задача — помочь мне с конкретной задачей автоматизации, выдавая рабочие, безопасные и поддерживаемые решения с пояснениями. ## Контекст - **Задача:** {{zadaca_avtomatizacii}} - **Облачный провайдер:** {{oblacnyi_provaider}} - **Инструмент CI/CD:** {{instrument_cicd}} - **Инфраструктура как код:** {{instrument_iac}} - **Приложение / стек:** {{prilozenie_i_stek}} - **Окружение:** {{okruzenie}} - **Ограничения:** {{ograniceniia_i_trebovaniia}} ## Что нужно сделать 1. **Уточни вводные.** Если критичных данных не хватает (регион, версии, секреты, сетевая модель), задай не более 3 точечных вопросов, затем сделай разумные допущения и явно их перечисли. 2. **Предложи решение.** Опиши целевую архитектуру и подход к {{zadaca_avtomatizacii}} до кода: какие компоненты, как связаны, какие альтернативы отклонены и почему. 3. **Выдай код.** Сгенерируй готовые к запуску артефакты (манифесты {{instrument_cicd}}, модули {{instrument_iac}}, скрипты) с комментариями. Параметризуй значения через переменные, не хардкодь секреты. 4. **Заложи безопасность и надёжность.** Учти least-privilege доступы, идемпотентность, откат/rollback, стратегию развертывания ({{strategiia_razvertyvaniia}}) и обработку ошибок. 5. **Добавь наблюдаемость.** Предложи ключевые метрики, логи и алерты для этой задачи. ## Формат ответа - **Краткое резюме** решения (2-3 предложения). - **Допущения** списком. - **Код** в блоках с указанием имён файлов. - **Пошаговое применение** (команды запуска, порядок, проверка). - **Чеклист перед production** и типичные ошибки. Объясняй нетривиальные решения кратко, но по существу. Если задача рискованная или невыполнима в текущих ограничениях — скажи прямо и предложи безопасную альтернативу. С чего начнём: {{zadaca_avtomatizacii}}?
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.

Значения переменных

Задача автоматизации
Настроить CI/CD пайплайн для автоматического развёртывания микросервиса в Kubernetes (EKS) с canary-релизами и автоматическим откатом при росте ошибок
Облачный провайдер
AWS (регион eu-central-1), кластер Amazon EKS 1.29
Инструмент ci/cd
GitLab CI (self-hosted GitLab 16.x, раннеры на Kubernetes executor)
Инструмент iac
Terraform 1.7 + модуль terraform-aws-modules/eks, состояние в S3 с блокировкой через DynamoDB
Приложение и стек
Node.js 20 + Express REST API «orders-service», хранит данные в PostgreSQL 15 (Amazon RDS Multi-AZ), кэш — Redis (ElastiCache), образ собирается в Docker и публикуется в Amazon ECR
Окружение
Три окружения: dev (namespace orders-dev), staging (namespace orders-staging), production (namespace orders-prod, 3 реплики, HPA 3-10)
Ограничения и требования
1. Деплой в production только из ветки main после ручного approve тимлида в GitLab (protected environment). 2. Секреты (DB creds, API-ключи) хранятся в AWS Secrets Manager и подтягиваются через External Secrets Operator — в манифестах и переменных CI секретов в открытом виде быть не должно. 3. Все IAM-роли для подов создаются по принципу least privilege через IRSA (IAM Roles for Service Accounts), общий admin-доступ к кластеру запрещён. 4. Canary-релиз обязателен для production: сначала 10% трафика на новую версию, автоматическая проверка error rate и p95 latency по метрикам Prometheus в течение 10 минут, при превышении порога (error rate > 2% или p95 > 800ms) — автоматический rollback без участия человека. 5. Для staging и dev допустим rolling update без canary, чтобы не замедлять итерации разработчиков. 6. Docker-образы должны проходить сканирование на уязвимости (Trivy) в пайплайне; сборка блокируется при обнаружении критических (CRITICAL) CVE. 7. Terraform apply в production выполняется только через CI (локальный apply запрещён), состояние защищено от параллельных изменений. 8. Требуется соответствие внутренней политике SOC 2: все действия в CI логируются, доступ к production namespace ограничен по RBAC для группы platform-team. 9. RTO для production — не более 15 минут, RPO для БД — не более 5 минут (используется автоматический снапшот RDS). 10. Бюджет на дополнительную инфраструктуру для мониторинга и canary-анализа — не более 300 USD/мес.
Стратегия развертывания
Canary (10% → 50% → 100% трафика) через Argo Rollouts с автоматическим анализом метрик и rollback; для dev/staging — rolling update

CI/CD для orders-service: EKS + GitLab CI + Argo Rollouts (canary с автооткатом)

Краткое резюме

GitLab CI собирает образ через rootless BuildKit и проверяет его Trivy до публикации: образ с CRITICAL CVE не попадает в ECR. Затем пайплайн по digest продвигает один и тот же образ по цепочке dev → staging → prod. В AWS CI авторизуется через GitLab OIDC, статических ключей нет. В Kubernetes CI работает через EKS Access Entries и namespace-scoped RBAC.

В production Argo Rollouts переключает трафик через взвешенные target groups ALB в пропорции 10% → 50% → 100%. На каждом шаге AnalysisTemplate проверяет error rate и p95 по Prometheus. При превышении порога или отсутствии данных rollout автоматически прерывается, и 100% трафика возвращается на stable-версию. Stable-поды всё это время остаются отмасштабированными, поэтому откат занимает секунды.


1. Уточняющие вопросы

Эти вопросы влияют на архитектуру. Ниже я исхожу из допущений, и решение работает при ответах по умолчанию.

  1. Редакция GitLab и доступность OIDC. У вас Premium или Ultimate? Deployment approvals для protected environments доступны только в Premium+. Доступны ли https://<gitlab>/.well-known/openid-configuration и JWKS из интернета? Это нужно AWS STS для OIDC-федерации.
  2. Ingress и топология. AWS Load Balancer Controller (ALB) уже установлен? Все три namespace находятся в одном кластере EKS?
  3. Метрики и трафик. Экспортирует ли приложение Prometheus-метрики и есть ли уже Prometheus или AMP? Какой примерно RPS в prod? От этого зависит статистическая значимость 10% canary.

2. Допущения

  • GitLab Premium 16.x. OIDC-эндпоинты доступны AWS STS. Если нет, см. fallback в разделе рисков.
  • Один кластер EKS (main-eks, eu-central-1) с namespaces orders-dev, orders-staging, orders-prod.
  • AWS Load Balancer Controller установлен. Ingress class alb, target-type: ip.
  • Prometheus ставим сами (kube-prometheus-stack в кластере). Метрики приложения (prom-client) добавляем. Эталонный фрагмент ниже.
  • Кластер создан модулем terraform-aws-modules/eks v20.x. В v21 переименованы входы (cluster_name → name и т. д.).
  • Секреты лежат в Secrets Manager по путям orders/<env>/app: JSON с DB_HOST, DB_USER, DB_PASSWORD, REDIS_URL, API-ключами. Шифрование CMK.
  • Миграции БД выполняются отдельным Job перед rollout и обратно совместимы (expand/contract).
  • Репозитории: platform/orders-service (приложение) и platform/infra (Terraform).
  • Tools-образ CI (ci-tools: awscli, kubectl, kubectl-argo-rollouts, kustomize, crane, yq) собирается один раз и лежит в ECR.

3. ⚠️ Проблемы во вводных, о которых нужно знать сейчас

Проблема Почему важно Что делать
EKS 1.29 Стандартная поддержка закончилась в марте 2025, расширенная в марте 2026. AWS будет принудительно обновлять control plane. Для SOC 2 это finding. Запланировать апгрейд 1.29 → 1.30 → … до поддерживаемой версии. Пошагово, проверяя deprecated API.
Node.js 20 EOL 30.04.2026, security-патчей больше нет. Trivy будет всё чаще блокировать сборки. Перейти на Node 22 LTS или новее. В Dockerfile это одна строка.
GitLab 16.x Давно вне окна security-релизов. Это проблема для SOC 2 и для CI как точки входа в prod. Обновить GitLab. На решение ниже это не влияет.
RPO 5 мин «через автоматический снапшот» Ежедневный снапшот даёт RPO до 24 ч. RPO около 5 мин обеспечивает PITR (логи транзакций в S3 примерно каждые 5 мин), который включается backup_retention_period > 0. Включить PITR (см. rds-backup.tf) и проверять restore-drill.
RTO 15 мин Достижим для отказа релиза (откат за секунды) и отказа AZ (Multi-AZ failover 1–2 мин). Не гарантирован при потере кластера или региона и при PITR-restore большой БД. Зафиксировать scope RTO в документах SOC 2. Для полного DR нужен warm standby.
Один кластер на dev и prod Большой blast radius. Dev-нагрузки влияют на prod. Позже вынести prod в отдельный кластер или аккаунт. Решение ниже переносится без изменений.

4. Архитектура

 Developer ──MR──► GitLab (protected main, CODEOWNERS)
                     │
   ┌─────────────── GitLab CI (K8s executor) ───────────────────────────────┐
   │ test → build (BuildKit rootless → image.tar) → Trivy (CRITICAL=block)  │
   │   → publish (crane push → ECR, immutable, by digest)                   │
   │   → deploy-dev → deploy-staging → [approve тимлида] → deploy-prod      │
   │ Каждый job: id_token (OIDC) → sts:AssumeRoleWithWebIdentity            │
   │   роль на job/env (session name = job id → CloudTrail)                 │
   └───────────┬─────────────────────────────────────────────────────────────┘
               │ EKS Access Entry → k8s group orders-deployer-<env> → Role в ns
               ▼
  EKS ─ orders-prod ──────────────────────────────────────────────────────────
   ALB ─► listener rule (weights, управляет Argo Rollouts)
          ├─ TG stable (svc orders-service)          90% → 50% → 0%
          └─ TG canary (svc orders-service-canary)   10% → 50% → 100%
   Argo Rollouts ── AnalysisRun ──PromQL──► Prometheus ◄── PodMonitor (label pod-template-hash)
        │ fail/Error/no-data → abort → 100% на stable (секунды)
   External Secrets Operator ── IRSA (read orders/prod/*) ──► Secrets Manager
  ─────────────────────────────────────────────────────────────────────────────
  RDS PostgreSQL 15 Multi-AZ (PITR) · ElastiCache Redis
  Логи/аудит: GitLab audit events + job logs, CloudTrail, EKS audit → CloudWatch

Ключевые решения:

  • Build once, promote by digest. Во все окружения уходит один и тот же sha256-digest. Мутабельные теги не используются, ECR настроен с IMMUTABLE.
  • Скан до публикации. Образ собирается в tarball и сканируется. В ECR попадает только то, что прошло gate.
  • OIDC вместо ключей и вместо IRSA раннера. С IRSA раннера все jobs раннера получили бы одну роль. OIDC даёт роль на уровне конкретного job, а в trust policy проверяются проект и ветка.
  • Fail-safe анализ. Отсутствие метрик считается провалом. Упавший или CrashLoop-canary не отдаёт метрик и поэтому откатывается. Ошибки Prometheus после consecutiveErrorLimit тоже ведут к abort.
  • Stable не скейлится вниз во время canary (dynamicStableScale: false). Поэтому abort мгновенный и укладывается в RTO.
  • rollbackWindow. Откат на одну из трёх последних ревизий (например, кнопкой Re-deploy в GitLab Environments) проходит без canary-шагов.

Отклонённые альтернативы:

Альтернатива Почему нет
Istio/Linkerd для traffic split +CPU/RAM на каждый под, операционная сложность. Взвешенных TG в ALB достаточно.
AWS App Mesh Сервис выводится из эксплуатации AWS.
Flagger Функционально эквивалентен, но в требованиях Argo Rollouts. У Argo лучше CLI и UX abort/promote.
Amazon Managed Prometheus Нужна SigV4-аутентификация в Argo, стоимость зависит от объёма samples и запросов. In-cluster Prometheus дешевле и предсказуемее в рамках 300 USD.
Argo CD (pull GitOps) Хороший следующий шаг. Сейчас push из CI проще сочетается с approvals GitLab и аудитом в одном месте.
Docker-in-Docker Требует privileged-подов на раннерах.
Kaniko Upstream-проект заархивирован. Выбран rootless BuildKit.

5. Код

5.1 Структура репозиториев

platform/orders-service/
├── .gitlab-ci.yml
├── .trivyignore
├── Dockerfile
├── src/metrics.js
├── ci/aws-oidc.sh
├── ci/deploy.sh
└── deploy/
    ├── base/        kustomization.yaml rollout.yaml service.yaml serviceaccounts.yaml
    │                externalsecret.yaml podmonitor.yaml pdb.yaml
    └── overlays/
        ├── dev/kustomization.yaml
        ├── staging/kustomization.yaml
        └── prod/    kustomization.yaml rollout-canary-patch.yaml service-canary.yaml
                     ingress.yaml hpa.yaml analysis-template.yaml prometheus-rules.yaml
platform/infra/
├── .gitlab-ci.yml
├── bootstrap/state.tf           # однократно, break-glass
├── ci-tools/Dockerfile
└── infra/ versions.tf providers.tf variables.tf eks.tf gitlab-oidc.tf ecr.tf irsa.tf
           access.tf k8s-rbac.tf k8s/rbac.yaml.tftpl addons.tf rds-backup.tf
           backend/prod.hcl terraform.tfvars.example

5.2 Приложение: Dockerfile и метрики

Dockerfile

# syntax=docker/dockerfile:1.7
# Закрепите по digest: node:20-alpine@sha256:...  (план: перейти на node:22-alpine — Node 20 EOL)
ARG NODE_IMAGE=node:20-alpine

FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# --ignore-scripts снижает риск supply-chain; уберите, если нужны нативные модули
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force

FROM ${NODE_IMAGE} AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Обновляем пакеты ОС; удаляем npm из рантайма — он не нужен и тянет свои CVE
RUN apk upgrade --no-cache \
 && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
 && addgroup -S -g 10001 app && adduser -S -u 10001 -G app app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER 10001
EXPOSE 8080 9464
CMD ["node", "src/server.js"]

src/metrics.js: метрики отдаются на отдельном порту 9464, чтобы /metrics не был доступен через ALB.

'use strict';
const client = require('prom-client');
const express = require('express');

const register = new client.Registry();
client.collectDefaultMetrics({ register });

// ВАЖНО: бакет 0.8 обязателен — порог p95 = 800ms, иначе histogram_quantile интерполирует неточно
const httpDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 0.8, 1, 1.5, 3, 5],
  registers: [register],
});

const PROBE_PATHS = new Set(['/healthz', '/readyz']);

function metricsMiddleware(req, res, next) {
  // Пробы kubelet/ALB не учитываем — они размывают error rate и p95
  if (PROBE_PATHS.has(req.path)) return next();
  const end = httpDuration.startTimer();
  res.on('finish', () => {
    // Шаблон маршрута, а не реальный URL — иначе взрыв кардинальности
    const route = req.route?.path ? `${req.baseUrl}${req.route.path}` : 'unmatched';
    end({ method: req.method, route, status_code: String(res.statusCode) });
  });
  next();
}

function startMetricsServer(port = Number(process.env.METRICS_PORT || 9464)) {
  const app = express();
  app.get('/metrics', async (_req, res) => {
    res.set('Content-Type', register.contentType);
    res.end(await register.metrics());
  });
  return app.listen(port);
}

module.exports = { metricsMiddleware, startMetricsServer };

.trivyignore: исключения только с обоснованием и сроком действия.

# Формат: CVE-ID exp:YYYY-MM-DD  # причина, тикет, кто одобрил
# CVE-2099-00000 exp:2026-10-31  # не эксплуатируемо: модуль не используется, SEC-123, approved by @security-lead

5.3 GitLab CI приложения

ci/aws-oidc.sh: обмен OIDC-токена job на временные креды AWS.

#!/usr/bin/env bash
# Использование: source ci/aws-oidc.sh <role_arn>
# Не использует set -x: токен не должен попасть в лог.
role_arn="${1:?role ARN required}"
if [[ -z "${AWS_ID_TOKEN:-}" ]]; then
  echo "AWS_ID_TOKEN не задан (проверьте id_tokens в job)" >&2; return 1
fi
token_file="$(mktemp)"
chmod 600 "$token_file"
printf '%s' "$AWS_ID_TOKEN" > "$token_file"
export AWS_WEB_IDENTITY_TOKEN_FILE="$token_file"
export AWS_ROLE_ARN="$role_arn"
# Session name попадает в CloudTrail → трассируемость «job → действие в AWS» (SOC 2)
AWS_ROLE_SESSION_NAME="$(printf 'gl-%s-%s' "${CI_JOB_ID}" "${GITLAB_USER_LOGIN:-na}" \
  | tr -c 'A-Za-z0-9+=,.@_-' '_' | cut -c1-64)"
export AWS_ROLE_SESSION_NAME
aws sts get-caller-identity --output text --query Arn

ci/deploy.sh

#!/usr/bin/env bash
# Деплой в окружение: ci/deploy.sh <dev|staging|prod>
set -euo pipefail

ENV="${1:?env required}"
NAMESPACE="orders-${ENV}"
ROLLOUT="orders-service"
TIMEOUT="${ROLLOUT_TIMEOUT:-35m}"
: "${IMAGE_DIGEST:?}" "${ECR_REPO:?}" "${EKS_CLUSTER:?}" "${AWS_REGION:?}"

log() { echo -e "\n\033[1;34m==> $*\033[0m"; }

aws eks update-kubeconfig --name "$EKS_CLUSTER" --region "$AWS_REGION" --alias ci >/dev/null

# 1. Рендер манифестов (копия, чтобы не мутировать рабочее дерево)
workdir="$(mktemp -d)"
cp -r deploy "$workdir/"
( cd "$workdir/deploy/overlays/$ENV" \
  && kustomize edit set image "orders-service=${ECR_REPO}@${IMAGE_DIGEST}" \
  && kustomize build . > "$workdir/rendered.yaml" )

# 2. Diff в лог job — аудиторский след «что именно изменилось» (SOC 2)
log "kubectl diff"
set +e
kubectl diff -n "$NAMESPACE" -f "$workdir/rendered.yaml"
rc=$?
set -e
[[ $rc -gt 1 ]] && { echo "kubectl diff завершился ошибкой ($rc)"; exit $rc; }

# 3. Предусловия: SA, SecretStore, ExternalSecret, ConfigMap — нужны миграциям
log "Применяю предусловия (секреты/конфиг)"
yq eval 'select(.kind == "ServiceAccount" or .kind == "SecretStore" or .kind == "ExternalSecret" or .kind == "ConfigMap")' \
  "$workdir/rendered.yaml" | kubectl apply -n "$NAMESPACE" -f -
kubectl wait -n "$NAMESPACE" --for=condition=Ready externalsecret/orders-service-secrets --timeout=120s

# 4. Миграции БД (идемпотентно в рамках пайплайна; должны быть обратно совместимы!)
run_migrations() {
  local job="orders-migrate-${CI_PIPELINE_ID}"
  if ! kubectl -n "$NAMESPACE" get job "$job" >/dev/null 2>&1; then
    kubectl -n "$NAMESPACE" apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
  name: ${job}
  labels:
    app.kubernetes.io/name: orders-migrate   # НЕ orders-service — иначе попадёт в Service
    gitlab.com/pipeline-id: "${CI_PIPELINE_ID}"
spec:
  backoffLimit: 0                # миграции не повторяем вслепую
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels: { app.kubernetes.io/name: orders-migrate }
    spec:
      restartPolicy: Never
      serviceAccountName: orders-service
      automountServiceAccountToken: false
      securityContext: { runAsNonRoot: true, runAsUser: 10001, seccompProfile: { type: RuntimeDefault } }
      containers:
        - name: migrate
          image: ${ECR_REPO}@${IMAGE_DIGEST}
          command: ["node", "src/migrate.js"]
          envFrom: [ { secretRef: { name: orders-service-secrets } } ]
          resources: { requests: { cpu: 100m, memory: 128Mi }, limits: { memory: 256Mi } }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
EOF
  fi
  for _ in $(seq 1 130); do
    local ok fail
    ok="$(kubectl -n "$NAMESPACE" get job "$job" -o jsonpath='{.status.succeeded}')"
    fail="$(kubectl -n "$NAMESPACE" get job "$job" -o jsonpath='{.status.failed}')"
    if [[ "${ok:-0}" -ge 1 ]]; then kubectl -n "$NAMESPACE" logs "job/$job" --tail=100 || true; return 0; fi
    if [[ "${fail:-0}" -ge 1 ]]; then kubectl -n "$NAMESPACE" logs "job/$job" --tail=200 || true; return 1; fi
    sleep 5
  done
  echo "Таймаут миграций"; return 1
}
log "Миграции БД"
run_migrations

# 5. Применяем всё
log "kubectl apply"
kubectl apply -n "$NAMESPACE" -f "$workdir/rendered.yaml"
kubectl annotate rollout "$ROLLOUT" -n "$NAMESPACE" --overwrite \
  "gitlab.com/job-url=${CI_JOB_URL}" "gitlab.com/commit=${CI_COMMIT_SHA}" "gitlab.com/user=${GITLAB_USER_LOGIN}"

# 6. Ждём завершения. В prod — через canary+анализ; при abort status вернёт ошибку.
log "Ожидаю rollout (timeout ${TIMEOUT})"
if ! kubectl argo rollouts status "$ROLLOUT" -n "$NAMESPACE" --timeout "$TIMEOUT"; then
  echo "❌ Rollout не завершился успешно. Трафик на stable-версии (при abort)."
  kubectl argo rollouts get rollout "$ROLLOUT" -n "$NAMESPACE" || true
  last_ar="$(kubectl get analysisrun -n "$NAMESPACE" --sort-by=.metadata.creationTimestamp -o name | tail -1)"
  [[ -n "$last_ar" ]] && kubectl describe -n "$NAMESPACE" "$last_ar" | tail -60 || true
  exit 1
fi
log "✅ ${ENV}: ${IMAGE_DIGEST} развёрнут"

.gitlab-ci.yml

# orders-service CI/CD
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never                      # не дублируем branch+MR пайплайны
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

stages: [test, build, scan, publish, deploy-dev, deploy-staging, deploy-prod]

variables:
  AWS_REGION: eu-central-1
  AWS_ACCOUNT_ID: "111111111111"     # не секрет; лучше задать в CI/CD variables группы
  EKS_CLUSTER: main-eks
  ECR_REPO: ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/orders-service
  CI_TOOLS_IMAGE: ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/ci-tools:1.0.0
  # Закрепите актуальные версии по digest (@sha256:...)
  NODE_IMAGE: node:20-alpine
  BUILDKIT_IMAGE: moby/buildkit:v0.13.2-rootless   # обновите до актуальной
  TRIVY_IMAGE: aquasec/trivy:0.50.1                 # обновите до актуальной
  IMAGE_TAR: image.tar
  # ARN ролей — не секреты; создаются Terraform (gitlab-oidc.tf)
  CI_BUILD_ROLE_ARN: arn:aws:iam::${AWS_ACCOUNT_ID}:role/gitlab-orders-build
  DEPLOY_ROLE_DEV: arn:aws:iam::${AWS_ACCOUNT_ID}:role/gitlab-orders-deploy-dev
  DEPLOY_ROLE_STAGING: arn:aws:iam::${AWS_ACCOUNT_ID}:role/gitlab-orders-deploy-staging
  DEPLOY_ROLE_PROD: arn:aws:iam::${AWS_ACCOUNT_ID}:role/gitlab-orders-deploy-prod

default:
  interruptible: true
  retry:
    max: 1
    when: [runner_system_failure, stuck_or_timeout_failure, scheduler_failure]

.aws_oidc:
  id_tokens:
    AWS_ID_TOKEN:
      aud: https://gitlab.example.com   # = client_id в aws_iam_openid_connect_provider

.main_only:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ---------- TEST ----------
unit-test:
  stage: test
  image: $NODE_IMAGE
  cache:
    key: { files: [package-lock.json] }
    paths: [.npm/]
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run lint
    - npm test -- --ci
  artifacts:
    when: always
    reports: { junit: junit.xml }

# ---------- BUILD (rootless, без privileged) ----------
build-image:
  stage: build
  image: { name: $BUILDKIT_IMAGE, entrypoint: [""] }
  needs: []
  variables:
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  script:
    - >
      buildctl-daemonless.sh build
      --frontend dockerfile.v0
      --local context=. --local dockerfile=.
      --opt build-arg:NODE_IMAGE=${NODE_IMAGE}
      --opt label:org.opencontainers.image.revision=${CI_COMMIT_SHA}
      --opt label:org.opencontainers.image.source=${CI_PROJECT_URL}
      --output type=docker,name=orders-service:${CI_COMMIT_SHA},dest=${IMAGE_TAR}
  artifacts:
    paths: [image.tar]
    expire_in: 1 day

# ---------- SCAN (gate до публикации) ----------
trivy-scan:
  stage: scan
  image: { name: $TRIVY_IMAGE, entrypoint: [""] }
  needs: [build-image]
  variables:
    TRIVY_CACHE_DIR: .trivycache/
    TRIVY_NO_PROGRESS: "true"
    # Зеркало БД в ECR Public — обходим rate limit GHCR
    TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db
    TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db
  cache:
    key: trivy-db
    paths: [.trivycache/]
  script:
    # Полный отчёт HIGH+CRITICAL — артефакт для аудита, не блокирует
    - trivy image --input "$IMAGE_TAR" --severity HIGH,CRITICAL --format json --output trivy-report.json --exit-code 0
    - trivy image --input "$IMAGE_TAR" --severity HIGH,CRITICAL --exit-code 0 --skip-db-update
    # Блокирующий gate: любой CRITICAL (кроме явно одобренных в .trivyignore с exp:)
    - trivy image --input "$IMAGE_TAR" --severity CRITICAL --exit-code 1 --ignorefile .trivyignore --skip-db-update
  artifacts:
    when: always
    paths: [trivy-report.json]
    expire_in: 90 days           # evidence для SOC 2

# ---------- PUBLISH ----------
publish-image:
  stage: publish
  extends: [.aws_oidc, .main_only]
  image: $CI_TOOLS_IMAGE
  needs: [unit-test, build-image, trivy-scan]
  script:
    - source ci/aws-oidc.sh "$CI_BUILD_ROLE_ARN"
    - aws ecr get-login-password --region "$AWS_REGION" | crane auth login "${ECR_REPO%%/*}" -u AWS --password-stdin
    # Идемпотентность: тег immutable; при retry job не падаем, если образ уже опубликован
    - |
      if crane digest "${ECR_REPO}:${CI_COMMIT_SHA}" >/dev/null 2>&1; then
        echo "Образ уже в ECR — пропускаю push"
      else
        crane push "$IMAGE_TAR" "${ECR_REPO}:${CI_COMMIT_SHA}"
      fi
    - echo "IMAGE_DIGEST=$(crane digest "${ECR_REPO}:${CI_COMMIT_SHA}")" | tee image.env
  artifacts:
    reports: { dotenv: image.env }

# ---------- DEPLOY ----------
.deploy:
  extends: [.aws_oidc]
  image: $CI_TOOLS_IMAGE
  interruptible: false            # деплой нельзя прерывать новым пайплайном
  script:
    - source ci/aws-oidc.sh "$DEPLOY_ROLE_ARN"
    - ci/deploy.sh "$DEPLOY_ENV"

deploy-dev:
  extends: [.deploy, .main_only]
  stage: deploy-dev
  needs: [publish-image]
  variables: { DEPLOY_ENV: dev, DEPLOY_ROLE_ARN: $DEPLOY_ROLE_DEV, ROLLOUT_TIMEOUT: 10m }
  resource_group: orders-dev
  environment: { name: dev, deployment_tier: development }

deploy-staging:
  extends: [.deploy, .main_only]
  stage: deploy-staging
  needs: [publish-image, deploy-dev]
  variables: { DEPLOY_ENV: staging, DEPLOY_ROLE_ARN: $DEPLOY_ROLE_STAGING, ROLLOUT_TIMEOUT: 10m }
  resource_group: orders-staging
  environment: { name: staging, deployment_tier: staging }

deploy-prod:
  extends: [.deploy]
  stage: deploy-prod
  needs: [publish-image, deploy-staging]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual                # + approval тимлида через protected environment
  variables: { DEPLOY_ENV: prod, DEPLOY_ROLE_ARN: $DEPLOY_ROLE_PROD, ROLLOUT_TIMEOUT: 35m }
  timeout: 50m
  resource_group: orders-prod     # нет параллельных деплоев в prod
  environment:
    name: production
    deployment_tier: production
    url: https://orders.example.internal

5.4 Kubernetes-манифесты (Kustomize)

deploy/base/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - serviceaccounts.yaml
  - service.yaml
  - rollout.yaml
  - externalsecret.yaml
  - podmonitor.yaml
  - pdb.yaml
configMapGenerator:            # hash-суффикс → изменение конфига = новый rollout
  - name: orders-service-config
    literals:
      - PORT=8080
      - METRICS_PORT=9464
      - LOG_LEVEL=info
      - NODE_OPTIONS=--max-old-space-size=384
images:
  - name: orders-service       # заменяется в CI на ECR_REPO@sha256:...

deploy/base/serviceaccounts.yaml

# SA приложения: AWS-доступ не нужен → нет IRSA, токен не монтируется
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-service
automountServiceAccountToken: false
---
# SA только для ESO: IRSA-роль с чтением orders/<env>/*
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-eso
  annotations:
    eks.amazonaws.com/role-arn: REPLACED_IN_OVERLAY
automountServiceAccountToken: false

deploy/base/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: orders-service
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: orders-service
  ports:
    - name: http
      port: 80
      targetPort: http

deploy/base/rollout.yaml: в dev/staging это обычный rolling update. У canary без steps и без trafficRouting поведение как у RollingUpdate. replicas намеренно не задан, потому что в prod им управляет HPA.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: orders-service
spec:
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app.kubernetes.io/name: orders-service
  template:
    metadata:
      labels:
        app.kubernetes.io/name: orders-service
    spec:
      serviceAccountName: orders-service
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 45
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile: { type: RuntimeDefault }
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: { app.kubernetes.io/name: orders-service }
      containers:
        - name: app
          image: orders-service
          ports:
            - { name: http, containerPort: 8080 }
            - { name: metrics, containerPort: 9464 }
          envFrom:
            - configMapRef: { name: orders-service-config }
            - secretRef: { name: orders-service-secrets }
          startupProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:          # /readyz проверяет PG и Redis
            httpGet: { path: /readyz, port: http }
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:           # /healthz — только процесс, без зависимостей
            httpGet: { path: /healthz, port: http }
            periodSeconds: 10
            failureThreshold: 3
          resources:
            requests: { cpu: 250m, memory: 256Mi }
            limits: { memory: 512Mi }
          lifecycle:
            preStop:               # даём ALB вывести target до SIGTERM
              exec: { command: ["sh", "-c", "sleep 15"] }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: {}
  strategy:
    canary:
      maxSurge: "25%"
      maxUnavailable: 0

deploy/base/externalsecret.yaml

apiVersion: external-secrets.io/v1      # ESO >= 0.17; для старых версий — v1beta1
kind: SecretStore                        # namespaced: store одного env не видит чужие секреты
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-central-1
      auth:
        jwt:
          serviceAccountRef:
            name: orders-eso
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: orders-service-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: orders-service-secrets
    creationPolicy: Owner
    deletionPolicy: Retain                # не терять Secret при сбое AWS API
  dataFrom:
    - extract:
        key: orders/ENV/app               # патчится в overlay

deploy/base/podmonitor.yaml

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: orders-service
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: orders-service
  # Ключевой момент: label пода → label метрики rollouts_pod_template_hash
  # Так AnalysisRun отличает canary-поды от stable.
  podTargetLabels:
    - rollouts-pod-template-hash
  podMetricsEndpoints:
    - port: metrics
      path: /metrics
      interval: 15s

deploy/base/pdb.yaml

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: orders-service
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: orders-service

deploy/overlays/dev/kustomization.yaml (staging такой же: orders-staging, replicas: 2, свой ARN).

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: orders-dev
resources: [../../base]
patches:
  - target: { kind: Rollout, name: orders-service }
    patch: |-
      - op: add
        path: /spec/replicas
        value: 1
  - target: { kind: ExternalSecret, name: orders-service-secrets }
    patch: |-
      - op: replace
        path: /spec/dataFrom/0/extract/key
        value: orders/dev/app
  - target: { kind: ServiceAccount, name: orders-eso }
    patch: |-
      - op: replace
        path: /metadata/annotations/eks.amazonaws.com~1role-arn
        value: arn:aws:iam::111111111111:role/orders-dev-eso

deploy/overlays/prod/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: orders-prod
resources:
  - ../../base
  - service-canary.yaml
  - ingress.yaml
  - hpa.yaml
  - analysis-template.yaml
  - prometheus-rules.yaml
patches:
  - path: rollout-canary-patch.yaml
    target: { kind: Rollout, name: orders-service }
  - target: { kind: ExternalSecret, name: orders-service-secrets }
    patch: |-
      - op: replace
        path: /spec/dataFrom/0/extract/key
        value: orders/prod/app
  - target: { kind: ServiceAccount, name: orders-eso }
    patch: |-
      - op: replace
        path: /metadata/annotations/eks.amazonaws.com~1role-arn
        value: arn:aws:iam::111111111111:role/orders-prod-eso

deploy/overlays/prod/rollout-canary-patch.yaml

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: orders-service
spec:
  # replicas не задаём: HPA (min 3). Иначе каждый apply сбрасывает масштаб HPA.
  # Дедлайн должен быть больше самого длинного analysis-шага (~11 мин)
  progressDeadlineSeconds: 1200
  progressDeadlineAbort: true         # canary не стал Ready → abort, а не зависание
  rollbackWindow:
    revisions: 3                      # откат на 3 последние ревизии — без canary-шагов (быстрый RTO)
  strategy:
    canary:
      stableService: orders-service
      canaryService: orders-service-canary
      dynamicStableScale: false       # stable держим на 100% мощности → мгновенный abort
      abortScaleDownDelaySeconds: 30
      scaleDownDelaySeconds: 60
      trafficRouting:
        alb:
          ingress: orders-service
          servicePort: 80
      steps:
        - setWeight: 10
        - analysis:                   # ~10 мин анализа на 10% трафика
            templates: [ { templateName: orders-canary-analysis } ]
            args:
              - name: canary-hash
                valueFrom: { podTemplateHashValue: Latest }
              - { name: namespace, value: orders-prod }
              - { name: count, value: "10" }
        - setWeight: 50
        - analysis:                   # ~5 мин на 50%
            templates: [ { templateName: orders-canary-analysis } ]
            args:
              - name: canary-hash
                valueFrom: { podTemplateHashValue: Latest }
              - { name: namespace, value: orders-prod }
              - { name: count, value: "5" }
        - setWeight: 100

deploy/overlays/prod/analysis-template.yaml

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: orders-canary-analysis
spec:
  args:
    - name: canary-hash
    - name: namespace
    - name: count
      value: "10"
    - name: error-rate-threshold      # 2%
      value: "0.02"
    - name: p95-threshold-seconds     # 800ms
      value: "0.8"
    - name: prometheus-address
      value: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090
  metrics:
    - name: error-rate
      initialDelay: 1m                # прогрев: rate[2m] по свежим подам
      interval: 1m
      count: "{{args.count}}"
      failureLimit: 1                 # 1 выброс допустим, второй → FAIL → abort (детект ≤ ~2 мин)
      consecutiveErrorLimit: 3        # Prometheus недоступен 3 раза подряд → Error → abort
      # Нет данных (нет трафика / canary упал) → условие false → провал. Fail-safe.
      successCondition: "len(result) > 0 && !isNaN(result[0]) && result[0] <= {{args.error-rate-threshold}}"
      provider:
        prometheus:
          address: "{{args.prometheus-address}}"
          query: |
            (
              sum(rate(http_request_duration_seconds_count{namespace="{{args.namespace}}",rollouts_pod_template_hash="{{args.canary-hash}}",status_code=~"5.."}[2m]))
              or vector(0)
            )
            /
            sum(rate(http_request_duration_seconds_count{namespace="{{args.namespace}}",rollouts_pod_template_hash="{{args.canary-hash}}"}[2m]))
    - name: p95-latency
      initialDelay: 1m
      interval: 1m
      count: "{{args.count}}"
      failureLimit: 1
      consecutiveErrorLimit: 3
      successCondition: "len(result) > 0 && !isNaN(result[0]) && result[0] <= {{args.p95-threshold-seconds}}"
      provider:
        prometheus:
          address: "{{args.prometheus-address}}"
          query: |
            histogram_quantile(0.95,
              sum by (le) (
                rate(http_request_duration_seconds_bucket{namespace="{{args.namespace}}",rollouts_pod_template_hash="{{args.canary-hash}}"}[2m])
              )
            )

deploy/overlays/prod/service-canary.yaml

apiVersion: v1
kind: Service
metadata:
  name: orders-service-canary
spec:
  type: ClusterIP
  selector:                     # Argo Rollouts сам добавит rollouts-pod-template-hash
    app.kubernetes.io/name: orders-service
  ports:
    - name: http
      port: 80
      targetPort: http

deploy/overlays/prod/ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: orders-service
  annotations:
    alb.ingress.kubernetes.io/scheme: internal
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:eu-central-1:111111111111:certificate/REPLACE
    alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
    alb.ingress.kubernetes.io/healthcheck-path: /readyz
    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30
    # Аннотацию actions.orders-service с весами пишет Argo Rollouts — не добавляйте её вручную
spec:
  ingressClassName: alb
  rules:
    - host: orders.example.internal
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: orders-service
                port:
                  name: use-annotation

deploy/overlays/prod/hpa.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: orders-service
spec:
  scaleTargetRef:
    apiVersion: argoproj.io/v1alpha1
    kind: Rollout
    name: orders-service
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300

deploy/overlays/prod/prometheus-rules.yaml описан в разделе 7 «Наблюдаемость».

5.5 Terraform

bootstrap/state.tf: применяется однократно break-glass ролью. Это единственный локальный apply, дальше всё идёт через CI.

variable "state_bucket"            { type = string }
variable "terraform_apply_role_arn" { type = string }  # роль CI для apply
variable "break_glass_role_arn"    { type = string }  # аварийная, под MFA и алертом

resource "aws_s3_bucket" "state" {
  bucket = var.state_bucket
  lifecycle { prevent_destroy = true }
}
resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id
  versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_public_access_block" "state" {
  bucket                  = aws_s3_bucket.state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
resource "aws_kms_key" "state" {
  description         = "Terraform state encryption"
  enable_key_rotation = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.state.arn
    }
  }
}

data "aws_iam_policy_document" "state" {
  statement {
    sid     = "DenyInsecureTransport"
    effect  = "Deny"
    actions = ["s3:*"]
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    resources = [aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*"]
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
  # Запись prod-стейта — только CI apply-роль (и break-glass). Локальный apply невозможен.
  statement {
    sid     = "OnlyCIWritesProdState"
    effect  = "Deny"
    actions = ["s3:PutObject", "s3:DeleteObject", "s3:DeleteObjectVersion"]
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    resources = ["${aws_s3_bucket.state.arn}/*/prod/*"]
    condition {
      test     = "ArnNotEquals"
      variable = "aws:PrincipalArn"
      values   = [var.terraform_apply_role_arn, var.break_glass_role_arn]
    }
  }
}
resource "aws_s3_bucket_policy" "state" {
  bucket = aws_s3_bucket.state.id
  policy = data.aws_iam_policy_document.state.json
}

# Terraform 1.7 → блокировки через DynamoDB (нативный S3-lock появился только в 1.10)
resource "aws_dynamodb_table" "locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  attribute {
    name = "LockID"
    type = "S"
  }
  point_in_time_recovery { enabled = true }
  server_side_encryption { enabled = true }
  lifecycle { prevent_destroy = true }
}

infra/backend/prod.hcl

bucket         = "acme-tfstate-eu-central-1"
key            = "platform/prod/terraform.tfstate"
region         = "eu-central-1"
dynamodb_table = "terraform-locks"
encrypt        = true
kms_key_id     = "alias/terraform-state"

infra/versions.tf

terraform {
  required_version = "~> 1.7.0"
  required_providers {
    aws        = { source = "hashicorp/aws", version = "~> 5.40" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.27" }
    helm       = { source = "hashicorp/helm", version = "~> 2.12" }
    tls        = { source = "hashicorp/tls", version = "~> 4.0" }
  }
  backend "s3" {} # partial config: -backend-config=backend/prod.hcl
}

infra/providers.tf

provider "aws" {
  region = var.region
  default_tags {
    tags = { ManagedBy = "terraform", Repo = var.gitlab_infra_project_path, Service = "orders" }
  }
}

data "aws_caller_identity" "current" {}

# Токен без aws cli (образ hashicorp/terraform его не содержит); живёт ~15 мин
data "aws_eks_cluster_auth" "this" {
  name = module.eks.cluster_name
}

provider "kubernetes" {
  host                   = module.eks.cluster_endpoint
  cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
  token                  = data.aws_eks_cluster_auth.this.token
}

provider "helm" {
  kubernetes {
    host                   = module.eks.cluster_endpoint
    cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
    token                  = data.aws_eks_cluster_auth.this.token
  }
}

locals {
  account_id = data.aws_caller_identity.current.account_id
  envs       = toset(["dev", "staging", "prod"])
}

infra/variables.tf

variable "region"                    { type = string, default = "eu-central-1" }
variable "cluster_name"              { type = string, default = "main-eks" }
variable "gitlab_url"                { type = string }  # https://gitlab.example.com
variable "gitlab_app_project_path"   { type = string }  # platform/orders-service
variable "gitlab_infra_project_path" { type = string }  # platform/infra
variable "default_branch"            { type = string, default = "main" }
variable "terraform_ci_role_arn"     { type = string }  # из bootstrap
variable "platform_team_role_arn"    { type = string }  # SSO permission set role
variable "secrets_kms_key_arn"       { type = string }
variable "permissions_boundary_arn"  { type = string, default = null }

# Версии чартов закрепляйте явно (проверенные в staging)
variable "argo_rollouts_chart_version" { type = string }
variable "eso_chart_version"           { type = string }
variable "kps_chart_version"           { type = string }

Однострочная запись { type = string, default = ... } в HCL недопустима для variable. В реальном файле каждый аргумент пишется на своей строке. Здесь сокращение ради компактности.

infra/eks.tf: изменения к вашему существующему модулю, остальное без изменений.

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.31"

  cluster_name    = var.cluster_name
  cluster_version = "1.29" # ⚠️ вне поддержки — планируйте апгрейд

  # Access Entries вместо aws-auth. Переход только CONFIG_MAP → API_AND_CONFIG_MAP → API, необратим.
  authentication_mode                      = "API_AND_CONFIG_MAP" # после миграции → "API"
  enable_cluster_creator_admin_permissions = false                # нет «вечного» админа

  # SOC 2: аудит API-сервера
  cluster_enabled_log_types              = ["api", "audit", "authenticator"]
  cloudwatch_log_group_retention_in_days = 365

  enable_irsa = true

  # Единственный admin-принципал — роль Terraform CI (доступна только из main infra-репо после approve)
  access_entries = {
    terraform_ci = {
      principal_arn = var.terraform_ci_role_arn
      policy_associations = {
        admin = {
          policy_arn   = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
          access_scope = { type = "cluster" }
        }
      }
    }
  }

  # ... vpc_id, subnet_ids, eks_managed_node_groups — как у вас
}

infra/gitlab-oidc.tf

locals {
  gitlab_host = trimprefix(var.gitlab_url, "https://")
  app_main    = "project_path:${var.gitlab_app_project_path}:ref_type:branch:ref:${var.default_branch}"

  ci_roles = {
    "build"          = { sub = local.app_main }
    "deploy-dev"     = { sub = local.app_main }
    "deploy-staging" = { sub = local.app_main }
    "deploy-prod"    = { sub = local.app_main }
  }
}

data "tls_certificate" "gitlab" {
  url = var.gitlab_url
}

resource "aws_iam_openid_connect_provider" "gitlab" {
  url             = var.gitlab_url
  client_id_list  = [var.gitlab_url]
  thumbprint_list = [data.tls_certificate.gitlab.certificates[0].sha1_fingerprint]
}

data "aws_iam_policy_document" "gitlab_trust" {
  for_each = local.ci_roles
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.gitlab.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.gitlab_host}:aud"
      values   = [var.gitlab_url]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.gitlab_host}:sub"
      values   = [each.value.sub]
    }
  }
}

resource "aws_iam_role" "ci" {
  for_each             = local.ci_roles
  name                 = "gitlab-orders-${each.key}"
  assume_role_policy   = data.aws_iam_policy_document.gitlab_trust[each.key].json
  max_session_duration = 3600
  permissions_boundary = var.permissions_boundary_arn
}

# build: push только в свой репозиторий
data "aws_iam_policy_document" "ci_build" {
  statement {
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }
  statement {
    actions = [
      "ecr:BatchCheckLayerAvailability", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
      "ecr:CompleteLayerUpload", "ecr:PutImage", "ecr:BatchGetImage",
      "ecr:GetDownloadUrlForLayer", "ecr:DescribeImages",
    ]
    resources = [aws_ecr_repository.orders.arn]
  }
}
resource "aws_iam_role_policy" "ci_build" {
  role   = aws_iam_role.ci["build"].id
  policy = data.aws_iam_policy_document.ci_build.json
}

# deploy: в AWS — только DescribeCluster; права в k8s — через Access Entry + RBAC
data "aws_iam_policy_document" "ci_deploy" {
  statement {
    actions   = ["eks:DescribeCluster"]
    resources = [module.eks.cluster_arn]
  }
}
resource "aws_iam_role_policy" "ci_deploy" {
  for_each = local.envs
  role     = aws_iam_role.ci["deploy-${each.key}"].id
  policy   = data.aws_iam_policy_document.ci_deploy.json
}

infra/ecr.tf

resource "aws_ecr_repository" "orders" {
  name                 = "orders-service"
  image_tag_mutability = "IMMUTABLE"
  image_scanning_configuration { scan_on_push = true } # второй эшелон (новые CVE после сборки)
  encryption_configuration { encryption_type = "KMS" }
}

resource "aws_ecr_lifecycle_policy" "orders" {
  repository = aws_ecr_repository.orders.name
  policy = jsonencode({
    rules = [
      { rulePriority = 1, description = "untagged > 7d",
        selection = { tagStatus = "untagged", countType = "sinceImagePushed", countUnit = "days", countNumber = 7 },
        action = { type = "expire" } },
      { rulePriority = 2, description = "keep last 100",
        selection = { tagStatus = "any", countType = "imageCountMoreThan", countNumber = 100 },
        action = { type = "expire" } },
    ]
  })
}

infra/irsa.tf: IRSA для ESO (на каждый env) и Argo Rollouts.

# --- ESO: каждый env читает только orders/<env>/* ---
data "aws_iam_policy_document" "eso_trust" {
  for_each = local.envs
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [module.eks.oidc_provider_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${module.eks.oidc_provider}:sub"
      values   = ["system:serviceaccount:orders-${each.key}:orders-eso"]
    }
    condition {
      test     = "StringEquals"
      variable = "${module.eks.oidc_provider}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "eso" {
  for_each = local.envs
  statement {
    actions   = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
    resources = ["arn:aws:secretsmanager:${var.region}:${local.account_id}:secret:orders/${each.key}/*"]
  }
  statement {
    actions   = ["kms:Decrypt"]
    resources = [var.secrets_kms_key_arn]
    condition {
      test     = "StringEquals"
      variable = "kms:ViaService"
      values   = ["secretsmanager.${var.region}.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "eso" {
  for_each             = local.envs
  name                 = "orders-${each.key}-eso"
  assume_role_policy   = data.aws_iam_policy_document.eso_trust[each.key].json
  permissions_boundary = var.permissions_boundary_arn
}
resource "aws_iam_role_policy" "eso" {
  for_each = local.envs
  role     = aws_iam_role.eso[each.key].id
  policy   = data.aws_iam_policy_document.eso[each.key].json
}

# --- Argo Rollouts: только Describe для --alb-verify-weight ---
data "aws_iam_policy_document" "argo_rollouts_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [module.eks.oidc_provider_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${module.eks.oidc_provider}:sub"
      values   = ["system:serviceaccount:argo-rollouts:argo-rollouts"]
    }
    condition {
      test     = "StringEquals"
      variable = "${module.eks.oidc_provider}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}
resource "aws_iam_role" "argo_rollouts" {
  name                 = "argo-rollouts-controller"
  assume_role_policy   = data.aws_iam_policy_document.argo_rollouts_trust.json
  permissions_boundary = var.permissions_boundary_arn
}
resource "aws_iam_role_policy" "argo_rollouts" {
  role = aws_iam_role.argo_rollouts.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "elasticloadbalancing:DescribeLoadBalancers", "elasticloadbalancing:DescribeListeners",
        "elasticloadbalancing:DescribeRules", "elasticloadbalancing:DescribeTargetGroups",
        "elasticloadbalancing:DescribeTargetHealth", "elasticloadbalancing:DescribeTags",
      ]
      Resource = "*" # Describe* не поддерживают resource-level permissions
    }]
  })
}

infra/access.tf

# CI-роль env → k8s-группа orders-deployer-<env> → Role только в orders-<env>
resource "aws_eks_access_entry" "ci_deploy" {
  for_each          = local.envs
  cluster_name      = module.eks.cluster_name
  principal_arn     = aws_iam_role.ci["deploy-${each.key}"].arn
  kubernetes_groups = ["orders-deployer-${each.key}"]
  type              = "STANDARD"
}

# Люди из platform-team (SSO) → группа platform-team.
# Проверьте формат ARN SSO-роли (path aws-reserved/sso.amazonaws.com/) по документации EKS.
resource "aws_eks_access_entry" "platform_team" {
  cluster_name      = module.eks.cluster_name
  principal_arn     = var.platform_team_role_arn
  kubernetes_groups = ["platform-team"]
  type              = "STANDARD"
}

infra/k8s-rbac.tf

resource "kubernetes_namespace_v1" "orders" {
  for_each = local.envs
  metadata {
    name = "orders-${each.key}"
    labels = {
      "pod-security.kubernetes.io/enforce"         = "restricted"
      "pod-security.kubernetes.io/enforce-version" = "v1.29"
      # Readiness gate ALB: под не считается Ready, пока target не healthy в TG → без 5xx при раскатке
      "elbv2.k8s.aws/pod-readiness-gate-inject" = "enabled"
    }
  }
}

locals {
  rbac_docs = merge([
    for env in local.envs : {
      for idx, doc in split("\n---\n", templatefile("${path.module}/k8s/rbac.yaml.tftpl", { env = env })) :
      "${env}-${idx}" => yamldecode(doc)
    }
  ]...)
}

resource "kubernetes_manifest" "rbac" {
  for_each   = local.rbac_docs
  manifest   = each.value
  depends_on = [kubernetes_namespace_v1.orders]
}

infra/k8s/rbac.yaml.tftpl

# CI-деплойер: всё для раскатки, НО без чтения Secret, без exec, без RBAC
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: orders-deployer
  namespace: orders-${env}
rules:
  - apiGroups: ["argoproj.io"]
    resources: ["rollouts", "analysistemplates", "analysisruns", "experiments"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: [""]
    resources: ["services", "configmaps", "serviceaccounts"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: [""]
    resources: ["pods", "pods/log", "events", "endpoints"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["replicasets"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["batch"]
    resources: ["jobs"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: ["autoscaling"]
    resources: ["horizontalpodautoscalers"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: ["policy"]
    resources: ["poddisruptionbudgets"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: ["monitoring.coreos.com"]
    resources: ["podmonitors", "prometheusrules"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: ["external-secrets.io"]
    resources: ["externalsecrets", "secretstores"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: orders-deployer
  namespace: orders-${env}
subjects:
  - kind: Group
    name: orders-deployer-${env}
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: orders-deployer
  apiGroup: rbac.authorization.k8s.io
---
# platform-team: чтение + операции с rollout (abort/promote/undo/restart) + удаление подов.
# Нет secrets, нет pods/exec — требование SOC 2.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: platform-operator
  namespace: orders-${env}
rules:
  - apiGroups: ["", "apps", "batch", "autoscaling", "policy", "networking.k8s.io"]
    resources: ["pods", "pods/log", "events", "services", "endpoints", "configmaps",
                "replicasets", "jobs", "horizontalpodautoscalers", "poddisruptionbudgets", "ingresses"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["delete"]
  - apiGroups: ["argoproj.io"]
    resources: ["rollouts", "analysisruns", "analysistemplates", "experiments"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: ["external-secrets.io"]
    resources: ["externalsecrets", "secretstores"]
    verbs: ["get", "list", "watch", "patch"]   # patch = force-sync аннотацией
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: platform-operator
  namespace: orders-${env}
subjects:
  - kind: Group
    name: platform-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: platform-operator
  apiGroup: rbac.authorization.k8s.io

infra/addons.tf

resource "helm_release" "argo_rollouts" {
  name             = "argo-rollouts"
  repository       = "https://argoproj.github.io/argo-helm"
  chart            = "argo-rollouts"
  version          = var.argo_rollouts_chart_version
  namespace        = "argo-rollouts"
  create_namespace = true
  atomic           = true
  values = [yamlencode({
    controller = {
      replicas  = 2                         # leader election; HA контроллера
      extraArgs = ["--alb-verify-weight"]   # проверять, что ALB реально применил веса
      metrics   = { enabled = true, serviceMonitor = { enabled = true } }
    }
    serviceAccount = {
      name        = "argo-rollouts"
      annotations = { "eks.amazonaws.com/role-arn" = aws_iam_role.argo_rollouts.arn }
    }
    dashboard = { enabled = false }
  })]
}

resource "helm_release" "external_secrets" {
  name             = "external-secrets"
  repository       = "https://charts.external-secrets.io"
  chart            = "external-secrets"
  version          = var.eso_chart_version
  namespace        = "external-secrets"
  create_namespace = true
  atomic           = true
  values = [yamlencode({
    installCRDs    = true
    serviceMonitor = { enabled = true }
    # Контроллер без собственной IRSA: доступ только через SA orders-eso конкретного namespace
  })]
}

resource "helm_release" "kube_prometheus_stack" {
  name             = "kube-prometheus-stack" # → svc kube-prometheus-stack-prometheus
  repository       = "https://prometheus-community.github.io/helm-charts"
  chart            = "kube-prometheus-stack"
  version          = var.kps_chart_version
  namespace        = "monitoring"
  create_namespace = true
  atomic           = true
  timeout          = 900
  values = [yamlencode({
    prometheus = {
      prometheusSpec = {
        retention     = "15d"
        retentionSize = "40GB"
        # Подхватывать PodMonitor/PrometheusRule из всех namespace без label release=
        podMonitorSelectorNilUsesHelmValues     = false
        serviceMonitorSelectorNilUsesHelmValues = false
        ruleSelectorNilUsesHelmValues           = false
        resources = { requests = { cpu = "500m", memory = "2Gi" }, limits = { memory = "4Gi" } }
        storageSpec = { volumeClaimTemplate = { spec = {
          storageClassName = "gp3"
          accessModes      = ["ReadWriteOnce"]
          resources        = { requests = { storage = "50Gi" } }
        } } }
      }
    }
    grafana = {
      persistence = { enabled = true, size = "5Gi" }
      # Пароль admin — из Secret (создаётся ESO), НЕ дефолтный
      admin = { existingSecret = "grafana-admin", userKey = "admin-user", passwordKey = "admin-password" }
    }
    # alertmanager.config — получатели (Slack/PagerDuty) через secret, см. доку чарта
  })]
}

infra/rds-backup.tf: фрагмент для вашего существующего aws_db_instance или module "rds".

  multi_az                     = true   # отказ AZ: failover ~1–2 мин, RPO ≈ 0
  backup_retention_period      = 14     # включает PITR: логи транзакций в S3 каждые ~5 мин → RPO ≤ 5 мин
  backup_window                = "01:00-02:00"
  delete_automated_backups     = false
  deletion_protection          = true
  copy_tags_to_snapshot        = true
  manage_master_user_password  = true   # мастер-пароль в Secrets Manager; приложению — отдельный пользователь
  performance_insights_enabled = true
  # Опционально для DR региона: aws_db_instance_automated_backups_replication в другой регион

infra/terraform.tfvars.example

gitlab_url                  = "https://gitlab.example.com"
gitlab_app_project_path     = "platform/orders-service"
gitlab_infra_project_path   = "platform/infra"
terraform_ci_role_arn       = "arn:aws:iam::111111111111:role/gitlab-infra-terraform-apply"
platform_team_role_arn      = "arn:aws:iam::111111111111:role/AWSReservedSSO_PlatformTeam_xxxxxxxx"
secrets_kms_key_arn         = "arn:aws:kms:eu-central-1:111111111111:key/REPLACE"
argo_rollouts_chart_version = "REPLACE"   # закрепите версию, проверенную в staging
eso_chart_version           = "REPLACE"   # >= версии с API external-secrets.io/v1
kps_chart_version           = "REPLACE"

platform/infra/.gitlab-ci.yml

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

stages: [validate, plan, apply]

variables:
  TF_ROOT: infra
  TF_ENV: prod
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"
  AWS_REGION: eu-central-1
  AWS_ACCOUNT_ID: "111111111111"
  TF_PLAN_ROLE: arn:aws:iam::${AWS_ACCOUNT_ID}:role/gitlab-infra-terraform-plan    # read-only + state read + lock
  TF_APPLY_ROLE: arn:aws:iam::${AWS_ACCOUNT_ID}:role/gitlab-infra-terraform-apply  # trust: только ref main

image:
  name: hashicorp/terraform:1.7.5
  entrypoint: [""]

.aws:
  id_tokens:
    AWS_ID_TOKEN:
      aud: https://gitlab.example.com
  before_script:
    - umask 077
    - printf '%s' "$AWS_ID_TOKEN" > /tmp/web-identity-token
    - export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web-identity-token
    - export AWS_ROLE_SESSION_NAME="gl-${CI_JOB_ID}-${GITLAB_USER_LOGIN}"
    - cd "$TF_ROOT"
    - terraform init -backend-config="backend/${TF_ENV}.hcl"

validate:
  stage: validate
  script:
    - cd "$TF_ROOT"
    - terraform fmt -check -recursive
    - terraform init -backend=false
    - terraform validate

plan:
  stage: plan
  extends: .aws
  variables: { AWS_ROLE_ARN: $TF_PLAN_ROLE }
  resource_group: terraform-prod
  script:
    - terraform plan -lock-timeout=5m -out=plan.tfplan
    - terraform show -no-color plan.tfplan > plan.txt
  artifacts:
    paths: [infra/plan.tfplan, infra/plan.txt]
    expire_in: 7 days            # plan может содержать чувствительные значения — ограничьте доступ к артефактам

apply:
  stage: apply
  extends: .aws
  variables: { AWS_ROLE_ARN: $TF_APPLY_ROLE }
  needs: [plan]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  resource_group: terraform-prod # сериализация в GitLab + блокировка в DynamoDB
  environment:
    name: infra-production       # protected environment с approval
    deployment_tier: production
  script:
    # Применяем ровно тот plan, что был проверен; если state изменился — Terraform откажется (stale plan)
    - terraform apply -lock-timeout=5m plan.tfplan

5.6 Образ CI-инструментов

ci-tools/Dockerfile

FROM amazon/aws-cli:2.15.30   # закрепите актуальную версию по digest
ARG KUBECTL_VERSION=v1.29.4
ARG ROLLOUTS_VERSION=v1.7.2           # = версии контроллера
ARG KUSTOMIZE_VERSION=v5.4.1
ARG CRANE_VERSION=v0.19.1
ARG YQ_VERSION=v4.44.1
# SHA256 заполните из официальных checksums релизов
ARG ROLLOUTS_SHA256
ARG KUSTOMIZE_SHA256
ARG CRANE_SHA256
ARG YQ_SHA256

RUN yum install -y tar gzip bash && yum clean all
WORKDIR /tmp
RUN curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
 && echo "$(curl -fsSL https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256)  kubectl" | sha256sum -c - \
 && install -m 0755 kubectl /usr/local/bin/kubectl \
 && curl -fsSLo kar "https://github.com/argoproj/argo-rollouts/releases/download/${ROLLOUTS_VERSION}/kubectl-argo-rollouts-linux-amd64" \
 && echo "${ROLLOUTS_SHA256}  kar" | sha256sum -c - && install -m 0755 kar /usr/local/bin/kubectl-argo-rollouts \
 && curl -fsSLo k.tgz "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2F${KUSTOMIZE_VERSION}/kustomize_${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" \
 && echo "${KUSTOMIZE_SHA256}  k.tgz" | sha256sum -c - && tar xzf k.tgz -C /usr/local/bin kustomize \
 && curl -fsSLo c.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
 && echo "${CRANE_SHA256}  c.tgz" | sha256sum -c - && tar xzf c.tgz -C /usr/local/bin crane \
 && curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \
 && echo "${YQ_SHA256}  yq" | sha256sum -c - && install -m 0755 yq /usr/local/bin/yq \
 && rm -rf /tmp/*
RUN useradd -u 10001 ci
USER 10001
ENTRYPOINT []

6. Пошаговое применение

Шаг 0. Предусловия. Нужны ответы на вопросы 1–3. Проверьте, что STS видит OIDC GitLab:

curl -fsS https://gitlab.example.com/.well-known/openid-configuration | jq .jwks_uri

Шаг 1. Bootstrap state. Однократно, break-glass ролью под MFA. Действие фиксируется в change log.

cd platform/infra/bootstrap
terraform init && terraform apply -var-file=bootstrap.tfvars

В этом же bootstrap создайте роли gitlab-infra-terraform-plan и gitlab-infra-terraform-apply с OIDC-trust на project_path:platform/infra:ref_type:branch:ref:main (для apply). Навесьте permissions boundary.

Шаг 2. Настройки GitLab. Через UI или API, под аудитом.

# protected environment + approval тимлида (Premium)
curl -sS -X POST -H "PRIVATE-TOKEN: $GL_ADMIN_TOKEN" -H "Content-Type: application/json" \
  "https://gitlab.example.com/api/v4/projects/<ORDERS_PROJECT_ID>/protected_environments" \
  -d '{"name":"production",
       "deploy_access_levels":[{"group_id":<PLATFORM_TEAM_GROUP_ID>}],
       "approval_rules":[{"group_id":<TEAM_LEADS_GROUP_ID>,"required_approvals":1}]}'

Дополнительно:

  • main защищена: push запрещён, merge только через MR с approvals.
  • Файл CODEOWNERS покрывает .gitlab-ci.yml, ci/, deploy/.
  • В Settings → CI/CD включены «Prevent outdated deployment jobs» и «Allow job retries for rollback deployments».
  • Запрещено самоутверждение деплоя инициатором пайплайна.
  • То же самое для infra-production в infra-репо.
  • Retention job logs не короче срока, который требует SOC 2.

Шаг 3. Terraform через CI. Создайте MR в platform/infra и проверьте plan в артефакте plan.txt. После merge тимлид делает approve job apply. Затем проверьте:

aws eks list-access-entries --cluster-name main-eks
kubectl get pods -n argo-rollouts -n external-secrets -n monitoring   # под ролью platform-team

Шаг 4. Секреты. Их заводит владелец секрета, не через git и не через CI.

aws secretsmanager create-secret --name orders/prod/app --kms-key-id <key> \
  --secret-string file://<(read-from-vault-or-prompt)   # не сохраняйте в shell history

Шаг 5. Образ ci-tools. Соберите его один раз через тот же паттерн build → Trivy → push и опубликуйте в ECR.

Шаг 6. Первый деплой. Merge в main запускает цепочку test → build → scan → publish → dev → staging. Для production нажмите Run на deploy-prod, после чего тимлид делает Approve. Наблюдать за раскаткой:

kubectl argo rollouts get rollout orders-service -n orders-prod --watch
kubectl get analysisrun -n orders-prod
aws elbv2 describe-rules --listener-arn <arn> --query 'Rules[].Actions[].ForwardConfig.TargetGroups'

Шаг 7. Обязательная проверка автоотката (game day). Сделайте это до первого боевого релиза. Временный overlay overlays/staging-canary — копия prod-патча с namespace orders-staging — раскатывается на staging с нагрузкой (k6/hey, ≥ 20 RPS). В него выкатывается сборка с флагом, который возвращает 5% ответов 500 (только для non-prod). Ожидаемый результат:

  • AnalysisRun в статусе Failed;
  • Rollout в статусе Degraded (aborted);
  • вес ALB на stable 100%;
  • job в CI красный;
  • сработал алерт OrdersRolloutDegraded.

Замерьте время от начала ошибок до abort. Его нужно зафиксировать как evidence для SOC 2 и RTO.

Ручные операции для platform-team:

kubectl argo rollouts abort   orders-service -n orders-prod   # немедленно вернуть трафик на stable
kubectl argo rollouts undo    orders-service -n orders-prod   # откат на предыдущую ревизию (rollbackWindow → без шагов)
kubectl argo rollouts promote orders-service -n orders-prod   # осознанно пропустить шаг (фиксируется в audit log)

Предпочтительный путь отката — Re-deploy предыдущего успешного деплоя в GitLab → Environments → production. Он проходит через approval и аудит, а благодаря rollbackWindow выполняется быстро.


7. Наблюдаемость

Метрики (RED + USE + доставка):

Слой Метрики
Приложение http_request_duration_seconds (rate, 5xx ratio, p50/p95/p99 по route); event loop lag; heap; активные соединения к пулу PG
Canary те же метрики с разрезом по rollouts_pod_template_hash (stable vs canary на одном дашборде Grafana)
Argo Rollouts rollout_info{phase}, analysis_run_metric_phase, длительность rollout
Инфраструктура CPU/RAM подов, рестарты, HPA current vs max, ALB 5xx/TargetResponseTime (CloudWatch)
Зависимости RDS: connections, replica lag, FreeStorageSpace, CPU. ElastiCache: evictions, CPU, memory
DORA deployment frequency, change failure rate (доля abort), MTTR — из GitLab Environments API

deploy/overlays/prod/prometheus-rules.yaml

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: orders-service
spec:
  groups:
    - name: orders-service.recording
      rules:
        - record: orders:http_error_ratio:rate5m
          expr: |
            (sum by (namespace) (rate(http_request_duration_seconds_count{status_code=~"5.."}[5m])) or on(namespace) vector(0))
            / sum by (namespace) (rate(http_request_duration_seconds_count[5m]))
        - record: orders:http_p95_seconds:rate5m
          expr: histogram_quantile(0.95, sum by (namespace, le) (rate(http_request_duration_seconds_bucket[5m])))
    - name: orders-service.alerts
      rules:
        - alert: OrdersHighErrorRate
          expr: orders:http_error_ratio:rate5m{namespace="orders-prod"} > 0.02
          for: 5m
          labels: { severity: critical }
          annotations:
            summary: "orders-prod: 5xx > 2% (5m)"
            runbook_url: https://wiki.example.com/runbooks/orders/high-error-rate
        - alert: OrdersHighLatencyP95
          expr: orders:http_p95_seconds:rate5m{namespace="orders-prod"} > 0.8
          for: 10m
          labels: { severity: warning }
          annotations: { summary: "orders-prod: p95 > 800ms (10m)" }
        - alert: OrdersRolloutDegraded
          expr: rollout_info{namespace="orders-prod", name="orders-service", phase="Degraded"} == 1
          for: 1m
          labels: { severity: critical }
          annotations: { summary: "Canary отклонён / rollout aborted — трафик на stable, нужен разбор" }
        - alert: OrdersAnalysisRunError
          expr: analysis_run_metric_phase{namespace="orders-prod", phase="Error"} == 1
          labels: { severity: warning }
          annotations: { summary: "AnalysisRun не может получить метрики (Prometheus?)" }
        - alert: OrdersExternalSecretNotSynced
          expr: externalsecret_status_condition{namespace=~"orders-.*", condition="Ready", status="False"} == 1
          for: 10m
          labels: { severity: warning }
        - alert: OrdersPodCrashLooping
          expr: increase(kube_pod_container_status_restarts_total{namespace="orders-prod"}[15m]) > 3
          labels: { severity: warning }
        - alert: OrdersHPAAtMax
          expr: |
            kube_horizontalpodautoscaler_status_current_replicas{namespace="orders-prod"}
              >= kube_horizontalpodautoscaler_spec_max_replicas{namespace="orders-prod"}
          for: 15m
          labels: { severity: warning }
          annotations: { summary: "HPA упёрся в 10 реплик — capacity-риск" }

Проверьте точные имена меток rollout_info и analysis_run_metric_phase в /metrics вашей версии Argo Rollouts: между версиями они менялись.

Логи:

  • Приложение пишет JSON в stdout через pino. Обязательные поля: trace_id, req_id, route, status, duration_ms, version (git sha) и pod. Поле version позволяет сравнивать canary и stable в логах.
  • Сбор через Fluent Bit (aws-for-fluent-bit) в CloudWatch Logs с retention 30 дней для приложения. Для аудита: EKS audit 365 дней, CloudTrail — в S3 с Object Lock.
  • Логи healthcheck отфильтруйте: это главный источник лишних расходов.

Аудит для SOC 2:

  • GitLab audit events: кто запустил и кто одобрил деплой.
  • Лог job: kubectl diff и digest образа.
  • CloudTrail: session name gl-<job_id>-<user>.
  • EKS audit log: действие на уровне API. Цепочка восстанавливается end-to-end.

Бюджет (оценка, eu-central-1):

Компонент ~USD/мес
Prometheus + Alertmanager + Grafana (≈1 vCPU / 3–4 GiB на существующих нодах или отдельная m6g.large) 40–70
EBS gp3 55 GiB ~6
Argo Rollouts + ESO (контроллеры) ~10
CloudWatch: EKS audit logs (сильно зависит от объёма, 10–40 GB) 10–30
Secrets Manager (~6 секретов + API) ~3
ECR storage ~2–5
Итого ~70–125 из лимита 300

Главный риск перерасхода — логи приложения в CloudWatch. Их стоит считать отдельно и держать на коротком retention с фильтрацией.


8. Чеклист перед production

  • Ответы на вопросы 1–3 получены. Подтверждены GitLab Premium и доступность OIDC.
  • План апгрейда EKS 1.29 и Node 20 заведён (оба вне поддержки).
  • main защищена, CODEOWNERS стоят на .gitlab-ci.yml, ci/, deploy/. Protected environments production и infra-production требуют approval, self-approval запрещён.
  • В CI/CD variables нет секретов. Выполнен поиск (gitleaks) по истории репозитория.
  • Trust policy всех CI-ролей проверена: sub привязан к проекту и main. aws iam simulate-principal-policy подтверждает, что deploy-dev не может трогать prod.
  • kubectl auth can-i --list --as-group=orders-deployer-prod --as=x -n orders-prod: нет secrets, нет pods/exec.
  • Приложение отдаёт /metrics на порту 9464 с бакетом 0.8. В Prometheus видна метка rollouts_pod_template_hash.
  • Game day автоотката проведён, время до abort зафиксировано.
  • Миграции обратно совместимы. Есть ревью-правило «никаких DROP/RENAME в одном релизе с кодом».
  • PITR включён. Проведён restore-drill RDS в тестовый инстанс, фактическое время restore известно.
  • Алерты доходят до on-call (Alertmanager → Slack/PagerDuty). Runbooks написаны.
  • Пароль Grafana admin не дефолтный. Prometheus и Grafana не торчат наружу.
  • Протестирован Re-deploy предыдущей версии из GitLab Environments.

9. Типичные ошибки и остаточные риски

  1. replicas в Rollout при HPA. Каждый kubectl apply сбрасывает масштаб до значения из манифеста. Поэтому в prod replicas не задан. Побочный эффект: при самом первом деплое стартует 1 под, пока HPA не поднимет до 3.
  2. Нет бакета 0.8 в гистограмме. p95 интерполируется, и порог срабатывает неточно.
  3. Пробы учитываются в метриках. Error rate и p95 размываются, и плохой canary проходит анализ.
  4. Мало трафика на 10%. При RPS ~1 на canary одна ошибка даёт 100% error rate. Отсутствие трафика по fail-safe тоже считается провалом. В тихие часы возможны ложные откаты. Если prod-RPS низкий, увеличьте окно rate или вес, либо добавьте условие минимального числа запросов. Это нужно обсудить (вопрос 3).
  5. ALB fail-open. Если все target в canary TG unhealthy, ALB шлёт трафик на них. Защиту дают readiness gate (label namespace), progressDeadlineAbort и fail-safe анализа.
  6. Откат не откатывает схему БД. Rollout возвращает код, но не миграции. Нужны expand/contract миграции и backward-compatible код.
  7. Секреты при ротации. ESO обновит Secret, но поды через envFrom новые значения не увидят. Нужен kubectl argo rollouts restart или Stakater Reloader, а RDS-ротацию стоит делать с двумя пользователями (alternating users).
  8. Остаточный риск OIDC. sub в GitLab 16 содержит проект и ветку, но не environment. Тот, кто может смержить в main изменённый .gitlab-ci.yml, теоретически может вызвать prod-роль в обход approval. Митигации:
    • CODEOWNERS на CI-файлы;
    • обязательные approvals MR;
    • алерт в CloudTrail на AssumeRoleWithWebIdentity роли deploy-prod без соответствующего approved deployment в GitLab;
    • в перспективе — вынести prod-деплой в отдельный проект с узким кругом maintainers или перейти на Argo CD (pull), где CI вообще не имеет доступа к prod.
  9. Fallback, если GitLab недоступен из интернета для STS. Выделенные раннеры на каждое окружение (отдельный namespace, отдельный SA с IRSA, protected runner только для protected-веток). Изоляция хуже, чем с OIDC на уровне job, но статических ключей по-прежнему нет.
  10. Права деплойера в namespace. Кто может создавать поды в namespace, тот фактически может прочитать его секреты (смонтировав Secret в под). Для CI-деплойера это принятый риск. Поэтому доступ к prod-роли и ограничен так жёстко.

Могу продолжить с любого места: Argo CD-вариант, отдельный prod-кластер, дашборд Grafana для canary vs stable, runbook отката. Но сначала лучше получить ответы на три вопроса, особенно про RPS в prod: от него зависит настройка анализа.

Похожие промпты

IT и Администрирование

Ревью и рефакторинг PowerShell-скрипта

Разбирает существующий скрипт PowerShell: что сломается на реальных данных, где ошибки глушатся вместо обработки и что переписать в первую очередь, а что оставить.

IT и Администрирование

Скрипт PowerShell под задачу

Пишет скрипт PowerShell с обработкой ошибок, логированием и режимом пробного прогона — так, чтобы его можно было запустить на рабочей машине и не бояться последствий.

IT и Администрирование

Диагностика проблем VPN-подключения

Методично диагностирует проблемы VPN: соединение не устанавливается, скорость падает, утекает DNS — по одному шагу, с командами под вашу ОС.

IT и Администрирование

Помощник по настройке WireGuard и OpenVPN

Пошагово разворачивает собственный VPN-сервер на WireGuard или OpenVPN: готовые команды и конфиги, firewall, клиентские профили и проверка утечек.