Как стать автором
Обновить
0
НПО Криста
Мы ж программисты, кодим для страны, греем сервера

Управление Java Flight Recorder

Время на прочтение15 мин
Количество просмотров38K


Не так давно в мире Java случилось грандиозное событие. Во всех актуальных версиях OpenJDK стал доступен Java Flight Recorder (или просто JFR).


Что такое Java Flight Recorder?

JFR – это механизм легковесного профилирования Java-приложения. Он позволяет записывать и в последствии анализировать огромное количество метрик и событий, происходящих внутри JVM, что значительно облегчает анализ проблем. Более того, при определённых настройках его накладные расходы настолько малы, что многие (включая Oracle) рекомендуют держать его постоянно включённым везде, в том числе прод, чтобы в случае возникновения проблем сразу иметь полную картину происходившего с приложением. Просто мечта любого SRE!


Раньше этот механизм был доступен только в коммерческих версиях Java от корпорации Oracle версии 8 и более ранних. В какой-то момент его реимплементировали с нуля в OpenJDK 12, затем бекпортировали в OpenJDK 11, которая является LTS-версией. Однако вот OpenJDK 8 оставалась за бортом этого праздника жизни. Вплоть до выхода апдейта 8u272, в который наконец-то тоже бекпортировали JFR. Теперь все (за редким исключением) пользователи OpenJDK могут начинать использовать эту функциональность.


Но вот незадача: большая часть документации в интернете относится к старой, коммерческой, версии JFR и во многом не соответствует версии, которая присутствует в OpenJDK. Да и та, что есть, весьма скудная и не способствует пониманию того, как это всё использовать.


В предлагаемой вашему вниманию статье я расскажу, как управлять работой JFR и как его настраивать.


Активация через параметры JVM


Активировать JFR можно несколькими путями. Один из них – задать соответствующие параметры при запуске Java-приложения. Для этого необходимо использовать два параметра:


  • -XX:StartFlightRecording (активирует JFR и определяет основные параметры его работы),
  • -XX:FlightRecorderOptions (задаёт дополнительные параметры работы).

Каждая из этих опций может быть дополнена настройками, например:


java -XX:StartFlightRecording=filename=/path/to/record/file.jfr

или


java -XX:StartFlightRecording:filename=/path/to/record/file.jfr

задаёт путь к файлу, куда будет записана вся собранная JFR информация после завершения записи (или остановки приложения).


Опций может быть несколько. В таком случае они разделяются запятыми (без пробелов!).


Посмотрим, какие есть настройки у каждой из этих опций и для чего они нужны.


XX:StartFlightRecording


Посмотреть список опций можно в первоисточнике. В исходном файле OpenJDK. Для OpenJDK 11 это файл src/hotspot/share/jfr/dcmd/jfrDcmds.cpp.


Опция Описание Умолчание
name Имя, по которому можно идентифицировать запись, например: "My Recording". Одновременно может быть запущено несколько разных записей. Чтобы их как-то различать и управлять ими независимо, используется это имя.
settings Файл(ы) с настройками, какие конкретно метрики и как профилировать. Это может быть или предопределённый профиль (см. JRE_HOME/lib/jfr), или путь к своему файлу с настройками. Позже рассмотрим, как сформировать такой файл под себя.
delay Если требуется начать запись не в момент старта приложения, а позже, то можно задать задержку данным параметром. Размерности значения: (s)econds, (m)inutes), (h)ours) или (d)ays. Пример: 5h. 0
duration Промежуток времени, в течение которого будет происходить запись. Размерности значения: (s)econds, (m)inutes, (h)ours или (d)ays. Пример: 300s. 0
filename Файл, куда будет записан результат записи. Этот файл будет записан при завершении записи или при остановке приложения, если установлен флаг dumponexit. Пример: "/home/user/My Recording.jfr".
disk Флаг, обозначающий, что запись должна сохраняться на диске по мере работы. Если этот флаг не установлен, то вся записанная информация будет копиться в памяти. false
maxage Максимальное время, в течение которого будут сохраняться записанные данные. Если в ходе записи данные скидываются на диск, то будет производиться ротация записанных файлов. Размерности значения: (s)econds, (m)inutes, (h)ours или (d)ays. Пример: 60m или 0 для безлимитной записи. 0
maxsize Максимальное количество данных, которое будет сохраняться при записи. Если в ходе записи данные скидываются на диск, то будет производиться ротация записанных файлов. Размерности значений: (k)B, (M)B или (G)B. Пример: 500M или 0 для безлимитной записи. 0
dumponexit Флаг, обозначающий необходимость сброса записанных данных на диск при завершении работы JVM. Включая аварийное завершение. false
path-to-gc-roots Если в настройках JFR включена запись информации о живых объектах, то данным флагом можно включить запись ещё и маршрутов по ссылкам до корней сборки мусора. false

