O blog da AWS

Utilizando runtimes customizados no Amazon ECS

Vinicius Schettino, Engenheiro DevOps, Passei Direto
Rodrigo Martins, Engenheiro DevOps, Passei Direto
Gabriel Bella Martini, Arquiteto de Soluções, AWS Brasil Setor Público
Thiago Pádua, Arquiteto de Soluções, AWS Brasil Setor Público

A PasseiDireto criou uma solução para executores self-hosted do GitHub Actions com o Amazon ECS, fornecendo aos times de engenharia uma plataforma escalável e dinâmica para a execução de centenas de workflows de CI/CD. Contudo, com a crescente adoção da proposta, o modelo Docker-in-a-Docker (DinD) escolhido apresentou algumas limitações durante a execução de dezenas de tarefas em paralelo. Essa limitação era esperada pela abordagem de compartilhar o socket de comunicação Docker entre os containers, com a opção privileged. Para aumentar a segurança e a confiabilidade da solução apresentada, foi disponibilizado o sysbox no cluster Amazon ECS, um runtime Docker alternativo que fornece maior isolamento entre os containers. Através de uma AMI personalizada, as instâncias do cluster podem suportar dezenas de executores autocontidos, cada um com sua instalação Docker independente e prontos para desempenhar todas as funções do GitHub Actions sem interferência externa.

Problemas encontrados

Antes do modelo paralelo, para executar diferentes tipos de testes no código de uma aplicação, era necessário criar um executor e instanciar cada um dos testes dentro de uma mesma tarefa, com etapas sequenciais:

Esse processo tem algumas limitações. Primeiro, o funcionamento sequencial aumenta o tempo de build consideravelmente, especialmente quando existem várias etapas custosas que poderiam ser paralelizadas. Segundo, não é possível usar o construtor matrix do GitHub Actions para lançar várias tarefas parecidas em instâncias diferentes (como por exemplo testes em várias versões do Node ou do Python, como iremos mostrar no final desse artigo). Assim, o objetivo era permitir a execução do seguinte fluxo utilizando os executores self-hosted escaláveis no ECS:

O fluxo acima, apesar de funcionar bem em condições de teste ou com poucas etapas, encontrou problemas na execução em escala. Essa situação começa a emergir com dezenas de usuários ou até mesmo com workflows contendo diversas tarefas em paralelo, como exemplificado na imagem:

Em situações como essa, tarefas começam a falhar aleatoriamente, por falta de disponibilidade do socket de comunicação docker:

Nas próximas seções são detalhados os principais desafios com a abordagem até então utilizada e como é possível utilizar recursos do ECS, AMIs e o EC2 Image Builder para permitir mais escalabilidade e performance nas builds paralelas com o GitHub Actions utilizando executores self-hosted.

Visão geral da solução

Ao criar um cluster ECS, o usuário pode escolher entre dois tipos de recursos computacionais para hospedar tarefas e serviços: instâncias Amazon EC2 ou AWS Fargate, sendo esse último um serviço para containers serverless. As instâncias EC2 contém uma instalação Docker e um ECS Agent, que é um container responsável pela comunicação com o cluster, atribuição de tarefas e controle dos recursos disponíveis. O Docker precisa de um ambiente de runtime, responsável por executar os containers e isolar os recursos necessários de acordo com a especificação da imagem e do usuário. O runtime padrão do Docker é o runC, compatível com o padrão da Open Container Initiative (OCI) e referência para sua definição original. Apesar da sua popularidade e versatilidade, o runC não oferece um isolamento suficiente para execução de containers dentro de containers de forma segura e escalável, abordagem essencial para executores self-hosted do GitHub Actions com todas as funcionalidades utilizando containers. A única abordagem viável requer duas configurações arriscadas: a utilização da configuração privileged durante a execução, que dá ao container poderes administrativos também no hospedeiro e o compartilhamento do caminho /var/lib/docker em vários containers ao mesmo tempo. Além de insegura, essa abordagem não escala bem quando vários containers são disparados em paralelo:

“This means that if you share your /var/lib/docker directory between multiple Docker instances, you’re gonna have a bad time. Of course, it might work, especially during early testing. “Look ma, I can docker run ubuntu!” But try to do something more involved (pull the same image from two different instances…) and watch the world burn.” Jérôme Petazzoni

