Как стать автором
Обновить
0
Huawei
Huawei – мировой лидер в области ИКТ

Облако для всех. Строим CI/CD pipeline для бессерверных функций

Время на прочтение12 мин
Количество просмотров2.5K

В публичном облаке SberCloud.Advanced, построенном на технологиях Huawei, имеется крайне полезный сервис бессерверных вычислений – Function Graph. С его помощью можно быстро набросать код для решения конкретной бизнес-задачи и запустить его на выполнение, не тратя время на развертывание и настройку отдельных серверов. Но все это замечательно и очень удобно, пока речь идет всего о паре функций. А если таких функций уже больше 5 и они активно развиваются, то это уже похоже на проект, а проект нужно ставить на контроль и организовывать хоть и простейший, но процесс. И конечно, важнейшим элементом такого процесса станет управление кодом в рамках системы контроля версий, к примеру – GitHub.

Дело в том, что Function Graph не поддерживает какую-либо интеграцию с системами контроля версий и перейти от «наколенной» разработки к промышленной с помощью встроенных средств невозможно. Впрочем, как оказалось, такую интеграцию нетрудно сделать самостоятельно с помощью того же самого Function Graph.

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

Итак, для того чтобы сделать интеграцию с GitHub воспользуемся тем, что он предоставляет возможность вызывать внешний webhook при наступлении какого-то события. В данном случае интересует фиксация изменений в мастер ветке. В качестве внешнего вебхука будем делать... конечно же функцию на FunctionGraph, вызов которой будет осуществляться через API Gateway. Далее эта функция будет получать список новых/измененных файлов и создавать/обновлять функции, забирая код из файлов репозитория и публикуя код этих файлов через АПИ облака. Схема процесса представлена на картинке:

Для автоматического деплоймента функций потребуется определить некоторые общие соглашения. К примеру, Function Graph умеет объединять функции в виртуальные «приложения», будем использовать название репозитория в GitHub как название такого приложения. Далее, при регистрации функций как бэкенда в API Gateway, потребуется дать название каждому API, и тут опять же по умолчанию применяем общий шаблон вида API_<название файла> (пример: код функции лежит в файле «clients.js», при публикации API получит имя API_clients_js). Все эти соглашения видны в коде функции и могут быть изменены по вашему усмотрение.

Не лишним будет упомянуть, что для упрощения процедуры развертывания, будем писать код функций на скриптовых языках, в данном случае - на NodeJS, которые не требуют какой либо предварительной компиляции перед публикацией кода. Также предполагаем, что сами функции будут выполнять роль «контроллеров» для логических сущностей и в представленном демо они будут выполнять базовые CRUD операции.

План действий следующий:

  1. Создаем функцию для CI-CD процесса. Потребуется создать IAM Agency для управления облачной инфраструктурой

  2. Подготавливаем окружение для проекта:

  3. Регистрируем API Gateway триггер для CI-CD функции и получаем ссылку для вебхука.

  4. Регистрируем вебхук в настройках репозитория GitHub.

  5. Создаем приложение проекта.

Поехали.

Функция для обработки событий из GitHub.

Для начала создадим собственно функцию в Function Graph, которая будет работать как webhook для приема событий из GitHub. Создайте функцию, выбрав Node.JS 12.13 как рантайм. Скопируйте код функции, который приведен ниже.

Код функции для приема событий из GitHub
const https=require("https");

