Есть у нас сайт, движком которому выступает Hugo. Написав что-то новое или поменяв структуру хочется как-то более менее просто “посмотреть” результат не только локально, но и выложить на временный домен.

TL;DR: полный файл будет в конце.

Для чего

Для чего выкладывать на временный домен? Для этого может быть несколько причин, например:

  • глянуть с “живых” устройств;
  • натравить инструмент проверки оптимизации (тот же PageSpeed Insights);
  • показать кому-то далекому от компьютера результат.

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

  • автоматическое создание временных поддоменов;
  • автоматическое удаление таких поддоменов (по таймауту или при удалении ветки);
  • автоматический выкладывание сайта на основной домен синхронизированный с веткой master.

Стек технологий

Будет использоваться следующий технологии для решения проблемы:

  • Hugo, так как сайт уже на Hugo и без него никуда;
  • GitLab CI, так как код хранится именно на этой площадке (есть резервные синхронизации, но это резерв, а не основной репозиторий);
  • nginx, так как исторически сложилось, что на хосте именно этот сервер обслуживает статику;
  • Cloudflare, так как это довольно-таки удобный DNS провайдер с функциями DDoS защиты, CDN и дашбордом (не хочется прикручивать аналитику/метрику на сайт).

Файл .gitlab-ci.yml

Файл будем разбирать по частям, а блоки по умолчанию пропускать. Тут только описание нужных параметров.

Все будет собираться внутри alpine. Почему так? Потому что официального образа Hugo в DockerHub нет, и мы будем использовать готовый пакет для Alpine Linux. Тем более, что нам так же понадобятся и другие программы, доступные в репозитории пакетов. Посему начнем .gitlab-ci.yml так:

1
2
default:
  image: alpine:3

Основные этапы

Итого основные этапы (stages) таковы:

  • build - сборка статического сайта;
  • upload - загрузка сайта на хост с nginx;
  • stage - стейджинг сайта (добавление домена);
  • cleanup - окончание стейджинга (удаление домена, удаление файлов сайта);
  • release - сброс кеша Cloudflare после загрузки финальной версии.

Вспомогательные функции

В обработчиках встречаются одинаковые места/блоки, которые будут вынесены в общие функции.

setup-ssh

Функция по добавлению и настройке ssh клиента, который нужен для загрузки и очистки файлов на хосте с nginx.

1
2
3
4
5
6
.setup-ssh: &setup-ssh
  - apk add --update openssh-client
  - eval $(ssh-agent -s)
  - chmod 400 "$SERVER_AUTH_KEY" && ssh-add "$SERVER_AUTH_KEY"
  - mkdir -p ~/.ssh && chmod 700 ~/.ssh
  - cp "$SERVER_HOST_KEY" ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts

install-curl-jq-jo

Функция для установки зависимостей используемых при общении с Cloudflare API:

1
2
.install-curl-jq-jo: &install-curl-jq-jo
  - apk add --update curl jq jo

Тут все стандартно: curl - как клиент и jq/jo - для разбора и сборки json.

get-cf-hostnames

Функция по поиску стейджевого поддомена в списке доступных в Cloudflare и записи данных в переменную, которая будет в дальнейшем сигнализировать нужно ли создавать/удалять поддомен или можно только сбросить кеш.

1
2
3
.get-cf-hostnames: &get-cf-hostnames
  - curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=CNAME" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" | tee hostnames.json
  - CF_HOSTNAME_ID=$(jq -r ."result[] | select(.name==\"$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN\") | .id" hostnames.json)

Окружения

Часть этапов будет доступна либо в одном, либо в другом окружении. Это нужно, что бы была возможность разделить некоторые настройки, которые влияют на сборку или отображение сайта.

Условия запуска задач

Для каждой задачи в GitLab (job), будем указывать rules блок. Блок ниже будет ограничивать работу задачи только продакшен окружением (master или main, в зависимости от настроек репозитория):

1
2
rules:
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Для задач стейджинга указан такой блок:

1
2
3
4
rules:
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    when: never
  - if: $CI_COMMIT_BRANCH

Сборка сайта

Это первый этап для каждого из окружений. Сборка немного отличается, и для продакшен окружения сборка будет выполнена так:

