Contêineres

Uma técnica recente de abstração dos detalhes de uma solução de software é a conteinirização. Esta consiste em rodar softwares dentro de um contêiner, o qual é uma máquina virtual simplificada. Os dados a serem usados são mapeados para dentro do contêiner, e os resultados são gravados fora dele. Desta forma, o processamento é feito nessa máquina virtual simplificada, como se o contêiner fosse mais uma camada “embrulhando” o software a ser usado.

Esta abstração permite que toda a configuração do sistema operacional relacionada ao software, como dependências e bibliotecas, seja feita no contêiner (configuração que pode então ser compartilhada usando algo como git), enquanto que o sistema operacional do grid não precisa ser modificado para rodar o software conteinerizado.

No GridUnesp, suportamos o uso do sistema de conteinerização chamado Apptainer. Para usa-lo, basta fazer o download de uma imagem (ou construí-la) via submissão de job – pois a construção de um contêiner pode levar um tempo considerável e/ou demandar muito processamento. Depois precisa incluir esse contêiner como input no script de submissão de jobs.

Como exemplo, digamos que a usuária Leia quisesse realizar uma simulação com a aplicação científica Deaph Star, a qual tenha sido desenvolvida e testada apenas com bibliotecas da distruibuição Ubuntu 20.04-LTS do Linux. A usuária estaria acostumada a processar essa aplicação em seu desktop pessoal e gostaria de submeter simulações usando recursos do GridUnesp. Contudo, por saber que o cluster funciona com distribuição AlmaLinux (RHEL), a solução seria processar o job usando um contêiner.

Neste caso, Leia teria que criar um contêiner usando comandos do Apptainer. Para isso, teria que realizar o download de uma imagem do Ubuntu e adicionar os pacotes/bibliotecas requisitados na documentação da Death Star similarmente aos que instalaria em seu desktop pessoal. Por fim, submeteria o job ao cluster executando a aplicação científica dentro do contêiner criado.

Atenção

Os exemplos e explicações desta página não visam abarcar todos os casos de utilização de contêineres, mas apenas fazer uma breve introdução de como usá-los no GridUnesp. A Equipe do GridUnesp estará disponível para ajudar no que for possível. Todavia, é importante ter em mente que a criação dos contêineres é de responsabilidade de cada usuária/o, sendo uma ferramenta com o intuito de propiciar ainda mais flexibilidade e autonomia às simulações e análise de dados.

Detalhes sobre criação e uso de contêineres podem ser obtidos na página oficial do Apptainer.

Como fazer o download de uma imagem

Para testes, usamos o contêiner Chlomito disponibilizado na internet. Criamos um job para fazer o download e construir o contêiner (um arquivo com sufixo “.sif”).

#!/bin/bash
#SBATCH -t 24:00:00
#SBATCH --job-name=apptainer

export INPUT="arquivo.txt"
export OUTPUT="chlomito_v1.sif"

job-nanny apptainer pull chlomito_v1.sif docker://songweidocker/chlomito:v1

Usando sbatch, executamos esse script, o qual resulta no arquivo chlomito_v1.sif.

Como usar uma imagem

O script abaixo dá um exemplo de como usar a imagem criada pelo Apptainer.

#!/bin/bash
#SBATCH -t 24:00:00
#SBATCH --job-name=apptainer

export INPUT="chlomito_v1.sif"
export OUTPUT="*"

job-nanny apptainer exec chlomito_v1.sif chlomito --help

O primeiro ponto é que usamos o job-nanny, que como está documentado em Script job-nanny, copia os arquivos do INPUT para o diretório onde o job será executado. Neste caso, copia o contêiner chlomito_v1.sif. Em seguida, o job-nanny executa o apptainer, que, por sua vez, usa o comando exec chlomito_v1.sif chlomito --help. Esse exec informa ao Apptainer para “exec”utar o comando chlomito –help dentro do contêiner chlomito_v1.sif. Finalmente, o comando que usamos foi chlomito --help que apenas mostra a ajuda online desse software. A depender do software usado, muitas opções de linha de comando podem ser necessárias, e elas devem ser adicionadas nesse espaço do script.

Como construir um contêiner para GPU

Para usar contêineres em todo seu potencial, é muito importante poder reconfigurar e construir novos contêineres. Isto é feito, no Apptainer, editando um arquivo de configuração como este abaixo. O script, denominado de gpu.def, mostra exemplo de como criar um contêiner para processar aplicações com GPU.

Bootstrap: docker
From: ubuntu:20.04

%post
    export DEBIAN_FRONTEND=noninteractive
    apt-get update && apt-get install -y \
    openmpi-bin \
    openmpi-common \
    libopenmpi-dev \
    gcc \
    g++ \
    tzdata \
    python3 \
    python3-pip \
    python3-dev \
    build-essential \
    libgl1-mesa-glx \
    libglib2.0.0 \
    git \
    && ln -fs /usr/share/zoneinfo/UTC /etc/localtime \
    && dpkg-reconfigure -f noninteractive tzdata
    pip install --upgrade pip
    pip install tensorflow

