Poprzez Onet Pocztę, która jest jednym z największych systemów poczty elektronicznej w Polsce, Onet świadczy usługę przeszło czterem milionom użytkowników. Stworzenie systemu zdolnego zapewnić obsługę tak dużego wolumenu danych wymagało zastosowania dedykowanej architektury, nakierowanej na wydajność, ale także na zapewnienie ciągłości działania, oraz redundancji danych. W tym wpisie chciałbym przybliżyć rozwiązania zastosowane w celu budowy skalowalnych serwisów będących sercem OnetPoczty, opisać sposób w jaki zwielokrotniamy, oraz integrujemy podsystemy Poczty w jeden spójny produkt.

Wyzwania

Poczta Onetu to przede wszystkim przeszło cztery i pół miliona aktywnych użytkowników, na których skrzynki dostarczamy dziennie dwadzieścia milionów wiadomości. Sam klient webowy Onet Poczty generuje w szczycie 80 tys. odwołań na minutę, a do tego doliczyć należy jeszcze równoczesne sesje POP3 i IMAP. Do obsługi wolumenu, liczącego sobie przeszło 1 petabajt danych, wykorzystywane jest 200 serwerów.

Przybliżając problem z którym się mierzymy należy również zaznaczyć że system pocztowy ma kilka możliwych ścieżek dostępu, z których każda ma własną charakterystykę, a co za tym idzie wymaga zastosowania specyficznych rozwiązań, czy nawet odmiennych technologii.

Storage

Model zapisu skrzynek pocztowych, oraz samych wiadomości w Onet Poczcie zakłada podział danych na część logiczną, gdzie przechowywane są metadane wiadomości email oraz struktura skrzynek, wraz z folderami, czy ustawieniami kont, oraz na magazyn plików, gdzie przechowywane są same wiadomości, znajdujące się na skrzynkach.

Struktura wiadomości w skrzynce oraz metadane o wiadomościach przechowywane są w relacyjnych bazach danych, co umożliwia wydajne pobieranie informacji o ilości nieprzeczytanych wiadomości, liście wiadomości w folderach oraz innych metadanych, szczególnie tych istotnych przy operacjach grupowych na skrzynce. Do obsługi obecnej ilości skrzynek wykorzystujemy 150 instancji baz danych, wydzielonych poprzez mechanizm shardingu.

Same wiadomości użytkowników przechowywane są na dedykowanym do tego celu klastrze serwerów, z zapewnioną redundancją danych, oraz tworzoną poza działającym systemem kopią zapasową. Zapis wiadomości na serwerach plikowych odbywa się w sposób losowy, dzięki czemu odczyt wielu wiadomości z jednego konta nie musi odbywać się w sposób szeregowy, gdyż serwer obsługujący daną sesję POP3, czy też żądanie wygenerowane przez klienta WWW może pobierać wiadomości równocześnie z wielu maszyn.

mechanizm-poczta

Opisany powyżej mechanizm oddzielenia metadanych od samych wiadomości do poprawnego działania wymaga warstwy pośredniej, ujednolicającej interfejs systemu za fasadą spójnego API. W tym miejscu stoją jedne z najbardziej kluczowych mechanizmów Onet Poczty, odpowiedzialne za dostęp do informacji o strukturze danych i osobne mechanizmy zapewniające parsowanie i dostęp do treści wiadomości pobranych z zapisanych plików.

Podział systemu

System Onet Poczty składa się z wydzielonych aplikacji, stanowiących funkcjonalnie spójne serwisy działające na osobnych klastrach maszyn wirtualnych, bądź fizycznych. Podejście takie umożliwia proste skalowanie obciążenia części systemu, analizę stanu usługi i wydajności konkretnych podsystemów. Usługi warstwy aplikacyjnej tworzone są w oparciu o środowisko Python, z wykorzystaniem zarówno własnych, tworzonych na miarę serwerów aplikacyjnych oraz dostępnych publicznie rozwiązań takich jak Tornado.

Warstwa aplikacyjna webmaila zbudowana jest w oparciu o asynchroniczne serwery pythonowe, utylizujące podsystemy odpowiedzialne za konkretne zadania, takie jak parsowanie maili czy listowanie zawartości folderów.

webmail-poczta

Ze względu na odmienną specyfikę, oraz szczególnie wysokie wymagania co do wydajności, serwery obsługujące protokoły SMTP oraz POP3/IMAP stworzone są w języku C. Dostęp do serwisów składowych realizowany jest poprzez biblioteki klienckie stworzone w tym właśnie języku.

Serwisy

Kluczowe elementy funkcjonalne systemu OnetPoczty, jak na przykład parsowanie wiadomości, zrealizowane są jako serwisy udostępniające określone API. Dzięki temu podejściu nie musimy powielać pewnych funkcjonalności w wielu systemach, oraz zapewniamy sobie możliwość spójnego wdrażania poprawionych, czy rozszerzonych mechanizmów we wszystkich aplikacjach. Dodatkowo podejście to umożliwia nam badanie obciążenia i łatwe skalowanie wybranych elementów, jeśli tylko zajdzie taka potrzeba.

