Анализ большого количества зомби-процессов в Docker-контейнере

Node.js задняя часть Linux

Некоторое время назад сервис, который использовал Google Puppeteer для создания изображений, взорвался, и в каждом контейнере Docker были тысячи бесхозных зомби-процессов, которые не были переработаны, как показано на следующем рисунке.

Эта статья относительно длинная и в основном касается следующих вопросов.

  • При каких обстоятельствах появятся зомби-процессы и процессы-сироты?
  • Процесс инициирования рабочего процесса Puppeteer и анализ онлайн-инцидентов
  • Что особенного в процессе с PID 1
  • Почему node/npm не должен быть процессом с PID 1 в зеркале
  • Почему Bash может быть процессом с PID 1 и каковы его недостатки при выполнении процесса с PID 1
  • Какова рекомендуемая практика для процесса инициализации в зеркале?

Puppeteer — это библиотека узлов и официальный безголовый инструмент Chrome, предоставляемый Chrome. Он предоставляет способ работы с Chrome API, позволяя разработчикам запускать процесс Chrome в программе и вызывать JS API для загрузки страниц и сканирования данных. автоматизированное тестирование и другие функции.

Сценарий, используемый в этом случае, состоит в том, чтобы использовать Puppeteer для загрузки html, а затем сделать снимок экрана для создания изображения плаката распространения. В статье анализируются причины этой проблемы, а затем начинается формальное содержание.

процесс

Каждый процесс имеет уникальный идентификатор, называемый pid.pid — это неотрицательное целочисленное значение, которое можно просмотреть с помощью команды ps.Выполните ps -ef на моем Mac, чтобы увидеть все запущенные в данный момент процессы, как показано ниже.

UID   PID  PPID   C STIME   TTY           TIME CMD
  0     1     0   0 六04下午 ??        23:09.18 /sbin/launchd
  0    39     1   0 六04下午 ??         0:49.66 /usr/sbin/syslogd
  0    40     1   0 六04下午 ??         0:13.00 /usr/libexec/UserEventAgent (System)

где PID — идентификатор процесса.

У каждого процесса в системе есть соответствующий родительский процесс, а PPID в выходных данных ps выше представляет номер родительского процесса процесса. Самый верхний процесс имеет PID 1 и PPID 0.

Откройте iTerm и выполните команду в терминале, например «ls», система фактически создаст новый подпроцесс iTerm, который, в свою очередь, создаст подпроцесс zsh. Команда ls, введенная в zsh, означает, что процесс zsh запускает другой подпроцесс ls. Отношения процесса для входа в процедуру команды ls в iTerm показаны ниже.

  UID   PID  PPID   C STIME   TTY           TIME CMD
  501   321     1   0 六04下午 ??        61:01.45 /Applications/iTerm.app/Contents/MacOS/iTerm2 -psn_0_81940
  501 97920   321   0  8:02上午 ttys039    0:00.07 /Applications/iTerm.app/Contents/MacOS/iTerm2 --server login -fp arthur
    0 97921 97920   0  8:02上午 ttys039    0:00.03 login -fp arthur
  501 97922 97921   0  8:02上午 ttys039    0:00.29 -zsh
  501 98369 97922   0  8:14上午 ttys039    0:00.00 ./a.out

Процесс и форк

Вышеупомянутый родительский процесс «создает» дочерний процесс, более строгое описание — fork (инкубация, порождение). Давайте рассмотрим практический пример, создадим новый файл fork_demo.c.

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

int main() {
  int ret = fork();
  if (ret) {
    printf("enter if block\n");
  } else {
    printf("enter else block\n");
  }
  return 0;
}

Выполнение приведенного выше кода выведет следующий оператор.

enter if block
enter else block

Вы можете видеть, что все операторы if и else выполняются.

вилочный вызов

fork — это системный вызов, объявление его метода показано ниже.

pid_t fork(void);

После завершения вызова fork будет сгенерирован новый дочерний процесс, а родительский и дочерний процессы будут продолжать выполняться с точки возврата fork. Особого внимания здесь требует значение возвращаемого fork значения, которое имеет разные значения в родительском процессе и новом дочернем процессе.

  • Возвращаемое значение fork в родительском процессе — это идентификатор вновь созданного дочернего процесса.
  • Возвращаемое значение fork в созданном дочернем процессе всегда равно 0