XX:FlightRecorderOptions


Посмотреть список опций можно в исходном файле src/hotspot/share/jfr/recorder/service/jfrOptionSet.cpp.


Опция Описание Умолчание
repository Каталог, в который будут по блокам записываться данные JFR, если указана опция disk=true.
threadbuffersize Размер буфера под запись данных JFR, выделяемого для каждого потока. Размерности значений: (k)B, (M)B или (G)B. 8k
memorysize Суммарное количество памяти, используемое JFR для записи данных. 10m
globalbuffersize Размер единичного глобального буфера для записи данных JFR, куда перекладывается информация из локальных буферов потоков. Размерности значений: (k)B, (M)B или (G)B. 512k
numglobalbuffers Количество глобальных буферов для записи данных JFR. 20
maxchunksize Размер одного файлового блока в каталоге, заданном параметром repository. Размерности значений: (k)B, (M)B или (G)B. 12m
old-object-queue-size Максимальное количество наблюдаемых старых объектов. 256
samplethreads Включение / выключение снятия дампов потоков (дампы снимаются, только если соответствующие настройки включены в файле настроек). true
sampleprotection Включение внутренней защиты для хождения по стеку при снятии дампов потоков. false
stackdepth Максимальная глубина снимаемых стектрейсов (минимум 1, максимум 2048) 64
retransform Нужно ли инструментировать классы событий с использованием JVMTI? true

Примеры использования


Минимально возможный пример запуска Java с JFR выглядит так:


java -XX:StartFlightRecording -version

В результате в консоль будет выдано сообщение:


Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 4787 JFR.dump name=1 filename=FILEPATH to copy recording data to file.
openjdk version "1.8.0_275"
OpenJDK Runtime Environment (build 1.8.0_275-b01)
OpenJDK 64-Bit Server VM (build 25.275-b01, mixed mode)

Тут мы видим, что, помимо версии Java, запрашиваемой параметром -version, выведены ещё две строки:


  • сообщение о том, что JFR запущен и с каким идентификатором,
  • подсказка, как получить результаты работы JFR через утилиту jcmd.

В параметрах мы не указали никаких настроек. Т.о., в силу быстрого завершения выполнения (сразу после вывода версии Java) мы не сможем получить результаты в виде файла. Данные JFR будут накапливаться в памяти и пропадут при остановке приложения.


Чтобы это исправить, можно задать дополнительные опции:


java -XX:StartFlightRecording=filename=recording.jfr -version

В результате по окончании работы приложения записанные данные будут сброшены в файл recording.jfr в текущем каталоге.


Вместо имени файла, можно указать имя каталога:


java -XX:StartFlightRecording=filename=./ -version

Тогда имя конкретного файла будет сгенерировано в момент записи, например: hotspot-pid-16330-id-1-2020_11_30_17_16_49.jfr. Это может быть полезно, если запускается много экземпляров JVM и не хочется перетирать данные предыдущих запусков.


Как было отмечено ранее, по умолчанию JFR накапливает все снятые данные в памяти в течение всей работы приложения. Полностью память при этом под записанные данные использоваться не будет. JFR имеет ряд настроек, ограничивающих максимальное использование памяти. Но что делать, если хочется производить запись в течение длительного времени, не забивая при этом память?


В таком случае можно использовать набор настроек, позволяющих сбрасывать записанные данные по частям на диск:


java \
  -XX:StartFlightRecording=disk=true,maxsize=10g,maxage=24h,filename=./recording.jfr \
  -XX:FlightRecorderOptions=repository=./diagnostics/,maxchunksize=50m,stackdepth=1024 \
  -jar application.jar

