Me encuentro trabajando con un proyecto en Elixir que involucra Nerves para IoT, y una interfaz de usuario en Phoenix que se conecta a una base de datos PostgreSQL. Esperaba que el proyecto en sí fuera relativamente sencillo, pero el siguiente error comenzó a quitarme más tiempo del esperado:

Postgrex.Protocol (#PID<0.1987.0>) failed to connect: ** (DBConnection.ConnectionError) tcp connect (top-secret-database.postgres.database.azure.com:5432): non-existing domain - :nxdomain

Para ponernos en contexto:

  • Estoy utilizando el adaptador que viene por defecto con Ecto, en Phoenix.
  • La base de datos PostgreSQL está alojada en la nube (Azure para este caso en específico, pero el host es irrelevante).
  • No tengo errores al correr el servidor en local (como siempre, “funciona bien en mi máquina”).
  • El deploy lo estoy realizando a una Raspberry Pi 2B, conectada a la red por Ethernet.

Con lo anterior es bastante evidente que estoy teniendo un problema de red con la Raspberry. Por fortuna, Nerves incluye la herramienta Toolshed para acceder a algunos comando básicos que encontraríamos en Unix, directamente desde la terminal de Elixir (que es a la única a la que tenemos acceso en Nerves). Entonces, lo obvio para confirmar que tenemos un error en la conexión es usar el comando tping (el equivalente a ping en Unix):

iex(1)> tping "google.com"
Response from google.com (142.250.81.14): time=7.858ms

Al parecer sí tengo internet, pero por alguna razón Phoenix sigue lanzándome el mismo error por el que no se puede conectar a la base de datos. No entraré en los detalles de cómo me aseguré que no fuera un problema con el firewall del host de la base de datos porque fue irrelevante para la solución del problema. Lo importante es que en este punto, además de lo anterior sabemos que:

  • El error no está del lado de la base de datos.
  • El error no es del lado de Phoenix (y Ecto) pues corre sin problemas en mi máquina.
  • El error no está en Nerves o en la Raspberry, porque puedo hacer ping.

Con lo anterior parece estar todo cubierto, no es fácil identificar dónde está el error. Con algo de ayuda de la comunidad Elixir en Slack, pude notar en dónde está el error.

Sucede que entre el momento en que comienzan a ejecutarse las aplicaciones Elixir y la conexión a la red hay una pequeña ventana de tiempo en que Elixir va a estar offline.

No lo pude identificar con ping porque para el momento en que ejecuté el comando ya se había establecido la conexión a internet. Realizamos un pequeño experimento para comprobar esta hipótesis.

En el archivo lib/proyecto/application.ex modificamos la función start de la siguiente manera:

  def start(_type, _args) do
    children = [ ... ]
    opts = [strategy: :one_for_one, name: Proyecto.Supervisor]

    Process.sleep(10_000)

    Supervisor.start_link(children, opts)
  end

Lo que estoy haciendo es básicamente retrasar el inicio de la aplicación 10 segundos. Como Elixir ejecuta las aplicaciones concurrentemente, esto no va a bloquear el inicio del resto de los servicios (en este caso es relevante que Nerves levante la red).

Y en efecto, como era esperado, Phoenix inicia 10 segundos más tarde sin errores. Ya quedó localizado el problema, falta buscar una forma más limpia de resolverlo pues con proyetos de IoT no puedo asegurar que la red va a estar funcionando después de 10 segundos.

Desde hace poco menos de 1 año Nerves ha estado utilizando la biblioteca VintageNet para manipular todo lo relacionado a red. En este caso podemos ir a la documentación y ver que podemos suscribirnos a los eventos relacionados con ethernet utilizando VintageNet.subscribe(["interface", "eth0"]), utilizando el patrón de diseño PubSub. Podemos usar lo anterior para suscribirnos a los cambios en la red dentro de un GenServer, y en el momento en que recibamos el mensaje de que estamos online, levantamos la aplicación en Phoenix.

No entraré en detalles de la estructura del proyecto (si tienes dudas siéntete libre de escribirme), pero básicamente puse un DynamicSupervisor y un GenServer como hijos del Supervisor principal en el árbol de supervisión. En el GenServer me suscribo a los cambios en red y espero un mensaje avisándome de la conexión a la red:

  def handle_info({VintageNet, ["interface", "eth0", "connection"], _, :internet, _metadata}, state) do
    Proyecto.IniciaPhoenix.start_phoenix()
    {:noreply, state}
  end

Proyecto.IniciaPhoenix corresponde al DynamicSupervisor que se va a encargar de levantar el proyecto en Phoenix. Uso un Supervisor Dinámico porque es el que nos permite agregar hijos al árbol de supervisión en cualquier momento, no necesariamente al comienzo de la ejecución del proyecto.

De esta manera, estamos asegurando que el servidor Phoenix se levante inmediatamente después de estar conectados a internet. Lo anterior se debe realizar para todos los servicios que corramos en Nerves que requieren de conexión a red. Además, es relativamente sencillo cambiar el código anterior para funcionar con redes inalámbricas en vez de ethernet. Solo consiste en modificar el pattern matching "eth0" en la función handle_info.