Таким образом, родительский и дочерний процессы можно отличить по возвращаемому значению fork, а метод getpid можно использовать для получения идентификатора текущего процесса во время выполнения процесса. Типичное использование вилки показано ниже.

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
  printf("before fork, pid=%d\n", getpid());
  pid_t childPid;
  switch (childPid = fork()) {
    case -1: {
      // fork 失败
      printf("fork error, %d\n", getpid());
      exit(1);
    }
    case 0: {
      // 子进程代码进入到这里
      printf("in child process, pid=%d\n", getpid());
      break;
    }
    default: {
      // 父进程代码进入到这里
      printf("in parent process, pid=%d, child pid=%d\n", getpid(), childPid);
      break;
    }
  }
  return 0;
}

Выполните приведенный выше код, вывод будет следующим.

before fork, pid=26070
in parent process, pid=26070, child pid=26071
in child process, pid=26071

Дочерний процесс является копией родительского процесса, а дочерний процесс имеет копию пространства данных родительского процесса, кучи и стека. , вилка использует технологию копирования при записи, и операция вилки может быть завершена почти мгновенно. Настоящая копия создается только в том случае, если дочерний процесс модифицирует соответствующий регион.

Процесс сиротства: не может родиться в один и тот же день в том же году и не умрет в один и тот же день в том же году.

Далее задайте вопрос, при зависании родительского процесса будет ли зависать дочерний процесс?

Представьте себе реальную сцену, отец ушел, может ли сын еще жить? Ответ положительный. В соответствии с процессом, когда родительский процесс завершается, дочерний процесс продолжает работать и не переходит в Huangquan вместе.

Процесс, родительский процесс которого завершился, называется процессом-сиротой. Большой родитель операционной системы более удобен для пользователя, а процесс-сирота, которым никто не управляет, будет передан процессу с идентификатором процесса 1. Этот процесс с PID 1 будет обсуждаться позже.

Затем немного измените предыдущий код, чтобы позволить родительскому процессу разветвить дочерний процесс, чтобы совершить самоубийство и выйти позже, создав осиротевший процесс. Код показан ниже.

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
  printf("before fork, pid=%d\n", getpid());
  pid_t childPid;
  switch (childPid = fork()) {
    case -1: {
      printf("fork error, %d\n", getpid());
      exit(1);
    }
    case 0: {
      printf("in child process, pid=%d\n", getpid());
      sleep(100000); // 子进程 sleep 不退出
      break;
    }
    default: {
      printf("in parent process, pid=%d, child pid=%d\n", getpid(), childPid);
      exit(0); // 父进程退出
    }
  }
  return 0;
}

Скомпилируйте и запустите приведенный выше код

gcc fork_demo.c -o fork_demo; ./fork_demo

Вывод следующий.

before fork, pid=21629
in parent process, pid=21629, child pid=21630
in child process, pid=21630

Вы можете видеть, что идентификатор родительского процесса — 21629, а идентификатор сгенерированного дочернего процесса — 21630.

Используйте ps для просмотра информации о текущем процессе. Результаты следующие.

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 12月12 ?      00:00:53 /usr/lib/systemd/systemd --system --deserialize 21
ya       21630     1  0 19:26 pts/8    00:00:00 ./fork_demo

Видно, что родительский идентификатор осиротевшего дочернего процесса 21630 стал процессом верхнего уровня с идентификатором 1.

процесс зомби

Родительский процесс отвечает за жизнь. Если он не несет ответственности за повышение, это не хороший отец. Детский процесс зависает. Если родительский процесс не «собирает труп» (позвоните в ожидании / ждите) для дочернего процесса, то детский процесс станет процессом зомби.

Создайте новый файл make_zombie.c со следующим содержимым.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {

  printf("pid %d\n", getpid());
  int child_pid = fork();
  if (child_pid == 0) {
    printf("-----in child process:  %d\n", getpid());
    exit(0);
  } else {
    sleep(1000000);
  }
  return 0;
}

Скомпилируйте и запустите приведенный выше код, чтобы сгенерировать зомби-процесс с номером процесса 22538, как показано ниже.

