끄적끄적

[Docker] PID1 과 SIGTERM 문제 (feat dumb-init) 본문

개발/인프라

[Docker] PID1 과 SIGTERM 문제 (feat dumb-init)

코리이 2023. 8. 25. 01:28

현재 회사의 대부분 서버는 Docker를 기반으로 배포되고 있다. 그런데 이 때 특정한 서버에서 배포 시마다 운영에는 문제 없는 에러 로그가 지속적으로 발생하는 이슈가 있었다. 물론 이 문제로 인해 다른 로직에 오류가 발생한 적은 없었기 때문에, 그 동안 다른 개발 작업에 집중하다가 최근에 조금 여유가 생겨 이 이슈를 조사해보기로 했다.

 



결론부터 이야기 하자면 문제의 원인은 docker stop 명령 실행 시 docker 는 컨테이너에 TERM signal (SIGTERM) 을 보내고 10 초 동안에도 종료되지 않았다면 Kill signal (SIGKILL) 을 보내 강제로 종료시키는 과정에서 발생했다. 컨테이너는 SIGTERM 시에 모든 리소스를 정리하고 종료되어야 하지만, DB 리소스는 정리되었으나 Web3 Websocket 리소스는 정리되지 않아 문제가 발생한 것이다. 즉, Websocket 에서 블록을 전달받으면 DB 에 싱크를 맞추는 로직이 존재했는데 DB 커넥션은 이미 닫힌 상태기 때문에 DB connection 문제로 인해 에러가 계속 발생했었던 것이다. 그래서 이번 기회에 이 이슈에 대해 이것저것 조사하고 공부하고 실험해본 경험을 공유해보고자 한다.

Docker alpine 이미지(경량 이미지)와 PID1

회사에서 직접적으로 k8s 는 쓰지 않다보니 이 이슈는 크게 신경쓰이지 않았었다. 하지만 조사하다 보니 k8s 를 사용하는 분들은 이 이슈에 대해서 많이 다루고 있는걸 보았다. 우선 PID1 이란 OS 에서 가장 첫번째 PID 를 의미하며 대부분의 경우 init 관련 프로세스가 PID1 을 가지고 있다. 하지만 docker 경량 이미지의 경우 PID1 프로세스는 Dockerfile 에 명시된 ENTRYPOINT(CMD) 가 가지게 된다. 

 

리눅스의 pid1 은 systemd (init) 이 가지고 있다.
docker alpine 에서 pid1 은 CMD 에 있는 node 가 가지고 있다.

PID1 과 npm

위 도커 컨테이너 그림은 dist/main 으로 실행한 결과 이지만 npm start 명령어로 도커를 실행시키는 경우도 많이 있다. 하지만 만약 npm start 로 CMD 를 구성한다면 어떻게 될까? 

이를 실험해보기 위해 직접 node 앱을 만들고 이 node 앱을 npm script 로 CMD 를 구성해서 Dockerize 해보자. 이때 signal 을 받는지 확인하기 위해 nest-app 의 lifecycle 을 활용해서 SIGTERM 을 수신하는지 확인해보려 한다.

@Injectable()
export class LifecycleHandler implements OnApplicationShutdown {
    onApplicationShutdown(signal?: string): any {
        console.log(`onApplicationShutdown ======> signal: ${signal}`);
    }
}
CMD ["npm", "start"]

이를 실행시키면 아래와 같이 npm 이 pid1 을 가지고 dist/main 이 자식 프로세스로 실행되는걸 확인할 수 있게 된다.

 

그 후에 docker kill 명령어를 통해 SIGTERM 을 전달해 보자. 실행시키면 node 앱에 의해 onApplicationShudown 이 콘솔로 찍힐 것으로 생각되지만 실은 아무런 반응이 나타나지 않는다.

 

SIGTERM 에는 아무 반응이 없다.

만약 CMD 에 npm start 를 사용하지 않고 직접 node dist/main 을 사용하면 어떻게 될까? 이를 위해 CMD 만 변경된 새로운 dockerfile 을 만들어 실행시켜보자.

CMD ["node", "dist/main"]

이 경우에는 아래처럼 signal 을 잘 받으면서 성공적으로 종료되는것을 확인할 수 있다.

 

SIGTERM 을 받아 콘솔을 찍고 종료한다.

이번 실험에서도 확인할 수 있듯이 npm 의 경우 시그널을 처리할 수 없다. 즉, 대부분 node 앱의 경우 SIGINT 혹은 SIGTERM 을 수신하게 되면 전체 리소스를 정리한 후 종료하는 과정을 가지고 있는데 docker stop 명령어를 사용하면 정리 로직을 제대로 수행할 수 없고 10 초 뒤에 곧바로 SIGKILL 을 받아 깔끔하게 정리하지 못한 채로 단순히 종료하는 과정을 거치게 된다.

따라서 Dockerfile CMD 에는 절대 npm (yarn) 을 사용하지 말 것을 권장한다.

dumb-init 과 Signal Handler

하지만 우리 회사에서는 npm(yarn) 을 사용해서 dockerize 하고 있지 않았었다. 그러면 이번에는 어디가 문제였을까? 이를 실험해보기 위해 실제로 문제가 생겼던 코드(Web3 코드)를 가져와 보았다.