Com isso, foi necessário buscar uma solução de runtime que permitisse maior isolamento, incluindo o uso de containers dentro de containers sem efeitos colaterais de segurança e impacto na performance/confiabilidade da plataforma. Essa é a proposta do sysbox, que proporciona um ambiente com funcionalidades de isolamento parecidas com máquinas virtuais, com maior separação de recursos e compatibilidade a OCI e, consequentemente, com o runC. Além disso é possível observar maior desempenho na solução quando existe a necessidade de aninhamento de containers, substituindo o mapeamento de volumes padrão do Docker por alternativas mais modernas.

Contudo, o ECS ainda não suporta configurações personalizadas de runtime. Ou seja, não existe uma configuração a nível de Task Definition, Cluster ou Capacity Provider para que o usuário possa escolher diferentes runtimes arbitrariamente de acordo com o perfil das tarefas que precisam ser executadas. A alternativa é criar uma imagem de máquina (AMI) com o sysbox disponível, além das configurações padrão do ECS, como detalhado na imagem:

Apesar de ser possível realizar mudanças nas instâncias EC2 dentro de um cluster ECS a partir das configurações de inicialização, a instalação do sysbox é relativamente demorada e aumentaria o tempo de inicialização dos clusters nos eventos de escalabilidade. Com a abordagem de uma AMI personalizada é possível manter o tempo de inicialização muito próximo da configuração padrão, uma vez que todo o processo de instalação do sysbox e suas dependências ocorre durante a construção da imagem, processo anterior à disponibilização das instâncias. Na próxima seção, são detalhados os passos para a criação da AMI com o AWS Image Builder.

Criando a AMI com Sysbox

A imagem construída através dos passos detalhados aqui está publicamente disponível nesta AMI e é semanalmente atualizada (etapa que também será explicada). A AWS fornece através do serviço Image Builder uma maneira de construir imagens sob demanda ou de forma agendada. A principal entidade desse serviço é a pipeline que pode ser agendada ou manual.

As pipelines contêm 3 outras entidades que merecem destaque:

  • Receita (recipe): define as etapas de instalação e configuração do SO e demais software (onde iremos dar maior enfâse a seguir);
  • Configuração de infraestrutura: define o tipo de instância EC2, volumes EBS, configurações de rede, grupo de segurança, logs e mais;
  • Distribuição: define se o acesso à AMI será público ou privado, a região de distribuição e o padrão de nomenclatura das versões que vão sendo geradas a cada nova execução da pipeline.

Para se configurar a receita da imagem é necessário escolher uma imagem base, seja as fornecidas pela AWS ou através de um AMI ID personalizado. A escolha foi usar uma imagem Linux Ubuntu 20.04 LTS para compatibilidade com todas as funcionalidades do sysbox.

Além da escolha da imagem base, é preciso adicionar a instalação personalizada de software que é separada em componentes reaproveitáveis. É possível utilizar alguns componentes fornecidos pela própria AWS. Na imagem abaixo é possível ver os componentes da nossa receita.

Exceto pela receita, as demais entidades podem ser configuradas direto da criação de pipeline (imagem da esquerda) e posteriormente podem ser gerenciadas de fora da mesma (imagem da direita).

O principal desafio foi criar o componente que instala o sysbox. Isso porque não há um pacote atualizado disponível via yum, apt-get ou similares, e várias dependências precisam ser manualmente instaladas. A definição do componente é data por um arquivo YAML, conforme a seguir:

name: InstallSysboxECSAgent

description: Install Sysbox and ECS Agent

schemaVersion: 1

phases:

- name: build

steps:

- name: InstallDeps

action: ExecuteBash

inputs:

commands:

- id

- sudo -s

- export TZ=UTC

- apt-get update

- |

apt-get -y install\

apt-transport-https \

ca-certificates \

curl \

gnupg-agent \

wget \

jq \

software-properties-common \

git \

make \

dkms

- name: InstallDocker

action: ExecuteBash

inputs:

commands:

- sudo -s

- >-

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -

- >-

add-apt-repository "deb [arch=amd64]

https://download.docker.com/linux/ubuntu $(lsb_release -cs)

stable"

- apt-get update

- apt-get -y install docker-ce docker-ce-cli containerd.io

- usermod -aG docker ubuntu

- name: InstallSysbox

action: ExecuteBash

inputs:

commands:

- sudo -s

- git clone https://github.com/toby63/shiftfs-dkms.git shiftfs-dkms

- cd shiftfs-dkms && make -f Makefile.dkms

- modprobe shiftfs

- lsmod | grep shiftfs

- >-

echo '{"default-runtime": "sysbox-runc", "runtimes": {

