Skip to main content

10 práticas recomendadas para conteinerizar aplicações web Node.js com Docker

feature cheat sheet

15 de setembro de 2022

0 minutos de leitura

Quer conhecer as práticas recomendadas para criar imagens Docker de Node.js para suas aplicações web? Você está no lugar certo!

Este artigo apresenta diretrizes prontas para produção para criar imagens Docker de Node.js otimizadas e seguras. Ele será útil independentemente da aplicação Node.js que você pretende desenvolver. Este artigo será útil se:

  • se seu objetivo é criar uma aplicação de front-end usando os recursos de renderização no servidor (SSR) do Node.js para React.

  • se você está buscando orientações para criar corretamente uma imagem Docker de Node.js para seus microsserviços, executados com Fastify, NestJS ou outros frameworks de aplicação.

Por que escrevemos este guia sobre conteinerização de aplicações web Node.js com Docker?

Este pode parecer mais um artigo sobre como criar imagens Docker para aplicações Node.js, mas muitos exemplos que encontramos em blogs são simplistas e têm como único objetivo ensinar o básico para executar uma aplicação em uma imagem Docker de Node.js, sem considerar com cuidado a segurança e as práticas recomendadas para criar essas imagens.

Vamos aprender a conteinerizar aplicações web Node.js passo a passo, começando com um Dockerfile simples e funcional, entendendo as armadilhas e falhas de segurança de cada instrução e, depois, corrigindo-as.

Uma imagem Docker simples de Node.js

A maioria dos artigos de blog que encontramos começa e termina com as instruções básicas a seguir para criar imagens Docker de Node.js:

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Copie isso para um arquivo chamado Dockerfile e, em seguida, crie e execute a imagem.

$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial

É simples e funciona.

Qual é o único problema? Há muitos erros e práticas ruins para criar imagens Docker de Node.js. Evite o exemplo acima a todo custo.

Vamos começar a melhorar este Dockerfile para criar aplicações web Node.js otimizadas com Docker.

Você pode acompanhar este tutorial clonando este repositório.

Siga estas 10 etapas para criar aplicações web Node.js otimizadas com Docker:

  1. Use tags explícitas e determinísticas para a imagem base do Docker

  2. Instale apenas as dependências de produção na imagem Docker de Node.js

  3. Otimize as ferramentas do Node.js para produção

  4. Não execute contêineres como root

  5. Encerre com segurança as aplicações web Node.js no Docker

  6. Faça o encerramento controlado das suas aplicações web Node.js

  7. Encontre e corrija vulnerabilidades de segurança na sua imagem Docker de Node.js

  8. Use builds em várias etapas

  9. Evite arquivos desnecessários nas imagens Docker de Node.js

  10. Monte secrets na imagem de compilação do Docker

1. Use tags explícitas e determinísticas para a imagem base do Docker

Pode parecer óbvio criar sua imagem com base na imagem Docker node, mas o que você realmente está baixando ao criar a imagem? As imagens Docker são sempre referenciadas por tags e, quando você não especifica uma, a tag padrão :latest é usada.

Na prática, ao especificar o seguinte no seu Dockerfile, você sempre cria a versão mais recente da imagem Docker compilada pelo grupo de trabalho do Docker para Node.js:

FROM node

Estas são as desvantagens de criar a imagem com base na imagem padrão node:

  1. As compilações de imagens Docker são inconsistentes. Assim como usamos lockfiles para garantir um comportamento determinístico do npm install sempre que instalamos pacotes npm, também queremos compilações determinísticas de imagens Docker. Se criarmos a imagem a partir de node — o que, na prática, significa usar a tag node:latest —, cada compilação baixará uma nova imagem Docker de node. Não queremos introduzir esse tipo de comportamento não determinístico.

  2. A imagem Docker node é baseada em um sistema operacional completo, repleto de bibliotecas e ferramentas que você talvez precise ou não para executar sua aplicação web Node.js. Isso traz duas desvantagens. Primeiro, uma imagem maior significa um download maior, além de exigir mais armazenamento e levar mais tempo para baixar e recompilar. Segundo, você pode introduzir na imagem vulnerabilidades de segurança presentes nessas bibliotecas e ferramentas.

Na verdade, a imagem Docker node é bastante grande e inclui centenas de vulnerabilidades de segurança de diferentes tipos e níveis de gravidade. Se você a usa, sua base já começa com 642 vulnerabilidades de segurança e centenas de megabytes de dados de imagem baixados a cada extração e compilação.

Gráfico de barras que compara vulnerabilidades em imagens oficiais de contêiner entre 2018 e 2019 para node, postgres, nginx, httpd, mongo, mysql, couchbase, memcached e