UID        PID  PPID  C STIME TTY          TIME CMD
ya       22537 20759  0 19:57 pts/8    00:00:00 ./make_zombie
ya       22538 22537  0 19:57 pts/8    00:00:00 [make_zombie] <defunct>

Несуществующий в имени CMD указывает на то, что это зомби-процесс.

Также используйте команду ps для просмотра статуса процесса, который отображается как «Z» или «Z+», чтобы указать, что это процесс-зомби, как показано ниже.

ps -ho pid,state -p 22538
22538 Z

После завершения дочернего процесса большая часть ресурсов освобождается для использования другими процессами, но слоты в таблице процессов ядра не освобождаются.

Процессы-зомби обладают очень волшебной особенностью. Нет никакого способа убить процессы-зомби с помощью сигнала kill -9. Такая схема имеет смешанные преимущества и недостатки. Хорошо то, что родительский процесс всегда может иметь возможность выполнять такие команды, как wait/waitpid для сбора дочерних процессов. Плохо то, что нет способа принудительно перезапустить этот процесс-зомби.

PID 1 процесс

После инициализации ядра в Linux будет запущен первый процесс системы, а PID равен 1, который также можно назвать процессом инициализации или корневым (ROOT) процессом. На моей машине Centos процесс инициализации systemd, как показано ниже.

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 12月12 ?      00:00:54 /usr/lib/systemd/systemd --system --deserialize 21

На моем Mac процесс запускается, как показано ниже.

UID   PID  PPID   C STIME   TTY           TIME CMD
  0     1     0   0 六04下午 ??        28:40.65 /sbin/launchd

Процесс инициализации имеет следующие функции

  • Если родительский процесс завершается, процесс инициализации берет на себя осиротевший процесс.
  • Если родительский процесс завершается без выполнения команды wait/waitpid, процесс инициализации возьмет на себя управление дочерним процессом и автоматически вызовет метод ожидания, чтобы убедиться, что зомби-процесс в системе может быть удален.
  • Передача сигналов дочерним процессам, которые будут рассмотрены позже.

Почему Node.js не подходит для процесса с PID 1 в образе Docker

В официальных рекомендациях для Node.js написано: «Node.js не предназначен для работы с PID 1, что приводит к неожиданному поведению при работе внутри Docker». Картинка ниже взята изGitHub.com/node будет /dock….

Будут проведены следующие два эксперимента: первый эксперимент проводится на машине Centos, а второй — на образе Docker.

Эксперимент 1: В Centos systemd как процесс с PID 1

Давайте проведем несколько тестов, изменим приведенный выше код, сократим время ожидания родительского процесса до 15 с и создадим новый файл make_zombie.c, как показано ниже.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
  printf("pid %d\n", getpid());
  int child_pid = fork();
  if (child_pid == 0) {
    printf("-----in child process:  %d\n", getpid());
    exit(0);
  } else {
    sleep(15);
    exit(0);
  }
}

Скомпилируйте и сгенерируйте исполняемый файл make_zombie.

gcc make_zombie.c -o make_zombie

Затем создайте новый код run.js и запустите внутри процесс для запуска make_zombie, как показано ниже.

const { spawn } = require('child_process');
const cmd = spawn('./make_zombie');
cmd.stdout.on('data', (data) => {
    console.log(`stdout: ${data}`);
});

cmd.stderr.on('data', (data) => {
    console.error(`stderr: ${data}`);
});

cmd.on('close', (code) => {
    console.log(`child process exited with code ${code}`);
});
setTimeout(function () {
    console.log("...");
}, 1000000);

Выполните узел run.js, чтобы запустить этот код js, и используйте ps -ef, чтобы просмотреть взаимосвязь процесса следующим образом.

UID        PID  PPID  C STIME TTY          TIME CMD
ya       19234 19231  0 12月20 ?       00:00:00 sshd: ya@pts/6
ya       19235 19234  0 12月20 pts/6   00:00:01 -zsh
ya       29513 19235  3 15:28 pts/6    00:00:00 node run.js
ya       29519 29513  0 15:28 pts/6    00:00:00 ./make_zombie
ya       29520 29519  0 15:28 pts/6    00:00:00 [make_zombie] <defunct>

Через 15 секунд снова выполните ps -ef, чтобы просмотреть запущенные в данный момент процессы, и вы увидите, что процессы, связанные с make_zombie, исчезли.

