Деплой JavaScript-приложений

Почему это важно?

В работа над любым проектом рано или поздно наступает момент, когда его нужно запустить на продакшн-сервере. Очевидно, чтобы запустить, нужно выгрузить.

Всегда есть варианты и свобода выбора, и деплой не исключение. Само слово deployment означает “развёртывание, размещение”, что, в целом, отражает конечную цель этого процесса.

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

Я расскажу вам историю о том, как я пришёл к хорошему деплою, каким способом я пользуюсь и почему он мне нравится. А заодно мы рассмотрим несколько возможных вариантов.

Стадия 1: “Зачем мне что-то? Хостер уже дал мне FTP!”

Итак, за окном 2015 год, и я только начинаю осваивать искусство вебдева. Мой сайт – PHP/jQuery/CSS, никаких сборщиков, гита и прочего, просто файлы, которые достаточно положить в правильную директорию на сервере. Чем я, собственно, и занимаюсь.

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

Первый вариант – самый простой – доступ к серверу по FTP и ручная загрузка файлов.

Это может быть хорошо, потому что:

  • Не требуется абсолютно ничего, кроме FileZilla или аналогов;
  • Сэкономит вам время, если сайт статический, и кодовая база в обозримом будущем не будет обновляться;
  • Есть на подавляющем большинстве платных и бесплатных хостингов.

Однако, эта статья называется не “Деплой статических сайтов”, так что вы вполне законно можете спросить: при чём тут статика? И будете правы. Плох данный способ тем, что он лишает вас очень много полезных штук:

  • Версионирования релизов;
  • Возможности быстро “откатить” релиз, если в нём ошибка;
  • В случае использования сборщиков (Webpack и т.д.), помечающих собранные файлы хэшем, этот способ приведёт к файловой путанице.

Естественно, есть и самый очевидный минус – это неудобно, долго, да ещё и руками надо что-то делать. Мне, как чертовски ленивому человеку, это не подходит. Едем дальше!

Стадия 2: “Окей, я буду как большие дяденьки! Где мой Git?..”

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

Хранить код? Git. Библиотека фоток? Git. Список контактов? Git. Деплой? Конечно же, тоже Git. И, вроде бы, это уже лучше, чем просто заливать файлы вручную. Особенно если на этой стадии наш герой, как и я в то время, уже более-менее мог в какую-нибудь автоматизацию, типа шелл-скриптов.

В голове у меня сидела мысль: если Git - система контроля версий, то кому, как ни ей, заниматься контролем версий на продакшне? Таким образом процесс деплоя представляет собой клонирование репозитория в нужную папку и периодические git pull.

Плюсы такого способа:

  • Вы всегда знаете, что на продакшне тот же код, что и на вашем компьютере
  • Решение более-менее автоматизированное: достаточно лишь вызвать команду.

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

  • Конфликты могут сломать ВСЁ. Поверьте, я знаю, о чём говорю. Если случается конфликт при обновлении кодовой базы, Git пометит мх прямо в файлах, ломая синтаксис. Если познания Git не очень большие, быстро устранить проблему может быть сложно.
  • Если доступ к серверу имеют несколько человек с разными логинами и правами, нужно обладать неплохими познаниями Linux, чтобы все они могли выполнять git pull
  • Раз уж речь про JavaScript, то нужно сказать, что этот способ вынуждает нас хранить собранные файлы в репозитории. А это как-то не круто. Тем более, если проект серверный: нужно либо дёргать npm install после обновления, либо хранить в репозитории папку node_modules. Чувствуете мурашки на коже?

Последний минус - банальная лень. Мне лично через какое-то время стало лень следить за синхроном репозитория и продакшна, и через какое-то время продакшн и гитхаб разошлись далеко друг от друга.

Мой вердикт: это тоже не подходит.

Стадия 3: Shipit, потому что каждый инструмент должен делать своё дело

Через какое-то время я увидел инструмент PHP Deployer, который сразу мне понравился. Я начал поиски аналога для JavaScript, и вскоре нашёл Shipit. По заверению их репозитория, это универсальный инструмент для автоматизации и деплоя.

Вкратце, Shipit делает следующее:

  1. Создаёт на вашем компьютере временную папку, куда клонирует ваш репозиторий для получения актуальной кодовой базы
  2. Даёт возможность делать с этими файлами что угодно, к примеру, устанавливать зависимости и запускать сборку
  3. Подключается к вашему серверу по SSH и загружает файлы в удалённую папку