%environment
    export PYTHONPATH=/usr/local/lib/python3.10/dist-packages:$PYTHONPATH

Este arquivo especifica que a imagem base a ser utilizada é a imagem Ubuntu 20.04 do repositório Docker Hub. A seção %post instala alguns pacotes no contêiner usando o gerenciador de pacotes e instala o módulo tensorflow do Python 3 por meio do comando pip.

O seguinte script constrói o contêiner ubuntu20gpu.sif utilizando como input o arquivo de configuração “gpu.def”:

#!/bin/bash
#SBATCH -t 24:00:00
#SBATCH --job-name=apptainer

export INPUT="gpu.def"
export OUTPUT="ubuntu20gpu.sif"

job-nanny apptainer build ubuntu20gpu.sif gpu.def

Uma vez concluído com sucesso o processo de construção, o arquivo ubuntu20gpu.sif corresponderá ao contêiner Ubuntu 20.04 com suporte a GPUs. Para usar, basta dar os comandos apptainer run ou apptainer exec:

  • apptainer run: Executa o comando padrão definido no contêiner (runscript)

  • apptainer exec: Executa um comando específico dentro do contêiner

Abaixo temos um exemplo de script para processamento com suporte a GPU usando apptainer run.

Atenção

Importante observar que a opção “–nv” é necessária para suporte a GPU.

#!/bin/bash
#SBATCH -t 24:00:00
#SBATCH --gres=gpu:2
#SBATCH --mem=16G
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1

module load cuda/12.9

export INPUT="ubuntu20gpu.sif script_python.py"
export OUTPUT="*"

# Processa o conteúdo do script 'script_python.py' dentro do conteiner
job-nanney apptainer run --nv ubuntu20gpu.sif script_python.py

Abaixo temos um exemplo de script para processamento com suporte a GPU usando apptainer exec.

#!/bin/bash
#SBATCH -t 24:00:00
#SBATCH --gres=gpu:2  # Solicitar 2 GPUs
#SBATCH --mem=16G
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1

module load cuda/12.9

export INPUT="ubuntu20gpu.sif script_python.py"
export OUTPUT="*"

# Executa o comando 'python3 script_python.py' dentro do conteiner
job-nanney apptainer exec --nv ubuntu20gpu.sif python3 script_python.py

Exemplo de job MPI usando Apptainer

No GridUnesp, sugerimos utilizar a solução híbrida informada na seção Apptainer and MPI applications da página do Apptainer.

Para exemplificar, iremos executar um job com MPI e Apptainer no Slurm. Primeiramente, escrevemos um programa do tipo “Hello World” para MPI (mpitest.c):

#include <mpi.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char** argv) {
    MPI_Init(&argc, &argv);

    int world_rank, world_size;
    char hostname[256];

    MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);
    MPI_Comm_size(MPI_COMM_WORLD, &world_size);
    gethostname(hostname, sizeof(hostname));

    printf("Hello, World, from process %d of %d on host %s\n",
           world_rank, world_size, hostname);

    MPI_Finalize();
    return 0;
}

Para compilar esse programa, iremos criar um contêiner com Ubuntu 22.04 e OpenMPI 4.1.5. Necessitaremos então de um arquivo de configuração (ubuntu-openmpi.def) para construir o contêiner. Eis um exemplo retirado da página do Apptainer:

Bootstrap: docker
From: ubuntu:22.04

%environment
    # Point to OMPI binaries, libraries, man pages
    export OMPI_DIR=/opt/ompi
    export PATH="$OMPI_DIR/bin:$PATH"
    export LD_LIBRARY_PATH="$OMPI_DIR/lib:$LD_LIBRARY_PATH"
    export MANPATH="$OMPI_DIR/share/man:$MANPATH"

%post
    echo "Installing required packages..."
    apt-get update && apt-get install -y wget git bash gcc gfortran g++ make file bzip2

    echo "Installing Open MPI"
    export OMPI_DIR=/opt/ompi
    export OMPI_VERSION=4.1.5
    export OMPI_URL="https://download.open-mpi.org/release/open-mpi/v4.1/openmpi-$OMPI_VERSION.tar.bz2"
    mkdir -p /tmp/ompi
    mkdir -p /opt
    # Download
    cd /tmp/ompi && wget -O openmpi-$OMPI_VERSION.tar.bz2 $OMPI_URL && tar -xjf openmpi-$OMPI_VERSION.tar.bz2
    # Compile and install
    cd /tmp/ompi/openmpi-$OMPI_VERSION && ./configure --prefix=$OMPI_DIR && make -j$(nproc) install

    # Set env variables so we can compile our application
    export PATH=$OMPI_DIR/bin:$PATH
    export LD_LIBRARY_PATH=$OMPI_DIR/lib:$LD_LIBRARY_PATH

Com o exemplo de script abaixo (build_container.sh), a partir do arquivo de configuração acima (ubuntu-openmpi.def), podemos construir o contêiner (ubuntu-openmpi.sif):

