Cuando comencé a trabajar en crun, una alternativa runc compatible con Open Container Initiative (OCI) para tiempos de ejecución de contenedores de Linux, en 2017, estaba buscando una forma más rápida de iniciar y detener contenedores. Estoy trabajando para mejorar el tiempo de ejecución de OCI, que es el componente de la pila de OCI que se comunica con el kernel y configura el entorno de tiempo de ejecución del contenedor.
[ Getting started with containers? Check out Deploying containerized applications: A technical overview. ]
El tiempo de ejecución de OCI tiene un tiempo de ejecución muy limitado y su trabajo consiste principalmente en ejecutar una serie de llamadas al sistema que se asignan directamente al archivo de configuración de OCI.
Me sorprendió que una tarea tan trivial llevara tanto tiempo.
Descargo de responsabilidad: utilicé el kernel predeterminado disponible en la instalación de Fedora y todas las bibliotecas que probé. Además de las correcciones descritas en esta publicación de blog, existen otras correcciones que pueden afectar el rendimiento general.
Usé la misma versión de crun utilizada para todas las pruebas a continuación.
Para comparar las pruebas, utilicé súper bien al instalar bienes.
Tabla de Contenidos
Cómo fue en 2017
Para comprobar hasta dónde hemos llegado, debe volver a 2017 (o simplemente instalar una imagen antigua de Fedora). Para las pruebas a continuación, utilicé Fedora 24 basado en Linux kernel 4.5.5.
En una instalación nueva de Fedora 24, construyendo crun desde la rama maestra, observé estos puntos de referencia:
# hyperfine 'crun run foo'
Benchmark 1: 'crun run foo'
Time (mean ± σ): 159.2 ms ± 21.8 ms [User: 43.0 ms, System: 16.3 ms]
Range (min … max): 73.9 ms … 194.9 ms 39 runs
160 milisegundos es mucho, y recuerdo que es similar a lo que observé hace cinco años.
Hice un perfil del tiempo de ejecución de OCI e inmediatamente mostró que la mayor parte del tiempo del usuario se pasaba en libseccomp
compilar seccomp
filtrar.
Para verificar esto, ejecuté una ejecución con la misma configuración pero sin seccomp
contorno:
# hyperfine 'crun run foo'
Benchmark 1: 'crun run foo'
Time (mean ± σ): 139.6 ms ± 20.8 ms [User: 4.1 ms, System: 22.0 ms]
Range (min … max): 61.8 ms … 177.0 ms 47 runs
Tomó una décima parte del tiempo que les tomó a los usuarios anteriores, ¡y el tiempo total también ha mejorado!
Entonces hay dos problemas: 1) el tiempo del sistema es bastante largo, 2) libseccomp
Domina el tiempo del usuario. Necesito resolver los dos.
reducir el tiempo del sistema
Hay muy pocos culpables de la mayor parte del tiempo perdido en el kernel.Primero procesaría la hora del sistema y regresaría seccomp
después.
Crear y destruir espacios de nombres de red
Crear y destruir espacios de nombres de red solía ser costoso.Puedo reproducir el problema usando unshare
herramienta. En Fedora 24, obtengo los siguientes resultados:
# hyperfine 'unshare -n true'
Benchmark 1: 'unshare -n true'
Time (mean ± σ): 47.7 ms ± 51.4 ms [User: 0.6 ms, System: 3.2 ms]
Range (min … max): 0.0 ms … 190.5 ms 365 runs
¡Eso es mucho tiempo!
Traté de arreglarlo en el kernel y sugerí reparar.Florian Westphal reescribió esto como una serie de una mejor manera y lo fusionó con el kernel de Linux:
commit 8c873e2199700c2de7dbd5eedb9d90d5f109462b
Author: Florian Westphal
Date: Fri Dec 1 00:21:04 2017 +0100
netfilter: core: free hooks with call_rcu
Giuseppe Scrivano says:
"SELinux, if enabled, registers for each new network namespace 6
netfilter hooks."
Cost for this is high. With synchronize_net() removed:
"The net benefit on an SMP machine with two cores is that creating a
new network namespace takes -40% of the original time."
This patch replaces synchronize_net+kvfree with call_rcu().
We store rcu_head at the tail of a structure that has no fixed layout,
i.e. we cannot use offsetof() to compute the start of the original
allocation. Thus store this information right after the rcu head.
We could simplify this by just placing the rcu_head at the start
of struct nf_hook_entries. However, this structure is used in
packet processing hotpath, so only place what is needed for that
at the beginning of the struct.
Reported-by: Giuseppe Scrivano
Signed-off-by: Florian Westphal
Signed-off-by: Pablo Neira Ayuso
commit 26888dfd7e7454686b8d3ea9ba5045d5f236e4d7
Author: Florian Westphal
Date: Fri Dec 1 00:21:03 2017 +0100
netfilter: core: remove synchronize_net call if nfqueue is used
since commit 960632ece6949b ("netfilter: convert hook list to an array")
nfqueue no longer stores a pointer to the hook that caused the packet
to be queued. Therefore no extra synchronize_net() call is needed after
dropping the packets enqueued by the old rule blob.
Signed-off-by: Florian Westphal
Signed-off-by: Pablo Neira Ayuso
commit 4e645b47c4f000a503b9c90163ad905786b9bc1d
Author: Florian Westphal
Date: Fri Dec 1 00:21:02 2017 +0100
netfilter: core: make nf_unregister_net_hooks simple wrapper again
This reverts commit d3ad2c17b4047
("netfilter: core: batch nf_unregister_net_hooks synchronize_net calls").
Nothing wrong with it. However, followup patch will delay freeing of hooks
with call_rcu, so all synchronize_net() calls become obsolete and there
is no need anymore for this batching.
This revert causes a temporary performance degradation when destroying
network namespace, but its resolved with the upcoming call_rcu conversion.
Signed-off-by: Florian Westphal
Signed-off-by: Pablo Neira Ayuso
Estos parches son muy diferentes. El tiempo para crear y destruir un espacio de nombres de red se reduce a una cantidad ridícula en un kernel 5.19.15 moderno:
# hyperfine 'unshare -n true'
Benchmark 1: 'unshare -n true'
Time (mean ± σ): 1.5 ms ± 0.5 ms [User: 0.3 ms, System: 1.3 ms]
Range (min … max): 0.8 ms … 6.7 ms 1907 runs
[ Modernize your IT with managed cloud services. ]
cola de montaje
Instalar mqueue
También solía ser una operación relativamente costosa.
En Fedora 24, solía verse así:
# mkdir /tmp/mqueue; hyperfine 'unshare --propagation=private -m mount -t mqueue mqueue /tmp/mqueue'; rmdir /tmp/mqueue
Benchmark 1: 'unshare --propagation=private -m mount -t mqueue mqueue /tmp/mqueue'
Time (mean ± σ): 16.8 ms ± 3.1 ms [User: 2.6 ms, System: 5.0 ms]
Range (min … max): 9.3 ms … 26.8 ms 261 runs
También traté de resolver este problema y se me ocurrió una repararNo fue aceptado, pero a Al Viro se le ocurrió una versión mejor para solucionar este problema:
commit 36735a6a2b5e042db1af956ce4bcc13f3ff99e21
Author: Al Viro
Date: Mon Dec 25 19:43:35 2017 -0500
mqueue: switch to on-demand creation of internal mount
Instead of doing that upon each ipcns creation, we do that the first
time mq_open(2) or mqueue mount is done in an ipcns. What's more,
doing that allows to get rid of mount_ns() use - we can go with
considerably cheaper mount_nodev(), avoiding the loop over all
mqueue superblock instances; ipcns->mq_mnt is used to locate preexisting
instance in O(1) time instead of O(instances) mount_ns() would've
cost us.
Based upon the version by Giuseppe Scrivano ; I've
added handling of userland mqueue mounts (original had been broken in
that area) and added a switch to mount_nodev().
Signed-off-by: Al Viro
Después de este parche, el costo de crear un mqueue
Las monturas también están fuera de línea:
# mkdir /tmp/mqueue; hyperfine 'unshare --propagation=private -m mount -t mqueue mqueue /tmp/mqueue'; rmdir /tmp/mqueue
Benchmark 1: 'unshare --propagation=private -m mount -t mqueue mqueue /tmp/mqueue'
Time (mean ± σ): 0.7 ms ± 0.5 ms [User: 0.5 ms, System: 0.6 ms]
Range (min … max): 0.0 ms … 3.1 ms 772 runs
Crear y destruir espacios de nombres IPC
Dejé de trabajar en los tiempos de inicio de contenedores durante algunos años y comencé de nuevo a principios de 2020. Otro problema es cuando se crea y se destruye el espacio de nombres de IPC.
Puede reproducir el problema del espacio de nombres de la red con unshare
herramienta:
# hyperfine 'unshare -i true'
Benchmark 1: 'unshare -i true'
Time (mean ± σ): 10.9 ms ± 2.1 ms [User: 0.5 ms, System: 1.0 ms]
Range (min … max): 4.2 ms … 17.2 ms 310 runs
Esta vez, la versión del parche que envié fue aceptada aguas arriba:
commit e1eb26fa62d04ec0955432be1aa8722a97cb52e7
Author: Giuseppe Scrivano
Date: Sun Jun 7 21:40:10 2020 -0700
ipc/namespace.c: use a work queue to free_ipc
the reason is to avoid a delay caused by the synchronize_rcu() call in
kern_umount() when the mqueue mount is freed.
the code:
#define _GNU_SOURCE
#include
#include
#include
#include
int main()
{
int i;
for (i = 0; i < 1000; i++)
if (unshare(CLONE_NEWIPC) < 0)
error(EXIT_FAILURE, errno, "unshare");
}
goes from
Command being timed: "./ipc-namespace"
User time (seconds): 0.00
System time (seconds): 0.06
Percent of CPU this job got: 0%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:08.05
to
Command being timed: "./ipc-namespace"
User time (seconds): 0.00
System time (seconds): 0.02
Percent of CPU this job got: 96%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.03
Signed-off-by: Giuseppe Scrivano
Signed-off-by: Andrew Morton
Reviewed-by: Paul E. McKenney
Reviewed-by: Waiman Long
Cc: Davidlohr Bueso
Cc: Manfred Spraul
Link:
Signed-off-by: Linus Torvalds
Con este parche, el tiempo para crear y destruir IPC se ha reducido considerablemente, como se informa en el mensaje de confirmación. En un kernel 5.19.15 moderno, ahora obtengo:
# hyperfine 'unshare -i true'
Benchmark 1: 'unshare -i true'
Time (mean ± σ): 0.1 ms ± 0.2 ms [User: 0.2 ms, System: 0.4 ms]
Range (min … max): 0.0 ms … 1.5 ms 1966 runs
reducir el tiempo del usuario
El tiempo del kernel parece estar bajo control ahora. ¿Qué puedo hacer para reducir el tiempo de usuario?
me enteré antes libseccomp
fue el culpable aquí, así que lo arreglé tan pronto como arreglé IPC en el kernel.
La mayoría de los costos están relacionados con libseccomp
se debe al código de búsqueda de llamada del sistema. Los archivos de configuración de OCI contienen una lista de nombres de llamadas al sistema.Este seccomp_syscall_resolve_name
La llamada de función busca cada llamada del sistema y devuelve el número de llamada del sistema según el nombre de la llamada del sistema.
Libseccomp solía realizar una búsqueda lineal a través de la tabla de llamadas al sistema para cada nombre de llamada al sistema. Por ejemplo, x86_64 se ve así:
/* NOTE: based on Linux v5.4-rc4 */
const struct arch_syscall_def x86_64_syscall_table[] = { \
{ "_llseek", __PNR__llseek },
{ "_newselect", __PNR__newselect },
{ "_sysctl", 156 },
{ "accept", 43 },
{ "accept4", 288 },
{ "access", 21 },
{ "acct", 163 },
.....
};
int x86_64_syscall_resolve_name(const char *name)
{
unsigned int iter;
const struct arch_syscall_def *table = x86_64_syscall_table;
/* XXX - plenty of room for future improvement here */
for (iter = 0; table[iter].name != NULL; iter++) {
if (strcmp(name, table[iter].name) == 0)
return table[iter].num;
}
return __NR_SCMP_ERROR;
}
Establecer seccomp
a través del perfil libseccomp
hay un complejo O(n*metro)Dónde no es el número de llamadas al sistema en el perfil, y Metro es el número de llamadas al sistema conocidas libseccomp
.
Seguí los consejos en los comentarios del código y pasé un tiempo tratando de solucionarlo. En enero de 2020, estaba en un reparar por libseccomp
Use una función hash perfecta para encontrar el nombre de llamada al sistema para resolver el problema.
aquí está libseccomp
reparar:
commit 9b129c41ac1f43d373742697aa2faf6040b9dfab
Author: Giuseppe Scrivano
Date: Thu Jan 23 17:01:39 2020 +0100
arch: use gperf to generate a perfact hash to lookup syscall names
This patch significantly improves the performance of
seccomp_syscall_resolve_name since it replaces the expensive strcmp
for each syscall in the database, with a lookup table.
The complexity for syscall_resolve_num is not changed and it
uses the linear search, that is anyway less expensive than
seccomp_syscall_resolve_name as it uses an index for comparison
instead of doing a string comparison.
On my machine, calling 1000 seccomp_syscall_resolve_name_arch and
seccomp_syscall_resolve_num_arch over the entire syscalls DB passed
from ~0.45 sec to ~0.06s.
PM: After talking with Giuseppe I made a number of additional
changes, some substantial, the highlights include:
* various style tweaks
* .gitignore fixes
* fixed subject line, tweaked the description
* dropped the arch-syscall-validate changes as they were masking
other problems
* extracted the syscalls.csv and file deletions to other patches
to keep this one more focused
* fixed the x86, x32, arm, all the MIPS ABIs, s390, and s390x ABIs as
the syscall offsets were not properly incorporated into this change
* cleaned up the ABI specific headers
* cleaned up generate_syscalls_perf.sh and renamed to
arch-gperf-generate
* fixed problems with automake's file packaging
Signed-off-by: Giuseppe Scrivano
Reviewed-by: Tom Hromatka
[PM: see notes in the "PM" section above]
Signed-off-by: Paul Moore
El parche ha sido fusionado y lanzado.actualmente en construcción seccomp
La complejidad del perfil es superior) y no El número de llamadas al sistema en el archivo de configuración.
Las mejoras son significativas, con suficientes nuevos libseccomp
:
# hyperfine 'crun run foo'
Benchmark 1: 'crun run foo'
Time (mean ± σ): 28.9 ms ± 5.9 ms [User: 16.7 ms, System: 4.5 ms]
Range (min … max): 19.1 ms … 41.6 ms 73 runs
El tiempo de usuario es de solo 16,7 ms.Solía ser más de 40 ms y alrededor de 4 ms cuando seccomp
No utilizado.
Entonces, usar 4.1ms como tiempo de usuario no cuesta seccomp
Tengo:
time_used_by_seccomp_before = 43.0ms - 4.1ms = 38.9ms time_used_by_seccomp_after = 16.7ms - 4.1ms = 12.6ms
¡Más de tres veces más rápido!La búsqueda de llamadas al sistema es solo una parte de la ecuación libseccomp
Hacer. Nuevamente tomó una cantidad considerable de tiempo compilar el filtro BPF.
Compilación de filtros BPF
¿Puedo hacerlo mejor?Este seccomp_export_bpf
la función hace filtro de paso de banda compilación de filtros, que sigue siendo bastante cara.
Una simple observación es que la mayoría de los contenedores reutilizan el mismo seccomp
Análisis repetido, casi no se produjo personalización. Por lo tanto, tiene sentido almacenar en caché el resultado de la compilación y reutilizarlo cuando sea posible.
[ Read Improving Linux container security with seccomp. ]
hay uno Nueva función de ejecución Guarde en caché el resultado de la compilación del filtro BPF. En el momento de escribir este artículo, el parche no se ha fusionado, aunque está casi terminado.
Con esto, el costo de compilar seccomp
El perfil solo se pagará si el filtro BPF generado no está en el caché. Esto es lo que tengo ahora:
# hyperfine 'crun-from-the-future run foo'
Benchmark 1: 'crun-from-the-future run foo'
Time (mean ± σ): 5.6 ms ± 3.0 ms [User: 1.0 ms, System: 4.5 ms]
Range (min … max): 4.2 ms … 26.8 ms 101 runs
En conclusión
Durante cinco años, el tiempo total requerido para crear y destruir un contenedor OCI ha disminuido de casi 160 milisegundos a poco más de 5 milisegundos.
¡Eso es casi una mejora de 30x!
[ Get this complimentary eBook from Red Hat: Managing your Kubernetes clusters for dummies. ]
Este artículo fue adaptado de Acelere su viaje a los contenedores OCI y republicado con permiso.