UID        PID  PPID  C STIME TTY          TIME CMD
ya       19234 19231  0 12月20 ?       00:00:00 sshd: ya@pts/6
ya       19235 19234  0 12月20 pts/6   00:00:01 -zsh
ya       29513 19235  3 15:28 pts/6    00:00:00 node run.js

Это связано с тем, что родительский процесс make_zombie с PID 29519 завершается через 15 секунд, а дочерний процесс-зомби размещается в процессе инициализации, который вызывает ожидание/ожидание для сбора зомби.

Эксперимент 2: в Docker узел как процесс с PID 1

Упакуйте исполняемый файл make_zombie и run.js в пакет .tar.gz, а затем создайте новый файл Dockerfile со следующим содержимым.

#指定基础镜像
FROM  registry.gz.cctv.cn/library/your_node_image:your_tag

WORKDIR /

#复制包文件到工作目录,. 代表当前目录,也就是工作目录
ADD test.tar.gz .

#指定启动命令
CMD ["node", "run.js"]

Выполните команду сборки docker для создания образа, идентификатор образа на моем компьютере — ab71925b5154, Выполните команду docker run ab71925b5154, чтобы запустить образ докера, и используйте команду docker ps, чтобы найти идентификатор КОНТЕЙНЕРА образа, который здесь равен e37f7e3c2e39. Затем используйте docker exec для входа в зеркальный терминал.

docker exec -it e37f7e3c2e39 /bin/bash 

Выполните команду ps, чтобы просмотреть текущее состояние процесса, как показано ниже.

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  1 07:52 ?        00:00:00 node run.js
root        12     1  0 07:52 ?        00:00:00 ./make_zombie
root        13    12  0 07:52 ?        00:00:00 [make_zombie] <defunct>

Подождите некоторое время (15 секунд) и снова выполните команду ps, чтобы просмотреть текущий процесс, как показано ниже.

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 07:52 ?        00:00:00 node run.js
root        13     1  0 07:52 ?        00:00:00 [make_zombie] <defunct>

Видно, что зомби-процесс с PID 13 был размещен в процессе узла с PID 1, но не был перезапущен.

Это основная причина, по которой node не подходит в качестве процесса инициализации: он не может перерабатывать зомби-процессы.

Говоря об узле, давайте упомянем здесь npm.npm фактически использует процесс npm для запуска подпроцесса для запуска сценария запуска, написанного в сценариях в package.json.Пример сценария package.json показан ниже.

{
  "name": "test-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node run.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
  }
}

Запустите его с помощью npm run start, и результирующий процесс будет выглядеть следующим образом.

ya       19235 19234  0 12月20 pts/6  00:00:01 -zsh
ya       32252 19235  0 16:32 pts/6    00:00:00 npm
ya       32262 32252  0 16:32 pts/6    00:00:00 node run.js

Как и node, npm не обрабатывает повторное использование зомби-подпроцессов.

онлайн анализ проблем

Когда у нас возникают проблемы в сети, мы используем npm start для запуска проекта Puppeteer, и каждый раз, когда создается изображение, создаются 4 процесса, связанных с chrome, как показано ниже.

.
|
└── chrome(1)
    ├── gpu-process(2)
    └── zygote(3)
        └── renderer(4)

Когда создание образа завершено, основной процесс Chrome завершается, а оставшиеся три зомби-процесса-сироты размещаются в процессе npm верхнего уровня, но процесс npm не может быть перезапущен, поэтому каждый раз при создании образа будут добавляться три новых зомби-процесса. генерируется. После создания тысяч изображений система заполняется процессами-зомби.

Решение

Чтобы решить эту проблему, мы не можем сделать node/npm процессом инициализации и позволить службе, способной взять на себя процесс зомби, стать процессом инициализации.Есть два решения.

  • Запустить узел или npm с помощью bash
  • Добавьте специальный процесс инициализации, например tini.

Решение 1. Запустите узел с помощью bash

Это более быстрый способ сделать bash процессом верхнего уровня.Процесс bash будет отвечать за повторное использование зомби-процесса и изменять Dockerfile, как показано ниже.