Estas são as recomendações para criar imagens Docker melhores:

  1. Use imagens Docker pequenas. Isso reduz a quantidade de software na imagem, diminuindo os possíveis vetores de vulnerabilidade, e também o tamanho, acelerando o processo de compilação.

  2. Use o digest da imagem Docker, o hash SHA256 estático da imagem. Isso garante compilações determinísticas de imagens Docker com base na imagem-base.

Escrevemos um artigo completo sobre como escolher a melhor imagem Docker de Node.js. O artigo explica por que a opção ideal é uma distribuição slim do Debian atualizada, com uma versão do runtime Node.js com suporte de longo prazo.

A imagem Docker de Node.js recomendada é:

FROM node:20.9.0-bullseye-slim

Esta tag de imagem Docker de Node.js usa uma versão específica do runtime Node.js (20.9.0), correspondente à versão mais recente com suporte de longo prazo. Ela usa a variante de imagem bullseye, a versão estável atual do Debian 11, que ainda está longe do fim do suporte. Por fim, usa a variante slim, que reduz a quantidade de software do sistema operacional. O resultado é uma imagem com menos de 200 MB, incluindo o runtime e as ferramentas do Node.js.

Dito isso, uma prática equivocada bastante comum em tutoriais e guias é recomendar a seguinte instrução do Docker para a imagem-base:

FROM node:alpine

Esses artigos recomendam a imagem Docker Alpine de Node.js, mas será que ela é mesmo ideal? Em geral, isso acontece porque a imagem Docker Alpine de Node.js é conhecida por incluir menos software. No entanto, ela difere bastante em outros aspectos, o que a torna uma imagem-base de produção pouco adequada para runtimes de aplicações Node.js.

O que é o Node Alpine?

Node.js Alpine é uma compilação não oficial de imagem de contêiner Docker, mantida pela equipe do Docker para Node.js. A imagem Node.js inclui o sistema operacional Alpine, que usa as ferramentas mínimas de software busybox e a implementação da biblioteca C musl. Essas duas características fazem com que a imagem Alpine de Node.js não tenha suporte oficial da equipe do Node.js. Além disso, muitos scanners de vulnerabilidades de segurança não conseguem detectar facilmente artefatos de software ou runtimes em imagens Alpine de Node.js, o que dificulta os esforços para proteger suas imagens de contêiner.

Mesmo ao usar a tag de imagem Alpine de Node.js, uma instrução de imagem-base com um alias em formato de palavra ainda pode baixar novas compilações dessa tag, pois as tags de imagens Docker são mutáveis. Você pode encontrar o hash SHA256 no Docker Hub para esta tag do Node.js ou executar o comando a seguir depois de baixar a imagem localmente e localizar o campo Digest na saída:

$ docker pull node:20.9.0-bullseye-slim
20.9.0-bullseye-slim: Pulling from library/node
ca426296fe92: Pull complete
0d5f60f923bb: Pull complete
cc6fa81c4559: Pull complete
ec5e8e3b63b3: Pull complete
ca7cb04b0758: Pull complete
Digest: sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
Status: Downloaded newer image for node:20.9.0-bullseye-slim
docker.io/library/node:20.9.0-bullseye-slim

Outra forma de encontrar o hash SHA256 é executar o seguinte comando:

$ docker images --digests
REPOSITORY                                   TAG                     DIGEST                                                                    IMAGE ID       CREATED         SIZE
node                                         20.9.0-bullseye-slim    sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8   9ea15fe618bd   7 days ago      200MB

Agora, podemos atualizar o Dockerfile desta imagem Docker de Node.js:

FROM node@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

No entanto, o Dockerfile acima especifica apenas o nome da imagem Docker de Node.js, sem uma tag. Isso deixa ambíguo qual tag exata está sendo usada; além de ser difícil de ler e manter, não oferece uma boa experiência para o desenvolvedor.

Vamos corrigir isso atualizando o Dockerfile e especificando a tag completa da imagem-base para a versão do Node.js correspondente a esse hash SHA256:

FROM node:20.9.0-bullseye-slim@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Usar o digest da imagem Docker garante uma imagem determinística, mas pode confundir ou prejudicar algumas ferramentas de análise de imagens, que talvez não saibam interpretá-lo. Por isso, é preferível usar uma versão explícita do runtime Node.js, como 20.9.0. Mesmo que, em teoria, ela seja mutável e possa ser substituída, na prática, se precisar receber atualizações de segurança ou outras, elas serão lançadas em uma nova versão, como 20.9.1. Portanto, é seguro considerar que as compilações serão determinísticas.

Portanto, nesta etapa, o Dockerfile final que propomos é:

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Leia mais dicas e práticas recomendadas para criar imagens de contêiner seguras.

