16.5. GitLab CI
Цели
После этого материала вы сможете:
- перенести pipeline из GitHub Actions в GitLab CI и назвать, что пришлось изменить;
- выбрать между Docker-in-Docker и сокетом runner'а, зная цену каждого;
- объяснить, чем
cacheотличается отartifacts, и не перепутать их; - объяснить, почему переменная не видна в задаче, хотя она задана;
- использовать встроенный registry и его переменные;
- назвать, где логика переносится один в один, а где требует другого подхода.
Предварительные знания
- 16.1. Проектирование pipeline;
- 16.2. GitHub Actions — исходный pipeline;
- 16.3. Build cache в CI;
- 12.2. Daemon и socket — риск доступа к socket.
Ключевые термины
| Термин | Объяснение |
|---|---|
stage | Группа задач; следующая начинается после завершения предыдущей |
runner | Исполнитель задач |
executor | Способ запуска: docker, shell, kubernetes |
services | Дополнительные container'ы, доступные задаче |
artifacts | Файлы, передаваемые между задачами и сохраняемые |
cache | Файлы, ускоряющие повторные запуски |
Теория
Соответствие понятий
| GitHub Actions | GitLab CI | Отличие |
|---|---|---|
jobs | jobs | В GitLab задачи группируются в stages |
needs | needs | В GitLab по умолчанию действует порядок stages |
steps | script | В GitLab шаг — строка команды, не действие |
uses: action | Шаблон или образ | Действий как переиспользуемых модулей нет |
if: | rules: | Разный синтаксис, схожая семантика |
permissions | Токен задачи и его область | Настраивается вне файла |
actions/upload-artifact | artifacts: | Встроено |
actions/cache | cache: | Встроено |
container: | image: | Задача всегда идёт в образе |
Главное различие модели: в GitLab задача выполняется внутри образа, указанного в image:. Это ближе к тому, как устроен Docker, и снимает вопрос «что установлено на исполнителе».
stages и needs
stages: [check, test, build, publish]
lint:
stage: check
unit-tests:
stage: test # начнётся после ВСЕХ задач стадии check
По умолчанию задача ждёт завершения всей предыдущей стадии. Это удобно и медленно.
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 |
# 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 — разные вещи
Их путают постоянно, и последствия разные.
| Свойство | cache | artifacts |
|---|---|---|
| Назначение | Ускорение | Передача результата |
| Гарантия наличия | Нет | Да |
| Между задачами | Может не успеть | Передаётся |
| Между запусками | Да | Только как сохранённый файл |
| Что класть | Зависимости, кэш сборки | Отчёты, собранные пакеты, SBOM |
Ключевая строка — «гарантия наличия». Кэш может отсутствовать: он не восстановился, устарел, был очищен. Задача, зависящая от кэша, ломается.
# ВЕРНО: кэш ускоряет, но задача работает и без него
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:
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 |
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
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 для каждой задачи:
- Создаёт container из образа, указанного в
image:. - Поднимает container'ы из
services:в той же сети. - Клонирует репозиторий внутрь.
- Восстанавливает
cacheи скачиваетartifactsзадач изneeds. - Выполняет
before_scriptиscript. - Сохраняет
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
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
Ожидаемый вывод:
═══ проверка синтаксиса ═══
стадий: 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
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
Ожидаемый вывод:
═══ cache и artifacts ═══
свойство cache artifacts
────────────────────────────────────────────────────────────────────────────────────
Назначение ускорение передача результата
Гарантия наличия НЕТ да
Между задачами одного запуска может не успеть передаётся
Между запусками да только как сохранённый файл
Виден в интерфейсе нет да, можно скачать
Срок хранения до вытеснения expire_in
Ключевая строка — «гарантия наличия».
Задача, ЗАВИСЯЩАЯ от кэша, ломается непредсказуемо.
результат сборки (dist/) в cache
симптом: публикация иногда работает, иногда падает
почему: кэш мог не восстановиться — гарантии нет
исправление: artifacts: paths: [dist/]
...
Правило: cache — ускоряет; artifacts — передаёт.
Если без него задача не работает — это artifacts.
Первая ошибка из трёх — самая дорогая: она даёт нестабильность, а не отказ. Публикация проходит в девяти случаях из десяти, и связь с кэшем находят не сразу.
Почему переменная пуста
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
Ожидаемый вывод:
═══ почему переменная пуста ═══
причина признак частота
────────────────────────────────────────────────────────────────────────────────────────────────
переменная protected, ветка не защищена пусто на feature-ветках, есть на main самая частая
переменная задана на уровне окружения пусто в задачах без environment: средняя
переменная masked, значение не подходит не сохраняется или видна в логах редкая
опечатка в имени пусто везде частая
Первая причина — самая непонятная в момент столкновения:
pipeline работает на main и падает на ветке разработчика.
...
Docker-in-Docker или сокет
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
Ожидаемый вывод:
═══ способы сборки ═══
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 — это те же четыре ослабления.
Что пришлось изменить при переносе
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
Ожидаемый вывод:
═══ перенос 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 и назовите, что изменилось.
Требования:
- Написать
.gitlab-ci.yml, эквивалентный workflow из урока 16.2. - Объявить
needsявно и объяснить, что даёт отказ от порядка стадий. - Разделить
cacheиartifactsправильно; привести пример неверного использования и его симптом. - Выбрать способ сборки образов и обосновать выбор через риск.
- Реализовать проверку наличия защищённой переменной с понятным сообщением.
- Составить перечень: что перенеслось механически, что потребовало изменений.
Подсказки
Подсказка 1
Без needs задача ждёт завершения всей предыдущей стадии, а не только нужных ей задач.
Подсказка 2
Признак artifacts: без этих файлов следующая задача не работает. Признак cache: без него она работает, но медленнее.
Подсказка 3
Проверка переменной ставится в before_script — до первой команды, которая её использует.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требования 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: разобран по документации; фактическое поведение при отказе одного варианта не проверялось.
Проверка результата
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 на ветке | Так и задумано; менять надо структуру задач |
Контрольные вопросы
На понимание:
- Что даёт объявление
needsпри наличииstages? - Чем
cacheотличается отartifactsи какой признак различает их? - Почему
dindне хранит кэш слоёв между задачами? - Чем опасен доступ к сокету runner'а на общем исполнителе?
- Почему переменная пуста на ветке и не пуста на
main?
На применение:
- Как выбрать между
dind, сокетом и сборщиком без демона? - Как дать понятное сообщение при отсутствии защищённой переменной?
- Что нужно изменить при переносе pipeline из GitHub Actions?
На диагностику:
- Публикация иногда проходит, иногда падает с «файл не найден». Гипотеза?
- Сборка в
dindкаждый раз идёт полные восемь минут. Причина?
Краткое резюме
- В GitLab задача всегда выполняется внутри образа из
image:. - Без
needsзадача ждёт завершения всей предыдущей стадии. cacheускоряет и не гарантирует наличия;artifactsпередаёт и гарантирует.- Признак
artifacts: без этих файлов следующая задача не работает. dindдаёт изоляцию и требуетprivilegedна уровне runner'а.- Сокет runner'а даёт постоянный кэш и равносилен root на host.
- При
dindвнешний кэш обязателен: локального не бывает никогда. CI_REGISTRY_*дают вход во встроенный registry без отдельного токена.- Пустая защищённая переменная на незащищённой ветке — замысел, а не ошибка.
- Задачи, которым нужны защищённые переменные, ограничивают правилами по ветке.
rulesзавершаютwhen: never, чтобы поведение не зависело от контекста.- Механически переносится то, что вынесено в скрипты; меняется обвязка.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
GitLab CI: .gitlab-ci.yml | https://docs.gitlab.com/ee/ci/yaml/ | Полный синтаксис |
GitLab CI: needs | https://docs.gitlab.com/ee/ci/yaml/#needs | Граф вместо стадий |
GitLab CI: cache | https://docs.gitlab.com/ee/ci/caching/ | Отсутствие гарантии наличия |
GitLab CI: artifacts | https://docs.gitlab.com/ee/ci/jobs/job_artifacts/ | Передача результата |
GitLab CI: rules | https://docs.gitlab.com/ee/ci/yaml/#rules | Условия запуска |
| GitLab: переменные | https://docs.gitlab.com/ee/ci/variables/ | protected, masked, области |
| GitLab: Docker-in-Docker | https://docs.gitlab.com/ee/ci/docker/using_docker_build/ | dind, сокет, требования |
| GitLab: Container Registry | https://docs.gitlab.com/ee/user/packages/container_registry/ | CI_REGISTRY_* |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление