2013/03/01

Experiencia de refactorización aplicación de red c/c++ con shunit

El otro día me vi en la necesidad de modificar una aplicación de stress test de un protocolo de red y tuve una excelente experiencia de refactorización que paso a relatar.

Ten en cuenta que el objetivo de este relato es mostrar el espíritu del proceso de refactorización, no el programa en cuestión, que tratándose de una herramienta de stress test bien prodría utilizarse para denegación de servicio. Aunque el protocolo es marginal y carece de importancia, ¿quién sabe que puede ocurrir mañana?

Aplicación original


El código original consiste en un archivo con cinco funciones minúsculas y un main gigante.


Tiene una sección para procesar las opciones, una para crear el payload, una para armar el paquete ip y un loop donde modifica el paquete sin necesidad de rehacerlo y lo envía, tantas veces como se le pida.

La elección de que poner en funciones y que no, parece ser muy laxa. No es conceptual, ya que un par de operaciones inversas se han implementado con una función y con código suelto.

La característica más interesante es que para que los paquetes sean distintos y no sea trivial detectarlos como provenientes de la herramienta, hay unos punteritos al payload que permiten modificarlo y reenviar sin hacer la system call para construir el paquete, con una importante ventaja en la performance, como luego se apreciará.

Este es el pseudo código del programa original:

procesar opciones

crear payload


crear ip packet


tantas veces como se pida

   actualizar payload sin modificar el tamaño del packet
   enviar

limpieza



Pros y contras de la implementación original

+ Muy veloz

+ Simple si estás familiarizado con el protocolo (no era mi caso al comenzar)

- Código difícil de modificar

- Fácilmente detectable

Objetivos

Poder generar tráfico más creible.

Mantener la performance en la medida de lo posible.

Mejorar el código para modificaciones posteriores

Decisiones 


Pude haber optado por intentar comprender bien el protocolo y rescribir en algún lenguaje más sencillo que C o C++. En ese momento no sabía cual era el cuello de botella de la aplicación, asi que decidí no incorporar un elemento extra contra la performance.

Decidí convertir el código a C++ para poder utilizar los contenedores con los que ya estoy familiarizado, que al final no usé. En última instancia la elección fue muy influida por no invertir trabajo previo en comprender y el deseo de usar C++.

Decidí utilizar shunit2 y testear funcionalmente en lugar de utilizar testeo unitario, ya que pese ha haber decidido usar c++, la verdad es que no sabía si iba a terminar haciéndolo en perl. De hecho, de continuar el proyecto, será embebiendo un intérprete de algún lenguaje a determinar.

Primer paso: el test


Lo primero que hice fue un test con shunit2:

#! /bin/bash

testFullDump() {

    ./prg param1 param2 param3 | \

     sed -e "s/\(some headers=\)......../\1xxxxxxxx/" \
        -e "s/\(other header=\)......../\1xxxxxxxx/" \
        > /tmp/shunit.txt
    cmp output/fullDump.txt /tmp/shunit.txt
    result=$?
    assertEquals 0 $result || return
    rm /tmp/shunit.txt
}

. shunit2


O sea:

./prg param1 param2 param3

Ejecutá el programa con tales parámetros

     sed -e "s/\(some headers=\)......../\1xxxxxxxx/" \
         -e "s/\(other header=\)......../\1xxxxxxxx/" \


Que sed reemplace lo modificado por la actualización del payload por "xxxxxxxx" de modo tal que sea igual a mi archivo de referencia, output/fullDump.txt


> /tmp/shunit.txt

Poné en /tmp/shunit.txt la salida del programa

cmp output/fullDump.txt /tmp/shunit.txt

Compará utilizando cmp mi archivo de referencia con la salida del programa

result=$?

Costumbre mía, no confiar en que $? tenga el exit code más alla de la ejecución inmediata.

assertEquals 0 $result

Comprobá que la salida de cmp sea 0, o sea que sean iguales

|| return

Si no eran iguales, interrumpí el test

rm /tmp/shunit.txt

Limpiá

testFullDump() {
   ...
}

. shunit2


Este es el modo de hacer un test con shunit2, primero definir funciones de la forma testXXX y al final ejecutar en este mismo proceso shunit2.

Segundo paso: refactorización


Teniendo esta red de protección procedí a mover algunas secciones de código a funciones, cambiar de gcc a g++, creé clases y finalmente tuve la misma funcionalidad con la que había comenzado, pero en código modularizado.

También incorporé el uso de valgrind, para evitar perder memoria, ya que este programa bien podría ser ejecutado durante mucho tiempo.

