понедельник, 22 августа 2016 г.

Универсальные счетчики в nginx: замечания к реализации модуля

Не так давно реализовал универсальные счетчики для nginx в виде модуля. По задумке счетчики могут быть объявлены как в серверной части конфигурации, так и в отдельных локейшнах внутри сервера. Они должны разделяться всеми рабочими процессами, а также, опционально, наборами отдельных виртуальных серверов — это для того, чтобы их можно было опрашивать из отдельного, выделенного сервера. Вот как это может выглядеть на практике.
http {
    server {
        listen       8010;
        server_name  main monitored;

        counter $cnt_all_requests inc;

        location /test {
            counter $cnt_test_requests inc;
            if ($arg_a) {
                counter $cnt_test_a_requests inc;
                echo "/test?a=$arg_a";
                break;
            }
            return 200;
        }
    }

    server {
        listen       8020;
        server_name  monitor monitored;

        allow 127.0.0.1;
        deny  all;

        location / {
            echo -n "all = $cnt_all_requests";
            echo -n " | /test = $cnt_test_requests";
            echo    " | /test?a = $cnt_test_a_requests";
        }
    }
}
Здесь объявлены два виртуальных сервера main и monitor, слушающие на разных портах 8010 и 8020. Второй сервер нужен только для опроса счетчиков, объявленных в первом сервере, поэтому доступ к нему ограничен директивами allow и deny. В первом сервере объявлены три счетчика: cnt_all_requests, cnt_test_requests и cnt_test_a_requests. Первый счетчик должен увеличиваться на единицу (операция inc со значением по умолчанию 1) при любом запросе к серверу main, второй — при попадании в локейшн /test, третий – при попадании в этот же локейшн, но с условием, что параметр a присутствует в URI запроса. Обратите внимание, что оба сервера имеют одинаковое последнее имя monitored — это и есть тот самый способ объявить набор счетчиков, разделяемый несколькими виртуальными серверами (для этой же цели в модуле имеется отдельная директива counter_set_id). Давайте подумаем, как можно было бы реализовать счетчики из этого примера. Во-первых, все рабочие процессы должны разделять одни и те же значения счетчиков, а это значит, что они должны храниться в разделяемой памяти. А как сделать так, чтобы при попадании в локейшн /test счетчики cnt_all_requests и cnt_test_requests увеличивались бы на единицу, а третий счетчик cnt_test_a_requests делал бы это только в том случае, если в URI присутствует параметр a? Для ответа на этот вопрос обратите внимание на выделенное мною в нем слово попадание. В самом деле, попадание запроса в определенный локейшн есть результат большой работы, проделанной nginx на этапе чтения конфигурации, а именно создания и слияния (merge) конфигураций уровня локейшнов (location configuration). При получении запроса nginx сопоставляет его URI с метками всех конфигураций уровня локейшнов и выбирает наиболее подходящую. С помощью механизма слияния конфигураций мы сможем протолкнуть верхнеуровневые конфигурации со счетчиками cnt_all_requests и cnt_test_requests вниз в целевые конфигурации вплоть до уровней if в локейшнах. Таким образом, мы будем хранить ссылки на метаинформацию о счетчиках (ссылку на значение в разделяемой памяти, которое нужно будет изменить, а также операцию — inc или set и соответствующее ей значение) в кофигурации уровня локейшнов нашего модуля. И не стоит удивляться тому, что счетчик, объявленный на уровне server выше всяких локейшнов имеет собственную конфигурацию уровня локейшнов — просто во время чтения конфигурации nginx сольет эту конфигурацию со всеми другими, объявленными на уровне локейшнов и внутри всех if во всех локейшнах. Правила слияния мы запрограммируем сами, гарантировав, что верхнеуровневые счетчики попадут во все конфигурации ниже. А теперь представьте такой фрагмент конфигурации.
        counter $cnt_requests_1 inc;

        location /test {
            counter $cnt_requests_1 inc;
            if ($arg_a) {
                counter $cnt_requests_1 inc -1;
            }
            return 200;
        }