ADD test.tar.gz .
# CMD ["npm", "run", "start"]
CMD ["/bin/bash", "-c", "set -e && npm run start"]

Использовать этот метод относительно просто, и раньше на линии не было проблем, потому что метод bash использовался для запуска узла в начале, а младший брат изменил команду, чтобы запустить команду единообразно.npm run start, возникает проблема.

Но использование bash не является идеальным решением, у него есть серьезная проблема, bash не будет передавать сигналы запускаемому процессу, и такие функции, как корректное завершение работы, не могут быть достигнуты.

Затем проведите эксперимент, чтобы убедиться, что bash не передает сигналы дочерним процессам.Создайте новый файл signal_test.c, который обрабатывает три сигнала: SIGQUIT, SIGTERM и SIGTERM.Содержимое выглядит следующим образом.

#include <signal.h>
#include <stdio.h>

static void signal_handler(int signal_no) {
  if (signal_no == SIGQUIT) {
    printf("quit signal receive: %d\n", signal_no);
  } else if (signal_no == SIGTERM) {
    printf("term signal receive: %d\n", signal_no);
  } else if (signal_no == SIGTERM) {
    printf("interrupt signal receive: %d\n", signal_no);
  }
}

int main() {
  printf("in main\n");

  signal(SIGQUIT, signal_handler);
  signal(SIGINT, signal_handler);
  signal(SIGTERM, signal_handler);

  getchar();
}

Когда я запускаю программу signal_test на своих Centos и Mac и посылаю этой программе kill -2, -3, -15, будут соответствующие распечатки, указывающие на то, что сигнал получен. Следующее.

kill -15 47120
term signal receive: 15

kill -3 47120
quit signal receive: 3

kill -2 47120
interrupt signal receive: 2

При использовании bash для запуска этой программы в образе Docker после отправки команды kill в bash bash не передает сигнал программе signal_test. После выполнения остановки докера докер отправит сигнал SIGTERM (15) в bash, bash не передаст этот сигнал запущенному приложению, он может только подождать некоторое время до истечения времени ожидания, докер отправит kill -9, чтобы принудительно убить docker, функция корректного завершения работы не может быть достигнута.

Итак, второе решение ниже.

Решение 2. Используйте специальный процесс инициализации

Node.js предоставляет два решения, первое — использовать официальную упрощенную систему инициализации Docker, как показано ниже.

docker run -it --init you_docker_image_id

Этот метод запуска будет использовать /sbin/docker-init в качестве процесса инициализации с PID 1 и не будет использовать CMD в Dockerfile в качестве первого процесса запуска.

Возьмите в качестве примера следующее содержимое Dockerfile.

...
CMD ["./signal_test"]
...

воплощать в жизньdocker run -it --init image_idЗапустите образ докера, и процессы в образе следующие.

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:30 pts/0    00:00:00 /sbin/docker-init -- /app/node-default
root         6     1  0 15:30 pts/0    00:00:00 ./signal_test

Вы можете видеть, что программа signal_test запускается как подпроцесс docker-init.

После того как команда docker stop отправит сигнал SIGTERM в образ, процесс docker-init перенаправит сигнал в signal_test, а процесс приложения сможет получить сигнал SIGTERM для пользовательской обработки, такой как корректное завершение работы.

В дополнение к официальному решению Docker, лучшие практики Node.js также рекомендуют крошечный процесс инициализации, написанный на языке C, например tini.GitHub.com/Hunting Coming/In vivo…. Это краткий код, который стоит прочитать, и он очень полезен для понимания сигнализации и работы с зомби-процессами.

резюме

Я надеюсь, что благодаря этой статье вы сможете понять, что такое процесс-зомби, процесс-сирота, процесс с PID 1, и почему node/npm не подходит для процесса с PID 1, и каковы недостатки bash как процесса. с ПИД 1.

Оставьте домашнее задание ниже, чтобы проверить свое понимание функции ветвления процесса. Сколько новых процессов будет создано после того, как следующая программа выполнит три последовательных вызова fork()?

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

int main() {
  printf("Hello, World!\n");

  fork();
  fork();
  fork();
  sleep(100);
  return 0;
}

Если у вас есть какие-либо вопросы, вы можете отсканировать QR-код ниже и подписаться на мою официальную учетную запись, чтобы связаться со мной.