Прежде всего необходимо установить опцию disk=true, чтобы переключить JFR из режима записи в память в режим записи на диск.


Далее необходимо указать каталог, в который будет производиться запись: repository=./diagnostics/. В этом каталоге начнут появляться файлы с фрагментами записи данных JFR. Размер каждого такого отдельного фрагмента задаётся опцией maxchunksize=50m, но не стоит обманываться. Этот параметр задаёт размер чистых данных JFR, но в каждый файл в конце будет дописываться ещё довольно много служебной информации, что в отдельных случаях может увеличить размер файла в разы. Зато каждый такой файл будет самодостаточным и доступным для независимого изучения.


По умолчанию файлы будут писаться до бесконечности, пока приложение не остановится. Но, если оно будет работать достаточно долго, место на диске может закончиться. Чтобы этого не произошло, можно ограничить количество сохраняемых данных по времени (maxage=24h) или объёму (maxsize=10g). Если один из этих параметров будет превышен, то старые файлы в каталоге ./diagnostics/ начнут стираться.


Ну и последний параметр из приведённого ранее примера — stackdepth=1024. Он необходим для приложений с глубокими стеками вызовов, чтобы стек захватывался полностью.


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


Единственный существенный момент, который остался за скобками, — настройка снимаемых метрик JFR.


Файл конфигурации профиля JFR


