Статьи

Изящное завершение работы Java в контейнерах: почему стоит перепроверить!

Грациозность — это не только замечательное человеческое качество: она также обязательна для любой прикладной программы, особенно когда она ложится тяжелым бременем на критически важные области.

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

Когда мы ips-worker образ Docker ips-worker для нашей Интеграционной платформы (IPS) как адаптированную версию UltraESB-X, мы могли гарантировать, что ESB, работающие на платформе, будут корректно завершать работу — или мы так думали.

К сожалению нет.

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

В нашем основном выпуске IPS на основе Kubernetes мы извлекаем журналы экземпляров ESB (pods) через API K8s, а также через приложение базы данных, чтобы мы могли проанализировать их позже. Анализируя журналы, мы заметили, что мы никогда не видели журналы выключения ESB, независимо от того, насколько велико хранилище журналов. Казалось, что ESB были жестоко убиты, как только был получен сигнал завершения.

Чтобы исследовать проблему, я начал с упрощенной Java-программы: программы, которая регистрирует хук отключения — всемирно известный способ реализации изящного завершения работы в Java, который мы использовали в обоих наших ESB, — и продолжает работать вечно, печатая некоторый текст периодически (чтобы указать, что main поток активен). Как только запускается ловушка отключения, я прерывал main поток, изменял вывод, чтобы указать, что мы выключаемся, и позволяю обработчику завершить работу через несколько секунд (в соответствии с «ложным» изящным отключением).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Kill {
 
    private static Thread main;
 
    public static void main(String[] a) throws Exception {
 
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            public void run() {
                System.out.println("TERM");
                main.interrupt();
                for (int i = 0; i < 4; i++) {
                    System.out.println("busy");
                    try {
                        Thread.sleep(1000);
                    } catch (Exception e) {}
                }
                System.out.println("exit");
            }
        }));
 
        main = Thread.currentThread();
        while (true) {
            Thread.sleep(1000);
            System.out.println("run");
        }
    }
}

Тестировать это довольно просто:

1
2
javac Kill.java
java Kill

Пока программа продолжает печатать:

1
2
3
4
run
run
run
...

нажмите Ctrl + C, чтобы увидеть, что происходит:

01
02
03
04
05
06
07
08
09
10
11
12
...
run
run
^CTERM
busy
Exception in thread "main" java.lang.InterruptedException: sleep interrupted
        at java.lang.Thread.sleep(Native Method)
        at Kill.main(Kill.java:22)
busy
busy
busy
exit

Выглядит неплохо.

После этого преобразование в полноценный Docker-контейнер заняло всего несколько минут, а следующий Dockerfile :

1
2
3
FROM openjdk:8-jre-alpine
ADD Kill*.class /
ENTRYPOINT ["java", "Kill"]

docker build -t kill:v1 .

Затем я запустил контейнер с новым изображением:

docker run -it --rm kill:v1

который дал ожидаемый результат:

1
2
3
4
run
run
run
...

Затем я отправил в TERM сигнал TERM (который отображается на Ctrl + C на обычном жаргоне и является триггером по умолчанию для ловушки завершения работы Java) с помощью команды kill :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
# pardon the fancy functions;
# they are quite useful for me when dealing with processes
 
function pid() {
    ps -ef | grep $1 | grep -v grep | awk '{print $2}'
}
 
function killsig() {
    for i in pid $2; do
        sudo kill $1 $i
    done
}
 
alias termit='killsig -15'
 
# with all the above in place, I just have to run:
termit Kill

Как и ожидалось, отключающий хук вызывался и выполнялся плавно.

Идя дальше, я превратил все это в отдельный модуль K8s (поддерживаемый развертыванием с одной репликой):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: kill
spec:
  selector:
    matchLabels:
      k8s-app: kill
  template:
    metadata:
      labels:
        k8s-app: kill
    spec:
      containers:
      - name: kill
        image: kill:v1

