Prometheus Python Client 多進程問題的解釋與解決

本文描述基於 prometheus-client (0.8.0) 版本。

Client 存儲數據的方法與問題

官方 client 用於存儲數據(不管是什麼 Metric 類型)使用的是一個 ValueClass 對象,默認情況下定義是:

class MutexValue(object):
    """A float protected by a mutex."""

    _multiprocess = False

    def __init__(self, typ, metric_name, name, labelnames, labelvalues, **kwargs):
        self._value = 0.0
        self._lock = Lock()

    def inc(self, amount):
        with self._lock:
            self._value += amount

    def set(self, value):
        with self._lock:
            self._value = value

    def get(self):
        with self._lock:
            return self._value

使用這個對象而不是 float 的目的主要應該是爲了在多線程情況下加鎖。

顯然這個值是不能在多進程場景下共享的,而多進程模式對於 Python 來說又是一種非常常用的模式,因此出現了一個

如何在多進程模式下共享採集的數據?

的問題。每個進程分別採集是解決問題的方案之一,但並不總是好使。

一種情況是如果進程數非常多,重複採集會導致存儲在 Prometheus 服務端的數據量非常大,這些數據的統計維度只有 PID 的區別,這種區別往往沒有實際用處,卻耗費了很多的服務端性能。

另一種情況是在使用類似 Gunicorn 這種 Server 的時候,多子進程對外只暴露一個端口,HTTP Scrap 的採集方式無法生效,因爲你一次只能隨機訪問到其中一個子進程的數據。

官方解決方案

官方給出的解決方案描述在 github.com/prometheus/client_python 可以看到。

具體地說就是爲上面的 MutexValue 提供了一個替代品 MultiProcessValue

def MultiProcessValue(process_identifier=os.getpid):
    """Returns a MmapedValue class based on a process_identifier function.

    The 'process_identifier' function MUST comply with this simple rule:
    when called in simultaneously running processes it MUST return distinct values.

    Using a different function than the default 'os.getpid' is at your own risk.
    """
    files = {}
    values = []
    pid = {'value': process_identifier()}
    # Use a single global lock when in multi-processing mode
    # as we presume this means there is no threading going on.
    # This avoids the need to also have mutexes in __MmapDict.
    lock = Lock()

    class MmapedValue(object):
        """A float protected by a mutex backed by a per-process mmaped file."""

        _multiprocess = True

        def __init__(self, typ, metric_name, name, labelnames, labelvalues, multiprocess_mode='', **kwargs):
            self._params = typ, metric_name, name, labelnames, labelvalues, multiprocess_mode
            with lock:
                self.__check_for_pid_change()
                self.__reset()
                values.append(self)

        def __reset(self):
            typ, metric_name, name, labelnames, labelvalues, multiprocess_mode = self._params
            if typ == 'gauge':
                file_prefix = typ + '_' + multiprocess_mode
            else:
                file_prefix = typ
            if file_prefix not in files:
                filename = os.path.join(
                    os.environ['prometheus_multiproc_dir'],
                    '{0}_{1}.db'.format(file_prefix, pid['value']))

                files[file_prefix] = MmapedDict(filename)
            self._file = files[file_prefix]
            self._key = mmap_key(metric_name, name, labelnames, labelvalues)
            self._value = self._file.read_value(self._key)

        def __check_for_pid_change(self):
            actual_pid = process_identifier()
            if pid['value'] != actual_pid:
                pid['value'] = actual_pid
                # There has been a fork(), reset all the values.
                for f in files.values():
                    f.close()
                files.clear()
                for value in values:
                    value.__reset()

        def inc(self, amount):
            with lock:
                self.__check_for_pid_change()
                self._value += amount
                self._file.write_value(self._key, self._value)

        def set(self, value):
            with lock:
                self.__check_for_pid_change()
                self._value = value
                self._file.write_value(self._key, self._value)

        def get(self):
            with lock:
                self.__check_for_pid_change()
                return self._value

    return MmapedValue


def get_value_class():
    # Should we enable multi-process mode?
    # This needs to be chosen before the first metric is constructed,
    # and as that may be in some arbitrary library the user/admin has
    # no control over we use an environment variable.
    if 'prometheus_multiproc_dir' in os.environ:
        return MultiProcessValue()
    else:
        return MutexValue


ValueClass = get_value_class()

簡單解釋一下就是:鎖的部分沒有變,值的存儲從 float 的基礎上又增加了 MmapedDict 的一個 value:

class MmapedDict(object):
    """A dict of doubles, backed by an mmapped file.

    The file starts with a 4 byte int, indicating how much of it is used.
    Then 4 bytes of padding.
    There's then a number of entries, consisting of a 4 byte int which is the
    size of the next field, a utf-8 encoded string key, padding to a 8 byte
    alignment, and then a 8 byte float which is the value.

    Not thread safe.
    """