Здесь мы хотим, чтобы при попадании запроса в локейшн /test счетчик cnt_requests_1 увеличивался на 1, а во все другие локейшны — на 2. Вот таким хитроумным способом, эксплуатируя механизм слияния конфигураций уровня локейшнов, мы можем добиться этого результата. Но… Когда мы говорили о слиянии конфигураций, мы имели ввиду простое добавление разных счетчиков сверху вниз. В данном же случае нам придется не просто добавлять новый счетчик, но каким-то образом сливать метаинформацию одного и того же счетчика в нижней конфигурации. К метаинформации счетчика относятся ссылка на элемент в разделяемой памяти (которая не изменится при слиянии), а также операция и ее значение. В общем, нам нужно изменить операцию и/или значение счетчика на нижнем уровне в зависимости от их значений у счетчика на верхнем уровне. И это не сложно. Если операция нижнего счетчика равна set, то метаинформация на нижнем счетчике не меняется, иначе, если операция верхнего счетчика равна set, то операцией нижнего счетчика становится set, а ее значением является сумма значений операций верхнего и нижнего счетчиков, иначе операция нижнего счетчика не изменяется, а ее значением становится сумма значений операций верхнего и нижнего счетчиков. Внимание, вопрос. Когда мы будем обновлять значения счетчиков в разделяемой памяти? Простой вопрос? Как это ни удивительно, но ответ отрицательный. Поскольку мы опираемся на метаинформацию о счетчиках, которая привязана к конфигурации уровня локейшнов, во время обновления данных счетчиков в разделяемой памяти в метаданных запроса nginx (это указатель на объект типа ngx_http_request_t) должна находится правильная ссылка на конфигурацию уровня локейшнов нашего модуля. Запрос nginx проживает насыщенную событиями жизнь, состоящую из нескольких фаз. В течение всей жизни ссылки на конфигурации уровня локейшнов могут меняться несколько раз. Наибольший вклад в этот процесс изменения конфигураций вносит модуль nginx rewrite с его директивами rewrite, if и return. Директивы модуля rewrite действуют на ранних фазах NGX_HTTP_SERVER_REWRITE_PHASE (только те, которые объявлены на уровне серверов, однако в этом случае они не изменяют локейшны) и NGX_HTTP_REWRITE_PHASE (директивы, объявленные на уровне локейшнов). Более того, директива return может убить запрос на одной из этих фаз. Мы могли бы объявить хэндлер, который обновлял бы значения счетчиков на одной из поздних фаз (см. подробную информацию о фазах nginx здесь). Если бы не return… Если объявить наш хэндлер на самой ранней из доступных фаз (NGX_HTTP_REWRITE_PHASE), до запуска хэндлера модуля rewrite, то ссылка на конфигурацию уровня локейшнов может оказаться неверной, поскольку директивы модуля rewrite могут изменить локейшн. Ввиду изложенных обстоятельств я решил поместить хэндлер для обновления счетчиков в фильтр заголовков ответа (response headers filter): он должен работать всегда вне зависимости от наличия или отсутствия директив модуля rewrite и изменять счетчики, связанные с последним переписанным локейшном. Ну а что, если мы хотим регистрировать входные локейшны, которые связаны с запросом до любых изменений локейшнов директивами модуля rewrite? Для этого случая я добавил так называемые ранние счетчики, хэндлеры которых вызываются в фазе NGX_HTTP_REWRITE_PHASE. Вот пример.
        location /test/rewrite {
            early_counter $ecnt_test_requests inc;
            rewrite ^ /test/0;
        }

        location /test/0 {
            early_counter $ecnt_test0_requests inc;
            rewrite ^ /test;
        }