exports.handler = async (event, context) => {
    let eventBody = JSON.parse(Buffer.from(event.body, 'base64').toString('ascii'));
    //Getting the name of the dependency package 
    const dependencyPackageID = context.getUserData("dependency_package");
    let token = context.getToken();
    let project_id = context.getProjectID();

    //Getting the list of existing functions
    const getFunctionsParams = {
        host: "functiongraph.ru-moscow-1.hc.sbercloud.ru",
        method: "GET",
        path: "/v2/"+project_id+"/fgs/functions",
        headers: { "X-Auth-Token": token }
    }
    const function_list = JSON.parse(await httpRequest(getFunctionsParams));
    
    //Getting the list of all APIs inside our project API group
    const getApisParams={
        host: "apig.ru-moscow-1.hc.sbercloud.ru",
        method: "GET",
        path: "/v1.0/apigw/apis?group_id="+context.getUserData("api_group_id"),
        headers: { "Content-Type":"application/json", "X-Auth-Token": token }
    }
    let apisList = JSON.parse(await httpRequest(getApisParams));    

    //Getting all files - both added and modified and pushing the changes into the FG functions
    for (const filename of eventBody.head_commit.modified.concat(eventBody.head_commit.added)){
        func_name = filename.replace(".js","_js");

        const fileGetParams={
            host: "raw.githubusercontent.com",
            method: "GET",
            port: 443,
            path: "/"+eventBody.repository.full_name+"/"+eventBody.after+'/'+filename,
            headers: {Accept: "application/vnd.github.v3+json", "user-agent":"function graph"}
        }
        let fileBody = await httpRequest(fileGetParams)

        let functionExists = function_list.functions.find(item =>{
            return item.func_name == func_name
        })
        let func_urn = functionExists ? functionExists.func_urn : undefined;
        let http_path = "/v2/"+project_id+"/fgs/functions" + (functionExists ? "/"+func_urn+"/code" :""); 
        let http_method = functionExists ? "PUT": "POST";
        const createFunctionParams = {
            host: "functiongraph.ru-moscow-1.hc.sbercloud.ru",
            method: http_method,
            path: http_path,
            headers: { "Content-Type":"application/json", "X-Auth-Token": token }
        }
        //Function connection parameters, must be provided as string inside the function creation object
        const userData = {
            dbhost: context.getUserData("dbhost"),
            dbuser: context.getUserData("dbuser"),
            dbpwd:  context.getUserData("dbpwd"),
            databasename: context.getUserData("databasename")
        }
        const functionData = {
            func_name: func_name,
            package:eventBody.repository.name,
            code_type:"inline",
            code_filename: "index.js",
            handler:"index.handler",
            memory_size:256,
            runtime:"Node.js12.13",
            timeout: 30, 
            depend_list: [dependencyPackageID],
            func_code : {
                file:Buffer.from(fileBody).toString('base64')
            },
            xrole: context.getUserData("vpc_access_agency_name"),
            func_vpc:{
                vpc_id: context.getUserData("vpc_id"),
                subnet_id: context.getUserData("subnet_id")
            },
            user_data: JSON.stringify(userData)
        }
        let createResult = await httpRequest(createFunctionParams, JSON.stringify(functionData));
        const funcInfo = JSON.parse(createResult)
    
        //Looking for a API for this function
        var existingAPI = apisList.apis.find(item=>{
            return item.name=="API_"+func_name;
        });
        if (!existingAPI)
        {
            //Registering function in API Gateway
            const createAPIRequest={
                group_id: context.getUserData("api_group_id"),
                name: "API_"+func_name,
                type: 1,
                req_method: "ANY", //Allows any HTTP method so we can use this function as Controller
                req_uri: "/api/"+func_name,
                match_mode: "SWA", //We need Prefix match to be able to bypass URI with parameters to this function
                auth_type: "None",
                backend_type: "FUNCTION",
                func_info : {
                    function_urn: funcInfo.func_urn,
                    invocation_type: "sync",
                    timeout: 30000
                }
            }
            const createAPIParams={
                host: "apig.ru-moscow-1.hc.sbercloud.ru",
                method: "POST",
                path: "/v1.0/apigw/apis",
                headers: { "Content-Type":"application/json", "X-Auth-Token": token }
            }
            const registerAPIResult=await httpRequest(createAPIParams, JSON.stringify(createAPIRequest));
            const registeredAPI = JSON.parse(registerAPIResult);
            existingAPI = registeredAPI;
        }
        if (!existingAPI.publish_id)
        {
            const publishAPIRequest={
                env_id: "DEFAULT_ENVIRONMENT_RELEASE_ID",
                remark: "published by GitHub webhook"
            }
            const publishAPIParams={
                host: "apig.ru-moscow-1.hc.sbercloud.ru",
                method: "POST",
                path: '/v1.0/apigw/apis/publish/'+existingAPI.id,
                headers: { "Content-Type":"application/json", "X-Auth-Token": token }
            }
            const publishAPIResult = await httpRequest(publishAPIParams, JSON.stringify(publishAPIRequest));
        }
    }
    console.log('Successfully deployed functions:'+eventBody.head_commit.modified.concat(eventBody.head_commit.added).join(', '));
    const output =
    {
        'statusCode': 200,
        'headers':
        {
            'Content-Type': 'text/plain'
        },
        'isBase64Encoded': false,
        'body': 'OK'
    }
    return output;
}