Выглядит негусто, но это ещё и не всё. Главное, что даёт Shipit - систему событий, на которые можно подписаться и выполнить какую-либо команду на локальном либо удалённом сервере.

Начать использовать его очень просто. Проверьте, вы соблюли эти условия:

  1. У вас есть доступ к Git-репозиторию
  2. У вас есть SSH-доступ к продакшн серверу

Далее, вам нужно установить два пакета:

npm install --save-dev shipit-cli
npm install --save-dev shipit-deploy

Первый пакет - инструменты командной строки, второй - набор тасков (от оригинального “tasks”), которые предназначены для деплоя.

Далее, в корне вашего проекта нужно создать конфиг-файл shipitfile.js. В репозитории проекта есть пример этого файла, но я покажу вам конфигурацию, которая мне показалось наиболее удачной и которой я сам пользуюсь.

I. Деплой компилируемого фронтенда

Это может быть приложение на каком-нибудь фреймворке или библиотеке, в моём случае - на Vue.js. Будем рассматривать конфиг-файл на примере выдуманного проекта MyFrontend

// shipitfile.js
const path = require('path')
const WORKSPACE_DIR = '/tmp/myfrontend'

Нам понадобится path из стандартной библиотеки Node. Переменная WORKSPACE_DIR – это временная папка, в которую Shipit будет клонировать репозиторий и производить манипуляции с ним.

// shipitfile.js
const path = require('path')
const WORKSPACE_DIR = '/tmp/myfrontend'

module.exports = (shipit) => {
    require ('shipit-deploy').(shipit)
    
    shipit.initConfig({
        default: {
            workspace: WORKSPACE_DIR,
            dirToCopy: path.join(WORKSPACE_DIR, 'dist'),
            deployTo: '/var/www/myapp/myfrontend',
            repositoryUrl: 'https://gitgit.git/myapp/myfrontend.git',
            ignores: ['.git', 'node_modules'],
            keepReleases: 5,
            keepWorkspace: false,
            shallowClone: false,
            deploy: {
                remoteCopy: {
                    copyAsDir: false
                }
            }   
        },
        staging: {
            servers: 'myuser@123.45.678.90'
        }
    })
}

Далее нужно написать конфиг для Shipit. Вообще, у него очень много разных ключей на все случаи жизни, и я очень рекомендую ознакомиться с ними в документации. Мы же рассмотрим только те, которые важны для нас сейчас.

  • workspace – передаем путь до локальной папки
  • dirToCopy – какую папку из склонированного репозитория мы будем загружать. Если не прописать этот ключ, Shipit загрузит всю папку workspace
  • deployTo – куда на удалённом сервере нужно положить наши файлы из предыдущей папки
  • repositoryUrl – адрес вашего Git-репозитория
  • ignores – какие файлы и папки точно не нужно загружать. Вообще, указание dirToCopy решает эту проблему, но лучше перестраховаться, как мне кажется.
  • keepReleases - сколько релизов будет храниться на продакш сервере, и, соответственно, на сколько релизов назад вы сможете откатиться
  • copyAsDir – как копировать папку dirToCopy на сервер: создавать на сервере эту же папку (true) или же просто загрузить её содержимое в deployTo (false)
  • staging – это всего лишь название конфигурации деплоя, оно может быть любым
  • servers - один или несколько IP-адресов сервера с указанием имени пользователя.

Если мы прямо сейчас запустим процесс деплоя, мы вряд ли добьёмся успеха: склонированный из Git проект не собран, а папка dist не существует. Значит, нам нужно “вклиниться” в процесс деплоя и вовремя собрать наш проект. В этом нам помогут уже упомянутые мной события.

Добавим в наш файл следующее:

// shipitfile.js
const path = require('path')
const WORKSPACE_DIR = '/tmp/myfrontend'

module.exports = (shipit) => {
    require ('shipit-deploy').(shipit)
    
    shipit.initConfig({
        default: {
            workspace: WORKSPACE_DIR,
            dirToCopy: path.join(WORKSPACE_DIR, 'dist'),
            deployTo: '/var/www/myapp/myfrontend',
            repositoryUrl: 'https://gitgit.git/myapp/myfrontend.git',
            ignores: ['.git', 'node_modules'],
            keepReleases: 5,
            keepWorkspace: false,
            shallowClone: false,
            deploy: {
                remoteCopy: {
                    copyAsDir: false
                }
            }   
        },
        staging: {
            servers: 'myuser@123.45.678.90'
        }
    })

    shipit.on('fetched', async () => {
        await shipit.start('app:build')
    })
    
    shipit.blTask('app:build', async () => {
        await shipit.local(
          `cd ${WORKSPACE_DIR} && npm install && npm run build`
        )
    })
}