#!/bin/bash
#SBATCH --time=24:00:00
#SBATCH --nodes=1
#SBATCH --ntasks=1
#SBATCH --cpus-per-task=4
#SBATCH --mem=8G

export INPUT="ubuntu-openmpi.def"
export OUTPUT="ubuntu-openmpi.sif"

# Construir a imagem do conteiner
echo "Construindo conteiner ubuntu-openmpi.sif..."
job-nanny apptainer build ubuntu-openmpi.sif ubuntu-openmpi.def

O programa em .C precisa ser compilado dentro do contêiner. Por isso, invocamos a linha de comando do contêiner e lá rodamos o comando para compilar o código. Mostramos abaixo um exemplo de script (compile_mpi.slurm) para compilar o programa mpitest.c. Na linha job-nanny apptainer exec $CONTAINER mpicc -o $OUTPUT_BINARY $SOURCE_FILE, o Apptainer executa o comando mpicc para compilar o programa dentro do contêiner.

#!/bin/bash
#SBATCH --job-name=compile_mpi
#SBATCH --output=compile_mpi_%j.out
#SBATCH --error=compile_mpi_%j.err
#SBATCH --time=00:10:00
#SBATCH --nodes=1
#SBATCH --ntasks=1
#SBATCH --cpus-per-task=2
#SBATCH --mem=2G

export INPUT="ubuntu-openmpi.sif mpitest.c"
export OUTPUT="mpitest"

# Definir caminhos
CONTAINER="ubuntu-openmpi.sif"
SOURCE_FILE="mpitest.c"
OUTPUT_BINARY="mpitest"

echo "Compilando $SOURCE_FILE usando o conteiner $CONTAINER"

# Compilar o programa MPI usando o conteiner
job-nanny apptainer exec $CONTAINER mpicc -o $OUTPUT_BINARY $SOURCE_FILE

# Verificar se a compilação foi bem-sucedida
if [ -f "$OUTPUT_BINARY" ]; then
    echo "Compilação bem-sucedida!"
    echo "Arquivo gerado: $OUTPUT_BINARY"

    # Verificar o tipo do arquivo
    echo "Informações do executável:"
    file $OUTPUT_BINARY

    # Testar execução simples (apenas para verificar)
    echo "Testando execução básica..."
    apptainer exec $CONTAINER ./$OUTPUT_BINARY
else
    echo "Erro na compilação!"
    exit 1
fi

Após a compilação do programa mpitest.c, é hora de executar o executável mpitest. Enfim, eis um exemplo de script (run_mpi.slurm) para executar o programa.

#!/bin/bash
#SBATCH --job-name=run_mpi
#SBATCH --output=run_mpi_%j.out
#SBATCH --error=run_mpi_%j.err
#SBATCH --time=00:15:00
#SBATCH --nodes=2              # Número de nós
#SBATCH --ntasks-per-node=4    # Tarefas por nó

# Carregar o modulo do OpenMPI do sistema
module load openmpi/4.1.8

export INPUT="ubuntu-openmpi.sif mpitest"
export OUTPUT="*"

echo "Executando programa MPI com $SLURM_NTASKS processos"
echo "Nós utilizados: $SLURM_JOB_NODELIST"
echo "Tarefas por nó: $SLURM_NTASKS_PER_NODE"

# Executar o programa MPI de dentro do conteiner em conjunto
# com o MPI do sistema.
job-nanny mpirun -np 8 apptainer exec --sharens ubuntu-openmpi.sif ./mpitest

echo "Execução MPI concluída!"

Este job irá executar 8 processos, sendo 4 deles em cada nó. O OpenMPI utilizado é aquele já instalado no contêiner (ubuntu-openmpi.sif) e o executável (mpitest) é processado dentro deste contêiner. O MPI instaldo no contêiner é processado em conjunto com aquele do sistema, carregado via módulo (module load openmpi/4.1.8). Note que a versão do OpenMPI instalada no contêiner, para o exemplo acima, é a 4.1.5, ao passo que a versão do módulo é a 4.1.8. As versões de ambos os OpenMPI devem ser compatíveis, mas é recomendado que sejam as mesmas.

Atenção

Segundo a seção Using –sharens mode da página do Apptainer, no intuito de garantir que não haja conflito relacionado a namespace na paralelização, recomenda-se incluir o parâmetro --sharens logo após o comando exec. Assim: apptainer exec --sharens.

Eis a saída do arquivo de output para o exemplo sugerido:

Executando programa MPI com 8 processos
Nós utilizados: node[045-046]
Tarefas por nó: 4

Hello, World from process 0 of 8 on host node045
Hello, World from process 3 of 8 on host node045
Hello, World from process 1 of 8 on host node045
Hello, World from process 2 of 8 on host node045
Hello, World from process 4 of 8 on host node046
Hello, World from process 5 of 8 on host node046
Hello, World from process 6 of 8 on host node046
Hello, World from process 7 of 8 on host node046

Execução MPI concluída!