//function to "promisify" http requests
function httpRequest(params, postData) {
    return new Promise(function(resolve, reject) {
        try {
            var req = https.request(params, function(res) {
                var body = [];
                res.on('data', function(chunk) {
                    body.push(chunk);
                });
                // resolve on end
                res.on('end', function() {
                    try {
                        body = Buffer.concat(body).toString();
                    } catch(e) {
                        reject(e);
                    }
                    resolve(body);
                });
            });
            // reject on request error
            req.on('error', function(err) {
                reject(err);
            });
            if (postData) {
                req.write(postData);
            }
            req.end();
        }
        catch(e){
            console.log(e);
            reject(e);
        }
    });
}

Для этой функции нужно провести некоторую настройку. Во-первых, потребуется указать IAM Agency, которое обладает правами на управление облаком. В нашем случае такие права нужны на сам сервис Function Graph, а также на API Gateway (чтобы регистрировать наши функции как бэкенд). Для этого открываем сервис IAM и создаем там агенство с нужными правами на доступ к Function Graph:

На той же странице добавляем еще одно право доступа, которое называется APIG Administrator – оно потребуется для того чтобы можно было автоматически регистрировать  новые функции в API Gateway.

На странице Configuration нашей функции нужно выбрать это агенство:

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

Подготовка окружения для проекта

При запуске проекта потребуется решить как минимум вопрос о том, в какой виртуальной сети он будет работать, ведь функции вряд ли будут работать сами по себе, им потребуется доступ к другим сервисам и базам данных. Кроме этого, к этим функциям необходимо обращаться. Сами по себе функции можно запустить из консоли облака, но чтобы они были доступны через HTTP запросы, потребуется сервис API Gateway. Сбором этих настроек дальше и займемся.

Создание API группы в API Gateway

Для того, чтобы код проекта был доступен извне,  потребуется регистрация наших функций как бэкенда в API Gateway. В-общем, это стандартный паттерн – пишете необходимый бэкенд, а во внешний мир ваш бэкенд смотрит через «единое окно» в виде некоего единого АПИ. Обеспечивает это «единое окно» сервис API Gateway. Поэтому первое, что потребуется, это создать группу, которая будет объединять все функции в единый проект и будет обеспечивать общую точку входа для всех функций проекта.

После того как группа создана, необходимо получить ее идентификатор. Как его найти, показано на скриншоте:

Получение идентификаторов сети

С сетями все гораздо проще. Откройте раздел Virtual Private Cloud, щелкните по разделу Virtual Private Cloud:

выберите в списке свою сеть и в свойствах сети скопируйте ID:

Теперь там же, слева, выберите Subnets, найдите свою подсеть, в которой будет работать ваша функция и в свойствах подсети скопируйте поле Network_id. Важно (!): необходим именно network_id,  а не subnet_id:

