Что нам стоит CDN построить


    Медленные сайты раздражают пользователей. Когда основной контент — фоточки, а сайт тормозит — это раздражает вдвойне. И как бы мы ни оптимизировали свой сервис, всегда остаётся такой фактор, как качество связи между пользователем и нашим ЦОДом. В решении этой проблемы нам помогает CDN.

    Мы — это компания «Колёса Крыша Маркет», разработчик самых крупных и посещаемых сайтов частных объявлений в Казахстане и фотографии из объявлений — критически важная часть нашего бизнеса.

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

    В этих условиях нам нужно максимально быстро отдать пользователям 1.5 Гбит/сек фотографий автомобилей, недвижимости и товаров личного потребления.

    Мы искали публичный CDN под свои нужды и нашли только присутствующий в Алматы Akamai без каких-либо подробностей по стоимости и планах расширения на остальную часть Казахстана. Мы приняли решение строить свой.

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

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

    Мы построили свой CDN на связке OpenResty и Lua с использованием JavaScript. Это не потребовало никаких доработок в коде сайтов (менеджеры и разработчики рады — можно «пилить» фичи вместо инфраструктурных задач :)) и немножко «допилов» в мобильных приложениях.

    OpenResty — это прекрасный форк Nginx от китайских разработчиков, о котором неоднократно писали на Хабре. Мы использовали его в качестве реверс-прокси.

    Lua — простой, мощный, встраиваемый язык, который тоже получил достаточно внимания на Хабре.

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

    function getFastestHost() {
        var
            fastest         = arguments[0], 
            fastestDuration = 600000,
            timing          = [],
            track           = function (host) {
                var tracker = new Image();
    
                tracker.src = "/set.gif?cdn=" + host;
            };
    
        for (var i = 0; i < arguments.length; i++) {
            (function(host) {
                var
                    image     = new Image(),
                    timeStart = (new Date()).getTime();
    
                image.onload = function () {
                    var duration = (new Date()).getTime() - timeStart;
    
                    if (duration < fastestDuration) {
                        fastestDuration = duration;
                        fastest = host;
                    }
    
                    timing[timing.length] = duration;
    
                    if (timing.length == arguments.length) {
                        track(fastest);
                    }
                }
    
                image.onerror = function () {
                    timing[timing.length] = -1
    
                    if (timing.length == arguments.length) {
                        track(fastest);
                    }
                }
    
                image.src = host + "/empty.gif";
            }(arguments[i]));
        }
    }

    При последующих запросах OpenResty запускает код на Lua, который проверяет наличие куки, валидирует её и, если всё хорошо, подменяет в URL изображений хост на тот, что был получен из куки.

    init_by_lua_block {
        -- получение хостов из файла
        function getCdnHosts(file)
            local hosts = {}
            for line in io.lines(file) do
                table.insert(hosts, line)
            end
            return hosts
        end
    
        -- разбор строки хостов в массив по регулярному выражению
        function stringToTable(t, s)
            local it, err = ngx.re.gmatch(s, "(//[^;]+);?")
            while true do
                local m, err = it()
    
                if not m then
                    break
                end
    
                table.insert(t, m[1])
            end
    
            return t
        end
    
        -- поиск значения в таблице
        function valueExists(tbl, value)
            for k,v in pairs(tbl) do
                if value == v then
                    return true
                end
            end
    
            return false
        end
    }
    
    server {
        server_name kolesa.kz;
        # компонент куки cdn
        set $cdn_project kl;
        # хост куки cdn
        set $cookie_host .kolesa.kz;
        # файл с хостами cdn
        set $cdn_hosts_file "/etc/nginx/cdn/cdn.data.active";
        # хосты статики
        set $replace_hosts "//photos-a-kl.kcdn.kz;//photos-b-kl.kcdn.kz";
    
        # проверка наличия куки и подмена ответа с правильными uri
        location / { 
            proxy_set_header Host kolesa.kz;
            proxy_pass http://kolesa;
    
            header_filter_by_lua_block {
                ngx.header.content_length = nil
            }
    
            body_filter_by_lua_block {
                allCdnHosts = getCdnHosts(ngx.var["cdn_hosts_file"])
                replaceHosts = stringToTable({}, ngx.var["replace_hosts"])
                cdnHost = ngx.var["cookie_" .. ngx.var["cdn_project"] .. "_cdn_host"]
                replaceEof = ngx.arg[2]
    
                if cdnHost ~= nil and valueExists(allCdnHosts, cdnHost) == true then
                    -- кука есть, перезапишем на него всё, что нужно
                    for k,v in pairs(replaceHosts) do
                        local newStr, n, err = ngx.re.gsub(ngx.arg[1], v, cdnHost)
    
                        if n > 0 then
                            ngx.arg[1] = newStr
                            replaceEof = false
                        end
                    end
                else
                    -- кука ещё не установлена, добавим скрипт и он поставит куку
                    local scriptStr = "<script src='/cdn.js' type='text/javascript'></script>" ..
                        "<script type='text/javascript'>" ..
                        "(function(){" ..
                            "getFastestHost('" .. table.concat(allCdnHosts, "', '") .. "')" ..
                        "}())" ..
                        "</script>"
    
                    local newStr, n, err = ngx.re.gsub(ngx.arg[1], "(</body>)", scriptStr .. "$1", "i")
                    if n > 0 then
                        ngx.arg[1] = newStr
                        replaceEof = false
                    end
                end
    
                ngx.arg[2] = replaceEof
            }
        }
    }

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

    Хостов CDN на данный момент у нас 5 штук — три в Алматы и по одному в Астане и Шымкенте. Каждый хост обслуживают два сервера Supermicro (для отказоустойчивости). На каждом крутится OpenResty + Memcached на 120 Gb для кэширования фотографий.

    По результатам внедрения мы снизили трафик на основной ЦОД (1.2 Гбит против 400 Мбит) и увеличили общий трафик от нас к пользователям (1.5 Гбит против 1.2 Гбит). Фоточки перестали тормозить у пользователей отдельных интернет-провайдеров (что частенько бывало до внедрения CDN) и в целом наши клиенты стали счастливее.

    В ближайших планах установить серверы в ЦОДы мобильных операторов, поскольку для пользователей мобильного интернета проблема ещё более актуальна.
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 18
    • 0

      А почему не скажем cloudflare или cloudfront. _https://www.datanyze.com/market-share/site-delivery/Kazakhstan/cloudflare-cdn-market-share


      Насколько я знаю оба провайдера дают API для провайдеров, также первый еще и сертификат выдает. Есть ли какие то преимущества вашей реализации?

      • 0

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

      • +1
        По ряду причин, описанных в документации к lua-nginx-module из состава OpenResty, выгоднее всегда использовать локальные объявления.

        Для того, чтобы быстро проверить объявления в вашем коде, можно воспользоваться утилитой lua-releng от тех же авторов.

        Вывод для вашего кода:
        vladislav@dt1:~$ ./lua-releng init.lua 
        WARNING: No "_VERSION" or "version" field found in `init.lua`.
        Checking use of Lua global variables in file init.lua...
        	op no.	line	instruction	args	; code
        	2	[2]	SETGLOBAL	0 -1	; getCdnHosts
        	4	[11]	SETGLOBAL	0 -2	; stringToTable
        	6	[27]	SETGLOBAL	0 -3	; valueExists
        Checking line length exceeding 80...
        vladislav@dt1:~$ ./lua-releng body_filter.lua 
        WARNING: No "_VERSION" or "version" field found in `body_filter.lua`.
        Checking use of Lua global variables in file body_filter.lua...
        	op no.	line	instruction	args	; code
        	1	[1]	GETGLOBAL	0 -2	; getCdnHosts
        	6	[1]	SETGLOBAL	0 -1	; allCdnHosts
        	7	[2]	GETGLOBAL	0 -7	; stringToTable
        	13	[2]	SETGLOBAL	0 -6	; replaceHosts
        	23	[3]	SETGLOBAL	0 -9	; cdnHost
        	27	[4]	SETGLOBAL	0 -13	; replaceEof
        	28	[6]	GETGLOBAL	0 -9	; cdnHost
        	31	[6]	GETGLOBAL	0 -17	; valueExists
        	32	[6]	GETGLOBAL	1 -1	; allCdnHosts
        	33	[6]	GETGLOBAL	2 -9	; cdnHost
        	38	[8]	GETGLOBAL	1 -6	; replaceHosts
        	48	[9]	GETGLOBAL	8 -9	; cdnHost
        	56	[13]	SETGLOBAL	8 -13	; replaceEof
        	66	[21]	GETGLOBAL	5 -1	; allCdnHosts
        	91	[28]	SETGLOBAL	4 -13	; replaceEof
        	94	[32]	GETGLOBAL	1 -13	; replaceEof
        Checking line length exceeding 80...
        body_filter.lua:3:            cdnHost = ngx.var["cookie_" .. ngx.var["cdn_project"] .. "_cdn_host"]
        body_filter.lua:18:                local scriptStr = "<script src='/cdn.js' type='text/javascript'></script>" ..
        body_filter.lua:21:                        "getFastestHost('" .. table.concat(allCdnHosts, "', '") .. "')" ..
        body_filter.lua:25:                local newStr, n, err = ngx.re.gsub(ngx.arg[1], "(</body>)", scriptStr .. "$1", "i")
        
        • 0

          Спасибо за замечание :)

        • 0
          А почему не Anycast?
          • 0

            Если я правильно понимаю, нужна автономка, а это сильно дороже.

            • 0
              Ещё не факт, что это вопрос решит. Оптимальный по числу хопов маршрут не всегда наилучший по скорости или стоимости трафика, так что вы всё правильно сделали.
              • +1
                Вообще BGP как раз даёт возможность получать оптимальный с точки зрения провайдера-получателя траффика маршрут, если пириться с ним напрямую. Позволяет, в том числе, делать много стыков с одним и тем же провайдером в географически разных регионах, и всё это даже может работать, если они не накосячат в настройках маршрутизации внутри своей сети.

                Конечно, есть и минусы, например — нельзя перенаправить часть нагрузки, только всё или ничего. Нужна своя автономка, нужно пириться, нужно уметь настраивать всё это.
          • 0
            Мы — это компания «Колёса Крыша Маркет»
            Либо мои колёса виноваты, либо у вас действительно очень смешное название, для России.
            (есть у нас такой «КрепМаркет», крепёж продаёт)
            • 0
              Ой, простите, прочитал по диагонали и подумал что новый CDN в Казахстане называется «Колёса Крыша Маркет».
            • 0
              Спасибо, идея хорошая. У нас файлы размещаются аналогично, только используем мы nginx вместе с tmpfs вместо memcached.
              А заливку фоток на множество серверов каким образом делаете?
              • +1

                Файлы храним в Ceph, отложенную репликацию между дата-центрами с активным бэкапом в AWS S3 реализовали сами на RabbiyMQ + Go

                • 0
                  > RabbiyMQ + Go

                  Вместо кролика на рассмотрение: nats
                  • 0

                    Когда хранилище делали нужно было «ещё вчера», поэтому выбрали то, что умеем и знаем. К nats пока присматриваемся.

              • 0
                Шикарно. А если к всему этому добавить возврат статистики по пингам обратно на центральный сервер для статистики и мониторинга и добавить обработку не 200-х кодов переключением на второй по доступности хост, либо вообще реализовать подгрузку картинок через JavaScript на стороне клиента, разбрасывая нагрузку по запросам пропорционально пингам и при этом отправляя при переходах статистику по отличным от 200 кодам обратно в центр, решение будет совсем замечательным.

                И вопрос: а почему не использовали полный редирект на точку присутствия у соответствующего провайдера и не стали раздавать статику с него, при этом проксируя динамику в центр. Ведь на связность в точках присутствия больше влияния чем на связность клиента и сервера? Да добавляется лишний элемент, который ещё нужно мониторить, но с центра кэшированием можно снять часть нагрузки…
                • 0

                  Домен один, да и данных в сравнении с фотками гораздо меньше, след. тупит тоже меньше.

                • 0
                  Это вот этот пиксель?

                  Mixed Content: The page at 'https://kolesa.kz/' was loaded over HTTPS, but requested an insecure image 'http://sync-eu.exe.bid/image?source=mgid&id=h4ly0I_tPEx7'. This content should also be served over HTTPS.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.