@Injectable()
export class Web3Listener implements OnModuleInit {
    async onModuleInit() {
        const web3 = new Web3(new WebSocketProvider("HOST"));
        const subscription = await web3.eth.subscribe('newHeads');

        subscription.on('data', async (headerOutput) => {
            console.log(`[onNewBlock] ====> ${headerOutput.number}`);
            // 실제로는 이곳에서 db 작업을 진행한다.
        });
    }
}

이 코드를 추가한 후 node/dist 를 CMD 로 하는 docker 를 실행해보자. 그러면 새로운 블록이 들어올때마다 콜솔이 찍히는 것을 확인할 수 있다. 그 후에 SIGTERM 을 을 도커 컨테이너에 전달해보자. 그러면 onApplicationShudown 이 호출되어도 종료되지 않고 계속 블록 subscribe 를 수행하고 있는 것을 확인할 수 있다. 즉, 이렇게 계속 블록을 수신하면서 db 작업을 하는데 onApplicationShudown 이 수행되면서 db 는 close 되었지만 아직 프로세스가 종료되지 않았기 때문에 SIGKILL 에 의해 도커가 종료되기 전까지 계속 에러 로그를 내보낸 것이다. 

 

 또 다른 한가지 예시를 더 들어보자. node 에서 새로운 child process 를 실행시킬 수도 있다. 예를 들어 아래와 같은 파일이 있다고 생각해보자. 

console.log('[CHILD PROCESS] START');

setInterval(() => {
    console.log('[CHILD PROCESS] Interval');
}, 2000);

그리고 아래 코드와 함께 앱을 실행시켜보자

const child = spawn('node',[`${__dirname}/worker.js`]);

child.stdout.on('data', function(data) {
    console.log(`[PARENT] data sent: ${data}`);
});

그러면 비슷한 결과로 아래와 같이 SIGTERM 은 받지만 child process 는 계속 진행하고 있는 것을 확인할 수 있다. 즉 이 경우에도 SIGKILL 을 수신하기 전까지 계속해서 child process 를 진행하게 된다.

 

왜 이런 일이 일어날까 생각해보면 간단하다. 기본적으로 node 나 npm 등은 SIGNAL 을 핸들링하지 않고, 전파(propagation)하지도 않는다.(python, shell 등의 어플리케이션도 포함된다.) 즉, nest app 에서 onApplicationShudown 을 활용해서 SIGNAL 핸들링하는 로직을 직접 개발했기 때문에 nest app 에서는 SIGNAL 을 수신해서 핸들링 할 수 있었던 반면 Web3Websocket 은 따로 SIGNAL 을 핸들링하는 코드가 없었고 Child process 의 경우에는  node 는 SIGNAL 을 전파하지 않기 때문에 Signal 조차받을 수 없다.(직접 코드에 넣어주면 되긴 한다) 물론 일반적인 리눅스에서는 systemd 나 init 프로세스가 이를 핸들링하고 SIGNAL 도 다른 프로세스에 전파해 주기 때문에 크게 고려하지 않아도 된다. 하지만 docker 는 node 가 PID1 을 가지고 있다는 것을 기억해야 한다. 

 

이제 실제로 우리가 원하는 액션에 대해 다시 생각해보자. 우리는 SIGKILL 이 아니라 SIGTERM 에서 모든 앱이 함께 종료되는 걸 원한다. 즉 기본적으로 SIGNAL 을 핸들링하길 원하고 혹시나 모를 좀비 프로세스를 종료시키기 위해 SIGNAL 도 전파해주길 원한다. 이를 해결하기 위해서 사용하는 것이 dumb-init 이다. dumb-init 은 경량화된 컨테이너에서 사용하기 위해 개발된 초기화시스템으로 docker 내의 init 과 비슷한 역할을 한다.

 

그럼 이제 마지막 실험을 해보자. dumb-init 을 활용해서 Dockerfile 만 수정한 뒤 실행해보자

ENTRYPOINT ["dumb-init", "--"] # dumb-init 을 CMD 앞에 넣어도 된다.
CMD ["node", "dist/main"]

이를 실행한 뒤 process tree 형태를 확인해보자 PID1 을 dumb-init 이 가져가고 있다.

 

그 이후 같은 방식으로 SIGTERM 을 전달해보자.

 

그림을 보면 알겠지만 dumb-init 을 추가하기만 하면 websocket 도 종료되는걸 확인할 수 있다. 즉 dumb-init 덕분에 이제 SIGKILL 에 의해 종료되지 않도록 만들 수 있었다.

 

하지만 여기서 또 한가지 방법이 있기는 하다. nestapp 실행시 sigterm 을 핸들링 해주는 것이다. 

    // ... bootstrap 로직 
    
	process.on("SIGTERM", async (signal) => {
        console.log(`Signal Received ======> signal: ${signal}`);
        process.exit(0);
    });

    await app.listen(port, () => {
        console.log(`server is running with port: ${port}`);
    });
}

그러면 node 의 한 앱에서 sigterm 수신시 모두 종료시켜 버리므로 동일한 결과는 낼 수 있게 된다. 하지만 생각하지 못한 경우가 "항상" 발생하기 때문에 dumb-init 을 PID1 으로 설정후 실행시키는게 좋아 보인다.

 

결론적으로 말하면 그냥 Dockerfile 의 CMD 아래처럼 해서 모든 것이 해결되었다.

CMD ["dumb-init", "node", "dist/main"]

'개발 > 인프라' 카테고리의 다른 글

[Terraform] terraform cloud 사용하기  (2) 2022.11.06