Создание IAM Agency для доступа к виртуальной сети

Чтобы функция могла обращаться к прочим сервисам в вашей вируальной сети, недостаточно собственно параметров самой сети. Для функции нужно указать IAM Agency, в котором будет право доступа к сети. Для создания агенства перейдите в Identification and Access Management (IAM), выберите пункт Agencies и создайте агенство. Настройка показана на скриншоте:

Генерация зависимостей, загрузка пакета, получение идентификатора

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

После того, как пакет подготовлен и загружен в соответствующий раздел, нужно найти его внутренний технический идентификатор. Для этого в разделе Dependencies наведите курсов на поле Address в строке с вашим пакетом и скопируйте название zip файла из этого адреса (см скриншот):

Подготовка параметров CI-CD функции

Теперь необходимо прописать все собранные на предыдущих шагах значения в настройках CI-CD функции. Для этого открываем функцию, переходим в раздел Configuration и в блоке Environment Variables создаем 5 значений:

Назначение параметров:

dependency_package

Идентификатор пакета с зависимостями.

vpc_id

Идентификатор сети

subnet_id

Идентификатор подсети

vpc_access_agency_name

Название агенства с правами доступа к сети

api_group_id

Идентификатор API группы для внешнего доступа к функции.

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

Регистрация API Gateway триггера для функции и получение ссылки для вызова вебхука

Итак, закончили с конфигурацией проекта и внесли все параметры в настройки нашей CI/CD функции. Теперь нужно назначить триггер, с помощью которого функцию можно будет вызвать с помощью HTTP запроса и использовать в качестве вебхука. Для этого на закладке Triggers для функции нажмите Create Trigger, выберите тип триггера API Gateway. Рекомендуется создать отдельную API Group, чтобы адрес вызова для проекта не пересекался с адресом для вашего вебхука. Создайте группу, вернитесь в окно регистрации триггера и завершите его создание согласно скриншоту:

В списке триггеров появится плашка, из которой скопируйте URL – это и будет адрес нашего вебхука для регистрации в GitHub:

Настройка webhook в репозитории GitHub

Настройка webhook для репозитория производится очень просто, откройте настройки репозитория, выберите раздел webhooks и нажмите кнопку Add webhook:

Подготовка шаблона проекта

Вся подготовка выполнена, осталось написать код. Для быстрого старта в предложенном сценарии подготовлен шаблон такой функции – типичного «контроллера», который можно использовать как отправную точку для развития проекта. Шаблон представляет собой простейший вариант API для CRUD операций над данными. Для примера, была взята база данных с таблицей продуктов и реализованы основные операции создания/редактирования/удаления записей. Для функции также нужны настройки доступа к БД, они устанавливаются из конфигурации основной CI/CD функции.

Пример кода "контроллера"
//import section. Request your dependencies here
let mysql = require('mysql');

