Краткое содержание

"Whenever you receive a webhook update, you have two options"
Из Telegram Bot Api Faq


Привет, Хабр!


Долгое время концепция serverless (а если говорить точнее — её реализация в виде сервиса AWS Lambda) была для меня относительно понятной, но очень абстрактной идеей. Она часто звучала в Radio-T, обсуждениях на реддите, но никак не входила в мою жизнь. Рабочие проекты живут не в облаке, а домашние — зачем? Виртуалки дешевеют, Docker освоен, и всё отлично работает.


Но презентация Yandex Cloud Functions, и в особенности озвученные цены на этот сервис, дали новую пищу для размышлений.


TL;DR — дождливым пятничным вечером мы напишем простенького Telegram бота на javascript, который сможет отвечать на запросы простыми сообщениями. Если это ваш домашний проект — его использование почти наверняка обойдётся вам значительно дешевле самых бюджетных VPS.


Поехали.


Что такое Serverless в самом бытовом понимании?


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


?В чем преимущества такого подхода?


Безопасность


Вы получаете безопасное изолированное окружение с последней версией компилятора / интерпретатора.


Вместо того, чтобы следить за обновлением пакетов на настоящей ОС в Виртуальной машине, заниматься настройкой политик безопасности и файрволла — вы загружаете программу на сервер, и она работает.


Стабильность и отказоустойчивость


Вместо того чтобы конфигурировать pm2, настраивать политику перезагрузок, следить за утечкой памяти и постигать нюансы деплоя — да, вы просто загружаете программу на сервер, а всё остальное берёт на себя провайдер услуги.


Цена, особенно в условиях домашних малонагруженных проектов


При тарификации учитывается объём памяти, резервируемый под функцию за время её исполнения и количество вызовов. Согласно документации 10 000 000 запусков функции, работающей 800ms с ограничением по памяти 512мб будут стоить 3 900?.


Что это означает для меня? Мой типичный петпроджект — бот отвечающий на вопросы по расписанию для широко известного в узких кругах мероприятия. Запускать его нужно раз в год, на протяжении нескольких дней. В прошлом году он ответил на 1000 запросов от участников, ему более чем хватит 128мб, время выполнения функции 300ms. Такой сценарий использования будет стоить 0.046?.


Да, 4.6 копеек. Плюс я не буду тратить время на настройки, что ещё приятней. Никаких правил для pm2, никакой актуализации Dockerfile или окружения, и вишенкой на торте — SLA 99,9.


Из пока нерешённых задач (но, полагаю, это вопрос времени) — привязка внешних доменов, а так же тонкая настройка http методов, которые служат триггером функции. Сейчас http триггер срабатывает на любой из DELETE, GET, HEAD, OPTIONS, PATCH, POST или PUT запросов на авто-генерируемую точку входа типа https://functions.yandexcloud.net/xxxxxxxxxxxxxxxx.


Из хороших новостей — это полноценный https, который отвечает всем требованиям Telegram для работы с api через webHooks. Но у AWS Lambda есть дополнения в виде API Gateway, да и сама настройка триггера шире, если вам это нужно.


Очевидное ограничение самого serverless подхода, не зависимо от платформы — пользоваться приходится ровно тем, что дают. Написать код на неподдерживаемых языках программирования или использовать нестандартные параметры компилятора/интерпретатора вы не сможете. Также возможны дополнительные ограничения призванные защитить всех участников процесса разработки.


Как создать .js файл, чтобы работать в Яндекс.Облаке?


Краткий гайд через веб интерфейс:


  • создаём Функцию
  • создаём в веб интерфейсе файл с любым именем и расширением js
  • выбираем интерпретатор — nodejs10 или nodejs12
  • в файле пишем функцию с одним параметром в exports.myFunction (ну, в произвольное поле в exports)
  • указываем таймаут работы функции, оперативную память (128МБ-1024МБ с шагом в 128МБ), точку входа (имяфайла.myFunction)
  • делаем функцию публичной

Функция написанная в файле может:


Получить данные http запроса через входящий параметр:


функция не получает request в чистом виде, и конечно же не управляет ходом запроса — она получает в своём единственном параметре объект с информацией о запросе:


{
    "httpMethod": "<название HTTP метода>",
    "headers": "<словарь со строковыми значениями HTTP-заголовков>",
    "multiValueHeaders": "<словарь со списками значений HTTP-заголовков>",
    "queryStringParameters": "<словарь queryString-параметров>",
    "multiValueQueryStringParameters": "<словарь списков значений queryString-параметров>",
    "requestContext": "<словарь с контекстом запроса>",
    "body": "<содержимое запроса>",
    "isBase64Encoded": <true или false>
}

