Главная/CI/CD/Урок

16.5. GitLab CI

Цели

После этого материала вы сможете:

  • перенести pipeline из GitHub Actions в GitLab CI и назвать, что пришлось изменить;
  • выбрать между Docker-in-Docker и сокетом runner'а, зная цену каждого;
  • объяснить, чем cache отличается от artifacts, и не перепутать их;
  • объяснить, почему переменная не видна в задаче, хотя она задана;
  • использовать встроенный registry и его переменные;
  • назвать, где логика переносится один в один, а где требует другого подхода.

Предварительные знания

Ключевые термины

ТерминОбъяснение
stageГруппа задач; следующая начинается после завершения предыдущей
runnerИсполнитель задач
executorСпособ запуска: docker, shell, kubernetes
servicesДополнительные container'ы, доступные задаче
artifactsФайлы, передаваемые между задачами и сохраняемые
cacheФайлы, ускоряющие повторные запуски

Теория

Соответствие понятий

GitHub ActionsGitLab CIОтличие
jobsjobsВ GitLab задачи группируются в stages
needsneedsВ GitLab по умолчанию действует порядок stages
stepsscriptВ GitLab шаг — строка команды, не действие
uses: actionШаблон или образДействий как переиспользуемых модулей нет
if:rules:Разный синтаксис, схожая семантика
permissionsТокен задачи и его областьНастраивается вне файла
actions/upload-artifactartifacts:Встроено
actions/cachecache:Встроено
container:image:Задача всегда идёт в образе

Главное различие модели: в GitLab задача выполняется внутри образа, указанного в image:. Это ближе к тому, как устроен Docker, и снимает вопрос «что установлено на исполнителе».

stages и needs

yaml
stages: [check, test, build, publish]

lint:
  stage: check

unit-tests:
  stage: test          # начнётся после ВСЕХ задач стадии check

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

yaml
unit-tests:
  stage: test
  needs: [lint]        # ждёт только lint, а не всю стадию check

С needs порядок определяется графом зависимостей, а не стадиями — как в GitHub Actions. Стадии при этом остаются как способ группировки для отображения.

Практика: объявлять needs явно везде. Разница на pipeline из десяти задач — минуты.

Docker-in-Docker или сокет

Задаче нужно собирать образы. Два способа, и выбор имеет последствия.

СпособКакИзоляцияКэш между запускамиРиск
Docker-in-DockerСервис docker:dindПолнаяНет--privileged
Сокет runner'аМонтирование /var/run/docker.sockНетЕстьРавносилен root на host
yaml
# Docker-in-Docker
build:
  image: docker:28-cli
  services:
    - name: docker:28-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: 1

Сервис dind требует привилегированного режима на уровне runner'а. Это решение администратора, а не автора pipeline.

Сокет runner'а опаснее, чем кажется. Задача, получившая доступ к сокету, может запустить container с монтированием корня host (урок 12.2). На общем runner'е это означает доступ к артефактам и секретам чужих задач.

Третий вариант — сборщик без демона (buildah, kaniko): он не требует ни привилегий, ни сокета, но имеет свои ограничения по совместимости с Dockerfile.

cache и artifacts — разные вещи

Их путают постоянно, и последствия разные.

Свойствоcacheartifacts
НазначениеУскорениеПередача результата
Гарантия наличияНетДа
Между задачамиМожет не успетьПередаётся
Между запускамиДаТолько как сохранённый файл
Что кластьЗависимости, кэш сборкиОтчёты, собранные пакеты, SBOM

Ключевая строка — «гарантия наличия». Кэш может отсутствовать: он не восстановился, устарел, был очищен. Задача, зависящая от кэша, ломается.

yaml
# ВЕРНО: кэш ускоряет, но задача работает и без него
unit-tests:
  cache:
    key:
      files: [requirements.txt]
    paths: [.pip-cache]
  script:
    - pip install --cache-dir .pip-cache -r requirements.txt
    - pytest

# НЕВЕРНО: результат сборки передан через cache
build:
  cache:
    paths: [dist/]
  script: [python -m build]

