Блог
Инженерия Автор
08.04.2021

Its Steal Time!

После того, как администратор узнает про steal time, он может по-настоящему предъявить своему облачному провайдеру за то, что тот крадет процессорное время его виртуалки!

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

А значит это то, что провайдер, мало того, что ворует время у своих же клиентов, он ещё и сам им об этом сообщает. В этой статье я расскажу о том, откуда берется эта метрика, как она считается и как посмотреть её значение на гипервизоре. А также, как провайдер может мониторить стилтайм и можно ли украсть время у других процессов.

Надеюсь, эта статья поможет развеять туман войны и разрушить некоторые заблуждения о стилтайме.

По ходу рассказа я буду нещадно линковать исходники ядра и приводить выдержки исходного кода.

Откуда виртуалка берёт steal time

Чтобы понять откуда же берется метрика, посмотрим исходники ядра. Начнём с утилит, которые показывают нам статистику по утилизации ЦПУ, включая стилтайм. Все эти утилиты берут данные из /proc/stat. В девятой колонке как раз то, что мы ищем:

cpu  1303987570 1816332 517174528 5342230565 3340275 0 235543645 67911003 0 0
cpu0 667533551 881380 288651431 2743891571 2162694 0 694626 29052880 0 0
cpu1 636454019 934951 228523097 2598338993 1177581 0 234849019 38858122 0 0
...

Эта статистика в ядре лежит в той же структуре, что и все остальные данные по ЦПУ: user/system/etc. Нам же интересно, где этот счётчик инкрементируется. Происходит это в функции account_steal_time:

/*
 * Account for involuntary wait time.
 * @cputime: the CPU time spent in involuntary wait
 */
void account_steal_time(u64 cputime)
{
	u64 *cpustat = kcpustat_this_cpu->cpustat;

	cpustat[CPUTIME_STEAL] += cputime;
}

Собственно, пока ничего интересного, потому что эта функция просто обертка над метрикой. Значит, нам нужно понять, откуда она вызывается.

Здесь надо оговориться, что речь идёт только про steal time гипервизора KVM на архитектуре x86-64 в linux. На разных архитектурах могут быть свои приколы.

Вызов функции аккаунтинга происходит из steal_account_process_time():

static __always_inline u64 steal_account_process_time(u64 maxtime)
{
#ifdef CONFIG_PARAVIRT
	if (static_key_false(&paravirt_steal_enabled)) {
		u64 steal;

		steal = paravirt_steal_clock(smp_processor_id());
		steal -= this_rq()->prev_steal_time;
		steal = min(steal, maxtime);
		account_steal_time(steal);
		this_rq()->prev_steal_time += steal;

		return steal;
	}
#endif
	return 0;
}

А вот тут уже интереснее. Под низом paravirt_steal_clock() выполняется макрос, который запускает функцию по указателю time.steal_clock. В нашем случае, там находится функция KVM kvm_steal_clock():

static u64 kvm_steal_clock(int cpu)
{
        u64 steal;
        struct kvm_steal_time *src;
        int version;

        src = &per_cpu(steal_time, cpu);
        do {
                version = src->version;
                virt_rmb();
                steal = src->steal;
                virt_rmb();
        } while ((version & 1) || (version != src->version));

        return steal;
}

Этот небольшой кусок кода говорит о том, что steal считывается просто из памяти (src->steal), из уже проинициализированной структуры. Окей, значит зачем-то этот счетчик изначально хранится отдельно ото всей остальной статистики ЦПУ и попадает в неё уже путём копирования.

Рядом, в том же файле kvm.c, находим функцию регистрации стилтайма:

static void kvm_register_steal_time(void)
{
	int cpu = smp_processor_id();
	struct kvm_steal_time *st = &per_cpu(steal_time, cpu);

	if (!has_steal_clock)
		return;

	wrmsrl(MSR_KVM_STEAL_TIME, (slow_virt_to_phys(st) | KVM_MSR_ENABLED));
	pr_info("stealtime: cpu %d, msr %llx\n", cpu,
		(unsigned long long) slow_virt_to_phys(st));
}

Здесь инициализируется MSR-регистр, названный MSR_KVM_STEAL_TIME.

Документация на него тут.

MSR — Model Specific Register. По-русски будет “машинно-специфичный регистр”. Это не какое-то нововведение KVM, машинно-специфичные регистры есть и у реальных процессоров. А то, какие из них доступны, операционная система может узнать через вызов CPUID. MSR можно читать и писать с помощью msr-tools. Так что, на этом моменте можно смело брать Intel Software Developer’s Manual и начинать развлекаться;).