Хэндлер для раннего счетчика ecnt_test_requests в локейшне /test/rewrite будет вызван до директивы rewrite, поэтому данный счетчик увеличится на единицу до любых rewrite и if уровня локейшнов, но это при условии, что локейшн /test/rewrite был входным. Так, в случае запроса на /test/rewrite, счетчик ecnt_test0_requests в промежуточном локейшне /test/0 не изменится — нет никакого способа получить доступ к промежуточным конфигурациям уровня локейшнов во время последовательных перезаписей локейшнов модулем rewrite. Объявления ранних счетчиков разрешены только на уровне локейшнов, поскольку на уровнях серверов, а тем более в if внутри локейшнов они не имеют смысла. Один и тот же счетчик может быть объявлен как обычный и как ранний при условии, что процедура слияния конфигураций уровня локейшнов не выявит разночтений ни в одном случае — флаг признака раннего счетчика хранится в метаинформации счетчика наряду с операцией и значением и не должен изменяться при слиянии счетчиков. И, наконец, к вопросу о реализации разделения счетчиков между виртуальными серверами. Помимо конфигурации уровня локейшнов, nginx позволяет модулям использовать конфигурации уровня серверов (server configuration) и основную конфигурацию (main configuration). Для организации общего доступа к единому набору счетчиков из разных серверов, можно организовать массив таких наборов в основной конфигурации, а в конфигурациях уровня серверов разместить ссылку на определенный элемент из этого массива, и только в том случае, если данный сервер вообще ссылается на какие-либо счетчики. В качестве меток элементов массива наборов счетчиков в основной конфигурации подойдет любая строковая настройка уровня сервера, которая может разделяться разными виртуальными серверами, например последнее имя сервера или специально созданная для этого директива. Чего здесь не хватает? Представьте, что мы хотим увеличивать счетчик когда приходит запрос POST.
http {
    server {
        listen       8010;
        server_name  main monitored;

        if ($request_method = POST) {
            counter $cnt_post_requests inc;
        }
}
Соблазнительно. Но если бы объявления счетчиков и были разрешены внутри серверных if (а это не так), то это все равно не имело бы никакого смысла, поскольку конфигурации уровня локейшнов не доступны внутри серверных if (см. также мою статью о практической бесполезности серверных if для разработки модулей в таких случаях). Это можно исправить с помощью дополнительной переменной.
http {
    server {
        listen       8010;
        server_name  main monitored;

        if ($request_method = POST) {
            set $inc_post_requests 1;
        }

        counter $cnt_post_requests inc $inc_post_requests;
}
А вспомните предыдущий пример со стеком перенаправлений rewrite, в котором невозможно установить счетчик внутри промежуточных локейшнов, таких как /test/0. Если бы операция счетчика могла ссылаться на переменную, мы могли бы устанавливать значение переменной внутри промежуточного локейшна с помощью директивы set, а объявление счетчика перенести на уровень выше, чтобы оно наследовалось в окончательном локейшне после всех перенаправлений rewrite.
        location /test/rewrite {
            early_counter $ecnt_test_requests inc;
            rewrite ^ /test/0 last;
        }

        counter $cnt_test0_requests inc $inc_test0_requests;

        location /test/0 {
            set $inc_test0_requests 1;
            rewrite ^ /test last;
        }
Ссылка на переменную в качестве значения операции счетчика также полезна при задании сложных условий, с которыми директива if справиться не в состоянии несмотря на богатые возможности языка регулярных выражений (см. мои статьи на эту тему здесь и здесь). Вычисление сложного условия можно произвести в коде универсального высокоуровневого языка, такого как Javascript или Perl, когда переменная-значение операции счетчика связана с соответствующим хэндлером, ссылающимся на этот код. Ввиду богатых перспектив такого подхода, ссылка на переменную-значение операции разрешена в объявлении счетчиков. Это немного усложняет вычисление значения операции, поскольку теперь это значение может вычисляться не только в процедуре слияния счетчиков во время чтения конфигурации, но и во время обработки запроса (назовем это время рантаймом, а переменные-значения рантайм-переменными). Приведу пример. Давайте увеличивать счетчик в случае, если некоторое base64-закодированное значение (например, куки Misc), содержит тэг версии типа v=1. Я буду использовать код на языке Haskell из соответствующего модуля nginx. Вот полная конфигурация.
user                    nobody;
worker_processes        4;

events {
    worker_connections  1024;
}

error_log               /tmp/nginx-test-custom-counters-error.log warn;

http {
    default_type        application/octet-stream;
    sendfile            on;

    access_log          /tmp/nginx-test-custom-counters-access.log;

    # uncomment next line on ghc ambiguous interface error
    #haskell ghc_extra_flags '-hide-package regex-pcre';

    haskell compile standalone /tmp/ngx_haskell.hs '

import Data.ByteString.Base64
import Data.Maybe
import Text.Regex.PCRE

hasVTag = either (const False) (isJust . matchOnce r) . decode
    where r = makeRegex "\\\\bv=\\\\d+\\\\b" :: Regex

NGX_EXPORT_B_Y (hasVTag)

    ';

    server {
        listen          8010;
        server_name     main monitored;

        haskell_run hasVTag $hs_inc_cnt_vtag $cookie_misc;
        counter $cnt_cookie_misc_vtag inc $hs_inc_cnt_vtag;

        location / {
            return 200;
        }
    }

    server {
        listen          8020;
        server_name     monitor;
        counter_set_id  monitored;

        allow 127.0.0.1;
        deny  all;

        location / {
            echo "vtag_reqs = $cnt_cookie_misc_vtag";
        }
    }
}
Обратите внимание на то, что в регулярном выражении, переданном в функцию makeRegex, вместо одного обратного слэша используются четыре. Это связано с тем, что как строки Haskell, так и значения в директивах nginx требуют двойного обратного слэша для интерполяции в одинарный. Протестируем.
curl 'http://127.0.0.1:8020/'
vtag_reqs = 0
curl -b 'Misc=bj0yO3Y9MQ==' 'http://127.0.0.1:8010/'
curl 'http://127.0.0.1:8020/'
vtag_reqs = 1
curl -b 'Misc=bj0yO3Y9MQ=' 'http://127.0.0.1:8010/'
curl 'http://127.0.0.1:8020/'
vtag_reqs = 1
Значение bj0yO3Y9MQ== — это base64-закодированная строка n=2;v=1, то же самое значение без последнего символа = — просто сломанная base64-закодированная строка. Так что работает верно. Перейдем к вопросу о реализации разделяемой памяти для счетчиков. В модуле использован стандартный интерфейс nginx (детали его реализации можно найти в этом прекрасном руководстве), включающий вызов функции ngx_shared_memory_add() с размером выделяемой памяти, равным двум страницам (т.е. 2 раза по 4096 байт в Linux). Это минимальный размер сегмента, необходимый слабовому аллокатору ngx_slab_allocator. Каждый набор счетчиков, связанный с одним виртуальным сервером или группой, получает собственный сегмент памяти. Счетчики хранятся в массиве, поскольку на этапе инициализации разделяемой памяти есть возможность подсчитать их общий размер. Каждый элемент массива имеет размер 8 байт на 64-битной машине, первый элемент хранит общее количество счетчиков — эта информация используется при перезагрузке конфигурации, когда nginx получает сигнал HUP, для возможности восстановления старых значений счетчиков. Если слабовый аллокатор использует целую страницу памяти для своих нужд (я пока так и не выяснил этого), то остается целых 4092 байта на счетчики — этого хватит более чем на 500 элементов на один набор. Старые значения счетчиков восстанавливаются после перезагрузки конфигурации, если директива-флаг counters_survive_reload установлена в значение on на уровне сервера или основной конфигурации. Значения счетчиков не восстанавливаются, если их общее количество в наборе изменилось после перезагрузки. Изменение порядка объявления счетчиков в определенном наборе после перезагрузки приведет к тому, что они подхватят значения счетчиков, которые до этого были объявлены на этих позициях. Я не буду приводить исходный код модуля и объяснять значения отдельных строк — надеюсь, что приведенная здесь информация поможет разобраться в нем самостоятельно. Отмечу лишь, что основная, серверная и локейшн-конфигурации модуля соответствуют типам ngx_http_cnt_main_conf_t, ngx_http_cnt_srv_conf_t и ngx_http_cnt_loc_conf_t соответственно, метаинформация счетчиков описана в типе ngx_http_cnt_data_t, а информация, связанная с набором счетчиков — в типе ngx_http_cnt_set_t. В функции ngx_http_cnt_init() происходит инициализация хэндлера фазы NGX_HTTP_REWRITE_PHASE и фильтра заголовков ответа — обе функции вызывают одну и ту же рабочую функцию ngx_http_cnt_update(), в которой происходит изменение значений счетчиков в разделяемой памяти. Функция ngx_http_cnt_get_value() является хэндлером переменной-счетчика и осуществляет доступ к значению в разделяемой памяти.

Комментариев нет:

Отправить комментарий