1
2
3
script:
  - apk add --update hugo
  - hugo -e production -b "https://$SITE_DOMAIN/"

Тут мы указываем, что сборка должна пройти в production окружении и устанавливаем основной домен сайта в качестве базового URL. Для стейджинга используются частично другие настройки:

1
2
3
script:
  - apk add --update hugo
  - hugo -D -b "https://$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN/"

Тут сборка происходит вместе с шаблонами (draft) статей. Так же указан стейджевый домен конкретной ветки. Это важно, так как у нас одновременно может быть несколько стейдж окружений в рамках одного репозитория.

Так же указываем срок хранения артефактов (они будут переданы в следующие этапы):

1
2
3
4
artifacts:
  paths:
    - public/
  expire_in: 1 days

Срок хранения будет один день, что бы если следующий этап (загрузка) “свалится”, то у нас были сутки на правки без новой сборки всего сайта. Это может быть удобным для бесплатного аккаунта GitLab (есть лимиты на время работы CI), либо если сборка занимает продолжительное время.

Загрузка на хост nginx

Загрузка на хост будет одинакова для всех окружений:

1
2
3
script:
  - apk add --update rsync
  - rsync -at --delete --progress ./public/. "${SERVER_URI}:/var/www/html/${SITE_DOMAIN}/${CI_COMMIT_REF_SLUG}"

Тут для каждого окружения будет создана своя папка. При этом при обновлении окружения папка будет полностью обновлена с тем же путем в рамках файловой системы сервера.

Обновление продакшен окружения

После загрузки продакшен окружения нужно только сбросить кеш Cloudflare, что делается простым скриптом:

1
2
3
4
script:
  - DATA=$( jo purge_everything="true" )
  - curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" --data "${DATA}" | tee result.json
  - jq -e '.success?' result.json

Тут собирается специальный json, который говорит что нужно сбросить весь кеш. Этот json отправляется в Cloudflare API и проверяется “удачность” выполнения запроса.

Запуск/обновление стейджевого окружения

Работу со стейджевым окружением нужно разделить на два этапа: запуск и остановку.

Запуск стейджевого окружения

Для запуска стейджевого окружения используется такой скрипт:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
script:
  - ssh "${SERVER_URI}" ln -sf "/var/www/html/${SITE_DOMAIN}/${CI_COMMIT_REF_SLUG}" "/var/www/html/${SITE_DOMAIN}/${CI_ENVIRONMENT_SLUG}"
  - >
    if [ -z "${CF_HOSTNAME_ID}" ]; then
      DATA=$( jo type="CNAME" name="${CI_ENVIRONMENT_SLUG}.${SITE_DOMAIN}" content="${SITE_DOMAIN}" ttl="1" proxied="true" )
      curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" --data "${DATA}" | tee result.json
      jq -e '.success?' result.json
    else
      DATA=$( jo purge_everything="true" )
      curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" --data "${DATA}" | tee result.json
    fi    
  - jq -e '.success?' result.json

Тут мы указываем ссылку для того, что бы nginx начал обработку стейджевого домена. Так же в зависимости от того есть ли уже стейджевый домен в Cloudflare мы его либо создадим, либо сбросим для него кеш.

Так же важно указать в параметрах окружения к этой задаче следующее:

1
2
3
4
5
6
environment:
  name: staging/$CI_COMMIT_REF_SLUG
  url: https://$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN
  auto_stop_in: 30 minutes
  on_stop: teardown
  action: start

Главный параметры тут:

  • auto_stop_in - автоматическое отключение стейджевого сайта через период времени;
  • on_stop - название задачи, для остановки окружения (задачу будет запущена при удалении ветки, таймаута жизни окружения и т.д.);
  • action - указывает что оружение запускается этой задачей, что бы для него появилась ссылка в интерфейсе GitLab и начался отсчет его времени жизни.

Готовый файл

Так выглядит готовый файл на момент написания статьи:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
default:
  image: alpine:3

.setup-ssh: &setup-ssh
  - apk add --update openssh-client
  - eval $(ssh-agent -s)
  - chmod 400 "$SERVER_AUTH_KEY" && ssh-add "$SERVER_AUTH_KEY"
  - mkdir -p ~/.ssh && chmod 700 ~/.ssh
  - cp "$SERVER_HOST_KEY" ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