Проверяем в виртуалке, что код выше действительно выполняется при загрузке:

~# dmesg | grep steal
[    0.000000] kvm-stealtime: cpu 0, msr 3fc23040

Класс! Значит наша виртуалка просто считывает значение MSR, которое на другом конце кода KVM подставляется гипервизором. Происходит это на каждый тик, вместе с инкрементом всей остальной статистики ЦПУ.

Что знает гипервизор

Сейчас мы знаем откуда виртуальная машина читает значение стилтайма. Теперь нужно найти обратный конец. Где-то в гипервизоре должна происходить запись в MSR_KVM_STEAL_TIME.

Гипервизор периодически выполняет запись в некоторые MSR. Классификация запроса на запись в регистр происходит в функции kvm_set_msr_common(), откуда вызывается kvm_make_request() и запрос ставится в очередь vcpu->requests. После чего в vcpu_enter_guest() происходит обработка запроса из очереди. Запускается функция record_steal_time():

static void record_steal_time(struct kvm_vcpu *vcpu)
{
	struct kvm_host_map map;
	struct kvm_steal_time *st;

	if (!(vcpu->arch.st.msr_val & KVM_MSR_ENABLED))
		return;

...

	st = map.hva +
		offset_in_page(vcpu->arch.st.msr_val & KVM_STEAL_VALID_BITS);

...

	if (st->version & 1)
		st->version += 1;  /* first time write, random junk */

	st->version += 1;

	smp_wmb();

	st->steal += current->sched_info.run_delay -
		vcpu->arch.st.last_steal;
	vcpu->arch.st.last_steal = current->sched_info.run_delay;

	smp_wmb();

	st->version += 1;

...
}

Мы видим, что происходит запись в наш регистр. Разумно, но самое интересное, что значение, которое передается как стилтайм — это значение одного из стандартных счетчиков статистики процесса — run_delay:

struct sched_info {
#ifdef CONFIG_SCHED_INFO
	/* Cumulative counters: */

	/* # of times we have run on this CPU: */
	unsigned long			pcount;

	/* Time spent waiting on a runqueue: */
	unsigned long long		run_delay;

	/* Timestamps: */

	/* When did we last run on a CPU? */
	unsigned long long		last_arrival;

	/* When were we last queued to run? */
	unsigned long long		last_queued;

#endif /* CONFIG_SCHED_INFO */
};

Счетчик описан как “Время, потраченное в runqueue”. Чтобы понять, что это значит, нужно совсем чуть-чуть разобраться с состояниями процесса в очереди. Процесс может находится в четырех состояниях. Сначала он встаёт в очередь (enqueue), потом прибывает на ЦПУ (arrives). После того, как он отвыполнялся, он с ЦПУ отбывает (departs) и либо снова встает в очередь, если ему есть что делать, либо сразу же с этой очереди снимается (dequeue). Так вот, run_delay подсчитывается в моменты, когда процесс прибывает на ЦПУ и когда он снимается с очереди. Само значение берётся как now() - last_queued.

Происходит это вот здесь:

/*
 * Called when a task finally hits the CPU.  We can now calculate how
 * long it was waiting to run.  We also note when it began so that we
 * can keep stats on how long its timeslice is.
 */
static void sched_info_arrive(struct rq *rq, struct task_struct *t)
{
	unsigned long long now = rq_clock(rq), delta = 0;

	if (t->sched_info.last_queued)
		delta = now - t->sched_info.last_queued;
	sched_info_reset_dequeued(t);
	t->sched_info.run_delay += delta;
	t->sched_info.last_arrival = now;
	t->sched_info.pcount++;

	rq_sched_info_arrive(rq, delta);
}

И вот здесь:

/*
 * We are interested in knowing how long it was from the *first* time a
 * task was queued to the time that it finally hit a CPU, we call this routine
 * from dequeue_task() to account for possible rq->clock skew across CPUs. The
 * delta taken on each CPU would annul the skew.
 */
static inline void sched_info_dequeued(struct rq *rq, struct task_struct *t)
{
	unsigned long long now = rq_clock(rq), delta = 0;

	if (sched_info_on()) {
		if (t->sched_info.last_queued)
			delta = now - t->sched_info.last_queued;
	}
	sched_info_reset_dequeued(t);
	t->sched_info.run_delay += delta;

	rq_sched_info_dequeued(rq, delta);
}

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