У пакета shipit-deploy есть набор событий на каждое действие (ознакомиться можно тут). Нам интересно событие fetched, которое происходит сразу после клонирования репозитория. В нём вызываем наш собственный таск app:build.

app:build объявлен как blTask: в Shipit это значит, что задача блокирующая, и начинать следующую задачу нельзя, пока не завершится эта. В ней мы при помощи метода shipit.local запускам локальную команду: переходим в WORKSPACE_DIR, устанавливаем зависимости и собираем проект.

Если в процессе сборки что-то пойдёт не так, деплой прекратится: “сломанный” код не окажется на продакшне.

Всё готово к деплою: в командной строке набираем ./node_modules/.bin/shipit staging deploy. Когда процесс завершится, в папке на сервере вы увидите два элемента: директорию releases и симлинк current. Ваш веб-сервер нужно направить именно на симлинк, чтобы в браузер отдавались только актуальные файлы.

Если что-то всё же пошло не так, и нужно срочно вернуть предыдущий релиз, вы можете вызвать команду ./node_modules/.bin/shipit staging rollback. Она переключит симлинк current на предыдущий релиз.

II. Деплой бэкенда на Node.js

Здесь принципы те же, за исключением пары деталей: бэк на ноде не надо собирать, но нужно установить зависимости на продакшн-сервере. В итоге конфиг будет выглядеть как-то так:

// shipitfile.js
const path = require('path')
const WORKSPACE_DIR = '/tmp/mybackend'

module.exports = (shipit) => {
    require ('shipit-deploy').(shipit)
    
    shipit.initConfig({
        default: {
            workspace: WORKSPACE_DIR,
            deployTo: '/var/www/myapp/mybackend',
            repositoryUrl: 'https://gitgit.git/myapp/mybackend.git',
            ignores: ['.git', 'node_modules'],
            keepReleases: 5,
            keepWorkspace: false,
            shallowClone: false,
            deploy: {
                remoteCopy: {
                    copyAsDir: false
                }
            }   
        },
        staging: {
            servers: 'myuser@123.45.678.90'
        }
    })

    shipit.on('updated', async () => {
        await shipit.start('app:install-deps')
    })
    
    shipit.blTask('app:install-deps', async () => {
        await shipit.remote(
            `cd ${shipit.releasePath} && npm install`
        ) 
    })
}

Ключевые изменения:

  • Убрали dirToCopy, так как обычно бэкенд-проекты начинаются от корня репозитория
  • Теперь мы завязываемся на событие updated, которые происходит после создания новой папки внутри releases на сервере и копирования файлов туда.
  • В блокирующей задаче app:install-deps мы при помощи shipit.remote запускаем установку зависимостей на сервере, перемещаясь в папку релиза. Заранее мы не знаем, какую папку создаст Shipit, но её можно легко получить на этом этапе при помощи shipit.releasePath

Также может понадобиться перезапустить приложение Node. Для этого можно использовать событие deployed, и вызвать, к примеру, pm2 reload, если для запуска ноды в бою вы используете PM2.

IV. Деплой компилируемого бэкенда на Node.js

Да-да, я говорю про TypeScript. Однако, я думаю, что сложив вышеописанные шаги, вы получите как раз то, что вам нужно :)

Итоги

Хороший деплой – это, прежде всего, сэкономленные нервы. Вы знаете, что можете быстро развернуть новую версию, или вернуть предыдущую, если что-то пошло не так. Вы знаете, что у вас никогда не возникнет путаницы на продакшне, и вы всегда будете видеть, сколько релизов было сделано.

Расширив shipitfile.js, вы можете логировать деплои, собирать статистику, да и вообще делать всё, что вам нужно. Потратив на настройку минут 20 один раз, вы сохраните кучу времени в будущем.

Не в последнюю очередь – это красиво. Аккуратная структура директорий, полный контроль над продакшном, это эстетика, которая важна для нас, как для разработчиков, ведь видеть её – значит развиваться во всём.