Medí la performance antes y después y me dió parecido, así que por ese lado, no había problema.

Tercer paso: crear ip packet dentro del loop


Ahora, la llamada al sistema, que supongo debe ser cara, se hace dentro del loop:

procesar opciones


crear payload


tantas veces como se pida

   actualizar payload
   crear ip packet
   enviar

limpieza


Esto no agrega nada, es sólo un paso intermedio, desde el punto de vista del resultado sigue siendo refactorización. Estoy respetando "baby steps".

La performance se vió seriamente afectada, pero dentro de un rango aceptable.

Cuarto paso: crear payload packet dentro del loop


Ahora el payload es modificado dentro del loop, esto implica un payload de tamaño variable, cosa que en la implementación original no podiamos hacer, ya que hay que recrear el packet:


procesar opciones


tantas veces como se pida

   crear payload

   crear ip packet
   enviar

limpieza


Para crear payload usé unos pocos casos creados a mano, por ahora sólo me interesa que funcione y poder medir la performance. En teoría ya no puede empeorar.

Fin



Todo esto sólo fue el preparativo para poder cumplir el objetivo: crear payloads distintos pero coherentes.

Como me sigo resistiendo a terminar de entender el protocolo, en lugar de hacer generadores, me gusta la idea de generar las payloads a partir de la captura de tráfico legítimo, reduciendo la comprensión a que no se filtre información rastreable al tráfico original y que respeten el protocolo.

De un modo u otro, jamás lo haría en c++. Usaría una arquitectura de plugins con un intérprete de lua, python, perl, lo que sea. Si eso afectara la performance, prototiparía interpretando y al final volvería a c++.

Pero esas ya son otras historias...

Aprendizaje


Con un solo test, avanzando paso a paso y versionando con git, sin necesidad de una inversión inicial de investigación, pude tomar un programa funcionalmente correcto pero apestoso y convertirlo un programa limpio y manejable sin pérdida de performance.

Cuando empecé no sabía nada del protocolo, ni siquiera que existía; cuando terminé con esta etapa me vi obligado a leer algunas RFCs y no me costó nada pues ya estaba bastante familiarizado por haberlo usado.

Algunas métricas

Para medir las lineas usé:

for version in 0 1 2; do
   git checkout PerformanceV${version}
   echo "Version ${version}"
   for type in c h cpp hpp; do
       echo "  Type ${type}"
       echo -n "    Files "
       ls -1 *.$type 2>/dev/null | wc -l
       echo -n "    Lines "
       grep *.$type -ve "^ *$" -ve "^ */" 2>/dev/null | wc-l

   done
done


siendo $EXT hpp, cpp, h o c según el caso. Con el grep quité las lineas en blanco y casi todos los comentarios.


Para medir la performance utilicé un script para tirar ejecuciones simultáneas. Me da un poquito de vergüenza, no he sido muy estricto, ya sé que esta no es la manera de hacer profiling, es sólo una medida aproximada pero al menos tomé la precaución de dejar la velocidad del micro fija.

PROCESSES=$1
REQUESTS=$2

while [ $PROCESSES != 0 ] ; do
    ./run  $REQUESTS &
    PROCESSES=$(( $PROCESSES - 1 ))
done
wait



El  script se invoca:

time ./stresstest.sh $PROCESSES $REQUESTS

Cambiando el branch, recompiliando y con REQUESTS = 1000000

Original
c
Refactorizado
c++
Rediseñado
libnet_build_* en loop
Archivos headers 1 5 7
Archivos código 1 5 7
Lineas headers 71 98 117
Lineas código 349 473 473
1 proceso 1.2 1.4 7.5
2 procesos 2.3 2.5 8.5
3 procesos 1.8 1.9 8.1
4 procesos 3.9 3.7 8.8
5 procesos 4.9 4.9 12.5

Muy llamativa la anomalía con tres procesos, ¿sistema operativo? ¿hardware? ¡quién sabe! Mirá que lo probé varias veces y el comportamiento fué siempre el mismo.

Glosario


Dado que he utilizado algunos términos de modo un tanto "libre", paso a definirlos:

packet: me refiero al datagrama, lo que va a ser enviado a la red, se le ajustan las direcciones y puertos de origen y destino, el protocolo de red y los datos y se hace una llamada al sistema para que lo entienda y pueda enviarlo.

payload: es la carga específica del protocolo en cuestión, lo único en que afecta al packet es en su tamaño.

No hay comentarios:

Publicar un comentario