По умолчанию с OpenJDK поставляется два преднастроенных профиля:


  • default.jfc (предназначен для мониторинга приложений на проде и дающий пенальти около 1% производительности),
  • profile.jfc (предназначен для профилирования приложений на проде и дающий пенальти около 2% производительности и значительно больший объём снимаемых данных (больший размер jfr файлов).

Оба профиля тщательно подобраны разработчиками OpenJDK и лежат в каталоге JAVA_HOME/jre/lib/jfr для OpenJDK 8 или JAVA_HOME/lib/jfr для OpenJDK 11+.


Активировать их можно с помощью опции, например settings=profile.jfc:


java -XX:StartFlightRecording=settings=profile.jfc -version

Во многих ситуациях этого может оказаться достаточно, но не во всех. Дело в том, что в этих двух профилях остаётся отключённым очень большое количество метрик, которые могут очень помочь в тех или иных ситуациях. Например, в default.jfc практически отключён сбор информации о потреблении памяти, хотя он не даёт заметного пенальти, но при этом весьма полезен для понимания того, куда расходуется память приложения.


Т.о., становится понятно, что для своих частных случаев имеет смысл формировать отдельный, специально заточенный под себя файл с настройками. Но править его вручную – дело неблагодарное. Поэтому рекомендуется воспользоваться инструментом Java Mission Control (JMC), который умеет не только просматривать файлы, записанные JFR, но и управлять профилями записи этих файлов.


Скачать дистрибутив JMC можно, например, с сайта компании BellSoft.


Если мы запустим JMC и выберем пункт меню Window -> Flight Recording Template Manager, то попадём в окно управления настройками профилей записи JFR:




По умолчанию тут будут представлены два профиля, поставляемые с OpenJDK. Но мы можем создать свой профиль, если сначала продублируем имеющийся профиль с помощью кнопки Duplicate, а потом выберем копию и нажмём кнопку Edit:




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


Но если упрощённых настроек не хватает, можно перейти в подробный режим, нажав кнопку Advanced:




Перейдя в такой режим настроек, можно включить дополнительные метрики для записи. Например, можно включить метрики:


  • ObjectAllocationInNewTLAB
  • ObjectAllocationOutsideTLAB

Как показано на скриншоте выше. Значимого пенальти по скорости это не создаёт, зато позволяет оценить, где и чем мусорит приложение в памяти, что часто позволяет находить весьма нетривиальные проблемы.


Немного о метриках TLAB

Важно понимать, как работает сбор данных метрик, чтобы правильно оценивать показываемые ими данные. JFR не пишет информацию о создании каждого объекта. Вместо этого, делается более хитрый ход: JVM не выделяет память напрямую из кучи, а отрезает от последней равные фрагменты и распределяет их по каждому потоку в отдельности. Локальное выделение объектов из таких локальных блоков происходит гораздо быстрее и без лишних блокировок. Так вот, в момент, когда очередной такой блок заканчивается, JFR записывает, при создании объекта какого класса это произошло, а также может записать стек, чтобы понять, в каком месте программы это произошло. Т.о., получается, что нельзя увидеть абсолютно всю информацию о выделяемых объектах, но при достаточно длительной работе приложения можно получить адекватную картину происходящего просто по закону больших чисел.


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


После создания своего профиля работы JFR его можно экспортировать в отдельный файл и использовать в настройках запуска, указав путь к этому файлу:


java -XX:StartFlightRecording=settings=/path/to/custom/profile.jfc -version

Управление JFR через командную строку (jcmd)


Но что делать, если приложение уже работает и надо запустить запись JFR? Или приостановить запись по какой-то причине? Или ещё что-то сделать?


Для этого можно воспользоваться штатной утилитой OpenJDK — jcmd. Например, запустить JFR можно следующей командой:


jcmd <pid> JFR.start duration=60s filename=myrecording.jfr settings=/path/to/custom/profile.jfc

Где <pid> — PID процесса JVM, в котором необходимо запустить JFR. В ответ будет выдана информация о том, что JFR запущено и куда именно будет записан файл с результатом:


19380:
Started recording 2. The result will be written to:

/media/data/java/OpenJDK/lmc7.1.1/myrecording.jfr

Данная команда запускает запись JFR на 60 секунд и указывает на то, чтобы записать результат в файл myrecording.jfr. Причём при таком указании файл будет записан по пути, локальному относительно не каталога запуска jcmd, а каталога, из которого была запущена JVM, в которой запускается JFR.


В целом видно, что при использовании jcmd доступны все те же самые опции, что и при запуске через параметры JVM, но некоторые опции имеют другие имена, поэтому далее они будут рассмотрены подробнее.


Также в ответе jcmd видна цифра 2. Это имя записи. Если мы в дальнейшем захотим как-то повзаимодействовать с этой записью, нам необходимо будет использовать данное имя. Например, вот так можно остановить запись:


jcmd <pid> JFR.stop name=2

Утилита jcmd может выполнять несколько команд, связанных с JFR. Рассмотрим их по порядку.


JFR.start


Запускает запись JFR с заданным именем, аналогично параметру запуска JVM -XX:StartFlightRecording. Если имя явно не задано, оно генерируется автоматически и выводится в консоль.


Можно использовать следующие опции:


Опция Описание Умолчание
name Имя, которое может использоваться для идентификации запускаемой записи, например: "My Recording"
settings Имя или путь к конфигурации профиля записи. Имя может быть profile или default. См. JRE_HOME/lib/jfr
delay Задержка перед началом записи. Размерности значения: (s)econds, (m)inutes), (h)ours) или (d)ays, например: 5h. 0
duration Продолжительность записи. Размерности значения: (s)econds, (m)inutes, (h)ours или (d)ays, например: 300s. 0
filename Файл, куда следует внести результат записи, например: "/home/user/My Recording.jfr".
disk Запись должна вестись на диск, а не в памяти.
maxage Максимальный промежуток времени, в течение которого будут сохраняться на диске записываемые данные. Размерности значения: (s)econds, (m)inutes, (h)ours или (d)ays, например: 60m или 0 для отсутствия ограничения 0
maxsize Максимальное количество данных, сохраняемых при записи на диск. Размерности значения: (k)B, (M)B или (G)B, например: 500M или 0 для отсутствия ограничения. 0
dumponexit Сбросить записанные данные на диск, если JVM прекращает работу.
path-to-gc-roots Сохранять пути до корней GC у объектов. false

JFR.configure


Настраивает запись JFR аналогично параметру -XX:FlightRecorderOptions. Данная команда должна выполняться перед JFR.start, чтобы изменения вступили в силу.