"sysbox-runc": { "path": "/usr/local/sbin/sysbox-runc" } } }' | jq

'.' > /etc/docker/daemon.json

- "git config --system url.https://github.com/.insteadOf git@github.com:"

- git clone --recursive git@github.com:nestybox/sysbox.git

- cd sysbox && make sysbox && make install

- service docker restart

- name: InstallECSAgent

action: ExecuteBash

inputs:

commands:

- sudo -s

- >-

sh -c "echo 'net.ipv4.conf.all.route_localnet = 1' >>

/etc/sysctl.conf"

- sysctl -p /etc/sysctl.conf

- >-

echo iptables-persistent iptables-persistent/autosave_v4 boolean

true | debconf-set-selections

- >-

echo iptables-persistent iptables-persistent/autosave_v6 boolean

true | debconf-set-selections

- apt-get -y install iptables-persistent

- >-

iptables -t nat -A PREROUTING -p tcp -d 169.254.170.2 --dport 80

-j DNAT --to-destination 127.0.0.1:51679

- >-

iptables -t nat -A OUTPUT -d 169.254.170.2 -p tcp -m tcp --dport

80 -j REDIRECT --to-ports 51679

- iptables -A INPUT -i eth0 -p tcp --dport 51678 -j DROP

- sh -c 'iptables-save > /etc/iptables/rules.v4'

- mkdir -p /etc/ecs && touch /etc/ecs/ecs.config

- >-

curl -o ecs-agent.tar

https://s3.us-east-2.amazonaws.com/amazon-ecs-agent-us-east-2/ecs-agent-latest.tar

- docker load --input ./ecs-agent.tar

As etapas InstallDeps e InstallDocker são triviais, com a instalação de dependências básicas do sistema e a instalação padrão do Docker. Já no item InstallSysbox, algumas ações importantes são realizadas:

  • Instalação do shiftfs, utilizando as recomendações oficiais, módulo do kernel Linux que possibilita criação de sistemas de arquivo virtuais utilizando user namespaces. Essa é a principal tecnologia que dá suporte ao nível de isolamento que o sysbox oferece. A maior parte dos provedores serviços de nuvem não habilita o shiftfs por padrão nos Ubuntus mais novos.
  • Configuração do Docker deamon para utilizar o sysbox como runtime padrão através da configuração no arquivo /etc/docker/daemon.json. Esse passo precisa necessariamente ser executado antes da instalação do sysbox, evitando assim que o processo de instalação solicite interação do usuário para decidir se o sysbox deve ser o runtime padrão, bloqueando o processo totalmente automático.
  • Compilação e instalação do sysbox, utilizando o guia oficial. O pacote apt-get do sysbox é relativamente desatualizado e por isso a opção pela compilação.

Na etapa InstallECSAgent são executados as instruções da configuração do ECS manuais, sem maiores modificações. Como todos os passos acima são executados em tempo de compilação da imagem, algumas configurações rápidas são necessárias durante a inicialização das instâncias, para garantir que o agente de controle do cluster ECS seja corretamente executado utilizando o runtime runC e as configurações recomendadas:

sudo -s

/usr/local/sbin/sysbox

docker restart

echo ECS_CLUSTER=${cluster.clusterName} | tee /etc/ecs/ecs.config

echo ECS_DATADIR=/data | tee -a /etc/ecs/ecs.config

echo ECS_ENABLE_TASK_IAM_ROLE=true | tee -a /etc/ecs/ecs.config

echo ENABLE_TASK_IAM_ROLE_NETWORK_HOST=true | tee -a /etc/ecs/ecs.config

echo ECS_LOGFILE=/log/ecs-agent.log | tee -a /etc/ecs/ecs.config