.install-curl-jq-jo: &install-curl-jq-jo
  - apk add --update curl jq jo
.get-cf-hostnames: &get-cf-hostnames
  - curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=CNAME" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" | tee hostnames.json
  - CF_HOSTNAME_ID=$(jq -r ."result[] | select(.name==\"$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN\") | .id" hostnames.json)

stages:
  - build
  - upload
  - stage
  - cleanup
  - release

build-dev:
  stage: build
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
  script:
    - apk add --update hugo
    - hugo -D -b "https://$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN/"
  artifacts:
    paths:
      - public/
    expire_in: 1 days
  environment:
    name: staging/$CI_COMMIT_REF_SLUG
    url: https://$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN
    action: prepare
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: never
    - if: $CI_COMMIT_BRANCH

build-production:
  stage: build
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
  script:
    - apk add --update hugo
    - hugo -e production -b "https://$SITE_DOMAIN/"
  artifacts:
    paths:
      - public/
    expire_in: 1 days
  environment:
    name: production
    action: prepare
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

upload-site:
  stage: upload
  needs:
    - job: build-dev
      optional: true
    - job: build-production
      optional: true
  before_script:
    - *setup-ssh
  script:
    - apk add --update rsync
    - rsync -at --delete --progress ./public/. "${SERVER_URI}:/var/www/html/${SITE_DOMAIN}/${CI_COMMIT_REF_SLUG}"

setup-cf:
  stage: stage
  needs:
    - upload-site
  before_script:
    - *install-curl-jq-jo
    - *setup-ssh
    - *get-cf-hostnames
  script:
    - ssh "${SERVER_URI}" ln -sf "/var/www/html/${SITE_DOMAIN}/${CI_COMMIT_REF_SLUG}" "/var/www/html/${SITE_DOMAIN}/${CI_ENVIRONMENT_SLUG}"
    - >
      if [ -z "${CF_HOSTNAME_ID}" ]; then
        DATA=$( jo type="CNAME" name="${CI_ENVIRONMENT_SLUG}.${SITE_DOMAIN}" content="${SITE_DOMAIN}" ttl="1" proxied="true" )
        curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" --data "${DATA}" | tee result.json
        jq -e '.success?' result.json
      else
        DATA=$( jo purge_everything="true" )
        curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" --data "${DATA}" | tee result.json
      fi      
    - jq -e '.success?' result.json
  environment:
    name: staging/$CI_COMMIT_REF_SLUG
    url: https://$CI_ENVIRONMENT_SLUG.$SITE_DOMAIN
    auto_stop_in: 30 minutes
    on_stop: teardown
    action: start
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: never
    - if: $CI_COMMIT_BRANCH

teardown:
  stage: cleanup
  before_script:
    - *install-curl-jq-jo
    - *setup-ssh
    - *get-cf-hostnames
  script:
    - >
      if [ -z "${CF_HOSTNAME_ID}" ]; then
        curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${CF_HOSTNAME_ID}" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" | tee result.json
        jq -e '.success?' result.json
      fi      
    - ssh "${SERVER_URI}" unlink "/var/www/html/${SITE_DOMAIN}/${CI_ENVIRONMENT_SLUG}"
    - ssh "${SERVER_URI}" rm -r "/var/www/html/${SITE_DOMAIN}/${CI_COMMIT_REF_SLUG}"
  when: manual
  variables:
    GIT_STRATEGY: none
  environment:
    name: staging/$CI_COMMIT_REF_SLUG
    action: stop
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: never
    - if: $CI_COMMIT_BRANCH

deploy:
  stage: release
  needs:
    - upload-site
  before_script:
    - *install-curl-jq-jo
  script:
    - DATA=$( jo purge_everything="true" )
    - curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" -H "Authorization:Bearer ${CLOUDFLARE_TOKEN}" -H "Content-Type:application/json" --data "${DATA}" | tee result.json
    - jq -e '.success?' result.json
  only:
    - main
  environment: production

Такой файл позволяет легко, в автоматическом режиме запускать временные поддомены по веткам и выкатывать свежую версию по пушу в master ветку или слиянию мерж-реквеста в нее.