и попробовал то же самое, на этот раз путем обнуления spec.replicas (так же, как мы делаем это в IPS) с помощью kubectl edit deployment вместо ручного kill -TERM :

1
2
3
4
5
kubectl edit deployment kill
 
# vi is my default editor
# set "replicas" to 0 (line 20 in my case)
# <ESC>:wq<ENTER>

имея консольный хвост в отдельном окне:

01
02
03
04
05
06
07
08
09
10
11
12
# fancy stuff again
 
function findapp() {
    kubectl get pod -l k8s-app=$1 -oname | cut -b 6-;
}
 
function klog() {
    kubectl logs -f findapp $1;
}
 
# the final command
klog kill

показывая вывод:

01
02
03
04
05
06
07
08
09
10
11
12
13
run
run
...
run
TERM
busy
Exception in thread "main" java.lang.InterruptedException: sleep interrupted
        at java.lang.Thread.sleep(Native Method)
        at Kill.main(Kill.java:22)
busy
busy
busy
exit

Черт, он все еще изящно выключается!

Так что не так с моим ips-worker ?

Просто чтобы проверить, я получил кластер с одной репликой, работающий на IPS, вручную изменил образ ( spec.template.spec.containers[0].image ) и команду запуска ( spec.template.spec.containers[0].command ) о развертывании K8s с помощью kubectl edit ( kubectl edit всех других факторов — таких как переменные среды и монтирование объема) — и опробовал ту же последовательность обнуления;

Тот же результат! Изящное отключение!

Затем мне пришло в голову, что, хотя мой контейнер kill просто использует команду ips-worker java Kill , ips-worker использует немного более сложную команду:

/bin/sh -c <copy some files> && <run some custom command> && <run ultraesb-x.sh>

где, в последней части, мы создаем (со специально изготовленным classpath и некоторыми параметрами JVM) и выполняем довольно длинную команду java которая запускает зверя UltraESB-X.

В конечном итоге, последняя команда live в контейнере сводится к:

/bin/sh -c <basepath>/ultraesb-x.sh

Поэтому я попробовал команду оболочки в моем контейнере kill , слегка изменив Dockerfile:

слегка изменив Dockerfile:

1
2
3
4
FROM openjdk:8-jre-alpine
ADD Kill*.class /
# note the missing brackets and quotes, so that the command gets the default /bin/sh -c prefix
ENTRYPOINT java Kill

и ура! Изящного отключения больше не было. Процесс Java был жестоко убит как в Docker ( docker stop Docker), так и в K8s (обнуление реплики).

Исследуя далее, я руководствовался Google этим популярным постом SE, в котором говорилось, что оболочка ( sh ) по умолчанию не передает полученные сигналы своим дочерним процессам. Предложенная альтернатива состояла в том, чтобы запустить внутреннюю команду как exec которая в основном заменила бы родительский процесс ( sh ) на дочерний ( java , в случае kill ):

1
2
3
FROM openjdk:8-jre-alpine
ADD Kill*.class /
ENTRYPOINT exec java Kill

За kill это сразу же сработало.

Для ips-worker все было немного по-другому, так как было два уровня вызова: команда контейнера, вызывающая цепочку команд через /bin/sh -c , и встроенный ultraesb-x.sh вызывающий конечную команду java . Следовательно, мне пришлось включить exec в двух местах:

Однажды в конце цепочки команд:

1
2
3
4
/bin/sh -c \
<copy some files> && \
<run some custom command> && \
exec <basepath>/ultraesb-x.sh

И снова в конце ultraesb-x.sh :

1
2
3
# do some magic to compose the classpath and other info for ESB startup
 
exec $JAVA_HOME/bin/java <classpath and other params>

Как бы просто это ни казалось, этих двух exec было достаточно, чтобы вернуть ips-worker завершение работы ips-worker и, следовательно, нашей Интеграционной Платформе .