Опция Описание Умолчание
repositorypath Путь к каталогу репозитория, куда будет производиться запись данных JFR по мере работы, например, "/path/to/repository". Имеет смысл, если при старте будет указана опция disk=true.
dumppath Путь, куда будет сброшен результат записи при завершении работы JVM. Включая аварийное завершение.
stackdepth Глубина захватываемого стека при записи стектрейсов. 64
globalbuffercount Количество глобальных буферов для записи данных JFR. 20
globalbuffersize Размер единичного глобального буфера для записи данных JFR, куда перекладывается информация из локальных буферов потоков. Размерности значений: (k)B, (M)B или (G)B. 512k
thread_buffer_size Размер буфера под запись данных JFR, выделяемого для каждого потока. Размерности значений: (k)B, (M)B или (G)B. 8k
memorysize Суммарное количество памяти, используемое JFR для записи данных. 10m
maxchunksize Размер одного файлового блока в каталоге, заданном параметром repository. Размерности значений: (k)B, (M)B или (G)B. 12m
samplethreads Включение / выключение снятия дампов потоков (дампы снимаются, только если соответствующие настройки включены в файле настроек). true

JFR.stop


Останавливает запись и сбрасывает записанные данные в файл, указанный при старте или непосредственно при вызове данной команды.


Опция Описание
name Имя записи, которую необходимо остановить
filename Файл, куда нужно записать результат записи, например: "/home/user/My Recording.jfr".

JFR.dump


Эта команда позволяет получить записанные данные, не останавливая саму запись. Ещё одной отличительной особенностью данной команды от JFR.stop является возможность сохранить не всю запись, а только интересующую её часть.


Опция Описание Умолчание
name Имя записи, от которой необходимо получить данные.
filename Файл, в который необходимо записать выгружаемые данные.
maxage Максимальный промежуток времени, который будет выгружен в файл. Размерности значения: (s)econds, (m)inutes, (h)ours или (d)ays, например: 60m или 0 для отсутствия ограничения. 0
maxsize Максимальное количество данных, которое будет выгружено в файл. Размерности значения: (k)B, (M)B или (G)B, например: 500M или 0 для отсутствия ограничения. 0
begin Значение времени, начиная с которого нужно выгрузить данные записи, например: 09:00, 21:35:00, 2018-06-03T18:12:56.827Z, 2018-06-03T20:13:46.832, -10m, -3h или -1d.
end Значение времени, до которого необходимо выгрузить данные записи, например: 09:00, 21:35:00, 2018-06-03T18:12:56.827Z, 2018-06-03T20:13:46.832, -10m, -3h или -1d.
path-to-gc-roots Сохранять пути до корней GC у объектов. false

JFR.check


Выводит информацию об активных записях JFR. Данная команда полезна, чтобы понять, запущена ли уже запись JFR в JVM и требуется ли её запускать или можно смотреть информацию в уже запущенной. А также чтобы убедиться, что записываются интересуемые события (метрики) JFR.


Опция Описание Умолчание
name Имя записи, для которой запрашивается информация. Если не указано, то будет выведена информация для всех активных записей.
verbose Вывести полную информацию по записываемым событиям JFR. false

Примеры использования


Разберём типовой сценарий использования jcmd для работы с JFR.


В качестве подопытного буду использовать запущенный Java Mission Control, который ранее использовался для формирования настроек записи.


Прежде всего посмотрим, не запущена ли уже запись JFR:


jcmd <pid> JFR.check

Получаю результат:


vektor@work$ jcmd 28534 JFR.check
28534:
Recording 1: name=JMC_Default maxsize=100,0MB (running)

Видно, что уже запущена запись с именем JMC_Default.


Допустим, мне необходимо посмотреть, как расходуется куча JVM. Как понять, снимается данная информация в запущенной записи или нет? Для этого можно посмотреть подробную информацию и найти в ней настройки для jdk.ObjectAllocationInNewTLAB и jdk.ObjectAllocationOutsideTLAB. Выполняем:


jcmd <pid> JFR.check name=JMC_Default verbose=true

И получаем результат (сокращено для наглядности):


vektor@work$ jcmd 28534 JFR.check name=JMC_Default verbose=true
28534:
Recording 1: name=JMC_Default maxsize=100,0MB (running)

 Flight Recording (jdk.ActiveRecording)
   [enabled=true]
 Recording Setting (jdk.ActiveSetting)
...
 Allocation in new TLAB (jdk.ObjectAllocationInNewTLAB)
   [enabled=false,stackTrace=true]
 Allocation outside TLAB (jdk.ObjectAllocationOutsideTLAB)
   [enabled=false,stackTrace=true]