//Main function, that receive events. 
exports.handler = async (event, context) => {
    /*Where to get information for controller
        HTTPMethod: event.httpMethod
        extra path: event.pathParameters[""]. For example, if your api is located at /api/function/ path and
                    you are calling /api/function/details/123, then event.pathParameters[""] 
                    will contain "details/123"
        
        body:       event.body, but it is base64 encoded. So to get body as object, use: 
                    const eventBody = JSON.parse(Buffer.from(event.body, 'base64').toString('ascii'))

        query:      path.queryStringParameters. Example: /api/function?search=something, this object will look like:
                    {
                        search: "something"
                    }
                    

    */
    const eventBody = event.body ? JSON.parse(Buffer.from(event.body, 'base64').toString('ascii')): {}

    var controllerOutput={
        body: {},
        contentType: "application/json"
    }
    /* Database connection preparation */
    var connection = mysql.createConnection({
      host     : context.getUserData("dbhost"),
      user     : context.getUserData("dbuser"),
      password : context.getUserData("dbpwd"),
      database : context.getUserData("databasename")
    });
    connection.connect();
    /*
    Typical CRUD controller: action depends on HTTP method
    */
    switch (event.httpMethod) {
        case "GET":
            //GET could mean "Get list" and "Get One"
            if (event.pathParameters[""])  
            {
                //When pathParameters is not empty, then we have url like /api/products/{id}
                controllerOutput = await getProductDetails(connection, event.pathParameters[""])
            }
            else
            {
                //When nothing in the path - we are looking for the list
                controllerOutput = await getProducts(connection, event.queryStringParameters.name);
            }
            break;

        case "POST":
            controllerOutput = await createProduct(connection, eventBody);
            break;

        case "PUT":
            controllerOutput = await updateProduct(connection, eventBody);
            break;

        case "DELETE":
            controllerOutput = await deleteProduct(connection, event.pathParameters[""])
            break;
        default:
            controllerOutput = {
                body: "Unrecognized command",
                contentType: "text/plain"
            }
    }
    const output =
    {
        'statusCode': controllerOutput.statusCode? controllerOutput.statusCode : 200,
        'headers':
        {
            'Content-Type': controllerOutput.contentType
        },
        'isBase64Encoded': false,
        'body': (typeof controllerOutput.body)==='string' ? controllerOutput.body : JSON.stringify(controllerOutput.body)
    }
    return output;
}

async function getProducts(connection, searchForName)
{
    var SQL = "select * from products ";
    if (searchForName)
    {
        SQL = SQL +" where name like ?";
        searchForName=searchForName+'%';
    }
    SQL = SQL + ' limit 100';
    const products = await executeQuery(connection, SQL, [searchForName]);
    return {
        body: products,
        contentType: 'application/json'
    }
}
async function getProductDetails(connection, productId)
{
    var SQL = "select * from products where ID=?";
    const productData = await executeQuery(connection, SQL, [productId]);
    return {
        body: productData.length>=1? productData[0] : {},
        contentType: 'application/json'
    }
}

async function createProduct(connection, product)
{
    const SQL = "insert into Products (Name, Description, Price) values (?, ?, ?);";
    const result = await executeQuery(connection, SQL, [product.name, product.description, product.price]);
    return {
        body: 'OK',
        statusCode: 201,
        contentType: 'text/plain'
    }
}

async function updateProduct(connection, product)
{
    const SQL = "update Products set Name = ?, description = ?, price = ? where ID=?";
    const result = await executeQuery(connection, SQL, [product.name, product.description, product.price, product.id]);
    return {
        body: 'OK',
        contentType: 'text/plain'
    }
}

async function deleteProduct(connection, productId)
{
    const SQL = "delete from Products where ID=?";
    const result = await executeQuery(connection, SQL, [productId]);
    return {
        body: 'OK',
        contentType: 'text/plain'
    }
}

//Special function to "promisify" query execution
function executeQuery(connection, querySQL, queryParams){
    return new Promise(function(resolve, reject) {
        try {
            connection.query(querySQL, queryParams, function (error, results, fields) {
                if (error) throw error;
                resolve(results);
            });
        }
        catch (e){
            reject(e);
        }
    })
}

Заключение

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

А еще - это прекрасная демонстрация того, что с помощью Function Graph в SberCloud.Advanced можно реализовывать весьма нетривиальные сценарии по управлению облачной инфраструктурой. Ведь если в описанном кейсе было развернуто целое приложение, то и остальными компонентами облака можно управлять подобным образом. Так, к примеру, можно добавлять реплики БД, доступные на чтение, если у приложения сильно растет нагрузка. Вебинар на эту тему смотрите на канале.

Теги:
Хабы:
Всего голосов 2: ↑2 и ↓0+2
Комментарии2

Публикации

Информация

Сайт
huawei.ru
Дата регистрации
Дата основания
1987
Численность
свыше 10 000 человек
Местоположение
Россия

Истории