2. Instale apenas as dependências de produção na imagem Docker de Node.js

A instrução a seguir do Docker instala todas as dependências no contêiner, incluindo devDependencies, que não são necessárias para a aplicação funcionar. Isso aumenta desnecessariamente o tamanho da imagem e cria um risco de segurança adicional relacionado aos pacotes usados como dependências de desenvolvimento.

RUN npm install

Se você seguiu meu guia anterior sobre 10 práticas recomendadas de segurança para npm, sabe que deve garantir compilações determinísticas com npm ci. Isso evita surpresas no fluxo de integração contínua (CI), pois o processo é interrompido se houver qualquer divergência em relação ao lockfile.

Ao criar uma imagem Docker para produção, queremos garantir a instalação apenas das dependências de produção de forma determinística. Isso nos leva à seguinte prática recomendada para instalar dependências npm em uma imagem de contêiner:

RUN npm ci --only=production

Nesta etapa, o Dockerfile atualizado fica assim:

FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

Leia nosso artigo sobre dependências de software para saber mais.

3. Otimize as ferramentas do Node.js para produção

Ao criar sua imagem Docker de Node.js para produção, você precisa garantir que todos os frameworks e bibliotecas estejam usando as configurações ideais de desempenho e segurança.

Isso nos leva a adicionar a seguinte instrução ao Dockerfile:

ENV NODE_ENV production

À primeira vista, isso parece redundante, já que especificamos apenas dependências de produção na etapa npm install. Então, por que isso é necessário?

Em geral, os desenvolvedores associam a configuração da variável de ambiente NODE_ENV=production à instalação de dependências de produção. No entanto, essa configuração também tem outros efeitos que precisamos conhecer.

Alguns frameworks e bibliotecas só ativam a configuração otimizada para produção quando a variável de ambiente NODE_ENV está definida como production. Independentemente de considerarmos essa prática boa ou ruim para os frameworks, é importante saber disso.

Por exemplo, a documentação do Express destaca a importância de definir essa variável de ambiente para ativar otimizações relacionadas ao desempenho e à segurança:

Documentação do Express destacando os benefícios de NODE_ENV="production", incluindo templates em cache, CSS em cache e mensagens de erro menos detalhadas.

O impacto da variável NODE_ENV no desempenho pode ser bastante significativo.

A equipe da Dynatrace publicou um artigo que detalha os efeitos drásticos de não definir NODE_ENV nas suas aplicações Express.

Muitas das outras bibliotecas das quais você depende também podem esperar que essa variável esteja definida. Por isso, devemos configurá-la no Dockerfile.

Com a configuração da variável de ambiente NODE_ENV incluída, o Dockerfile atualizado deve ficar assim:

FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

4. Não execute contêineres como root

O princípio do menor privilégio é um conceito de segurança que remonta aos primórdios do Unix. Devemos sempre segui-lo ao executar nossas aplicações web Node.js em contêineres.

A avaliação de ameaças é bastante simples: se um invasor conseguir comprometer a aplicação web de modo a permitir injeção de comandos ou travessia de diretório, os comandos serão executados como o usuário proprietário do processo da aplicação. Se esse processo for executado como root, o invasor poderá fazer praticamente qualquer coisa dentro do contêiner, inclusive tentar escapar do contêiner ou escalar privilégios. Por que correr esse risco? Exatamente: não devemos.

Repita comigo: “amigos não deixam amigos executarem contêineres como root!”

A imagem oficial do Docker para node, assim como suas variantes, como alpine, inclui um usuário com privilégios mínimos com o mesmo nome: node. No entanto, não basta executar o processo como node. Por exemplo, o seguinte não é ideal para o funcionamento da aplicação:

USER node
CMD "npm" "start"

Isso acontece porque a diretiva USER do Dockerfile apenas garante que o processo pertença ao usuário node. E quanto a todos os arquivos que copiamos antes com a instrução COPY? Eles pertencem ao root. Esse é o comportamento padrão do Docker.

A forma completa e correta de reduzir privilégios é a seguinte, que também mostra as práticas atualizadas de Dockerfile que adotamos até aqui:

FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"

5. Encerre aplicativos web Node.js no Docker com segurança

Um dos erros mais comuns que vejo em blogs e artigos sobre conteinerização de aplicativos Node.js executados em contêineres Docker é a forma como o processo é iniciado. Todos os exemplos a seguir e suas variantes são práticas ruins que você deve evitar:

  • CMD “npm” “start”

  • CMD [“yarn”, “start”]

  • CMD “node” “server.js”

  • CMD “start-app.sh”

Vamos analisar! Vou explicar cada forma incorreta de iniciar processos e por que você deve evitá-las.