Ответить на http запрос


согласно документации:


{
    "statusCode": <HTTP код ответа>,
    "headers": "<словарь со строковыми значениями HTTP-заголовков>",
    "multiValueHeaders": "<словарь со списками значений HTTP-заголовков>",
    "body": "<содержимое ответа>",
    "isBase64Encoded": <true или false>
}

Итак, что-нибудь пятничное, бесполезное


Сначала, посмотрим на то что уже написано до нас — реализаций таких ботов для AWS Lambda вагон и маленькая тележка.


У них есть одна проблема — для того чтобы не изобретать велосипед, и обеспечить привычный интерфейс, все эти реализации при получении запроса инициируют post до сервера api telegram. Но можно поступить проще.


Как можно было заметить на КДПВ, и цитате в начале поста — при работе через webHook, telegram слушает ответ на своё update сообщение, чтобы понять, был ли он обработан нашим ботом. Более того, он готов принять сообщение в рамках этого же ответа.


Согласно документации в ответе должна содержаться лишь одна функция (проверил sendMessage и sendPhoto). Для многих проектов этого будет достаточно.


Соблюдём традиции, и передадим привет Хабровчанам:


exports.input = function (data){
    let body = JSON.parse(data.body);
    let answer = {
           "method":"sendMessage",
           "chat_id": body.message.chat.id, 
           "reply_to_message_id" : body.message.message_id, 
           "text" : "Привет, Habr!"
    };

    return {
        "statusCode": 200,
        "headers": {
            'Content-Type': 'application/json'
        },
        "body": JSON.stringify(answer),
        "isBase64Encoded": false
    }

}

Выставим настройки в минимум:



И сообщим в Telegram, что будем использовать webHook:


curl -F "url=https://functions.yandexcloud.net/{secret_function_id}" https://api.telegram.org/bot{secret_bot_key}/setWebhook

Всё. Бот работает.



С ним можно пообщаться: @YandexServerlessBot


Всё вышеописанное в одной картинке


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


Если вам стало интересно — добро пожаловать в документацию по Yandex Cloud Functions, там много интересного, от интеграции с другими сервисами облака до дебага, графиков нагрузки и т.д.


Видео с конференции также доступно на YouTube.


UPD: Как показало дальнейшее исследование (спасибо IRT за наводку) — сервера tg доступны и без таких ухищрений, так что можно смело использовать традиционные api запросы.