echo ECS_AVAILABLE_LOGGING_DRIVERS=[\"json-file\",\"awslogs\"] | tee -a /etc/ecs/ecs.config

echo ECS_LOGLEVEL=info | tee -a /etc/ecs/ecs.config

curl -o ecs-agent.tar https://s3.us-east-2.amazonaws.com/amazon-ecs-agent-us-east-2/ecs-agent-latest.tar

docker load --input ./ecs-agent.tar

docker run --name ecs-agent --privileged --detach=true --restart=on-failure:10 --volume=/var/run:/var/run --volume=/var/log/ecs/:/log:Z --volume=/var/lib/ecs/data:/data:Z --volume=/etc/ecs:/etc/ecs --net=host --userns=host --runtime=runc --env-file=/etc/ecs/ecs.config amazon/amazon-ecs-agent:latest

Os comandos acima podem ser configurados na configuração de execução das instâncias do grupo de autoscaling.

Implantação de exemplo

É possível ilustrar o funcionamento da solução com worfklows do GitHub Actions que envolvem várias etapas em paralelo. Um exemplo comum nos projetos de software é a execução de testes e verificações estáticas em diferentes versões do Node.js. Nesse caso essa etapa é necessária para aprovação de um Pull Request, garatindo que novas modificações não impactem negativamente usuários em nenhuma das versões suportadas do Node.js. Utilizando a action que inicializa um conjunto de executores self-hosted em um cluster ECS, esse fluxo pode ser descrito na sintaxe do GitHub Actions assim:

jobs:

pre-job:

runs-on: ubuntu-latest

steps:

- uses: aws-actions/configure-aws-credentials@v1

with:

aws-access-key-id: ${{ secrets.AWS_ID }}

aws-secret-access-key: ${{ secrets.AWS_KEY }}

aws-region: ${{ secrets.AWS_REGION }}

- name: Provide a self hosted to execute this job

uses: PasseiDireto/gh-runner-task-action@main

with:

github_pat: ${{ secrets.MY_SECRET_TOKEN }}

task_definition: 'gh-runner'

cluster: 'gh-runner'

task_count: 9 # 3 scripts x 3 versions

test:

needs: pre-job

runs-on: self-hosted

strategy:

matrix:

version: [ 13, 14, 15 ]

script: ["lint", "test-unit -- --coverage=true", "test-integration"]

steps:

- uses: actions/checkout@v2

- uses: actions/setup-node@v2

with:

node-version: ${{ matrix.version }}

- name: Perform checks

run: |

npm ci

npm run ${{ matrix.script }}

Nesse caso, três tipos de ação (testes unitários, integração e linting) são executados em três versões diferentes do Node.js. Por isso, 9 executores são criados no cluster ECS:

De acordo com os limites do Capacity Provider do cluster, novas instâncias vão sendo criadas para hospedar a nova carga de trabalho. Aos poucos, os novos executores vão sendo absorvidos e começam a processar as tarefas:

Depois de alguns segundos, todos os executores estão processsando as tarefas especificadas na matrix do GitHub:

Com o término das tarefas, o workflow é concluído com sucesso e o Pull Request pode ser aprovado:

Conclusão e próximos passos

O modelo tradicional com workflows sequenciais apresenta vários desafios à Engenharia de Software Contínua e à cultura DevOps, deixando automações mais lentas. A construção de pipelines paralelas diminui o tempo de espera dos desenvolvedores na aprovação dos Pull Requests e no feedback do impacto de suas mudanças do ponto de vista dos usuários. A solução proposta permite reduções significativas no tempo de execução das pipelines, evitando problemas de escala e isolamento do runtime Docker padrão com a introdução do sysbox no cluster ECS através de uma AMI personalizada.

Com a solução atuando durante dois meses, foi possível reunir alguns aspectos passíveis de melhorias nas próximas versões da AMI e da arquitetura como um todo:

  • Modelo de atualização automática das instâncias do cluster, garantindo que a versão mais nova da AMI seja sempre utilizada. Essa mudança busca disponibilizar melhorias de performance e segurança automaticamente de acordo com o lançamento semanal de novas versões da AMI.
  • Criação de testes para AMIs, garantindo que as novas versões estejam funcionais antes da disponibilização para o público geral.
  • Automação da infraestrutura do imagem builder com CDK, utilizando os contrutores equivalentes ao passo a passo apresentando utilizando o console da AWS.


Sobre os autores

Vinícius Schettino é DevOps Engineer na Passei Direto com mais de 10 anos de experiência em engenharia de software. Focado em Data CI/CD, MLOps, qualidade de software e automação.

Rodrigo Martins é DevOps Engineer na Passei Direto com mais de 10 anos de experiência em redes de computadores e arquitetura de software. Focado em melhorias no processo de CI/CD e segurança da informação.

Gabriel Bella Martini é um Arquiteto de Soluções na AWS com foco em clientes de Educação. Tem experiência em diferentes projetos relacionados a Inteligência Artificial e tem grande interesse na área de computação gráfica.

Thiago Pádua é Arquiteto de Soluções na AWS atua com o desenvolvimento e apoio aos parceiros do Setor Público. Trabalhou anteriormente com desenvolvimento de software e integração de sistemas, principalmente na indústria de Telecomunicações. Tem um interesse especial em arquitetura de microserviços, serverless e containers.