Os pontos a seguir são essenciais para entender o contexto de como executar e encerrar corretamente aplicativos Node.js no Docker:

  1. Um mecanismo de orquestração, como Docker Swarm, Kubernetes ou até mesmo o próprio mecanismo do Docker, precisa de uma forma de enviar sinais ao processo dentro do contêiner. Em geral, são sinais para encerrar um aplicativo, como SIGTERM e SIGKILL.

  2. O processo pode ser executado indiretamente e, nesse caso, não há garantia de que receberá esses sinais.

  3. O kernel do Linux trata os processos executados com o ID de processo 1 (PID) de forma diferente dos demais.

Com isso em mente, vamos analisar as formas de iniciar o processo em um contêiner, começando pelo exemplo do Dockerfile que estamos montando:

CMD "npm" "start"

Há duas ressalvas. Primeiro, estamos executando o aplicativo Node indiretamente, chamando diretamente o cliente npm. Quem garante que a CLI do npm encaminha todos os eventos para o runtime do Node? Na verdade, não encaminha, e é fácil testar isso.

No seu aplicativo Node.js, configure um manipulador de eventos para o sinal SIGHUP que registre uma mensagem no console sempre que você enviar um evento. Um exemplo simples de código seria:

function handle(signal) {
   console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)

Em seguida, execute o contêiner e, quando estiver ativo, envie especificamente o sinal SIGHUP usando a CLI do docker e a opção de linha de comando especial --signal:

$ docker kill --signal=SIGHUP elastic_archimedes

Nada aconteceu, certo? Isso ocorre porque o cliente npm não encaminha nenhum sinal para o processo Node que iniciou.

A outra ressalva diz respeito às diferentes formas de especificar a diretiva CMD no Dockerfile. Existem duas, e elas não são iguais:

  1. a notação shell form, em que o contêiner inicia um interpretador de shell que envolve o processo. Nesses casos, o shell pode não encaminhar os sinais corretamente ao processo.

  2. a notação exec form, que inicia um processo diretamente, sem envolvê-lo em um shell. Ela é especificada usando a notação de array JSON, por exemplo: CMD [“npm”, “start”]. Todos os sinais enviados ao contêiner são encaminhados diretamente ao processo.

Com isso em mente, vamos aprimorar a diretiva de execução de processos do nosso Dockerfile da seguinte forma:

CMD ["node", "server.js"]

Agora estamos iniciando o processo Node diretamente, garantindo que ele receba todos os sinais enviados, sem que um interpretador de shell o envolva.

No entanto, isso traz outra armadilha.

Quando os processos são executados como PID 1, eles assumem algumas responsabilidades de um sistema init, normalmente responsável por inicializar um sistema operacional e seus processos. O kernel trata o PID 1 de forma diferente dos outros identificadores de processo. Esse tratamento especial significa que, se o processo em execução não tiver definido um manipulador para o sinal SIGTERM, o comportamento padrão de encerrar o processo não será acionado.

Para citar a recomendação do grupo de trabalho Node.js Docker: “O Node.js não foi projetado para ser executado como PID 1, o que causa comportamentos inesperados dentro do Docker. Por exemplo, um processo Node.js executado como PID 1 não responderá a SIGINT (CTRL-C) nem a sinais semelhantes”.

A solução é usar uma ferramenta que funcione como processo init: ela é iniciada com PID 1 e, em seguida, inicia nosso aplicativo Node.js como outro processo, garantindo que todos os sinais sejam encaminhados a ele. Se possível, queremos usar uma ferramenta com o menor espaço ocupado possível, para não correr o risco de adicionar vulnerabilidades de segurança à imagem do contêiner.

Uma dessas ferramentas que usamos na Snyk é dumb-init, pois ela é vinculada estaticamente e ocupa pouco espaço. Veja como configurá-la:

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]

Assim, chegamos ao seguinte Dockerfile atualizado. Você vai notar que instalamos o pacote dumb-init logo depois da declaração da imagem para aproveitar o cache de camadas do Docker:

FROM node:20.9.0-bullseye-slim
RUN RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Quando usamos a instrução RUN do Docker para adicionar software, como fizemos com RUN apt-get update && apt-get install, algumas informações ficam na imagem do Docker. Para limpar esses arquivos após a execução do comando e manter uma imagem menor, podemos ampliá-lo da seguinte forma:

FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Também podemos aprimorar o exemplo acima usando ENTRYPOINT no Dockerfile para definir dumb-init e fornecer o processo Node.js a ser executado como argumento de linha de comando. Para isso, atualize a entrada do Dockerfile da seguinte forma:

FROM node:16.17.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]