Mmap 並不關鍵,理解成一種內存值序列化到文件系統的方法即可。更大的一個改變是實例化 ValueClass 時得到的對象。原來得到的每個對象之間都是獨立的,互相不感知。多進程模式下得到的對象都存儲在同一個閉包裏,這些對象還共享一個 MmapedDict 的存儲空間,通過 mmap_key 來區分彼此。

如果去看一下存儲 MMapDict 的目錄,會發現一些這樣的文件:

-rw-r--r--   1 foo  staff  1048576  8  4 17:17 counter_86997.db
-rw-r--r--   1 foo  staff  1048576  8  4 17:23 gauge_all_87328.db
-rw-r--r--   1 foo  staff  1048576  8  4 17:17 histogram_87029.db

這裏每個文件對應一個 MmapDict 對象,對象的每一對 k, v 則對應一個 MmapedValue 實例。

這種操作就像在 redis 裏創建一個 Hash 對象,key 是 histogram_{pid},裏面的鍵值對是:

django_http_requests_latency_seconds{le="1.0",method="GET",view="xxx.views.Metrics"} = 12.0
...

可以看到,在 observe 的時候,每個進程仍然是各採各的。MultiProcessValue 的功能僅僅是把數據序列化到了一個文件裏。多進程數據的真實合併操作發生在 collect 的時候:

class MultiProcessCollector(object):
    def collect(self):
        files = glob.glob(os.path.join(self._path, '*.db'))
        return self.merge(files, accumulate=True)

collect 方法掃描了文件目錄,並把所有數據合併起來。

官方方案存在的問題

1. 存儲文件性能差

大概是爲了繞過多進程寫文件鎖的問題,官方方案選擇讓每個進程寫一組專屬的文件,並將聚合後置。但是相同數量的數據,在 tag 分佈均勻的情況下,存儲 N 個文件的數據量是單個文件的 N 倍,這會帶來磁盤 IO 和 CPU 性能的額外開銷,而且增幅相當可觀。這個問題 Issue 裏也有人提,官方也反饋寫到一個文件裏是個值得一試的主意,但還沒有提上日程。

2. 文件清理不可靠

當進程推出時,文件應該被刪除,否則會造成髒數據和歷史文件的無限堆積。對於文件的清理官方提供了針對 Gunicorn 的配置函數:child_exit。但這個需要清理的場景其實廣泛存在:Celery 需要,Command 需要,自己起的 Shell 也需要。如果官方方案覆蓋不完整,是很難期望用戶自己能清理乾淨的。

一些替代方案的想法

1. DB

DB 方案的一種實現,Redis 方案的實現細節其實上面已經解釋過了。DB 天生適合解決多進程寫數據的問題,可以維護單獨一份數據,甚至還可以把主機維度的 tag 消除掉(如果不需要的話),這可以把存儲性能提到最高,且不需要耗費 CPU。

缺點的話就是增加了對 DB,以至網絡的依賴,降低了系統的 SLA。是否值得取決於具體系統。

Redis 和 SQL 的選擇區別不是很大,關鍵點都是 observe/collect 時候的循環語句問題。儘可能把多個 Value 的操作語句進行合併以提高網絡 IO 效率。比如 observe 一個 histogram 的小值,可能導致多個 Value 對象發生變化。collect 的時候也需要掃描全部數據。

我曾經基於 0.4 版本的 Client 實現過一個 RedisValue(吐槽一下早期版本的代碼質量有點偏低)。Redis 的好處是他的 observe 操作幾乎總是耗費常數時間。但 collect 的時間複雜度是 O(N)。這在數據量變大以後(幾千行)就慢的難以忍受了。後來我通過減少 Bucket 數量和使用 hmget 方法把複雜度降到了 O(LogN),接口響應時間也穩定在了一個可接受的水平,且增長緩慢。如果繼續優化的話,也許還可以利用 Pipeline 把耗時再降低一級。

值得思考的是,Prometheus 改變傳統的監控模式,把實時數據的存儲 client 化,其實一大優勢就是消除了監控數據集中丟失的風險。但本方案又引入了一箇中心化存儲組件,等於把風險又帶了回來... 因此雖然正在用着,我對這個方案還是持懷疑態度。如果改成與服務結伴部署的分佈式數據庫,似乎可以在取得 DB 優勢的情況下避免中心化風險,但維護成本會增高一些,尤其是在容器化場景下。這裏像 SQLite 這樣的無服務進程可能是最折衷的選擇,但我對 SQLite 不瞭解,又擔心它的性能或穩定性不好...

未完待續...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章