Медленные сайты раздражают пользователей. Когда основной контент — фоточки, а сайт тормозит — это раздражает вдвойне. И как бы мы ни оптимизировали свой сервис, всегда остаётся такой фактор, как качество связи между пользователем и нашим ЦОДом. В решении этой проблемы нам помогает 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)
YourChief
20.05.2017 18:21+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")
Eternalko
20.05.2017 19:25А почему не Anycast?
majesty
20.05.2017 19:51Если я правильно понимаю, нужна автономка, а это сильно дороже.
YourChief
20.05.2017 21:22Ещё не факт, что это вопрос решит. Оптимальный по числу хопов маршрут не всегда наилучший по скорости или стоимости трафика, так что вы всё правильно сделали.
ToSHiC
20.05.2017 23:27+1Вообще BGP как раз даёт возможность получать оптимальный с точки зрения провайдера-получателя траффика маршрут, если пириться с ним напрямую. Позволяет, в том числе, делать много стыков с одним и тем же провайдером в географически разных регионах, и всё это даже может работать, если они не накосячат в настройках маршрутизации внутри своей сети.
Конечно, есть и минусы, например — нельзя перенаправить часть нагрузки, только всё или ничего. Нужна своя автономка, нужно пириться, нужно уметь настраивать всё это.
kirillaristov
20.05.2017 21:56Мы — это компания «Колёса Крыша Маркет»
Либо мои колёса виноваты, либо у вас действительно очень смешное название, для России.
(есть у нас такой «КрепМаркет», крепёж продаёт)kirillaristov
20.05.2017 22:00Ой, простите, прочитал по диагонали и подумал что новый CDN в Казахстане называется «Колёса Крыша Маркет».
lubezniy
21.05.2017 15:18Спасибо, идея хорошая. У нас файлы размещаются аналогично, только используем мы nginx вместе с tmpfs вместо memcached.
А заливку фоток на множество серверов каким образом делаете?majesty
21.05.2017 17:26+1Файлы храним в Ceph, отложенную репликацию между дата-центрами с активным бэкапом в AWS S3 реализовали сами на RabbiyMQ + Go
a0fs
21.05.2017 17:24Шикарно. А если к всему этому добавить возврат статистики по пингам обратно на центральный сервер для статистики и мониторинга и добавить обработку не 200-х кодов переключением на второй по доступности хост, либо вообще реализовать подгрузку картинок через JavaScript на стороне клиента, разбрасывая нагрузку по запросам пропорционально пингам и при этом отправляя при переходах статистику по отличным от 200 кодам обратно в центр, решение будет совсем замечательным.
И вопрос: а почему не использовали полный редирект на точку присутствия у соответствующего провайдера и не стали раздавать статику с него, при этом проксируя динамику в центр. Ведь на связность в точках присутствия больше влияния чем на связность клиента и сервера? Да добавляется лишний элемент, который ещё нужно мониторить, но с центра кэшированием можно снять часть нагрузки…majesty
21.05.2017 17:27Домен один, да и данных в сравнении с фотками гораздо меньше, след. тупит тоже меньше.
darki73
А почему не скажем cloudflare или cloudfront. _https://www.datanyze.com/market-share/site-delivery/Kazakhstan/cloudflare-cdn-market-share
Насколько я знаю оба провайдера дают API для провайдеров, также первый еще и сертификат выдает. Есть ли какие то преимущества вашей реализации?
majesty
Спасибо за интересную ссылку. Например, одним из условий размещения у мобильных операторов является возможность ограничить раздачу контента только их клиентам. Плюс различные юридические тонкости. Трёхсторонний договор с западной компанией не всех обрадует. В общем, своё получается проще и гибче.