Второй случай касается миграции процесса на другой ЦПУ. Значение last_queued выставляется каждый раз, когда процесс ставится в очередь, а сбрасывается тогда, когда он прибывает на ЦПУ. Соответственно, если у процесса выставлено значение last_queued, значит он ждёт прибытия на ЦПУ, значит ему есть чем занять процессор. Снимать с очереди процесс с выставленным last_queued linux может в том случае, когда происходит миграция этого процесса на другой ЦПУ. То есть, в этом месте учитывается время ожидания в очереди на ЦПУ, на котором процесс мог вообще не выполняться. Здесь под ЦПУ имеется в виду логическое ядро процессора, которое видно в системе как ЦПУ, а миграция — это стандартное перемещение процесса между ядрами.

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

Важно, что метрика касается не только виртуалок, но всех процессов в системе вообще. Посмотреть её значение можно утилитой pidstat, оно будет в колонке %wait:

# pidstat -t 1
20:58:53      UID      TGID       TID    %usr %system  %guest   %wait    %CPU   CPU  Command
20:58:54        0        29        29    0.00    1.00    0.00    0.00    1.00     3  (ksoftirqd/3)__ksoftirqd/3
...
20:58:54        0   1136624         -    0.00    2.00    0.00    0.00    2.00    19  qemu-system-x86
20:58:54        0         -   1136624    0.00    2.00    0.00    0.00    2.00    19  |__qemu-system-x86
20:58:54        0   1182650         -    4.00    0.00    0.00    1.00    4.00    24  qemu-system-x86
20:58:54        0         -   1182650    0.00    5.00    0.00    1.00    5.00    24  |__qemu-system-x86
20:58:54        0   2538896         -    0.00    1.00    0.00    0.00    1.00     7  kworker/7:2
20:58:54     2001   2614918         -    1.00    0.00    0.00    0.00    1.00    21  nova-compute
20:58:54     2001         -   2614918    1.00    0.00    0.00    0.00    1.00    21  |__nova-compute
20:58:54        0   2888693         -    0.00   13.00   39.00    0.00   45.00    34  qemu-system-x86
20:58:54        0         -   2888693    1.00    3.00    0.00    0.00    4.00    34  |__qemu-system-x86
20:58:54        0         -   2888749    1.00    2.00    5.00    0.00    8.00    20  |__CPU 0/KVM
20:58:54        0         -   2888752    0.00    1.00    6.00    0.00    7.00    32  |__CPU 1/KVM
20:58:54        0         -   2888754    0.00    3.00    1.00    1.00    4.00    30  |__CPU 2/KVM
20:58:54        0         -   2888756    0.00    0.00   20.00    0.00    8.00    26  |__CPU 3/KVM
20:58:54        0         -   2888758    3.00    2.00    3.00    0.00    8.00    35  |__CPU 4/KVM
20:58:54        0         -   2888759    0.00    2.00    4.00    0.00    4.00    23  |__CPU 5/KVM
20:58:54        0   2888695         -    9.00   14.00    9.00    0.00   32.00    34  qemu-system-x86
20:58:54        0         -   2888695    1.00    3.00    0.00    0.00    4.00    34  |__qemu-system-x86
20:58:54        0         -   2888710    1.00    0.00    0.00    0.00    1.00     5  |__msgr-worker-2
20:58:54        0         -   2888748    3.00    2.00    3.00    0.00    8.00     6  |__CPU 0/KVM
20:58:54        0         -   2888750    1.00    2.00    2.00    0.00    5.00     5  |__CPU 1/KVM
20:58:54        0         -   2888751    0.00    2.00    1.00    0.00    3.00    10  |__CPU 2/KVM
20:58:54        0         -   2888753    1.00    1.00    2.00    0.00    4.00    13  |__CPU 3/KVM
20:58:54        0         -   2888755    0.00    1.00    1.00    0.00    2.00    28  |__CPU 4/KVM
20:58:54        0         -   2888757    3.00    3.00    1.00    0.00    7.00     3  |__CPU 5/KVM
20:58:54        0   3042453         -    0.00    1.00    0.00    0.00    1.00    27  kworker/u74:0
20:58:54        0         -   3042453    0.00    1.00    0.00    0.00    1.00    27  |__kworker/u74:0
20:58:54        0   3043239         -    5.00    7.00    0.00    0.00   12.00    25  pidstat
20:58:54        0         -   3043239    5.00    7.00    0.00    0.00   12.00    25  |__pidstat
20:58:54        0   3082533         -    0.00   26.00   19.00    0.00   35.00    13  qemu-system-x86
20:58:54        0         -   3082533    0.00    7.00    0.00    0.00    7.00    13  |__qemu-system-x86
20:58:54        0         -   3082539    0.00    1.00    0.00    0.00    1.00     6  |__msgr-worker-0
20:58:54        0         -   3082540    1.00    0.00    0.00    0.00    1.00     6  |__msgr-worker-1
20:58:54        0         -   3082541    0.00    1.00    0.00    0.00    1.00    27  |__msgr-worker-2
20:58:54        0         -   3082555    1.00    0.00    0.00    0.00    1.00    14  |__tp_librbd
20:58:54        0         -   3082559    1.00    2.00   16.00    0.00   19.00    10  |__CPU 0/KVM
20:58:54        0         -   3082560    1.00    1.00    3.00    0.00    5.00     9  |__CPU 1/KVM
20:58:54        0   3108848         -    4.00    0.00    0.00    0.00    4.00    21  qemu-system-x86
20:58:54        0         -   3108848    0.00    4.00    0.00    0.00    4.00    21  |__qemu-system-x86
20:58:54        0         -   3108874    1.00    1.00    0.00    0.00    2.00    28  |__CPU 0/KVM
20:58:54        0   3343378         -    0.00    5.00    0.00    0.00    5.00     6  qemu-system-x86
20:58:54        0         -   3343378    0.00    5.00    0.00    0.00    5.00     6  |__qemu-system-x86