Przykładem takiego rozwiązania jest nasz serwis parsujący maile. Mechanizm ten, nazwany MParser, oparty o serwer Tornado odpowiedzialny jest za przetwarzanie plików wiadomości na potrzeby ich serwowania do webmaila, wyciągania treści indeksowania, czy też pobieranie pewnych nagłówków wiadomości na potrzeby obsługi protokołu POP3.

Sercem tego mechanizmu jest bardzo wydajny parser wiadomości, wykorzystujący bibliotekę GMime, wyeksportowaną przy użyciu Cythona, a następnie opakowaną w serwis udostępniający funkcje poprzez JSON-RPC. Serwis ten wdrażany jest na serwerach z bezpośrednim dostępem do zapisanych wiadomości, oszczędzając nie tylko czas potrzebny na pobranie danych, ale również łącza sieciowe naszej wewnętrznej infrastruktury.

Opierając MParsera o Pythona zyskaliśmy dostęp do wysoce wydajnych i przetestowanych serwerów, takich jak Tornado, oraz bibliotek odpowiedzialnych za obsługę JSONa. Tworząc opisywane rozwiązanie mogliśmy skupić się na optymalizacji jego działania przez odpowiednie keszowanie zapytań, nie tracąc czasu na implementowanie obsługi wymaganych protokołów. Uzyskaliśmy w ten sposób mechanizm, oszczędzający zasoby i wygodny w użyciu, co pokauje przykładowa odpowiedź o składowe wiadomości:


{
  "result": {
    "subject": "build raw subject",
    "from": "mail@example.com",
    "attch": [],
    "to": "mparser@onet.pl",
    "reply_to": "",
    "text": "test mail text\n",
    "cc": "",
    "charset": "utf8",
    "bcc": "",
    "content_size": 15,
    "date": "Fri, 14 Sep 2012 12:18:12 +0200",
    "size": 291,
  },
  "error": null,
  "jsonrpc": "2.0",
  "id": 1
}

Stworzenie dopasowanych do naszych potrzeb rozwiązań w czystym C zajęłoby wielokrotnie więcej czasu, a niekoniecznie dałoby wymierny zysk w wydajności.

Eksporty

Aby zapewnić spójne API do podsystemów, które byłoby możliwe do użycia zarówno w projektach realizowanych w C jak i mechanizmach Pythonowych potrzebowaliśmy bibliotek klienckich w obu tych językach. Aby uniknąć potrzeby utrzymywania zduplikowanych funkcjonalności w obu środowiskach zdecydowaliśmy się na wykorzystanie mechanizmu eksportów bibliotek w C do Pythona. Użyliśmy do tego celu Cythona, który okazał się bardzo przyjemnym i
wdzięcznym narzędziem.

Obecnie biblioteki dostępowe do poszczególnych podsystemów tworzone są w języku C, tak aby można było użyć ich w serwerach POP3, lub przy dostarczaniu maili przez SMTP, a następnie eksportowane do Pythona i wykorzystywane do budowy warstwy aplikacyjnej webmaila.

Co ważne, eksporty stworzone przy użyciu Cythona pozwalają nie tylko wystawić API projektów z języka C, ale również stworzyć interfejs w pełni obiektowy, dużo bardziej przyjemny w użyciu i zgodny z ideologią Pythona.

Sposób budowy eksportu można przedstawić na przykładzie biblioteki klienckiej do opisanej wcześniej usługi MParser. Biblioteka ta powstać musiała oczywiście w języku C, tak aby mogły z niej korzystać serwery POP3, IMPA, czy aplikacja dostarczająca maile z kolejki – stworzone ze względów wydajnościowych w czystym C. Stworzenie i rozwijanie równoległej biblioteki w Pythonie wymagałoby dodatkowego nakładu pracy i mogłoby prowadzić do niebezpiecznych rozbieżności przy rozwijaniu samego mechanizmu MParser jak i jego interfejsu. Z tych powodów zdecydowaliśmy się na użycie Cythona, którego zastosowanie chciałbym pokazać.

Punktem wyjścia dla naszego eksportu jest oczywiście interfejs biblioteki w samym C, której fragment jest poniżej:


struct mparser_out {
	char *headers;
	char *headers_raw;
	[...]
	char *text;
	char *html;
};
void mparser_out_destroy(struct mparser_out *mparser_out);
[...]
int mparser_fetch_from_file(const struct mparser_server *mparser,
    const struct mparser_fetch_from_file_in* inp,
    struct mparser_out* outp);

Interfejs takiej biblioteki musimy następnie zdeklarować w pliku *.pxd, tak aby struktury i funkcje udostępniane przez naszą bibliotekę możliwe były do użycia w kodzie Pythona:


