He de reconocer que tengo una debilidad con rust. No es mi lenguaje de programación favorito ni de referencia. Ni si quiera lo uso en mi trabajo diario. Pero siempre que puedo hago alguna pequeña aplicación usándolo. Es un lenguaje de programación que me gusta mucho. Así que he decido crear una buena imagen de rust a base de aplicar buenas prácticas en un Dockerfile
.
Disclaimer: me considero un programador junior de rust. Y mis conocimientos de docker no es que sean de experto. El código que vais a ver a continuación puede no ser el ideal ni el más/mejor optimizado.
Como en toda aplicación de rust, lo primero que necesitaremos es crear el proyecto y a tal fin, recomendamos usar la herramienta cargo
:
cargo new rustainer
Nos movemos al directorio de nuestro proyecto:
cd rustainer
Y ahora editaremos el archivo Cargo.toml
para añadir las dependencias mínimas para crear un proyecto web sencillo:
tokio
: para el tema de asincroníahyper
: para crear un servidor webrouterify
: para crear las rutas y handlers de nuestros endpoints
El archivo Cargo.toml
podría quedar tal que así:
[package]
name = "rustainer"
version = "1.0.0"
authors = ["Fernando Escolar <f.escolar[at]hotmail.com>"]
edition = "2018"
[dependencies]
hyper = "0.14"
tokio = { version = "1.17", features = ["full"] }
routerify = "3.0"
Ahora vamos a desarrollar, en rust y usando estas librerías, una pequeña API que por HTTP devuelva un simple “Hola mundo”. No vamos a entrar en los detalles de implementación, pero aprovechamos para proponer una solución:
use std::{net::SocketAddr, convert::Infallible};
use hyper::{Server, Request, Response, StatusCode, Body};
use routerify::{RouterService, Router, RequestInfo};
async fn home_handler(_: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new(Body::from("Hello world!")))
}
async fn error_handler(err: routerify::RouteError, _: RequestInfo) -> Response<Body> {
eprintln!("{}", err);
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!("Something went wrong: {}", err)))
.unwrap()
}
fn router() -> Router<Body, Infallible> {
Router::builder()
.get("/", home_handler)
.err_handler_with_info(error_handler)
.build()
.unwrap()
}
#[tokio::main]
async fn main() {
let router = router();
let service = RouterService::new(router).unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let server = Server::bind(&addr).serve(service);
println!("Server is running on: {}", addr);
if let Err(err) = server.await {
eprintln!("Server error: {}", err);
}
}
Para comprobar que todo funciona como esperamos usaremos cargo
para compilar y lanzar nuestra aplicación:
cargo run
Y ahora desde otro terminal, podremos consultar el endpoint que hemos expuesto usando el comando curl
:
$ curl http://localhost:3000
Hello world!
La respuesta debería ser ese Hello world!
que vemos.
Ahora vamos a comenzar con el archivo Dockerfile
. La idea es que en el proceso de construcción de la imagen de tipo docker (sí, una imagen OCI) construyamos la aplicación que hemos programado en rust y la ejecutemos en otra imagen lo más simple posible. Siempre que hablamos de imagen simple dentro del ecosistema de docker, nos viene a la cabeza alpine
. Así que usaremos la imagen de rust oficial en su versión alpine3.15
ya que tenemos la intención de ejecutar nuestra aplicación en esa última imagen:
FROM rust:alpine3.15 AS build
Con el fin de crear una imagen lo más compatible posible y para no tener problemas con las dependencias de algunos crates, vamos a instalar musl. Esto es una implementación de la biblioteca estándar de C construida sobre la API base de Linux. Vamos a usar musl porque es ligero, rápido, sencillo, gratuito y cumple con los estándares. En una imagen alpine
solo tenemos que usar el comando apk
:
RUN apk add musl-dev --no-cache
Después crearemos el entorno de trabajo, seleccionaremos un directorio y copiaremos todos los archivos del proyecto:
WORKDIR /src
COPY . .
Para tener los binarios de nuestra aplicación, solo tenemos que ejecutar el comando cargo build
:
RUN cargo build --release
Ahora tendremos que configurar el entorno para ejecutar el binario resultado de la compilación de los pasos anteriores. En este caso, nos hemos decantado por usar una imagen alpine
:
FROM alpine:3.15
Copiaremos los binarios:
WORKDIR /app
COPY --from=build /src/target/release/rustainer .
Y ejecutaremos nuestra aplicación:
ENTRYPOINT [ "./rustainer" ]
Todo esto junto nos daría como resultado un archivo Dockerfile
tal que así:
FROM rust:alpine3.15 AS builder
RUN apk add musl-dev --no-cache
WORKDIR /src
COPY . .
RUN cargo build --release
FROM alpine:3.15
WORKDIR /app
COPY --from=builder /src/target/release/rustainer .
ENTRYPOINT [ "./rustainer" ]
Así que, ya podemos construir nuestra imagen:
docker build -t rustainer .
Y podemos lanzar un contenedor exponiendo el puerto 3000
:
docker run -p 3000:3000 rustainer
Y para comprobar que todo funciona correctamente, recurriremos de nuevo al comando curl
:
$ curl http://localhost:3000
Hello world!
De nuevo, si obtenemos un Hello world!
como resultado, significa que todo ha ido bien.
Añadir .dockerignore
Lo primero que haremos para mejorar la construcción de nuestra imagen es añadir el archivo .dockerignore
que contiene los archivos que no queremos que se incluyan en la construcción de la imagen cuando llamamos al comando COPY
:
.vscode
.gitignore
target
Cargo.lock
Lo importante es excluir la carpeta “target” para evitar llevar a la imagen binarios locales. Y después el archivo “Cargo.lock” para no cerrarnos a nuevas revisiones de los crates que estamos usando.
Realizar descarga de paquetes primero
Existe un crate llamado cargo-chef
. Una herramienta pensada para acelerar la construcción de imágenes tipo docker con rust. Pero nada recomendable para el uso en local.
Instalarla es muy sencillo, solo tendremos que llamar al comando cargo install cargo-chef
. Así que podríamos preparar una imagen basada en alpine
que tenga instaldo msul y esta herramienta:
FROM rust:alpine3.15 AS chef
RUN apk add musl-dev --no-cache && \
cargo install cargo-chef
WORKDIR /src
Después deberíamos lanzar el comando prepare
. Este comando analiza el proyecto actual para determinar el subconjunto mínimo de archivos (Cargo.lock y Cargo.toml) necesarios para construirlo y almacenar en caché las dependencias. Podríamos crear un stage a tal fin:
FROM chef AS planner
COPY . .
RUN cargo chef prepare
Entonces crearemos un nuevo stage para construir el proyecto. Y lo primero que haremos será copiar el resultado de cargo chef prepare
:
FROM chef AS builder
COPY --from=planner /src/recipe.json recipe.json
A continuación, lanzaremos el comando cook
. Esto rehidrata el esqueleto del proyecto usando las dependencias y archivos de configuración generados en el paso anterior.
RUN cargo chef cook
Y una vez tenemos el proyecto preparado, copiaremos el código fuente y lanzaremos la compilación:
COPY . .
RUN cargo build --release
El resultado final podría ser algo así:
FROM rust:alpine3.15 AS chef
RUN apk add musl-dev --no-cache && \
cargo install cargo-chef
WORKDIR /src
FROM chef AS planner
COPY . .
RUN cargo chef prepare
FROM chef AS builder
COPY --from=planner /src/recipe.json recipe.json
RUN cargo chef cook
COPY . .
RUN cargo build --release
FROM alpine:3.15
WORKDIR /app
COPY --from=builder /src/target/release/rustainer .
ENTRYPOINT [ "./rustainer" ]
Indicar al inicio la información importante
Siempre que leo un archivo Dockerfile
espero encontrar al principio cual es la imagen que se va a ejecutar y, en el caso de que sea una Web o una API, cual es el puerto donde se expone la aplicación. Pero en este caso, estamos especificando la imagen que vamos a ejecutar cerca del final. Esto tiene fácil solución, solo tenemos que indicarlo:
FROM alpine:3.15 as runtime
EXPOSE 3000
Y después donde llamemos a la imagen de alpine para ejecutar la aplicación, basta referenciarla como runtime
. El resultado final sería algo así:
FROM alpine:3.15 as runtime
EXPOSE 3000
FROM rust:alpine3.15 AS chef
RUN apk add musl-dev --no-cache && \
cargo install cargo-chef
WORKDIR /src
FROM chef AS planner
COPY . .
RUN cargo chef prepare
FROM chef AS builder
COPY --from=planner /src/recipe.json recipe.json
RUN cargo chef cook
COPY . .
RUN cargo build --release
FROM runtime AS runner
WORKDIR /app
COPY --from=builder /src/target/release/rustainer .
ENTRYPOINT [ "./rustainer" ]
Compilar con root, ejecutar con usuario
Cuando estamos trabajando con imágenes tipo docker es importante el tema de los permisos del usuario que ejecuta la aplicación. Pero en este caso, vamos a realizar una compilación de código y quizá alguna tarea extra, y estos procesos pueden requerir permisos elevados. Para usar este tipo de credenciales bastará con ejecutar este comando:
USER root
Pero cuando queramos ejecutar la aplicación compilada sería interesante usar otro tipo de cuenta de permisos reducidos. Para ello podríamos crear una nueva cuenta llamada webuser
en alpine
:
RUN addgroup -S webuser && adduser -S webuser -G webuser
Y para usar esta cuenta bastaría con indicarlo:
USER webuser
Si aplicamos esta estrategia a nuestro archivo Dockerfile
, tendríamos un resultado parecido al siguiente:
FROM alpine:3.15 as runtime
EXPOSE 3000
FROM rust:alpine3.15 AS chef
RUN apk add musl-dev --no-cache && \
cargo install cargo-chef
USER root
WORKDIR /src
FROM chef AS planner
COPY . .
RUN cargo chef prepare
FROM chef AS builder
COPY --from=planner /src/recipe.json recipe.json
RUN cargo chef cook
COPY . .
RUN cargo build --release
FROM runtime AS runner
WORKDIR /app
RUN addgroup -S webuser && adduser -S webuser -G webuser
COPY --from=builder /src/target/release/rustainer .
USER webuser
ENTRYPOINT [ "./rustainer" ]
Ser explícitos con los comandos
Otra buena práctica con rust y en general con todas las herramientas de línea de comandos es ser explícitos con los parámetros por defecto que queremos usar. Actualmente se usan ciertos parámetros por omisión, pero podría suceder que estos valores por defecto cambiaran y generaran problemas o incertidumbre.
El primer parámetro que podemos especificar es el nombre del archivo resultado de la ejecución de cargo chef prepare
. Este archivo es importante porque luego se consume para realizar el comando cargo chef cook
. Así que es recomendable especificarlo con el argumento:
--recipe-path recipe.json
Otro parámetro importante es la plataforma objetivo de la compilación. Aunque estamos usando musl como dependencias y estamos dentro de una imagen linux, estaría bien especificar qué tipo de binario esperamos obtener:
--target x86_64-unknown-linux-musl
Y, por último, merece la pena indicar qué proyecto es el que tenemos intención de compilar y ejecutar después:
--bin rust-devops
Si añadimos estos parámetros a los comandos de nuestro archivo Dockerfile
, tendríamos:
FROM alpine:3.15 as runtime
EXPOSE 3000
FROM rust:alpine3.15 AS chef
RUN apk add musl-dev --no-cache && \
cargo install cargo-chef
USER root
WORKDIR /src
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /src/recipe.json recipe.json
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin rustainer
FROM runtime AS runner
WORKDIR /app
RUN addgroup -S webuser && adduser -S webuser -G webuser
COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/rustainer .
USER webuser
ENTRYPOINT [ "./rustainer" ]
Es importante remarcar que, al especificar el target
de la compilación, la carpeta donde se almacenará los binarios resultantes cambiará.
Y todo seguirá funcionando a la perfección.
Añadir tests
El último detalle con el que podríamos mejorar el proceso sería añadiendo la ejecución de las pruebas unitarias.
Para definir un test bastará con añadir un archivo llamado “src/lib.rs” con un contenido similar a este:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
Y para ejecutar las pruebas unitarias bastaría con ejecutar el comando cargo test
:
FROM builder AS tests
RUN cargo test --workspace --release
Al construir la imagen, veremos que se ejecutan los tests unitarios y su resultado por el terminal. Deberíamos encontrarnos con algo parecido a esto:
running 1 tests
test result: ok. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Y finalmente, si añadimos este stage a nuestro archivo Dockerfile
, tendríamos:
FROM alpine:3.15 as runtime
EXPOSE 3000
FROM rust:alpine3.15 AS chef
RUN apk add musl-dev --no-cache && \
cargo install cargo-chef
USER root
WORKDIR /src
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /src/recipe.json recipe.json
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin rustainer
FROM builder AS tests
LABEL test=true
RUN cargo test --workspace --release
FROM runtime AS runner
WORKDIR /app
RUN addgroup -S webuser && adduser -S webuser -G webuser
COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/rustainer .
USER webuser
ENTRYPOINT [ "./rustainer" ]
Conclusiones
La verdad es que la generación de imágenes OCI (tipo docker) es todo un mundo. Puedes hacer algo muy sencillo o algo muy complejo. Se puede aprovechar para encapsular muchas operaciones en una sola construcción y luego sacar partido a todo en diferentes contenedores.
En este ejemplo hemos visto algunas buenas prácticas como reutilizar las capas con los paquetes externos. O cómo dividir en diferentes stages las diferentes tareas que ejecutamos durante la construcción de una imagen. También hemos visto como pasar las pruebas unitarias.
Un ejemplo que creemos muy completo y que esperamos que os pueda ayudar vuestro día a día…