...

Видно, что интересующие меня метрики отключены. Но не беда: можно запустить отдельную запись в том же процессе JVM с интересующими меня настройками:


jcmd <pid> JFR.start \
  name=memory_profile \
  duration=10m \
  filename=/path/to/recording.jfr \
  settings=/path/to/memory_profile.jfc

И получим вывод:


vektor@work$ jcmd 28534 JFR.start name=memory_profile duration=10m filename=/path/to/recording.jfr settings=/path/to/memory_profile.jfc
28534:
Started recording 4. The result will be written to:

/path/to/recording.jfr

Видно, что запись успешно запустилась.


Также можно посмотреть список всех записей:


jcmd <pid> JFR.check

И убедиться, что наша запись работает параллельно с ранее запущенной:


vektor@work$ jcmd 28534 JFR.check
28534:
Recording 1: name=JMC_Default maxsize=100,0MB (running)

Recording 4: name=memory_profile duration=10m (running)

Через 10 минут мы получим файл с результатами записи, который готов к открытию и изучению в Java Mission Control.


Но что делать, если есть необходимость включить запись на постоянной основе и иметь возможность анализировать результаты за сутки или более? В таком случае памяти на все записываемые данные может не хватить, а значит, нужно активировать их сброс на диск по мере записи. Для этого можно предварительно выполнить команду:


jcmd 28534 JFR.configure repositorypath=/path/to/disk/cahce maxchunksize=20m

Таким образом мы зададим каталог, куда будет скидываться записываемая информация. Плюс мы устанавливаем размер одного файла. Т.е. данные будут писаться фрагментами по 20 Мб, каждый из которых будет самодостаточным, и его можно будет просматривать в JMC независимо от всех остальных файлов.


В результате выполнения команды увидим следующий ответ:


vektor@work$ jcmd 28534 JFR.configure repositorypath=/path/to/disk/cahce maxchunksize=20m
28534:
Repository path: /path/to/disk/cahce/2020_12_03_16_58_40_28534

Max chunk size: 20,0 MB

Т.е. настройки установлены успешно. Теперь можно запускать непрерывную запись JFR:


jcmd 28534 JFR.start \
  name=continuouse_record \
  disk=true \
  maxage=24h \
  maxsize=10g \
  settings=/path/to/memory_profile.jfc

Самым главным моментом в данном примере является включение записи на диск, вместо хранения данных в памяти. Также ограничиваем максимальное время хранения данных одними сутками, а размер – 10 Гб. Как минимум для того, чтобы диск не переполнился.


После этого в каталоге /path/to/disk/cahce/2020_12_03_16_58_40_28534 начнут появляться файлы с записанными данными. А при превышении ограничений по времени или размеру старые файлы будут стираться.


Теперь, если мы захотим посмотреть на поведение JVM в какой-то момент времени, можно открыть соответствующий файл в JMC. Однако это не слишком удобно, т.к. нас может интересовать отрезок времени, не совпадающий с границами записи в файлах. Для решения этой проблемы мы можем выгрузить именно интересующий нас фрагмент записи:


jcmd 28534 JFR.dump \
  name=continuouse_record \
  begin=-10m \
  filename=/path/to/recording.jfr

В результате получим вывод:


vektor@work$ jcmd 28534 JFR.dump name=continuouse_record begin=-10m filename=/path/to/recording.jfr
28534:
Dumped recording "continuouse_record", 13,1 MB written to:

/path/to/recording.jfr

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


Заключение


В данной статье мы рассмотрели, как можно управлять записью Java Flight Recorder, и актуальный набор параметров для этого.


За скобками осталось ещё очень много интересного, т. к. управлять записью можно ещё и программно. А также добавлять свои типы событий и метрик, а потом анализировать всю собранную информацию в Java Mission Control.


Но это всё уже темы для других статей.


А тем, кого интересует, как использовать JFR для анализа и оптимизации приложения, я рекомендую серию статей от Алексея Рагозина:


Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии2

Публикации

Информация

Сайт
www.krista.ru
Дата регистрации
Дата основания
1992
Численность
1 001–5 000 человек
Местоположение
Россия

Истории