cdef extern from "mparser.h":
    cdef struct mparser_out:
        char *headers
        char *headers_raw
        [...]
        char *text
        char *html
    void mparser_out_destroy(mparser_out *outp)

    […]

    int mparser_fetch_from_file(mparser_server *mparser,
        mparser_fetch_from_file_in *inp,
        mparser_out *outp)

Mając tak przygotowany opis naszej biblioteki możemy teraz stworzyć obiektowy interfejs w Pythonie, wykorzystujący pod spodem naszą bibliotekę:


from libc.stdlib cimport free, malloc
cimport cnap

cdef class MParser(object):
    def __init__(MParser self, [...]):

    def parse_message_from_file(MParser self, [...]):
        cdef:
            nap_mparser_server server
            nap_mparser_fetch_from_file_in inp
            nap_mparser_out outp
        result = None
        try:
            error = nap_mparser_fetch_from_file(&server, &inp, &outp)
            if error < 0:
                raise MparserFetchError([...])
            result = self._make_dict_from_result(&outp)
        finally:
            nap_mparser_out_destroy(&outp)
        return result

Jak widać, przedstawiony kod nie jest napisany w czystym Pythonie, a w jego rozszerzonej wersji pozwalającej na odnoszenie się do zmiennych i funkcji wprost w języku C. Na podstawie tak przygotowanego pliku *.pyx Cython wygeneruje plik źródłowy w C, który po kompilacji da nam moduł możliwy do zaimportowania i używania w Pythonie. Proces ten definiuje się jako dodatkowe akcje, wykonywane przy budowaniu paczki. Realizowane jest to przy wykorzystaniu rozszerzeń modułu distutils, w pliku setup.py:


from distutils.extension import Extension

ext_modules = [
    Extension("mparser", ["mparser.pyx"],
        include_dirs=['/usr/include'],
        library_dirs=[],
        libraries=['nap'],
        extra_compile_args=['-O2'],
        extra_link_args = ['-g'],
    ),
]

setup(
    name = 'mparser',
    [...]
    cmdclass = {'build_ext': build_ext},
    ext_modules = ext_modules,
    [...]
)

Instalacja tak przygotowanej paczki ogranicza się jedynie do wykorzystania komendy pip install, a generacja niezbędnego kodu i jego kompilacja przeprowadzana jest przez samego Cythona.

Wdrażanie

Wydawać by się mogło że opisany scenariusz rozproszenia systemu i budowania bibliotek wymaga skomplikowanego sposobu wdrażania. W praktyce okazuje się że przy wykorzystaniu narzędzi takich jak PIP, repozytoria PyPI i środowisk Virtualenv jest on bardzo prosty, a za razem dobrze skonfigurowany rozwiązuje problem zależności, wymaganych wersji i pozwala na działanie różnych wersji aplikacji, lub bibliotek na tym samym systemie.

Pomijając kwestię budowania i dystrybucji paczek w C, gdyż te zależne są od wykorzystywanej architektury i dystrybucji systemu operacyjnego, paczki Pythonowe dystrybuowane są z naszego własnego repozytorium PyPI. Również eksporty bibliotek z C dystrybuowane są poprzez repozytorium PyPI, a wykorzystując Cythona możliwe jest ich zbudowanie na platformie docelowej. W takim scenariuszu instalacja nowego mechanizmu na serwerze ogranicza się do stworzenia wirtualnego środowiska i zainstalowania odpowiedniej paczki z aplikacją.

Podsumowanie

Dla zapewnienia wydajności i ciągłości działania systemu Onet Poczty zdecydowaliśmy się na wydzielenie wyspecjalizowanych serwisów. Dało nam to możliwość wzmacniania konkretnych elementów systemu i analizy jego zachowania pod zakładanym obciążeniem. Dzięki przyjętemu modelowi eksportów utrzymanie istniejących rozwiązań jest uproszczone i pozbawione zagrożeń wynikających z rozbieżności pomiędzy implementacją mechanizmów działających w różnych środowiskach. W tym modelu możemy „płynnie” opakowywać biblioteki niskopoziomowe w C serwisami w Pythonie, aby następnie przez wydzielone API korzystać z nich w aplikacjach niezależnie od technologii w której zostały stworzone.

Tak stworzony system może się pochwalić uptimem na poziomie 99,80% w skali roku przy zapewnieniu średniego czasu dostarczania wiadomości w obrębie systemu poniżej 1s.

Prezentacja naszego systemu pocztowego przedstawiona była na konferencji PyConPL 2012, zapraszam do zapozna się z nią pod adresem
https://prezi.com/qzr90hjtyibq/onetpoczta/
. Polecam również pozostałe prezentacje z tej właśnie konferencji, można je znaleźć na stronie
http://pl.pycon.org/2012/materialy
.

Igor Waligóra
kierownik w dziale IT