Специалист по DevOps автоматизации
Помогает проектировать и отлаживать CI/CD-конвейеры, инфраструктуру как код и облачную автоматизацию.
Пример запуска
Сгенерировано 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. Уточняющие вопросы
Эти вопросы влияют на архитектуру. Ниже я исхожу из допущений, и решение работает при ответах по умолчанию.
- Редакция GitLab и доступность OIDC. У вас Premium или Ultimate? Deployment approvals для protected environments доступны только в Premium+. Доступны ли
https://<gitlab>/.well-known/openid-configurationи JWKS из интернета? Это нужно AWS STS для OIDC-федерации. - Ingress и топология. AWS Load Balancer Controller (ALB) уже установлен? Все три namespace находятся в одном кластере EKS?
- Метрики и трафик. Экспортирует ли приложение Prometheus-метрики и есть ли уже Prometheus или AMP? Какой примерно RPS в prod? От этого зависит статистическая значимость 10% canary.
2. Допущения
- GitLab Premium 16.x. OIDC-эндпоинты доступны AWS STS. Если нет, см. fallback в разделе рисков.
- Один кластер EKS (
main-eks, eu-central-1) с namespacesorders-dev,orders-staging,orders-prod. - AWS Load Balancer Controller установлен. Ingress class
alb,target-type: ip. - Prometheus ставим сами (kube-prometheus-stack в кластере). Метрики приложения (prom-client) добавляем. Эталонный фрагмент ниже.
- Кластер создан модулем
terraform-aws-modules/eksv20.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 environmentsproductionи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. Типичные ошибки и остаточные риски
replicasв Rollout при HPA. Каждыйkubectl applyсбрасывает масштаб до значения из манифеста. Поэтому в prodreplicasне задан. Побочный эффект: при самом первом деплое стартует 1 под, пока HPA не поднимет до 3.- Нет бакета 0.8 в гистограмме. p95 интерполируется, и порог срабатывает неточно.
- Пробы учитываются в метриках. Error rate и p95 размываются, и плохой canary проходит анализ.
- Мало трафика на 10%. При RPS ~1 на canary одна ошибка даёт 100% error rate. Отсутствие трафика по fail-safe тоже считается провалом. В тихие часы возможны ложные откаты. Если prod-RPS низкий, увеличьте окно
rateили вес, либо добавьте условие минимального числа запросов. Это нужно обсудить (вопрос 3). - ALB fail-open. Если все target в canary TG unhealthy, ALB шлёт трафик на них. Защиту дают readiness gate (label namespace),
progressDeadlineAbortи fail-safe анализа. - Откат не откатывает схему БД. Rollout возвращает код, но не миграции. Нужны expand/contract миграции и backward-compatible код.
- Секреты при ротации. ESO обновит Secret, но поды через
envFromновые значения не увидят. Нуженkubectl argo rollouts restartили Stakater Reloader, а RDS-ротацию стоит делать с двумя пользователями (alternating users). - Остаточный риск 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.
- Fallback, если GitLab недоступен из интернета для STS. Выделенные раннеры на каждое окружение (отдельный namespace, отдельный SA с IRSA,
protectedrunner только для protected-веток). Изоляция хуже, чем с OIDC на уровне job, но статических ключей по-прежнему нет. - Права деплойера в namespace. Кто может создавать поды в namespace, тот фактически может прочитать его секреты (смонтировав Secret в под). Для CI-деплойера это принятый риск. Поэтому доступ к prod-роли и ограничен так жёстко.
Могу продолжить с любого места: Argo CD-вариант, отдельный prod-кластер, дашборд Grafana для canary vs stable, runbook отката. Но сначала лучше получить ответы на три вопроса, особенно про RPS в prod: от него зависит настройка анализа.
Похожие промпты
Ревью и рефакторинг PowerShell-скрипта
Разбирает существующий скрипт PowerShell: что сломается на реальных данных, где ошибки глушатся вместо обработки и что переписать в первую очередь, а что оставить.
Скрипт PowerShell под задачу
Пишет скрипт PowerShell с обработкой ошибок, логированием и режимом пробного прогона — так, чтобы его можно было запустить на рабочей машине и не бояться последствий.
Диагностика проблем VPN-подключения
Методично диагностирует проблемы VPN: соединение не устанавливается, скорость падает, утекает DNS — по одному шагу, с командами под вашу ОС.
Помощник по настройке WireGuard и OpenVPN
Пошагово разворачивает собственный VPN-сервер на WireGuard или OpenVPN: готовые команды и конфиги, firewall, клиентские профили и проверка утечек.