Dica: é ainda melhor instalar a ferramenta dumb-init em uma etapa de build anterior e, depois, copiar o arquivo /usr/bin/dumb-init resultante para a imagem final do contêiner, mantendo-a limpa. Mais adiante neste guia, vamos aprender sobre builds Docker em várias etapas.

É bom saber: os comandos docker kill e docker stop enviam sinais apenas ao processo do contêiner com PID 1. Se você estiver executando um script de shell que inicia seu aplicativo Node.js, lembre-se de que uma instância do shell — como /bin/sh, por exemplo — não encaminha sinais aos processos filhos. Isso significa que seu aplicativo nunca receberá um SIGTERM.

6. Encerre seus aplicativos web Node.js sem interromper os usuários

Já que estamos falando de sinais de processo que encerram aplicativos, vamos garantir que eles sejam encerrados corretamente e sem interrupções para os usuários.

Quando um aplicativo Node.js recebe um sinal de interrupção, também conhecido como SIGINT ou CTRL+C, o processo é encerrado abruptamente, a menos que haja manipuladores de eventos configurados para tratá-lo de outra forma. Isso desconecta imediatamente os clientes conectados a um aplicativo web. Agora imagine centenas de contêineres web Node.js orquestrados pelo Kubernetes, iniciando e parando conforme necessário para escalar ou lidar com erros. Não é uma ótima experiência para o usuário.

É fácil simular esse problema. Veja um exemplo de aplicativo web Fastify padrão, com um atraso inerente de 60 segundos na resposta de um endpoint:

fastify.get('/delayed', async (request, reply) => {
 const SECONDS_DELAY = 60000
 await new Promise(resolve => {
     setTimeout(() => resolve(), SECONDS_DELAY)
 })
 return { hello: 'delayed world' }
})

const start = async () => {
 try {
   await fastify.listen(PORT, HOST)
   console.log(`*^!@4=> Process id: ${process.pid}`)
 } catch (err) {
   fastify.log.error(err)
   process.exit(1)
 }
}

start()

Execute o aplicativo e, quando estiver em funcionamento, envie uma solicitação HTTP simples para este endpoint:

$ time curl https://localhost:3000/delayed

Pressione CTRL+C na janela do console do Node.js em execução e você verá que a solicitação curl é interrompida abruptamente. Isso simula a experiência que seus usuários teriam quando os contêineres fossem encerrados.

Para oferecer uma experiência melhor, podemos fazer o seguinte:

  1. Configure um manipulador de eventos para os vários sinais de encerramento, como SIGINT e SIGTERM.

  2. O manipulador aguarda as operações de limpeza, como encerrar conexões com o banco de dados, solicitações HTTP em andamento e outras.

  3. Em seguida, o manipulador encerra o processo Node.js.

Com o Fastify, especificamente, podemos fazer com que nosso manipulador chame fastify.close(), que retorna uma promise que vamos aguardar. O Fastify também se encarrega de responder a cada nova conexão com o código de status HTTP 503, indicando que o aplicativo está indisponível.