Комментарии (43)


  1. Groosha
    04.10.2019 23:02

    Не совсем понимаю, почему такое ограничение на вызов лишь одного sendMessage. Разве нельзя вызвать sendPhoto, к примеру?


    1. NiPh Автор
      05.10.2019 11:18

      Да, вы правы, призывал читать документацию, и сам не дочитал.
      Можно — @YandexServerless2Bot


  1. vanyas
    05.10.2019 08:09

    Т.е. Яндекс не блокирует доступ к телеграм Api?


    1. Wilidon
      05.10.2019 09:49

      У моего VPS российский ip, api телеграма работает.


    1. NiPh Автор
      05.10.2019 09:58

      Да, не блокирует.


  1. IRT
    05.10.2019 08:44
    +1

    все эти реализации при получении запроса инициируют post до сервера api telegram, что в условиях крупного Российского хостера невыполнимо


    Во-первых, провайдеры хостинга, датацентры и так далее не являются операторами связи. Они ничего не блокируют из списка РКН в своих сетях. В основном. Во всяком случае, если они что-то блокируют, это исключительно их инициатива, по закону они не обязаны. У меня есть прокси Telegram, поднятые на российских VPS, до сих пор работают.
    Во-вторых, если webhook с серверов Telegram приходит, то очевидно, что и API так же доступно. Я еще не видел блокировок только в одну сторону.

    Но за описанную возможность сразу отправить ответ, не дергая API телеги отдельным запросом спасибо, не знал.


    1. NiPh Автор
      05.10.2019 09:57

      Проверил, вы правы, добавил UPD в конце статьи. Мои хостеры в своё время блокировали telegram.org и всё что рядом, так что привык воспринимать это как данность.


    1. Soniclev
      05.10.2019 10:02

      Есть провайдеры хостинга, которые подключены к провайдерам интернета, причём последние блокируют доступ к Телеграм.


      1. Wendor
        05.10.2019 10:13

        Без "Есть".


  1. Anton23
    05.10.2019 10:32

    Не боитесь «попасть на деньги» от Хабра-эффекта?)


    1. NiPh Автор
      05.10.2019 11:03


      Пока нет ) Предполагаю что даже до стоимости проезда в метро не дойдёт.


      1. akdes
        05.10.2019 13:35
        +1

        Challenge accepted :D (нет)
        Но думаю, могут найтись, "доброжелатели"..


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


        1. tmin10
          05.10.2019 14:04

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


          1. akdes
            05.10.2019 14:10

            Проверка будет в скрипте, соответственно вызываться он так или иначе будет.


            1. Blaine_Mono
              05.10.2019 16:34

              Проверка будет занимать меньше времени чем работа самого скрипта.


              1. akdes
                05.10.2019 16:44

                Т.е. Вы считаете, что регистрация айди пользователя и времени/частоты его запросов, а также последующая проверка, будут работать быстрее чем ответ "Привет Хабр"?


                1. Blaine_Mono
                  05.10.2019 22:30

                  Вот в этом случае уже действительно регистрация id не осмысленна.
                  А в случае если бот имеет какую-то полезную нагрузку — регистрация id пользователя и подсчет его активности уже будет иметь смысл.


            1. tmin10
              05.10.2019 18:54

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


          1. vlanko
            05.10.2019 18:01

            да. Как вы в Serverless будете это помнить?


            1. vchslv13
              05.10.2019 19:08

              Так эта Лямбда же может без проблем обращаться, например, к вполне себе «serverfull» Redis'у автора. Помнить — не проблема, в общем.


  1. skrimafonolog
    05.10.2019 16:16

    и вишенкой на торте — SLA 99,9

    Не такая уж это и вишенка.

    Допустим простой 8ч 45м 57с в год.
    Полный рабочий день включая обеденный перерыв, другими словами.


    1. akdes
      05.10.2019 16:45

      Вы можете гарантировать больше?


      1. skrimafonolog
        06.10.2019 08:13
        +1

        Вы можете гарантировать больше?


        Несведующему человеку указанная 99,9% может показаться высокой, но это не так.

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

        Далее, для достижения уровня доступности 99,95% вам понадобится просто построить кластер active-passive.

        Если вы хотите перешагнуть за 99,982% (уровень доступности в дата-центрах Tier III), вам нужно строить систему, распределенную по нескольким ЦОД.

        Еще раз:

        Цифры SLA намного более высокой доступности, чем 99,9% — вполне реальны в нашем мире.


        1. Tiendil
          06.10.2019 09:14

          При нормальной организации репликации

          :-D

          сменить хостера при сбое дело нескольких минут

          :-DD

          просто построить кластер active-passive

          :-DDD

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


          1. gecube
            06.10.2019 10:06

            О чем мы спорим? Яндекс заявляет 99.9. ну, ок. Хорошо.
            В принципе, с этим можно жить.
            А дальше — как я пишу ниже — дьявол в деталях. Я просто внимталеьно смотрел договора (речь не про Яндекс как таковой — возможно, что там все ок ) И все там не так просто как "мы гарантируем". Ну, положим не выполнил поставщик / провайдер / облако свои гарантии — ну, заплатит он вам штраф. Или даст бесплатный бонус. И ЧТО? Репутацию и клиентов это вам не вернёт.


      1. gecube
        06.10.2019 10:04

        На самом деле даже 99.9 никто не гарантирует. Если подумать.
        Читайте договор мелким шрифтом. И скорее всего там будет, что доступность платформы (т.е. веб-панкели и АПИ), а сами сервисы… Ну, не летит трафик в вашу лямбду. И что? Это у вас провайдер фиговый — к нему и идите )


  1. Catsys2
    05.10.2019 19:43

    Но база то все равно должна где то лежать, что бы к ней дотянуться из этой функции? Это уже контейнер, проще на одном контейнере все поднять, если он все равно нужен. Тем более для пет-проекта


    1. NiPh Автор
      05.10.2019 21:49

      Это уже зависит от архитектуры приложения. Одному из моих ботов хватит json файлика лежащего рядом, который будет раз в пол года меняться. Другому — папки с иногда добавляющимися файлами. Третий ходит по внешним api.
      Если понадобится база — я скорее буду смотреть на cloud managed решения, тот же MySQL в Яндексе будет стоить 508? в месяц, и обслуживать несколько проектов. С бэкапами, администрированием и тем-же SLA. В Амазоне, кстати, примерно столько же RDS стоит.
      Но да, безусловно облако не панацея и есть множество случаев когда или надо переделывать под него архитектуру чтобы не разориться, или сразу завести всё на бюджетной vds за 1,5$ в месяц.


  1. RStarun
    05.10.2019 21:27

    Странно что бесплатного лимита нет. У меня давно крутится пара домашних ботов в Google App Engine, и вроде совсем бесплатно.
    Минус в том что решение в облаках раз в пару лет обычно нужно чуть чуть переделывать. Потому что облачное окружение меняется. Могут исключить встроенную библиотеку например.


    1. NiPh Автор
      05.10.2019 21:29

      Лимита нет, но есть бесплатный тестовый период cloud.yandex.ru/docs/free-trial


  1. Shersh
    05.10.2019 23:08

    А еще можете посмотреть на azure functions. Там есть большое кол-во триггеров и бесплатные первые 4 млн вызовов в месяц)


    1. gecube
      06.10.2019 00:48

      Я так понимаю, что дьявол в деталях. И хоть сами вызовы ничего не стоят, но их роутинг через api gateway или что там у ажура может влететь в копеечку. Как прокомментируете ?


      1. Shersh
        06.10.2019 09:00
        +1

        В отличии от aws там api routing бесплатен. :) в копеечку вам может влететь работа с бд. Или очень долгое выполнение функции.


  1. gecube
    06.10.2019 01:00

    Вы получаете безопасное изолированное окружение с последней версией компилятора / интерпретатора.

    Вместо того, чтобы следить за обновлением пакетов на настоящей ОС в Виртуальной машине, заниматься настройкой политик безопасности и файрволла — вы загружаете программу на сервер, и она работает.

    Звучит очень двояко. А если я не хочу использовать последнюю версию? А если я вообще питонщик и у меня завязка на конкретные версии модулей?
    И фраза, что загружаешь и оно работает слишком… Пафосная. Скажем так. Есть два подхода к написанию лямбд. Первый — поднять локальное окружение или эмулятор лямбды. Среды. Амазон, кстати, такой предоставляет. И в нем полностью написать и прогнать свой код. Ну, либо можете наживую отлаживать в облаке (не очень хорошо). Ну, либо — писать будто никакой лямбды нет, потом просто обернуть свой код и запушить в облако. Но могут быть сюрпризы.


    Ещё лямбда (=серверлесс) имеет кучу минусов. Это и мониторинг. И отладка. И в целом перенос сложности с одного уровня проекта на другой. Отдельный вопрос — как будут сосуществовать разные версии кода в одном облаке. Бр. В общем — когда нужно написать что-то маленькое, что-то быстрое — serverless прекрасен, но как только нужно написать полноценный сервис, то стоит подумать и об экономике (в промышленных масштабах лямбда дорога), об ограничениях (лимиты памяти, времени выполнения) и удобстве поддержки


    1. NiPh Автор
      06.10.2019 11:06
      +1

      Очевидное ограничение самого serverless подхода, не зависимо от платформы — пользоваться приходится ровно тем, что дают. Написать код на неподдерживаемых языках программирования или использовать нестандартные параметры компилятора/интерпретатора вы не сможете. Также возможны дополнительные ограничения призванные защитить всех участников процесса разработки.

      Да, всё так. Возможно перебрал пафоса с «последней версией», выбирается конкретный мажорный релиз, с nodejs10 на nodejs12 вас автоматически без предупреждений не переведут.


  1. Aspire89
    06.10.2019 14:45

    А подскажите как отправить «multipart/form-data», хочу отправить sendPhoto, но ничего не выходит

    import json
    import base64
    import urllib.request
    
    def handler(event, context):
        img  = urllib.request.urlopen("https://habr.com/images/habr.png").read()
    
        parsed_string = json.loads(event["body"])
        chat_id = parsed_string['message']['chat']['id']
    
        answerM = {
            "method": "sendMessage",
            "chat_id": chat_id,
            "text": "привет"
        }
    
        answerP = {
            "method": "sendPhoto",
            "chat_id": chat_id
            #"photo": base64.b64encode(img).decode()
        }
    
        return {
            'statusCode': 200,
            'headers': {"Content-Type": "multipart/form-data"},
            'body': json.dumps(answerP),
            'isBased64Encoded': 'true',
            'files': {'photo': base64.b64encode(img).decode()}
        }
    


    1. NiPh Автор
      06.10.2019 15:11

      Я не вижу в документации возможности отдельно прикладывать файлы в поле files, думаю что надо самостоятельно объявить delimiter в заголовке и подготовить данные с ним в body


      1. Aspire89
        06.10.2019 18:11

        Добавил Content-Disposition

        import json
        import base64
        import urllib.request
        
        def handler(event, context):
            img  = urllib.request.urlopen("https://habr.com/images/habr.png").read()
        
            parsed_string = json.loads(event["body"])
            chat_id = parsed_string['message']['chat']['id']
        
            boundary = 'Asrf456BGe4h'
            parts = []
            parts.append('--' + boundary)
            parts.append('Content-Disposition: form-data; name="method"')
            parts.append('')
            parts.append('sendPhoto')
            parts.append('--' + boundary)
            parts.append('Content-Disposition: form-data; name="chat_id"')
            parts.append('')
            parts.append(str(chat_id))
            parts.append('--' + boundary)
            parts.append('Content-Disposition: form-data; name="photo"; filename="%s"' % 'img.jpg')
            parts.append('Content-Type: image/jpeg')
            parts.append('')
            parts.append(base64.b64encode(img).decode())
            parts.append('--' + boundary + '--')
            parts.append('')
        
            body = '\r\n'.join(parts)
        
            return {
                'statusCode': 200,
                'headers': {
                    'content-type': 'multipart/form-data; boundary=' + boundary
                },
                'body': body,
                'isBased64Encoded': 'true'
            }
        

        И добился формирования следующего ответа, но не понимаю, что из этого не нравится телеграму:
        HTTP/1.1 200 OK
        Server: nginx
        Date: Sun, 06 Oct 2019 14:26:32 GMT
        Content-Type: multipart/form-data; boundary=Asrf456BGe4h
        Transfer-Encoding: chunked
        Connection: keep-alive
        X-Content-Type-Options: nosniff
        X-Function-Id: ...
        X-Function-Version-Id: ...
        X-Request-Id: ...
        Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
        X-Frame-Options: SAMEORIGIN
        
        --Asrf456BGe4h
        Content-Disposition: form-data; name="method"
        
        sendPhoto
        --Asrf456BGe4h
        Content-Disposition: form-data; name="chat_id"
        
        1111111
        --Asrf456BGe4h
        Content-Disposition: form-data; name="photo"; filename="img.jpg"
        Content-Type: image/jpeg
        
        iVBORw0KGgoAAAANSUhEUgAABLAAAAJ2CAYAAABPQHtcAAAABGdBTUEAALGPC/
        .../A0wmf2FFW4KcAAAAAElFTkSuQmCC
        --Asrf456BGe4h--
        


        1. NiPh Автор
          06.10.2019 23:53

          Явного упоминания такой готовности в доках уже телеграма нет, я пробовал sendPhoto в таком виде:


          exports.input = function (data){
              let body = JSON.parse(data.body);
              let answer = {
                  "method":"sendPhoto",
                  "photo": "https://habrastorage.org/webt/hr/f7/nl/hrf7nll2xn4c9leojqsc95uv7ji.png",
                  "chat_id": body.message.chat.id, 
                  "reply_to_message_id" : body.message.message_id, 
                  "text" : "Привет, Habr!"
              };
          
              return {
                  "statusCode": 200,
                  "headers": {
                      'Content-Type': 'application/json'
                  },
                  "body": JSON.stringify(answer),
                  "isBase64Encoded": false
              }
          
          }


        1. Groosha
          07.10.2019 12:19

          Как подсказали ниже/выше, для небольших картинок (меньше 5 Мб) можно указать просто URL.
          Если набор изображений более-менее стабильный и отбирается вручную, можно заранее отправить их своему боту в Telegram, через метод getUpdates посмотреть на fileID этих картинок и дальше вызывать sendPhoto с этими fileID, тогда отправка будет вообще мгновенной, ведь файл уже лежит на серверах Telegram.


          1. Aspire89
            07.10.2019 12:29

            Да, такой вариант работает, но не в моем случае.
            У меня URL картинки всегда один, а содержимое отличается (веб камера) и телеграм запоминает первое содержимое и дальше всегда его отдает.


            1. Groosha
              07.10.2019 12:30
              +1

              Может быть, костыль, но что если добавлять к URL картинки какой-нибудь невзрачный параметр? Типа https://example.com/pic.jpg?yourvalue=abcxyz


              1. Aspire89
                07.10.2019 13:09

                Да, спасибо, работает