Мой провайдер скрывает стилтайм?

Нет. Но он может, если захочет. Самый простой способ, это отключить сообщение стилтайма виртуальным машинам в настройках гипервизора. Проверить этот случай легко утилитой cpuid:

# cpuid | grep steal
      steal clock supported                   = true

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

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

Стилтайм у виртуальных машин может быть постоянно. Единицы процентов — это не критично. Особенно, если виртуалка с большим количеством ядер, активно их использует, то вероятность периодического ожидания процессора многократно выше, ведь нужно понимать, что ваши виртуальные ЦПУ — это не единственные процессы на гипервизоре.

Есть и забавный момент, когда клиент может сам у себя украсть процессорное время. И речь даже не про то, что оба его виртуальных ядра под нагрузкой попадут на одно ядро хоста. У виртуальной машины qemu есть дополнительные треды для обслуживания, например, дисковых операций. Стилтайм может появиться из-за конкуренции одного из этих тредов с виртуальным ядром.

Избавиться от стилтайма с точки зрения пользователя облака можно только одним способом — форсировать миграцию виртуалки на другой хост. Проблема в том, что провайдеры не дают доступа к такому апи, т.к. контролировать такие процессы сложно, а клиентам это не так часто нужно. Так что, наиболее простой вариант — пересоздать виртуальную машину. С большой вероятностью она будет создана на другом хосте виртуализации и стилтайма не будет. Другой вариант — сделать ресайз машинки, он тоже вызывает миграцию вм, фактически её пересоздание, но это может работать не с любым бэкендом облака.

Я провайдер, что делать?

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

Это, как минимум, удобно. Каждое обращение клиентов по стилтайму можно проверить. А после каждого случая, когда что-то пошло не так, можно оценить насколько действительно клиенты пострадали, и какие клиенты пострадали больше всего. Ведь, как мы видим, если в момент роста нагрузки на ЦПУ гипервизора виртуалке процессор был ненужен, то она и не пострадала. Нет нужды в ЦПУ — нет стилтайма.

А ещё, на основании этих метрик можно автоматизировать работу балансировщика. Классическое наблюдение за утилизацией ЦПУ может не показать настоящей картины. Например, пара крупных клиентов могут активно использовать ЦПУ, при этом не конкурируя между собой. В то же время несколько небольших виртуалок висят в фоне на том же хосте. Утилизация вроде бы и высокая, допустим, 80%. Виртуальных ядер больше, чем реальных. Но стилтайма нет, потому что нет конкуренции за ядра. Если на основании появившегося стилтайма балансировщик будет сразу размигрировать машинки в облаке, то для большинства клиентов это будет приемлемая ситуация.

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

В итоге-то что?

В итоге то, что стилтайм существует. Он действительно отражает процент процессорного времени, который виртуалка недополучила. Недополучить она его может только в том случае, когда она действительно пыталась выполняться на ЦПУ, но, из-за конкуренции за этот ресурс, пришлось подождать. Сообщает его виртуалке сам гипервизор. При этом на гипервизоре стилтайм это не отдельная метрика виртуальной машины, а стандартная метрика любого процесса в Linux.

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

  1. Появление steal time в kvm: KVM steal time implementation.
  2. Стилтайм в libvirt 7.2.0: qemu: add per-vcpu delay stats.

₽ с или .