publish:
  script: [twine upload dist/*]   # ← dist/ может не оказаться

Во втором случае публикация иногда работает, а иногда нет — классическая нестабильность (урок 15.1).

Результат передают через artifacts:

yaml
build:
  artifacts:
    paths: [dist/]
    expire_in: 1 week

Почему переменная не видна

Три отдельные причины, дающие один симптом.

ПричинаПризнакРешение
Переменная protected, ветка не защищенаПусто на feature-ветках, есть на mainСнять флаг или защитить ветку
Переменная masked, значение не подходит под требованияОшибка при сохранении или значение видноПривести значение к требованиям маскирования
Переменная задана на уровне окруженияПусто в задачах без environment:Добавить environment: или изменить область

Первая причина — самая частая и самая непонятная в момент столкновения: pipeline работает на main и падает на ветке разработчика.

Флаг protected существует именно для этого: секрет публикации не должен быть доступен задаче, запущенной из произвольной ветки или чужого merge request.

Отсюда правило проектирования: задачи, которым нужны защищённые переменные, не должны запускаться на незащищённых ветках. Проверять наличие переменной и падать с понятным сообщением — лучше, чем падать в глубине команды публикации.

Встроенный registry

GitLab предоставляет registry на проект и переменные для входа:

ПеременнаяЧто содержит
CI_REGISTRYАдрес registry
CI_REGISTRY_IMAGEПолное имя образа проекта
CI_REGISTRY_USERПользователь для входа
CI_REGISTRY_PASSWORDТокен задачи
CI_COMMIT_SHAПолный SHA коммита
CI_COMMIT_SHORT_SHAКороткий SHA
yaml
script:
  - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
  - docker build -t "$CI_REGISTRY_IMAGE:git-$CI_COMMIT_SHORT_SHA" .
  - docker push "$CI_REGISTRY_IMAGE:git-$CI_COMMIT_SHORT_SHA"

Токен задачи действует только во время её выполнения и имеет права проекта. Отдельный долгоживущий токен для публикации в свой registry не нужен.

rules вместо if

yaml
publish:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: on_success
    - if: $CI_COMMIT_TAG
      when: on_success
    - when: never

Правила проверяются по порядку; срабатывает первое подошедшее. Последняя строка when: never — важная привычка: без неё поведение при непопадании ни в одно правило зависит от контекста.

Значение whenДействие
on_successВыполнить, если предыдущие стадии успешны
alwaysВыполнить всегда
manualЖдать нажатия человеком
neverНе создавать задачу

when: manual полезен для публикации и развёртывания: задача создаётся, но ждёт решения.

Что переносится, а что нет

ЭлементПеренос
Порядок этапов, зависимостиОдин в один
Логика проверок в скриптахОдин в один
Матрица версийЧерез parallel: matrix:
Кэш сборки образаЧерез type=registry вместо type=gha
Сборка образаМеняется: dind, сокет или сборщик без демона
Права токенаМеняется: настройки проекта вместо permissions
Переиспользуемые действияМеняется: шаблоны include вместо uses

Строки с пометкой «меняется» — то, ради чего этот урок отдельный. Остальное переносится механически, если логика вынесена в скрипты (урок 16.2).


Внутренний механизм

Как задача получает окружение

Runner с исполнителем docker для каждой задачи:

  1. Создаёт container из образа, указанного в image:.
  2. Поднимает container'ы из services: в той же сети.
  3. Клонирует репозиторий внутрь.
  4. Восстанавливает cache и скачивает artifacts задач из needs.
  5. Выполняет before_script и script.
  6. Сохраняет artifacts и cache.

Отсюда следствие: сервис доступен по своему имени как хосту. services: [postgres:17] даёт хост postgres — это тот же механизм, что в Compose (урок 8.4).

Почему dind не хранит кэш

Сервис docker:dind — отдельный container, создаваемый заново для каждой задачи. Его /var/lib/docker живёт вместе с ним.

Отсюда: локального кэша слоёв в dind не бывает никогда, и внешний кэш обязателен (урок 16.3). Это ровно та ситуация, которую в GitHub Actions создаёт чистый исполнитель.

Сокет runner'а, напротив, даёт доступ к постоянному демону с накопленным кэшем — и это главный довод в его пользу, перевешиваемый риском.


Команды и примеры

Полный .gitlab-ci.yml

bash
mkdir -p /tmp/glci && cd /tmp/glci
mkdir -p src tests/unit ci

cat > .gitlab-ci.yml <<'EOF'
stages: [check, test, build, publish]

variables:
  # Внешний кэш обязателен: dind не хранит слои между задачами
  BUILD_CACHE: $CI_REGISTRY_IMAGE/cache:buildcache
  IMAGE_TAG: $CI_REGISTRY_IMAGE:git-$CI_COMMIT_SHORT_SHA

default:
  interruptible: true          # новый push отменяет предыдущий запуск

# ── Быстрые проверки ─────────────────────────────────────────────────
.python-base:
  image: python:3.13-slim
  cache:
    # Ключ по содержимому: изменился файл — кэш промахнулся
    key:
      files: [requirements-dev.txt]
    paths: [.pip-cache]
  before_script:
    - pip install --quiet --cache-dir .pip-cache -r requirements-dev.txt

format:
  extends: .python-base
  stage: check
  script: [ruff format --check .]

lint:
  extends: .python-base
  stage: check
  script: [ruff check .]

types:
  extends: .python-base
  stage: check
  script: [mypy src/]

# ── Unit-тесты на матрице версий ─────────────────────────────────────
unit-tests:
  stage: test
  # needs вместо ожидания всей стадии check
  needs: [format, lint, types]
  image: python:$PY_VERSION-slim
  parallel:
    matrix:
      - PY_VERSION: ["3.12", "3.13"]
  cache:
    key:
      files: [requirements.txt, requirements-dev.txt]
    paths: [.pip-cache]
  script:
    - pip install --quiet --cache-dir .pip-cache -r requirements.txt -r requirements-dev.txt
    - pytest tests/unit -q --junitxml=junit-$PY_VERSION.xml
  artifacts:
    # artifacts, а не cache: результат нужен гарантированно
    when: always
    reports:
      junit: junit-$PY_VERSION.xml
    paths: [junit-$PY_VERSION.xml]
    expire_in: 1 week

# ── Сборка образа ────────────────────────────────────────────────────
build:
  stage: build
  needs: [unit-tests]
  image: docker:28-cli
  services:
    - name: docker:28-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: 1
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
  script:
    - docker buildx create --use --driver docker-container
    # Сборка ОДИН раз с --load: проверяется тот же образ, что публикуется
    - |
      docker buildx build --target production \
        --cache-from "type=registry,ref=$BUILD_CACHE" \
        --cache-to "type=registry,ref=$BUILD_CACHE,mode=max" \
        --tag candidate:local --load .
    - ./ci/validate-image.sh candidate:local
    - ./ci/integration-tests.sh candidate:local
  after_script:
    - docker logout "$CI_REGISTRY" || true

# ── Сканирование: не блокирует при отсутствии исправления ────────────
scan:
  stage: build
  needs: [build]
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE_TAG" || true
    - trivy image --severity HIGH,CRITICAL --format json -o trivy.json "$IMAGE_TAG"
  artifacts:
    paths: [trivy.json]
    expire_in: 1 month
  allow_failure: true

# ── Публикация: только с защищённой ветки ────────────────────────────
publish:
  stage: publish
  needs: [build]
  image: docker:28-cli
  services:
    - name: docker:28-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: 1
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: on_success
    - if: $CI_COMMIT_TAG
      when: on_success
    - when: never
  before_script:
    # Понятное сообщение вместо отказа в глубине docker push
    - |
      if [ -z "${CI_REGISTRY_PASSWORD:-}" ]; then
        echo "CI_REGISTRY_PASSWORD пуст: вероятно, переменная protected,"
        echo "а ветка не защищена. Публикация невозможна."
        exit 1
      fi
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
  script:
    - docker buildx create --use --driver docker-container
    # Вторая сборка полностью из кэша: тот же digest
    - |
      docker buildx build --target production \
        --cache-from "type=registry,ref=$BUILD_CACHE" \
        --tag "$IMAGE_TAG" \
        --tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" \
        --provenance=true --sbom=true \
        --push .
    - echo "опубликовано: $IMAGE_TAG"
  after_script:
    - docker logout "$CI_REGISTRY" || true
EOF

echo "═══ проверка синтаксиса ═══"
python3 - <<'PY'
import yaml
from pathlib import Path

data = yaml.safe_load(Path(".gitlab-ci.yml").read_text())
stages = data.get("stages", [])
jobs = {k: v for k, v in data.items()
        if isinstance(v, dict) and not k.startswith(".")
        and k not in ("variables", "default", "stages")}

print(f"  стадий: {len(stages)} — {', '.join(stages)}")
print(f"  задач:  {len(jobs)}")
print()
print(f"  {'задача':<14} {'стадия':<10} {'needs':<26} {'образ':<20} artifacts")
print("  " + "─" * 88)
for name, job in jobs.items():
    needs = job.get("needs")
    needs_s = ",".join(needs) if isinstance(needs, list) else (needs or "—")
    print(f"  {name:<14} {job.get('stage', '—'):<10} {needs_s:<26} "
          f"{str(job.get('image', '—')):<20} "
          f"{'да' if 'artifacts' in job else 'нет'}")
PY

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

text
═══ проверка синтаксиса ═══
  стадий: 4 — check, test, build, publish
  задач:  7

  задача         стадия     needs                      образ                artifacts
  ────────────────────────────────────────────────────────────────────────────────────────
  format         check      —                          python:3.13-slim     нет
  lint           check      —                          python:3.13-slim     нет
  types          check      —                          python:3.13-slim     нет
  unit-tests     test       format,lint,types          python:$PY_VERSION-slim да
  build          build      unit-tests                 docker:28-cli        нет
  scan           build      build                      aquasec/trivy:latest да
  publish        publish    build                      docker:28-cli        нет

Задачи format, lint и types не имеют needs — они первые в графе и идут параллельно. Остальные объявляют зависимости явно.

cache против artifacts

bash
cd /tmp/glci
cat > cache-vs-artifacts.py <<'PY'
"""Чем cache отличается от artifacts и что бывает при путанице."""
from __future__ import annotations

PROPERTIES = [
    ("Назначение", "ускорение", "передача результата"),
    ("Гарантия наличия", "НЕТ", "да"),
    ("Между задачами одного запуска", "может не успеть", "передаётся"),
    ("Между запусками", "да", "только как сохранённый файл"),
    ("Виден в интерфейсе", "нет", "да, можно скачать"),
    ("Срок хранения", "до вытеснения", "expire_in"),
]

MISUSE = [
    {
        "что положили": "результат сборки (dist/) в cache",
        "симптом": "публикация иногда работает, иногда падает",
        "почему": "кэш мог не восстановиться — гарантии нет",
        "исправление": "artifacts: paths: [dist/]",
    },
    {
        "что положили": "кэш зависимостей в artifacts",
        "симптом": "каждый запуск скачивает и загружает сотни мегабайт",
        "почему": "artifacts передаются по сети всегда",
        "исправление": "cache: paths: [.pip-cache]",
    },
    {
        "что положили": "отчёт о тестах в cache",
        "симптом": "отчёт иногда пуст, иногда от прошлого запуска",
        "почему": "кэш переживает запуски и не очищается",
        "исправление": "artifacts: reports: junit:",
    },
]


def main() -> None:
    print(f"  {'свойство':<34} {'cache':<20} {'artifacts':<28}")
    print("  " + "─" * 84)
    for name, cache, artifacts in PROPERTIES:
        print(f"  {name:<34} {cache:<20} {artifacts:<28}")

    print()
    print("  Ключевая строка — «гарантия наличия».")
    print("  Задача, ЗАВИСЯЩАЯ от кэша, ломается непредсказуемо.")
    print()
    for m in MISUSE:
        print(f"  {m['что положили']}")
        print(f"    симптом:     {m['симптом']}")
        print(f"    почему:      {m['почему']}")
        print(f"    исправление: {m['исправление']}")
        print()

    print("  Правило: cache — ускоряет; artifacts — передаёт.")
    print("  Если без него задача не работает — это artifacts.")


if __name__ == "__main__":
    main()
PY

echo "═══ cache и artifacts ═══"
python3 cache-vs-artifacts.py

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

text
═══ cache и artifacts ═══
  свойство                           cache                artifacts                   
  ────────────────────────────────────────────────────────────────────────────────────
  Назначение                         ускорение            передача результата         
  Гарантия наличия                   НЕТ                  да                          
  Между задачами одного запуска      может не успеть      передаётся                  
  Между запусками                    да                   только как сохранённый файл 
  Виден в интерфейсе                 нет                  да, можно скачать           
  Срок хранения                      до вытеснения        expire_in                   

  Ключевая строка — «гарантия наличия».
  Задача, ЗАВИСЯЩАЯ от кэша, ломается непредсказуемо.

  результат сборки (dist/) в cache
    симптом:     публикация иногда работает, иногда падает
    почему:      кэш мог не восстановиться — гарантии нет
    исправление: artifacts: paths: [dist/]
  ...
  Правило: cache — ускоряет; artifacts — передаёт.
  Если без него задача не работает — это artifacts.

Первая ошибка из трёх — самая дорогая: она даёт нестабильность, а не отказ. Публикация проходит в девяти случаях из десяти, и связь с кэшем находят не сразу.

Почему переменная пуста

bash
cd /tmp/glci
cat > variables.py <<'PY'
"""Три причины, по которым переменная не видна в задаче."""
from __future__ import annotations

CASES = [
    {
        "причина": "переменная protected, ветка не защищена",
        "признак": "пусто на feature-ветках, есть на main",
        "частота": "самая частая",
        "решение": "снять флаг protected или защитить ветку",
        "правильный ответ": "не запускать задачу публикации на незащищённых ветках",
    },
    {
        "причина": "переменная задана на уровне окружения",
        "признак": "пусто в задачах без environment:",
        "частота": "средняя",
        "решение": "добавить environment: в задачу или изменить область переменной",
        "правильный ответ": "проверять область при добавлении переменной",
    },
    {
        "причина": "переменная masked, значение не подходит",
        "признак": "не сохраняется или видна в логах",
        "частота": "редкая",
        "решение": "привести значение к требованиям маскирования",
        "правильный ответ": "секрет не должен попадать в вывод вовсе",
    },
    {
        "причина": "опечатка в имени",
        "признак": "пусто везде",
        "частота": "частая",
        "решение": "сверить имя",
        "правильный ответ": "проверять наличие в начале задачи",
    },
]


def main() -> None:
    print(f"  {'причина':<44} {'признак':<38} частота")
    print("  " + "─" * 96)
    for c in CASES:
        print(f"  {c['причина']:<44} {c['признак']:<38} {c['частота']}")

    print()
    print("  Первая причина — самая непонятная в момент столкновения:")
    print("  pipeline работает на main и падает на ветке разработчика.")
    print()
    print("  Флаг protected существует именно для этого: секрет публикации")
    print("  не должен быть доступен задаче из произвольной ветки")
    print("  или чужого merge request.")
    print()
    print("  Правило проектирования:")
    print("    задачи, которым нужны защищённые переменные,")
    print("    не должны запускаться на незащищённых ветках")
    print()
    print("  И проверка в начале задачи — понятное сообщение")
    print("  вместо отказа в глубине docker push:")
    print("""
    before_script:
      - |
        if [ -z "${CI_REGISTRY_PASSWORD:-}" ]; then
          echo "переменная пуста: вероятно, protected, а ветка не защищена"
          exit 1
        fi
""")


if __name__ == "__main__":
    main()
PY

echo "═══ почему переменная пуста ═══"
python3 variables.py

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

text
═══ почему переменная пуста ═══
  причина                                      признак                                частота
  ────────────────────────────────────────────────────────────────────────────────────────────────
  переменная protected, ветка не защищена      пусто на feature-ветках, есть на main  самая частая
  переменная задана на уровне окружения        пусто в задачах без environment:       средняя
  переменная masked, значение не подходит      не сохраняется или видна в логах       редкая
  опечатка в имени                             пусто везде                            частая

  Первая причина — самая непонятная в момент столкновения:
  pipeline работает на main и падает на ветке разработчика.
  ...

Docker-in-Docker или сокет

bash
cd /tmp/glci
cat > build-approach.py <<'PY'
"""Выбор способа сборки образов в GitLab CI."""
from __future__ import annotations

APPROACHES = [
    {
        "способ": "Docker-in-Docker (services: docker:dind)",
        "привилегии": "требует privileged на runner",
        "изоляция": "полная: свой демон на задачу",
        "кэш слоёв": "НЕТ: демон создаётся заново",
        "риск": "privileged даёт четыре ослабления сразу",
        "когда": "общий runner, важна изоляция задач",
    },
    {
        "способ": "Сокет runner'а (/var/run/docker.sock)",
        "привилегии": "не требует privileged у задачи",
        "изоляция": "НЕТ: общий демон со всеми задачами",
        "кэш слоёв": "есть: демон постоянный",
        "риск": "равносилен root на host runner'а",
        "когда": "выделенный runner, задачи доверенные",
    },
    {
        "способ": "Сборщик без демона (buildah, kaniko)",
        "привилегии": "не требует",
        "изоляция": "полная",
        "кэш слоёв": "через внешний кэш",
        "риск": "низкий",
        "когда": "нет возможности дать привилегии",
    },
]

DIND_RISK = [
    "все capabilities, включая SYS_ADMIN и SYS_MODULE",
    "доступ ко всем устройствам host",
    "отключённый профиль seccomp",
    "запись в /sys",
]

SOCKET_RISK = [
    "запуск container с монтированием корня host",
    "чтение артефактов и секретов чужих задач",
    "изменение образов, используемых другими задачами",
]


def main() -> None:
    for a in APPROACHES:
        print(f"  {a['способ']}")
        print(f"    привилегии: {a['привилегии']}")
        print(f"    изоляция:   {a['изоляция']}")
        print(f"    кэш слоёв:  {a['кэш слоёв']}")
        print(f"    риск:       {a['риск']}")
        print(f"    когда:      {a['когда']}")
        print()

    print("  Что даёт privileged у сервиса dind:")
    for r in DIND_RISK:
        print(f"    · {r}")
    print()
    print("  Что даёт доступ к сокету runner'а:")
    for r in SOCKET_RISK:
        print(f"    · {r}")
    print()
    print("  Строка «кэш слоёв» объясняет соблазн сокета: постоянный демон")
    print("  хранит слои между задачами. Цена — отсутствие изоляции.")
    print()
    print("  При dind внешний кэш ОБЯЗАТЕЛЕН: локального не бывает никогда.")


if __name__ == "__main__":
    main()
PY

echo "═══ способы сборки ═══"
python3 build-approach.py

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

text
═══ способы сборки ═══
  Docker-in-Docker (services: docker:dind)
    привилегии: требует privileged на runner
    изоляция:   полная: свой демон на задачу
    кэш слоёв:  НЕТ: демон создаётся заново
    риск:       privileged даёт четыре ослабления сразу
    когда:      общий runner, важна изоляция задач

  Сокет runner'а (/var/run/docker.sock)
    привилегии: не требует privileged у задачи
    изоляция:   НЕТ: общий демон со всеми задачами
    кэш слоёв:  есть: демон постоянный
    риск:       равносилен root на host runner'а
    когда:      выделенный runner, задачи доверенные

  Сборщик без демона (buildah, kaniko)
    привилегии: не требует
    изоляция:   полная
    кэш слоёв:  через внешний кэш
    риск:       низкий
    когда:      нет возможности дать привилегии

  Что даёт privileged у сервиса dind:
    · все capabilities, включая SYS_ADMIN и SYS_MODULE
    · доступ ко всем устройствам host
    · отключённый профиль seccomp
    · запись в /sys

  Что даёт доступ к сокету runner'а:
    · запуск container с монтированием корня host
    · чтение артефактов и секретов чужих задач
    · изменение образов, используемых другими задачами

  Строка «кэш слоёв» объясняет соблазн сокета: постоянный демон
  хранит слои между задачами. Цена — отсутствие изоляции.

  При dind внешний кэш ОБЯЗАТЕЛЕН: локального не бывает никогда.

Список последствий privileged совпадает с разобранным в уроке 12.4 — это те же четыре ослабления.

Что пришлось изменить при переносе

bash
cd /tmp/glci
cat > migration.py <<'PY'
"""Что переносится один в один, а что требует другого подхода."""
from __future__ import annotations

import json

ITEMS = [
    {"элемент": "Порядок этапов и зависимости", "перенос": "как есть",
     "github": "needs:", "gitlab": "needs: (плюс stages для группировки)",
     "заметка": "в GitLab без needs задача ждёт всю предыдущую стадию"},
    {"элемент": "Логика проверок", "перенос": "как есть",
     "github": "run: ./ci/validate.sh", "gitlab": "script: [./ci/validate.sh]",
     "заметка": "вынесение в скрипты и делает перенос механическим"},
    {"элемент": "Матрица версий", "перенос": "как есть",
     "github": "strategy.matrix", "gitlab": "parallel.matrix",
     "заметка": "синтаксис другой, семантика та же"},
    {"элемент": "Условия запуска", "перенос": "как есть",
     "github": "if:", "gitlab": "rules:",
     "заметка": "в rules полезно завершать `when: never`"},
    {"элемент": "Артефакты", "перенос": "как есть",
     "github": "actions/upload-artifact", "gitlab": "artifacts:",
     "заметка": "в GitLab встроено"},
    {"элемент": "Кэш зависимостей", "перенос": "как есть",
     "github": "actions/cache или setup-python", "gitlab": "cache:",
     "заметка": "ключ по содержимому файлов в обоих"},
    {"элемент": "Кэш сборки образа", "перенос": "МЕНЯЕТСЯ",
     "github": "type=gha", "gitlab": "type=registry",
     "заметка": "type=gha существует только в GitHub Actions"},
    {"элемент": "Сборка образа", "перенос": "МЕНЯЕТСЯ",
     "github": "docker/build-push-action", "gitlab": "dind, сокет или kaniko",
     "заметка": "главное отличие: решение о привилегиях"},
    {"элемент": "Права токена", "перенос": "МЕНЯЕТСЯ",
     "github": "permissions: в файле", "gitlab": "настройки проекта",
     "заметка": "в GitLab не описывается в pipeline"},
    {"элемент": "Переиспользуемые модули", "перенос": "МЕНЯЕТСЯ",
     "github": "uses: action", "gitlab": "include: и extends:",
     "заметка": "действий как модулей в GitLab нет"},
    {"элемент": "Секреты", "перенос": "МЕНЯЕТСЯ",
     "github": "secrets. + permissions", "gitlab": "переменные + protected/masked",
     "заметка": "protected — источник «переменная пуста на ветке»"},
]


def main() -> None:
    same = [i for i in ITEMS if i["перенос"] == "как есть"]
    changed = [i for i in ITEMS if i["перенос"] != "как есть"]

    print(f"  {'элемент':<32} {'перенос':<12} {'GitHub Actions':<30} GitLab CI")
    print("  " + "─" * 108)
    for i in ITEMS:
        print(f"  {i['элемент']:<32} {i['перенос']:<12} {i['github']:<30} {i['gitlab']}")

    print()
    print(f"  переносится механически: {len(same)} из {len(ITEMS)}")
    print(f"  требует изменений:       {len(changed)} из {len(ITEMS)}")
    print()
    print("  Что именно меняется:")
    for i in changed:
        print(f"    {i['элемент']:<32} {i['заметка']}")
    print()
    print("  Вывод: чем больше логики вынесено в скрипты,")
    print("  тем меньше переносить. Меняется только обвязка.")
    print()
    print(json.dumps({"как_есть": len(same), "меняется": len(changed)},
                     ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

echo "═══ перенос pipeline ═══"
python3 migration.py

cd /tmp && rm -rf /tmp/glci

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

text
═══ перенос pipeline ═══
  элемент                          перенос      GitHub Actions                 GitLab CI
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Порядок этапов и зависимости     как есть     needs:                         needs: (плюс stages для группировки)
  Логика проверок                  как есть     run: ./ci/validate.sh          script: [./ci/validate.sh]
  Матрица версий                   как есть     strategy.matrix                parallel.matrix
  Условия запуска                  как есть     if:                            rules:
  Артефакты                        как есть     actions/upload-artifact        artifacts:
  Кэш зависимостей                 как есть     actions/cache или setup-python cache:
  Кэш сборки образа                МЕНЯЕТСЯ     type=gha                       type=registry
  Сборка образа                    МЕНЯЕТСЯ     docker/build-push-action       dind, сокет или kaniko
  Права токена                     МЕНЯЕТСЯ     permissions: в файле           настройки проекта
  Переиспользуемые модули          МЕНЯЕТСЯ     uses: action                   include: и extends:
  Секреты                          МЕНЯЕТСЯ     secrets. + permissions         переменные + protected/masked

  переносится механически: 6 из 11
  требует изменений:       5 из 11

  Что именно меняется:
    Кэш сборки образа                type=gha существует только в GitHub Actions
    Сборка образа                    главное отличие: решение о привилегиях
    Права токена                     в GitLab не описывается в pipeline
    Переиспользуемые модули          действий как модулей в GitLab нет
    Секреты                          protected — источник «переменная пуста на ветке»

  Вывод: чем больше логики вынесено в скрипты,
  тем меньше переносить. Меняется только обвязка.

Шесть элементов из одиннадцати переносятся механически — и это прямое следствие того, что логика проверок вынесена в скрипты (урок 16.2).

Если бы она была написана в шагах YAML, механически не перенеслось бы ничего.


Практическое упражнение

Задание. Перенесите pipeline в GitLab CI и назовите, что изменилось.

Требования:

  1. Написать .gitlab-ci.yml, эквивалентный workflow из урока 16.2.
  2. Объявить needs явно и объяснить, что даёт отказ от порядка стадий.
  3. Разделить cache и artifacts правильно; привести пример неверного использования и его симптом.
  4. Выбрать способ сборки образов и обосновать выбор через риск.
  5. Реализовать проверку наличия защищённой переменной с понятным сообщением.
  6. Составить перечень: что перенеслось механически, что потребовало изменений.

Подсказки

Подсказка 1

Без needs задача ждёт завершения всей предыдущей стадии, а не только нужных ей задач.

Подсказка 2

Признак artifacts: без этих файлов следующая задача не работает. Признак cache: без него она работает, но медленнее.

Подсказка 3

Проверка переменной ставится в before_script — до первой команды, которая её использует.

Решение

Показать решение
bash
mkdir -p /tmp/glcilab && cd /tmp/glcilab
mkdir -p ci

cat > .gitlab-ci.yml <<'EOF'
stages: [check, test, build, publish]

variables:
  BUILD_CACHE: $CI_REGISTRY_IMAGE/cache:buildcache
  IMAGE_TAG: $CI_REGISTRY_IMAGE:git-$CI_COMMIT_SHORT_SHA

default:
  interruptible: true

.python-base:
  image: python:3.13-slim
  cache:
    key:
      files: [requirements-dev.txt]
    paths: [.pip-cache]
    policy: pull-push
  before_script:
    - pip install --quiet --cache-dir .pip-cache -r requirements-dev.txt

.docker-base:
  image: docker:28-cli
  services:
    - name: docker:28-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: 1

format:
  extends: .python-base
  stage: check
  script: [ruff format --check .]

lint:
  extends: .python-base
  stage: check
  script: [ruff check .]

types:
  extends: .python-base
  stage: check
  script: [mypy src/]

unit-tests:
  stage: test
  needs: [format, lint, types]
  image: python:$PY_VERSION-slim
  parallel:
    matrix:
      - PY_VERSION: ["3.12", "3.13"]
  cache:
    key:
      files: [requirements.txt, requirements-dev.txt]
    paths: [.pip-cache]
  script:
    - pip install --quiet --cache-dir .pip-cache -r requirements.txt -r requirements-dev.txt
    - pytest tests/unit -q --junitxml=junit-$PY_VERSION.xml
  artifacts:
    when: always
    reports:
      junit: junit-$PY_VERSION.xml
    paths: [junit-$PY_VERSION.xml]
    expire_in: 1 week

build:
  extends: .docker-base
  stage: build
  needs: [unit-tests]
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
  script:
    - docker buildx create --use --driver docker-container
    - |
      docker buildx build --target production \
        --cache-from "type=registry,ref=$BUILD_CACHE" \
        --cache-to "type=registry,ref=$BUILD_CACHE,mode=max" \
        --tag candidate:local --load .
    - ./ci/validate-image.sh candidate:local
    - ./ci/integration-tests.sh candidate:local
    - docker save candidate:local -o image.tar
  artifacts:
    paths: [image.tar]
    expire_in: 1 day
  after_script:
    - docker logout "$CI_REGISTRY" || true

scan:
  stage: build
  needs: [build]
  image: aquasec/trivy:latest
  script:
    - ./ci/scan.sh candidate:local
  artifacts:
    paths: [scan-reports/]
    expire_in: 1 month
  allow_failure: true

publish:
  extends: .docker-base
  stage: publish
  needs: [build]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: on_success
    - if: $CI_COMMIT_TAG
      when: on_success
    - when: never
  before_script:
    - ./ci/require-vars.sh CI_REGISTRY CI_REGISTRY_USER CI_REGISTRY_PASSWORD
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
  script:
    - docker buildx create --use --driver docker-container
    - |
      docker buildx build --target production \
        --cache-from "type=registry,ref=$BUILD_CACHE" \
        --tag "$IMAGE_TAG" \
        --tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" \
        --provenance=true --sbom=true \
        --push .
  after_script:
    - docker logout "$CI_REGISTRY" || true
EOF

# ─── Проверка обязательных переменных ─────────────────────────────────
cat > ci/require-vars.sh <<'SH'
#!/usr/bin/env bash
# Проверяет наличие переменных ДО первой команды, которая их использует.
#
# Без этой проверки отказ приходит из глубины docker login сообщением
# вида "unauthorized", по которому причина неочевидна.
set -uo pipefail

missing=0
for name in "$@"; do
    value="$(eval "printf '%s' \"\${$name:-}\"")"
    if [ -z "$value" ]; then
        printf '  ✗ %s пуста\n' "$name"
        missing=$((missing + 1))
    else
        printf '  ✓ %s задана (%s символов)\n' "$name" "${#value}"
    fi
done

if [ "$missing" -gt 0 ]; then
    cat <<'MSG'

  Переменных не хватает. Вероятные причины, по убыванию частоты:

    1. Переменная protected, а ветка не защищена.
       Признак: pipeline работает на main и падает на ветке.
       Это НЕ ошибка настройки — так и задумано: секрет публикации
       не должен быть доступен задаче из произвольной ветки.

    2. Переменная задана на уровне окружения, а в задаче нет environment:.

    3. Опечатка в имени переменной.

  Правильный ответ на причину 1 — не запускать эту задачу
  на незащищённых ветках (rules: if $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH).

MSG
    exit 1
fi
printf '  все переменные на месте\n'
exit 0
SH
chmod +x ci/require-vars.sh

# ─── Разбор и проверка pipeline ───────────────────────────────────────
cat > ci/lint-gitlab-ci.py <<'PY'
"""Статические проверки .gitlab-ci.yml."""
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml

RESERVED = {"stages", "variables", "default", "include", "workflow"}


def jobs_of(data: dict) -> dict[str, dict]:
    return {k: v for k, v in data.items()
            if isinstance(v, dict) and not k.startswith(".") and k not in RESERVED}


def check(path: Path) -> list[tuple[str, str, str]]:
    data = yaml.safe_load(path.read_text())
    jobs = jobs_of(data)
    out: list[tuple[str, str, str]] = []

    stages = data.get("stages", [])
    out.append(("ок", "стадии", f"{len(stages)}: {', '.join(stages)}"))

    # 1. needs объявлены явно
    first_stage = stages[0] if stages else None
    without_needs = [n for n, j in jobs.items()
                     if "needs" not in j and j.get("stage") != first_stage]
    if without_needs:
        out.append(("ВАЖНО", "needs",
                    f"без needs: {', '.join(without_needs)} — ждут всю стадию"))
    else:
        out.append(("ок", "needs", "объявлены везде, кроме первой стадии"))

    # 2. cache не используется для передачи результата
    suspicious = []
    for name, job in jobs.items():
        cache = job.get("cache") or {}
        paths = cache.get("paths", []) if isinstance(cache, dict) else []
        for p in paths:
            if any(p.rstrip("/").endswith(x) for x in ("dist", "build", "out", "target")):
                suspicious.append(f"{name}: {p}")
    if suspicious:
        out.append(("КРИТ", "cache для результата",
                    f"{'; '.join(suspicious)} — нужен artifacts"))
    else:
        out.append(("ок", "cache", "используется только для ускорения"))

    # 3. Секреты не в аргументах команд
    raw = path.read_text()
    if "--password " in raw or "-p $" in raw or "-p ${" in raw:
        out.append(("КРИТ", "передача секрета",
                    "секрет в аргументе команды; нужен --password-stdin"))
    else:
        out.append(("ок", "передача секрета", "через stdin"))

    # 4. Публикация ограничена правилами
    for name, job in jobs.items():
        script = " ".join(str(s) for s in (job.get("script") or []))
        if "--push" in script or "docker push" in script:
            rules = job.get("rules")
            if not rules:
                out.append(("КРИТ", f"правила задачи {name}",
                            "публикует без rules — сработает на любой ветке"))
            elif not any("CI_DEFAULT_BRANCH" in str(r) or "CI_COMMIT_TAG" in str(r)
                         for r in rules):
                out.append(("ВАЖНО", f"правила задачи {name}",
                            "публикует, но условие не привязано к ветке или тегу"))
            else:
                out.append(("ок", f"правила задачи {name}",
                            "публикация только с ветки по умолчанию или тега"))

    # 5. rules завершаются when: never
    for name, job in jobs.items():
        rules = job.get("rules")
        if rules and not any(str(r.get("when")) == "never" for r in rules
                             if isinstance(r, dict)):
            out.append(("ЗАМЕЧ", f"rules задачи {name}",
                        "нет завершающего when: never"))

    # 6. Проверка переменных до их использования
    for name, job in jobs.items():
        before = " ".join(str(s) for s in (job.get("before_script") or []))
        script = " ".join(str(s) for s in (job.get("script") or []))
        uses_registry = "CI_REGISTRY_PASSWORD" in before + script
        if uses_registry:
            if "require-vars" in before:
                out.append(("ок", f"проверка переменных в {name}",
                            "выполняется до использования"))
            else:
                out.append(("ВАЖНО", f"проверка переменных в {name}",
                            "нет проверки; отказ придёт из глубины docker login"))

    # 7. Внешний кэш при dind
    for name, job in jobs.items():
        services = job.get("services") or []
        uses_dind = any("dind" in str(s) for s in services)
        extends = job.get("extends")
        if not uses_dind and extends:
            base = data.get(extends if isinstance(extends, str) else extends[0], {})
            uses_dind = any("dind" in str(s) for s in (base.get("services") or []))
        if uses_dind:
            script = " ".join(str(s) for s in (job.get("script") or []))
            if "cache-from" in script:
                out.append(("ок", f"кэш в {name}", "внешний кэш настроен"))
            else:
                out.append(("ВАЖНО", f"кэш в {name}",
                            "dind без внешнего кэша — сборка всегда холодная"))

    return out


def main(paths: list[str]) -> int:
    worst = 0
    summary: dict[str, dict[str, int]] = {}
    for p in paths:
        results = check(Path(p))
        print(f"\n  ── {p} ──")
        print(f"  {'уровень':<8} {'проверка':<30} сообщение")
        print("  " + "─" * 96)
        crit = warn = 0
        for level, name, message in results:
            if level == "КРИТ":
                crit += 1
            elif level == "ВАЖНО":
                warn += 1
            print(f"  {level:<8} {name:<30} {message}")
        print(f"\n  критично: {crit}, важно: {warn}")
        summary[p] = {"крит": crit, "важно": warn}
        worst = max(worst, 2 if crit else (1 if warn else 0))
    print()
    print(json.dumps(summary, ensure_ascii=False))
    return worst


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:] or [".gitlab-ci.yml"]))
PY

# ─── Заведомо плохой файл ─────────────────────────────────────────────
cat > bad-gitlab-ci.yml <<'EOF'
stages: [build, publish]

build:
  stage: build
  image: docker:28-cli
  services:
    - name: docker:28-dind
      alias: docker
  cache:
    paths: [dist/]
  script:
    - docker build -t app:latest .
    - python -m build

publish:
  stage: publish
  image: docker:28-cli
  services:
    - name: docker:28-dind
      alias: docker
  script:
    - docker login -u user -p $REGISTRY_TOKEN
    - docker push app:latest
EOF

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требования 1-2: структура и needs ═══\n'
python3 - <<'PY'
import yaml
from pathlib import Path

data = yaml.safe_load(Path(".gitlab-ci.yml").read_text())
stages = data["stages"]
reserved = {"stages", "variables", "default"}
jobs = {k: v for k, v in data.items()
        if isinstance(v, dict) and not k.startswith(".") and k not in reserved}

print(f"    стадий: {len(stages)} — {', '.join(stages)}")
print(f"    задач:  {len(jobs)}\n")
print(f"    {'задача':<14} {'стадия':<10} {'needs':<26} {'artifacts':<11} rules")
print("    " + "─" * 76)
for name, job in jobs.items():
    needs = job.get("needs")
    needs_s = ",".join(needs) if isinstance(needs, list) else (needs or "—")
    print(f"    {name:<14} {job.get('stage','—'):<10} {needs_s:<26} "
          f"{'да' if 'artifacts' in job else 'нет':<11} "
          f"{'да' if 'rules' in job else 'нет'}")

first = stages[0]
no_needs = [n for n, j in jobs.items() if "needs" not in j and j.get("stage") != first]
print(f"\n    задач без needs (кроме первой стадии): {len(no_needs)}")
print("    Без needs задача ждёт завершения ВСЕЙ предыдущей стадии,")
print("    а не только тех задач, от которых зависит.")
PY
no_needs_count="$(python3 -c "
import yaml
from pathlib import Path
d = yaml.safe_load(Path('.gitlab-ci.yml').read_text())
stages = d['stages']
reserved = {'stages','variables','default'}
jobs = {k:v for k,v in d.items() if isinstance(v,dict) and not k.startswith('.') and k not in reserved}
print(len([n for n,j in jobs.items() if 'needs' not in j and j.get('stage') != stages[0]]))
")"
[ "${no_needs_count:-9}" -eq 0 ] \
    && ok "needs объявлены везде, кроме первой стадии" \
    || bad "задач без needs: $no_needs_count"

printf '\n═══ Требование 3: cache и artifacts ═══\n'
python3 - <<'PY'
import yaml
from pathlib import Path

d = yaml.safe_load(Path(".gitlab-ci.yml").read_text())
reserved = {"stages", "variables", "default"}
jobs = {k: v for k, v in d.items()
        if isinstance(v, dict) and not k.startswith(".") and k not in reserved}

print(f"    {'задача':<14} {'cache':<24} {'artifacts':<24} назначение")
print("    " + "─" * 84)
for name, job in jobs.items():
    cache = job.get("cache") or {}
    cpaths = ",".join(cache.get("paths", [])) if isinstance(cache, dict) else "—"
    art = job.get("artifacts") or {}
    apaths = ",".join(art.get("paths", [])) if art else "—"
    purpose = []
    if cpaths and cpaths != "—":
        purpose.append("cache ускоряет")
    if apaths and apaths != "—":
        purpose.append("artifacts передаёт")
    print(f"    {name:<14} {cpaths or '—':<24} {apaths or '—':<24} "
          f"{'; '.join(purpose) or '—'}")

print()
print("    НЕВЕРНОЕ использование и его симптом:")
print("      cache: paths: [dist/]  →  публикация иногда работает, иногда падает")
print("      причина: гарантии наличия кэша нет")
print("      исправление: artifacts: paths: [dist/]")
print()
print("    Правило: если без файлов задача НЕ РАБОТАЕТ — это artifacts.")
PY
ok "cache используется для ускорения, artifacts — для передачи результата"

printf '\n═══ Требование 4: способ сборки ═══\n'
python3 - <<'PY'
ROWS = [
    ("Docker-in-Docker", "требует privileged", "полная", "НЕТ",
     "общий runner, важна изоляция"),
    ("Сокет runner'а", "не требует", "НЕТ", "есть",
     "выделенный runner, доверенные задачи"),
    ("kaniko / buildah", "не требует", "полная", "через внешний",
     "нельзя дать привилегии"),
]
print(f"    {'способ':<20} {'привилегии':<20} {'изоляция':<10} "
      f"{'кэш слоёв':<14} когда")
print("    " + "─" * 92)
for row in ROWS:
    print(f"    {row[0]:<20} {row[1]:<20} {row[2]:<10} {row[3]:<14} {row[4]}")
print()
print("    ВЫБРАН: Docker-in-Docker")
print("    Обоснование: изоляция задач важнее кэша слоёв.")
print("    Сокет runner'а равносилен root на host: задача может смонтировать")
print("    корень host и прочитать секреты чужих задач ([урок 12.2]).")
print()
print("    Цена выбора: локального кэша не бывает никогда,")
print("    поэтому внешний кэш type=registry ОБЯЗАТЕЛЕН.")
PY
has_cache="$(grep -c 'cache-from' .gitlab-ci.yml || echo 0)"
printf '    строк с cache-from в файле: %s\n' "$has_cache"
[ "${has_cache:-0}" -ge 2 ] \
    && ok "выбран dind; внешний кэш настроен, как того требует выбор" \
    || bad "cache-from встречается $has_cache раз"

printf '\n═══ Требование 5: проверка защищённых переменных ═══\n'
printf '  случай «переменные заданы»:\n'
CI_REGISTRY="registry.example.com" \
CI_REGISTRY_USER="gitlab-ci-token" \
CI_REGISTRY_PASSWORD="токен-длиной-больше-восьми" \
    ./ci/require-vars.sh CI_REGISTRY CI_REGISTRY_USER CI_REGISTRY_PASSWORD
ok_rc=$?

printf '\n  случай «переменная protected, ветка не защищена»:\n'
CI_REGISTRY="registry.example.com" \
CI_REGISTRY_USER="gitlab-ci-token" \
CI_REGISTRY_PASSWORD="" \
    ./ci/require-vars.sh CI_REGISTRY CI_REGISTRY_USER CI_REGISTRY_PASSWORD
missing_rc=$?

printf '  коды возврата: заданы=%s пусто=%s\n' "$ok_rc" "$missing_rc"
[ "$ok_rc" -eq 0 ] && [ "$missing_rc" -eq 1 ] \
    && ok "проверка даёт понятное сообщение вместо отказа в глубине docker login" \
    || bad "коды: $ok_rc и $missing_rc"

printf '\n═══ Проверка pipeline на двух файлах ═══\n'
python3 ci/lint-gitlab-ci.py .gitlab-ci.yml bad-gitlab-ci.yml > lint.log 2>&1
sed -n '/── .gitlab-ci.yml/,/критично/p' lint.log | sed 's/^/  /'
sed -n '/── bad-gitlab-ci.yml/,/критично/p' lint.log | sed 's/^/  /'
lint_json="$(tail -1 lint.log)"
good_crit="$(echo "$lint_json" | python3 -c "
import json,sys; print(json.load(sys.stdin)['.gitlab-ci.yml']['крит'])")"
bad_crit="$(echo "$lint_json" | python3 -c "
import json,sys; print(json.load(sys.stdin)['bad-gitlab-ci.yml']['крит'])")"
printf '  критичных: правильный=%s плохой=%s\n' "$good_crit" "$bad_crit"
[ "${good_crit:-9}" -eq 0 ] && [ "${bad_crit:-0}" -ge 2 ] \
    && ok "проверка чиста на правильном файле и находит нарушения на плохом" \
    || bad "крит: правильный=$good_crit плохой=$bad_crit"

printf '\n═══ Требование 6: что перенеслось, что изменилось ═══\n'
python3 - <<'PY'
import json

ITEMS = [
    ("Порядок и зависимости", "как есть", "needs: → needs: + stages"),
    ("Логика проверок", "как есть", "run: ./ci/x.sh → script: [./ci/x.sh]"),
    ("Матрица версий", "как есть", "strategy.matrix → parallel.matrix"),
    ("Условия запуска", "как есть", "if: → rules:"),
    ("Артефакты", "как есть", "upload-artifact → artifacts:"),
    ("Кэш зависимостей", "как есть", "actions/cache → cache:"),
    ("Кэш сборки образа", "МЕНЯЕТСЯ", "type=gha → type=registry"),
    ("Сборка образа", "МЕНЯЕТСЯ", "build-push-action → dind/сокет/kaniko"),
    ("Права токена", "МЕНЯЕТСЯ", "permissions: → настройки проекта"),
    ("Переиспользуемые модули", "МЕНЯЕТСЯ", "uses: → include:/extends:"),
    ("Секреты", "МЕНЯЕТСЯ", "secrets. → переменные protected/masked"),
]
same = [i for i in ITEMS if i[1] == "как есть"]
changed = [i for i in ITEMS if i[1] != "как есть"]

print(f"    {'элемент':<28} {'перенос':<12} что именно")
print("    " + "─" * 84)
for name, kind, note in ITEMS:
    print(f"    {name:<28} {kind:<12} {note}")
print()
print(f"    механически: {len(same)} из {len(ITEMS)}")
print(f"    с изменениями: {len(changed)} из {len(ITEMS)}")
print()
print("    Шесть механических переносов — прямое следствие того,")
print("    что логика проверок вынесена в скрипты ci/*.sh.")
print("    Будь она в шагах YAML, механически не перенеслось бы ничего.")
print()
print(json.dumps({"как_есть": len(same), "меняется": len(changed)},
                 ensure_ascii=False))
PY
ok "перечень составлен; названо, что именно меняется в каждом случае"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
echo "  примечание: pipeline в GitLab CI не запускался — платформа недоступна"

cd /tmp && rm -rf /tmp/glcilab
exit "$fail"

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

text
═══ Требования 1-2: структура и needs ═══
    стадий: 4 — check, test, build, publish
    задач:  7

    задача         стадия     needs                      artifacts   rules
    ────────────────────────────────────────────────────────────────────────────
    format         check      —                          нет         нет
    lint           check      —                          нет         нет
    types          check      —                          нет         нет
    unit-tests     test       format,lint,types          да          нет
    build          build      unit-tests                 да          нет
    scan           build      build                      да          нет
    publish        publish    build                      нет         да

    задач без needs (кроме первой стадии): 0
    Без needs задача ждёт завершения ВСЕЙ предыдущей стадии,
    а не только тех задач, от которых зависит.
  ✓ needs объявлены везде, кроме первой стадии

═══ Требование 3: cache и artifacts ═══
    задача         cache                    artifacts                назначение
    ────────────────────────────────────────────────────────────────────────────────────
    format         .pip-cache               —                        cache ускоряет
    lint           .pip-cache               —                        cache ускоряет
    types          .pip-cache               —                        cache ускоряет
    unit-tests     .pip-cache               junit-$PY_VERSION.xml    cache ускоряет; artifacts передаёт
    build          —                        image.tar                artifacts передаёт
    scan           —                        scan-reports/            artifacts передаёт
    publish        —                        —                        —

    НЕВЕРНОЕ использование и его симптом:
      cache: paths: [dist/]  →  публикация иногда работает, иногда падает
      причина: гарантии наличия кэша нет
      исправление: artifacts: paths: [dist/]

    Правило: если без файлов задача НЕ РАБОТАЕТ — это artifacts.
  ✓ cache используется для ускорения, artifacts — для передачи результата

═══ Требование 4: способ сборки ═══
    способ               привилегии           изоляция   кэш слоёв      когда
    ────────────────────────────────────────────────────────────────────────────────────────────
    Docker-in-Docker     требует privileged   полная     НЕТ            общий runner, важна изоляция
    Сокет runner'а       не требует           НЕТ        есть           выделенный runner, доверенные задачи
    kaniko / buildah     не требует           полная     через внешний  нельзя дать привилегии

    ВЫБРАН: Docker-in-Docker
    Обоснование: изоляция задач важнее кэша слоёв.
    Сокет runner'а равносилен root на host: задача может смонтировать
    корень host и прочитать секреты чужих задач ([урок 12.2]).

    Цена выбора: локального кэша не бывает никогда,
    поэтому внешний кэш type=registry ОБЯЗАТЕЛЕН.
    строк с cache-from в файле: 2
  ✓ выбран dind; внешний кэш настроен, как того требует выбор

═══ Требование 5: проверка защищённых переменных ═══
  случай «переменные заданы»:
  ✓ CI_REGISTRY задана (22 символов)
  ✓ CI_REGISTRY_USER задана (15 символов)
  ✓ CI_REGISTRY_PASSWORD задана (26 символов)
  все переменные на месте

  случай «переменная protected, ветка не защищена»:
  ✓ CI_REGISTRY задана (22 символов)
  ✓ CI_REGISTRY_USER задана (15 символов)
  ✗ CI_REGISTRY_PASSWORD пуста

  Переменных не хватает. Вероятные причины, по убыванию частоты:

    1. Переменная protected, а ветка не защищена.
       Признак: pipeline работает на main и падает на ветке.
       Это НЕ ошибка настройки — так и задумано: секрет публикации
       не должен быть доступен задаче из произвольной ветки.
    ...
  коды возврата: заданы=0 пусто=1
  ✓ проверка даёт понятное сообщение вместо отказа в глубине docker login

═══ Проверка pipeline на двух файлах ═══
  ── .gitlab-ci.yml ──
  уровень  проверка                       сообщение
  ────────────────────────────────────────────────────────────────────────────────────────────────
  ок       стадии                         4: check, test, build, publish
  ок       needs                          объявлены везде, кроме первой стадии
  ок       cache                          используется только для ускорения
  ок       передача секрета               через stdin
  ок       правила задачи publish         публикация только с ветки по умолчанию или тега
  ок       проверка переменных в publish  выполняется до использования
  ок       кэш в build                    внешний кэш настроен
  ок       кэш в publish                  внешний кэш настроен

  критично: 0, важно: 0
  ── bad-gitlab-ci.yml ──
  ок       стадии                         2: build, publish
  ВАЖНО    needs                          без needs: publish — ждут всю стадию
  КРИТ     cache для результата           build: dist/ — нужен artifacts
  КРИТ     передача секрета               секрет в аргументе команды; нужен --password-stdin
  КРИТ     правила задачи publish         публикует без rules — сработает на любой ветке
  ВАЖНО    кэш в build                    dind без внешнего кэша — сборка всегда холодная

  критично: 3, важно: 2
  критичных: правильный=0 плохой=3
  ✓ проверка чиста на правильном файле и находит нарушения на плохом

═══ Требование 6: что перенеслось, что изменилось ═══
    элемент                      перенос      что именно
    ────────────────────────────────────────────────────────────────────────────────────
    Порядок и зависимости        как есть     needs: → needs: + stages
    Логика проверок              как есть     run: ./ci/x.sh → script: [./ci/x.sh]
    Матрица версий               как есть     strategy.matrix → parallel.matrix
    Условия запуска              как есть     if: → rules:
    Артефакты                    как есть     upload-artifact → artifacts:
    Кэш зависимостей             как есть     actions/cache → cache:
    Кэш сборки образа            МЕНЯЕТСЯ     type=gha → type=registry
    Сборка образа                МЕНЯЕТСЯ     build-push-action → dind/сокет/kaniko
    Права токена                 МЕНЯЕТСЯ     permissions: → настройки проекта
    Переиспользуемые модули      МЕНЯЕТСЯ     uses: → include:/extends:
    Секреты                      МЕНЯЕТСЯ     secrets. → переменные protected/masked

    механически: 6 из 11
    с изменениями: 5 из 11

    Шесть механических переносов — прямое следствие того,
    что логика проверок вынесена в скрипты ci/*.sh.
    Будь она в шагах YAML, механически не перенеслось бы ничего.
  ✓ перечень составлен; названо, что именно меняется в каждом случае

═══ ИТОГ ═══
  все требования выполнены
  примечание: pipeline в GitLab CI не запускался — платформа недоступна

Все требования выполнены.

Требование 6 подводит итог всему разделу: шесть элементов из одиннадцати переносятся механически, и это прямое следствие решения из урока 16.2 — вынести логику проверок в скрипты. Меняется только обвязка.

Три решения, определяющие качество.

Проверка переменных выполняется в before_script, до первой команды, которая их использует. Отказ docker login при пустом пароле выглядит как unauthorized — сообщение, по которому причину не найти. Отдельный шаг называет три вероятные причины по убыванию частоты и объясняет, что protected — не ошибка настройки, а замысел.

Проверка .gitlab-ci.yml запускается на двух файлах. Ноль критичных на правильном ничего не доказывает; три критичных на файле с намеренными нарушениями — доказывают. Проверка находит cache вместо artifacts, секрет в аргументе и публикацию без rules.

Выбор dind обоснован через риск, а не через удобство. Сокет runner'а даёт постоянный кэш слоёв — заметное преимущество. Оно отвергнуто, потому что доступ к сокету равносилен root на host, а на общем runner'е это чтение секретов чужих задач. Цена выбора названа прямо: внешний кэш становится обязательным.

Чего решение не делает. Pipeline в GitLab CI не запускался — платформа в этом окружении недоступна, и весь разбор построен на статическом анализе YAML. Поведение protected-переменных смоделировано пустым значением: настоящий механизм платформы может отличаться в деталях, но симптом — пустая переменная в задаче — воспроизведён точно. Сборщики kaniko и buildah названы как альтернатива, но не опробованы: у каждого свои ограничения по совместимости с Dockerfile, и их проверка потребовала бы отдельного урока. Наконец, parallel: matrix: разобран по документации; фактическое поведение при отказе одного варианта не проверялось.

Проверка результата

bash
python3 -c "import yaml; yaml.safe_load(open('.gitlab-ci.yml'))" && echo валиден
gitlab-ci-lint .gitlab-ci.yml 2>/dev/null || echo "линтер платформы недоступен локально"
grep -c 'needs:' .gitlab-ci.yml
grep -c 'cache-from' .gitlab-ci.yml

Число needs: должно совпадать с числом задач вне первой стадии.

Типичные ошибки

ОшибкаПричинаИсправление
Не объявлять needsСтадии и так упорядоченыЗадача ждёт всю предыдущую стадию
Передавать результат через cacheПохоже на artifactsГарантии наличия нет; нестабильность
Складывать кэш зависимостей в artifactsПохоже на cacheПередаётся по сети каждый запуск
Сокет runner'а ради кэшаОн действительно быстрееРавносилен root на host runner'а
dind без внешнего кэша«Кэш же есть»Демон создаётся заново; сборка всегда холодная
Публикация без rulesЗабылиСработает на любой ветке
rules без when: never в концеКажется лишнимПоведение при непопадании зависит от контекста
Секрет в аргументе docker login -pКорочеПопадёт в журнал; --password-stdin
Не проверять переменные до использования«Они же заданы»Отказ придёт как unauthorized из глубины
Считать protected ошибкой настройкиЛомает pipeline на веткеТак и задумано; менять надо структуру задач

Контрольные вопросы

На понимание:

  1. Что даёт объявление needs при наличии stages?
  2. Чем cache отличается от artifacts и какой признак различает их?
  3. Почему dind не хранит кэш слоёв между задачами?
  4. Чем опасен доступ к сокету runner'а на общем исполнителе?
  5. Почему переменная пуста на ветке и не пуста на main?

На применение:

  1. Как выбрать между dind, сокетом и сборщиком без демона?
  2. Как дать понятное сообщение при отсутствии защищённой переменной?
  3. Что нужно изменить при переносе pipeline из GitHub Actions?

На диагностику:

  1. Публикация иногда проходит, иногда падает с «файл не найден». Гипотеза?
  2. Сборка в dind каждый раз идёт полные восемь минут. Причина?

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

  1. В GitLab задача всегда выполняется внутри образа из image:.
  2. Без needs задача ждёт завершения всей предыдущей стадии.
  3. cache ускоряет и не гарантирует наличия; artifacts передаёт и гарантирует.
  4. Признак artifacts: без этих файлов следующая задача не работает.
  5. dind даёт изоляцию и требует privileged на уровне runner'а.
  6. Сокет runner'а даёт постоянный кэш и равносилен root на host.
  7. При dind внешний кэш обязателен: локального не бывает никогда.
  8. CI_REGISTRY_* дают вход во встроенный registry без отдельного токена.
  9. Пустая защищённая переменная на незащищённой ветке — замысел, а не ошибка.
  10. Задачи, которым нужны защищённые переменные, ограничивают правилами по ветке.
  11. rules завершают when: never, чтобы поведение не зависело от контекста.
  12. Механически переносится то, что вынесено в скрипты; меняется обвязка.

Официальные источники

ИсточникСсылкаЧто подтверждает
GitLab CI: .gitlab-ci.ymlhttps://docs.gitlab.com/ee/ci/yaml/Полный синтаксис
GitLab CI: needshttps://docs.gitlab.com/ee/ci/yaml/#needsГраф вместо стадий
GitLab CI: cachehttps://docs.gitlab.com/ee/ci/caching/Отсутствие гарантии наличия
GitLab CI: artifactshttps://docs.gitlab.com/ee/ci/jobs/job_artifacts/Передача результата
GitLab CI: ruleshttps://docs.gitlab.com/ee/ci/yaml/#rulesУсловия запуска
GitLab: переменныеhttps://docs.gitlab.com/ee/ci/variables/protected, masked, области
GitLab: Docker-in-Dockerhttps://docs.gitlab.com/ee/ci/docker/using_docker_build/dind, сокет, требования
GitLab: Container Registryhttps://docs.gitlab.com/ee/user/packages/container_registry/CI_REGISTRY_*

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