Vamos adicionar nosso manipulador de eventos:

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${signal}`)

   await fastify.close()
   // await db.close() if we have a db connection in this app
   // await other things we should cleanup nicely
   process.kill(process.pid, signal);
}
process.once('SIGINT', closeGracefully)
process.once('SIGTERM', closeGracefully)

Reconheço que esse é um tema mais geral sobre aplicativos web do que algo específico de Dockerfile, mas é ainda mais importante em ambientes orquestrados.

7. Encontre e corrija vulnerabilidades de segurança na imagem Docker do Node.js

Lembra quando falamos sobre a importância de usar imagens base pequenas para nossos aplicativos Node.js? Vamos colocar isso em prática.

Vou usar a Snyk CLI para testar nossa imagem Docker. Você pode criar uma conta gratuita na Snyk aqui.

$ npm install -g snyk
$ snyk auth
$ snyk container test node:20.9.0-bullseye-slim --file=Dockerfile

O primeiro comando instala a Snyk CLI. Em seguida, um rápido processo de autenticação pela linha de comando recupera uma chave de API, e então podemos testar o contêiner em busca de problemas de segurança. Veja o resultado:

Organization:      lirantal
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:20.9.0-bullseye-slim
Platform:          linux/arm64
Base image:        node:lts-bullseye-slim
Licenses:          enabled

Tested 97 dependencies for known issues, found 44 issues.

According to our scan, you are currently using the most secure version of the selected base image

A Snyk detectou 97 dependências do sistema operacional, incluindo o executável do runtime Node.js, e não encontrou versões vulneráveis do runtime. No entanto, há 44 vulnerabilidades de segurança em alguns dos softwares da imagem do contêiner. Dessas dependências, 43 apresentam problemas de baixa gravidade e uma tem uma vulnerabilidade crítica relacionada à biblioteca zlib:

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:lts-bullseye-slim)

✗ Critical severity vulnerability found in zlib/zlib1g
  Description: Out-of-bounds Write
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-ZLIB-2976151
  Introduced through: meta-common-packages@meta
  From: meta-common-packages@meta > zlib/zlib1g@1:1.2.11.dfsg-2+deb11u1
  Image layer: Introduced by your base image (node:lts-bullseye-slim)
  Fixed in: 1:1.2.11.dfsg-2+deb11u2

Como corrigir vulnerabilidades em imagens Docker

Uma maneira rápida e eficaz de manter os softwares da sua imagem Docker seguros é recriá-la. Assim, você depende da imagem base Docker upstream que usa para buscar essas atualizações. Outra opção é instalar explicitamente as atualizações do sistema operacional para os pacotes, incluindo correções de segurança.

Com a imagem oficial do Docker para Node.js, a equipe pode demorar mais para disponibilizar atualizações da imagem. Por isso, recriar a imagem Docker do Node.js 20.9.0-bullseye-slim ou lts-bullseye-slim não será eficaz. A outra opção é gerenciar sua própria imagem base com softwares atualizados do Debian. No nosso Dockerfile, podemos fazer isso da seguinte forma:

RUN apt-get update && apt-get upgrade -y

Vamos executar a verificação de segurança da Snyk depois de criar a imagem Docker do Node.js com a instrução RUN recém-adicionada:

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:20.9.0-bullseye-slim)
…
Tested 98 dependencies for known issues, found 43 issues.
According to our scan, you are currently using the most secure version of the selected base image

Foi adicionada mais uma dependência do sistema operacional (98, contra 97 anteriormente), mas agora as 43 vulnerabilidades de segurança que afetam essa imagem Docker do Node.js são de baixa gravidade, e corrigimos a vulnerabilidade crítica da zlib. Um ótimo resultado!

O que aconteceria se tivéssemos usado a diretiva de imagem base FROM node? Melhor ainda: vamos supor que você tenha usado uma imagem base Docker mais específica do Node.js, como esta:

FROM node:14.2.0-slim
…

✗ High severity vulnerability found in node
  Description: Memory Corruption
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-570870
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.4.0

✗ High severity vulnerability found in node
  Description: Denial of Service (DoS)
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-674659
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.11.0

Organization:      snyk-demo-567
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:14.2.0-slim
Platform:          linux/amd64
Base image:        node:14.2.0-slim

Tested 78 dependencies for known issues, found 82 issues.

Base Image        Vulnerabilities  Severity
node:14.2.0-slim  82               23 high, 11 medium, 48 low

Recommendations for base image upgrade:

Minor upgrades
Base Image         Vulnerabilities  Severity
node:14.15.1-slim  71               17 high, 7 medium, 47 low

Major upgrades
Base Image        Vulnerabilities  Severity
node:15.4.0-slim  71               17 high, 7 medium, 47 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:14.15.1-buster-slim   55               12 high, 4 medium, 39 low
node:14.15.3-stretch-slim  71               17 high, 7 medium, 47 low

Embora pareça que uma versão específica do runtime Node.js, como FROM node:14.2.0-slim, seja suficiente por especificar uma versão exata (14.2.0) e usar uma imagem de contêiner pequena (graças à tag slim), a Snyk consegue encontrar vulnerabilidades de segurança em duas fontes principais:

  1. O próprio runtime Node.js — você notou as duas primeiras vulnerabilidades de segurança no relatório acima? São problemas de segurança conhecidos publicamente no runtime Node.js. A correção imediata é atualizar para uma versão mais recente do Node.js. A Snyk informa qual versão corrige o problema — 14.11.0, como você pode ver na saída.

  2. Ferramentas e bibliotecas instaladas nessa imagem base do Debian, como glibc, bzip2, gcc, perl, bash, tar, libcrypt e outras. Embora essas versões vulneráveis no contêiner talvez não representem uma ameaça imediata, por que mantê-las se não as usamos?

E o melhor do relatório da Snyk CLI? A Snyk também recomenda outras imagens base para você usar, então não precisa descobrir isso por conta própria. Encontrar imagens alternativas pode levar muito tempo; com a Snyk, você economiza esse esforço.

Neste momento, minha recomendação é:

  1. Se você gerencia suas imagens Docker em um registro, como Docker Hub ou Artifactory, pode importá-las para a Snyk facilmente para que a plataforma encontre essas vulnerabilidades. Você também receberá recomendações na interface da Snyk, além do monitoramento contínuo das suas imagens Docker para detectar vulnerabilidades de segurança recém-descobertas.

  2. Use a Snyk CLI na automação de CI. A CLI é muito flexível — foi exatamente por isso que a criamos: para que você possa aplicá-la aos seus fluxos de trabalho personalizados. Também temos o Snyk for GitHub Actions, caso prefira.

Para conhecer outras formas de gerenciar vulnerabilidades em imagens de contêiner, confira nosso guia de segurança de contêineres.

8. Use builds em vários estágios

Builds em vários estágios são uma ótima maneira de substituir um Dockerfile simples, mas potencialmente sujeito a erros, por etapas separadas para criar uma imagem Docker e evitar o vazamento de informações confidenciais. Além disso, podemos usar uma imagem base maior do Docker para instalar dependências e compilar pacotes npm nativos, se necessário, e depois copiar todos esses artefatos para uma imagem base pequena de produção, como o exemplo com Alpine.

Evite o vazamento de informações confidenciais

O caso de uso para evitar o vazamento de informações confidenciais é mais comum do que você imagina.

Se você cria imagens Docker para o trabalho, é bem provável que também mantenha pacotes npm privados. Nesse caso, provavelmente precisou encontrar uma maneira de disponibilizar o segredo NPM_TOKEN para o npm install.

Veja um exemplo do que quero dizer:

FROM node:20.9.0-bullseye-slim

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --only=production
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

No entanto, fazer isso deixa o arquivo .npmrc com o token secreto do npm dentro da imagem Docker. Você poderia tentar melhorar isso excluindo o arquivo depois, assim:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
RUN rm -rf .npmrc

Mas, agora, o arquivo .npmrc fica disponível em outra camada da imagem Docker. Se essa imagem for pública ou alguém conseguir acessá-la de alguma forma, seu token estará comprometido. Uma alternativa melhor seria:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Agora, o problema é que o próprio Dockerfile precisa ser tratado como um recurso secreto, pois contém o token secreto do npm.

Felizmente, o Docker permite passar argumentos para o processo de build:

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Depois, fazemos o build assim:

$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

Sei que você pensou que tínhamos terminado, mas lamento decepcionar.

É assim com a segurança: às vezes, o que parece óbvio é só mais uma armadilha.

Qual é o problema agora, você deve estar se perguntando? Argumentos de build passados ao Docker dessa forma ficam registrados no histórico. Vamos conferir. Execute este comando:

$ docker history nodejs-tutorial

que exibe o seguinte:

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
b4c2c78acaba   About a minute ago   CMD ["dumb-init" "node" "server.js"]            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   USER node                                       0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg…   5.71MB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG NPM_TOKEN                                   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY . . # buildkit                             15.3kB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENV NODE_ENV=production                         0B        buildkit.dockerfile.v0

Você viu o token secreto do npm ali? É disso que estou falando.

Há uma ótima maneira de gerenciar segredos para a imagem de contêiner, mas agora é hora de apresentar os builds em vários estágios como forma de mitigar esse problema e mostrar como criar imagens mínimas.

Apresentando builds em vários estágios para imagens Docker do Node.js

Assim como o princípio de Separação de Responsabilidades no desenvolvimento de software, vamos aplicar as mesmas ideias para criar nossas imagens Docker do Node.js. Teremos uma imagem para criar tudo o que a aplicação Node.js precisa para funcionar — o que, no universo Node.js, significa instalar pacotes npm e compilar módulos npm nativos, se necessário. Essa será a primeira etapa.

A segunda imagem Docker, correspondente à segunda etapa do build, será a imagem Docker de produção. Essa segunda e última etapa é a imagem que realmente otimizamos e publicamos em um registro, se houver um. A primeira imagem, que chamaremos de imagem de build, é descartada e fica sem referência no host Docker que a criou até ser removida.

Veja a atualização do nosso Dockerfile, que mostra o progresso até aqui, agora dividido em duas etapas:

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production && \
   rm -f .npmrc

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Como você pode ver, escolhi uma imagem maior para a etapa de build, pois talvez eu precise de ferramentas como gcc(GNU Compiler Collection) para compilar pacotes npm nativos ou para outras necessidades.

Na segunda etapa, há uma notação especial para a instrução COPY, que copia a pasta node_modules/ da imagem Docker de build para essa nova imagem base de produção.

Além disso, percebeu que agora o NPM_TOKEN é passado como argumento de build para a imagem Docker intermediária de build? Ele não aparece mais na saída do comando docker history nodejs-tutorial, porque não existe na imagem Docker de produção.

9. Mantenha arquivos desnecessários fora das imagens Docker do Node.js

Você tem um arquivo .gitignore para evitar que arquivos desnecessários — e possivelmente confidenciais — poluam o repositório Git, certo? O mesmo vale para imagens Docker.

O que é um arquivo de exclusão do Docker?

O Docker tem o arquivo .dockerignore, que impede o envio ao daemon do Docker de arquivos que correspondam aos padrões glob nele definidos. Veja uma lista de arquivos que talvez você inclua na imagem Docker, mas que idealmente deveria evitar: .dockerignore- node_modules- npm-debug.log- Dockerfile- .git- .gitignore

Como você pode ver, é muito importante excluir node_modules/. Se não tivéssemos feito isso, a versão simplificada do Dockerfile com que começamos teria copiado a pasta local node_modules/ para o contêiner sem alterações.

FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Na verdade, ter um arquivo .dockerignore é ainda mais importante ao usar builds Docker em vários estágios. Para relembrar como é a segunda etapa do build Docker:

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

A importância de ter um .dockerignore é que, ao executar COPY . /usr/src/app na segunda etapa do Dockerfile, também copiamos qualquer pasta node_modules/ local para a imagem Docker. Isso é um grande problema, pois podemos copiar código-fonte modificado para node_modules/.

Além disso, como usamos o curinga COPY ., podemos acabar copiando para a imagem Docker arquivos confidenciais com credenciais ou configurações locais.

Em resumo, um arquivo .dockerignore serve para:

  • Impedir que cópias potencialmente modificadas de node_modules/ sejam incluídas na imagem Docker.

  • Evitar a exposição de segredos, como credenciais presentes nos arquivos .env ou aws.json, que poderiam acabar na imagem Docker do Node.js.

  • Acelerar os builds do Docker ao ignorar arquivos que, de outra forma, invalidariam o cache. Por exemplo, a alteração de um arquivo de log ou de configuração do ambiente local invalidaria o cache da imagem Docker na camada que copia o diretório local.

10. Monte segredos na imagem de build do Docker

Vale observar que o arquivo .dockerignore adota uma abordagem de tudo ou nada: não é possível ativá-lo ou desativá-lo por etapa em um build Docker com vários estágios.

Por que isso é importante? Idealmente, queremos usar o arquivo .npmrc na etapa de build, pois talvez precisemos dele para acessar pacotes npm privados com um token secreto do npm. Talvez ele também contenha uma configuração específica de proxy ou registro para baixar pacotes.

Isso significa que faz sentido disponibilizar o arquivo .npmrc na etapa de build. No entanto, não precisamos dele na segunda etapa, a da imagem de produção, e tampouco queremos que fique lá, pois pode conter informações confidenciais, como o token secreto do npm.

Uma forma de contornar essa limitação do .dockerignore é montar um sistema de arquivos local disponível na etapa de build, mas há uma maneira melhor.

O Docker oferece um recurso relativamente novo chamado Docker secrets, ideal para o caso em que precisamos do .npmrc. Veja como funciona:

  • Ao executar o comando docker build, especificamos argumentos de linha de comando que definem um novo ID de segredo e apontam para um arquivo como origem do segredo.

  • No Dockerfile, adicionamos opções à instrução RUN para instalar o npm de produção. Isso monta o arquivo indicado pelo ID do segredo no local de destino: o arquivo .npmrc no diretório local onde queremos que ele fique disponível.

  • O arquivo .npmrc é montado como segredo e nunca é copiado para a imagem Docker.

  • Por fim, não se esqueça de incluir o arquivo .npmrc no .dockerignore para que ele não seja incluído em nenhuma imagem, nem na de build nem na de produção.

Vamos ver como tudo funciona em conjunto. Primeiro, o arquivo .dockerignore atualizado:

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

Em seguida, o Dockerfile completo, com a instrução RUN atualizada para instalar pacotes npm e especificar o ponto de montagem do .npmrc:

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Por fim, o comando que cria a imagem Docker do Node.js:

$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

Observação: Secrets é um recurso novo do Docker. Se você estiver usando uma versão mais antiga, talvez precise habilitar o Buildkit assim:

$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc

Resumo

Você chegou até o fim e criou uma imagem base Docker otimizada para Node.js. Muito bem!

Essa última etapa conclui este guia sobre como colocar aplicações web Node.js em contêineres Docker, considerando otimizações de desempenho e segurança para garantir a criação de imagens Docker do Node.js prontas para produção!

Recomendo muito que você confira também estes recursos:

Depois de criar imagens base Docker seguras e eficientes para suas aplicações Node.js, encontre e corrija vulnerabilidades nos contêineres com uma conta gratuita da Snyk.

Segurança de contêineres que prioriza os desenvolvedores

O Snyk encontra e corrige automaticamente vulnerabilidades em imagens de contêiner e workloads do Kubernetes.