Некоторое время назад сервис, который использовал 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-код ниже и подписаться на мою официальную учетную запись, чтобы связаться со мной.