컨테이너로 분산 애플리케이션 실행하기-02

jjunhwan.kim·2023년 6월 29일
1

도커

목록 보기
4/6
post-thumbnail

개요

  • 도커 스웜이나 쿠버네티스같은 컨테이너 플랫폼은 애플리케이션이 스스로 이상에서 회복할 수 있도록 해주는 기능을 제공합니다.
  • 컨테이너 플랫폼이 컨테이너에서 실행 중인 애플리케이션 상태가 정상인지 확인할 수 있는 정보를 도커 이미지에 함께 패키징 할 수 있습니다.
  • 이런 방법으로 애플리케이션이 정상적으로 동작하지 않게 되면 플랫폼이 비정상 컨테이너를 삭제하고 새 컨테이너로 대체합니다.
  • 이번 포스트는 도커 교과서 8장의 내용을 정리하였고, 이번 장은 플랫폼이 제공하는 기능을 활용하기 위해 필요한 정보를 컨테이너 이미지에 추가하는 방법을 설명합니다.

헬스 체크를 지원하는 도커 이미지 빌드하기

도커는 컨테이너를 시작할 때 애플리케이션의 상태를 확인합니다. 컨테이너를 실행하면 컨테이너 내부에서 애플리케이션 등의 프로세스가 실행되는데, 도커는 이 프로세스의 실행 상태를 확인합니다. 프로세스가 종료됐다면 컨테이너도 종료 상태가 됩니다. 이 상태를 확인하면 기본적인 헬스 체크는 가능합니다.

하지만 프로세스는 정상적으로 실행 중이지만 애플리케이션은 정상적인 상태가 아닐 수 있습니다.

도커는 애플리케이션의 상태가 실제로 정상인지 확인할 수 있는 정보를 도커 이미지에 직접 넣는 기능을 제공합니다. Dockerfile 스크립트에 상태 확인 로직을 추가하면 됩니다.

Dockerfile 스크립트에는 HEALTHCHECK 인스트럭션을 추가할 수 있습니다. HEALTHCHECK 인스트럭션에는 도커가 컨테이너 안에서 실행하는 명령을 지정합니다. 도커는 이 명령이 반환하는 상태 코드를 보고 애플리케이션의 상태를 판단합니다. 도커는 일정한 시간 간격으로 컨테이너 안에서 지정된 명령을 실행합니다. 상태코드가 정상이면 컨테이너도 정상으로 간주하고 상태 코드가 연속 일정 횟수 이상 실패로 나오면 컨테이너를 이상 상태로 간주합니다.

이전 포스트에서 사용했던 프로젝트의 백엔드 서버에 헬스 체크를 위한 API를 추가해 보겠습니다. HealthCheckController 클래스를 만들고 아래와 같이 /health 엔드포인트를 작성합니다. 여기서는 테스트를 위해 /health 엔드포인트에 HTTP 요청이 5번 까지는 정상적으로 응답하고 그 이후에는 500 상태코드를 리턴하도록 예외를 던지도록 작성합니다.

@RestController
public class HealthCheckController {

    private int count = 0;

    @GetMapping("/health")
    public String health() {

        count++;
        if (count > 5) {
            throw new RuntimeException();
        }

        return "OK";
    }
}

Dockerfile 스크립트를 아래와 같이 수정합니다.

먼저 헬스체크를 위해 curl 명령을 사용해야합니다. 패키지 매니저로 curl 패키지를 다운받는 명령어를 추가합니다.

그리고 HEALTHCHECK 인스트럭션에 도커가 컨테이너 내부에서 실행할 명령을 작성합니다. 여기서는 curl 명령을 통해 백엔드에 추가한 헬스체크 API를 호출하도록 합니다. curl 명령에 --fail 옵션을 붙이면 요청이 성공하면 0을 반환하고 실패하면 0 이외의 숫자를 반환합니다. 도커는 0을 헬스 체크 정상, 0 이의외 값을 비정상으로 간주합니다.

FROM gradle:7.6.1 as build

WORKDIR /src
COPY . .
RUN gradle bootJar

FROM amazoncorretto:17-alpine3.17

RUN apk update && apk add curl

WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
HEALTHCHECK CMD curl --fail http://localhost:8080/health

COPY --from=build /src/build/libs/*.jar app.jar

HEALTHCHECK 인스트럭션의 기본 문법은 HEALTHCHECK [OPTIONS] CMD command 입니다.

CMD 전에 위치하는 옵션들은 아래와 같습니다. 기본 옵션 값으로는 30초 간격으로 연속 3회 이상 실패하면 애플리케이션을 이상 상태로 간주합니다.

--interval=DURATION (default: 30s)
--timeout=DURATION (default: 30s)
--start-period=DURATION (default: 0s)
--retries=N (default: 3)

프로젝트를 docker compose up -d 명령으로 실행하고 docker ps 로 컨테이너 목록을 확인해봅니다.

헬스체크 API가 5번 호출될 때까지 약 3분까지는 애플리케이션 상태가 healthy로 출력됩니다.

docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED         STATUS                   PORTS                               NAMES
2f9962745054   react-frontend:1.0.0   "/docker-entrypoint.…"   3 minutes ago   Up 3 minutes             0.0.0.0:80->80/tcp                  project-frontend-1
3e8f7c6d301b   spring-backend:1.0.0   "java -jar app.jar"      3 minutes ago   Up 3 minutes (healthy)   0.0.0.0:62870->8080/tcp             project-backend-1
5155c6e28256   mysql:8                "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes             0.0.0.0:3306->3306/tcp, 33060/tcp   project-db-1

API가 6번 부터 실패하여 연속으로 3회 실패하는 8번째 호출 될 때 unhealthy로 출력됩니다.

docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED         STATUS                     PORTS                               NAMES
2f9962745054   react-frontend:1.0.0   "/docker-entrypoint.…"   4 minutes ago   Up 4 minutes               0.0.0.0:80->80/tcp                  project-frontend-1
3e8f7c6d301b   spring-backend:1.0.0   "java -jar app.jar"      4 minutes ago   Up 4 minutes (unhealthy)   0.0.0.0:62870->8080/tcp             project-backend-1
5155c6e28256   mysql:8                "docker-entrypoint.s…"   4 minutes ago   Up 4 minutes               0.0.0.0:3306->3306/tcp, 33060/tcp   project-db-1

docker inspect 명령어로 헬스 체크 수행 결과를 확인할 수 있습니다. 아래와 같이 실행하면 State 필드 아래의 Health 필드를 통해 헬스 체크 상태를 확인할 수 있습니다. FailingStreak는 연속 실패한 횟수이고, Log는 가장 최근에 수행한 헬스 체크 정보입니다.

docker inspect project-backend-1
[
    {
        
        ...
        "State": {
            "Status": "running",
            "Running": true,
            "Paused": false,
            "Restarting": false,
            "OOMKilled": false,
            "Dead": false,
            "Pid": 55694,
            "ExitCode": 0,
            "Error": "",
            "StartedAt": "2023-06-25T15:37:44.138126087Z",
            "FinishedAt": "2023-06-25T15:37:26.479289343Z",
            "Health": {
                "Status": "unhealthy",
                "FailingStreak": 52,
                "Log": [
                    {
                        "Start": "2023-06-25T16:04:22.722917674Z",
                        "End": "2023-06-25T16:04:22.838062716Z",
                        "ExitCode": 22,
                        "Output": "  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\ncurl: (22) The requested URL returned error: 500\n"
                    },
                    {
                        "Start": "2023-06-25T16:04:52.844361299Z",
                        "End": "2023-06-25T16:04:52.946994174Z",
                        "ExitCode": 22,
                        "Output": "  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\ncurl: (22) The requested URL returned error: 500\n"
                    },
                    {
                        "Start": "2023-06-25T16:05:22.960169299Z",
                        "End": "2023-06-25T16:05:23.045239549Z",
                        "ExitCode": 22,
                        "Output": "  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\ncurl: (22) The requested URL returned error: 500\n"
                    },
                    {
                        "Start": "2023-06-25T16:05:53.055990049Z",
                        "End": "2023-06-25T16:05:53.185017216Z",
                        "ExitCode": 22,
                        "Output": "  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\ncurl: (22) The requested URL returned error: 500\n"
                    },
                    {
                        "Start": "2023-06-25T16:06:23.197362799Z",
                        "End": "2023-06-25T16:06:23.288520299Z",
                        "ExitCode": 22,
                        "Output": "  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\ncurl: (22) The requested URL returned error: 500\n"
                    }
                ]
            }
        },
        
        ...
    }
]

헬스 체크 상태가 unhealthy로 바뀌어도 컨테이너가 재시작되거나 다른 컨테이너로 교체되지는 않습니다. 그 이유는 도커가 이런 작업을 안전하게 처리할 수 없기 때문입니다.

도커 엔진은 단일 서버에서 동작합니다. 이상이 생긴 컨테이너를 제거하고 같은 설정으로 새 컨테이너를 실핼할 수 있지만 컨테이너가 보관하던 데이터가 유실되고 그 시간 동안 애플리케이션이 동작하지 않습니다. 도커가 컨테이너 교체 작업을 수행했을 때 상황을 더 악화시키지 않을 것이라는 보장이 없습니다. 따라서 이상 상태 발생을 통보만 하고 컨테이너는 그대로 두는 것입니다. 헬스 체크는 계속 수행됩니다.

도커가 동작하는 여러 대의 서버로 구성되고 도커 스웜이나 쿠버네티스가 관리하는 클러스터 환경에서는 헬스 체크를 통해 플랫폼이 컨테이너의 이상 상태를 통보받으면 자동으로 조치를 취합니다. 이상 상태를 보이는 컨테이너를 두고 대체 컨테이너를 실행해 애플리케이션 중단 시간 없이 이상 상태를 회복할 수 있습니다.

예제 코드는 https://github.com/nefertirii/docker-example/tree/beee3234bf24025fc0ecf695ce0c959d38c9a8ce 를 참고합니다.

디펜던시 체크가 적용된 컨테이너 실행하기

여러 컨테이너로 나뉘어 실행되는 분산 애플리케이션은 이상이 생긴 컨테이너를 교체할 때 문제가 발생할 수 있습니다. 예를 들면 백엔드 컨테이너가 중지된 상태에서 백엔드 컨테이너를 호출하는 프론트엔드 컨테이너가 실행된다면 애플리케이션이 보기에는 동작하는 것 같지만 백엔드 API 호출 시점에 정상적으로 동작하지 않습니다.

즉 프론트엔드 컨테이너가 의존 관계를 만족하지 않는 상태에서 실행되었기 때문에 정상적으로 동작하지 않습니다.

의존 관계를 만족하는지 점검하는 디펜던시 체크 기능도 도커 이미지에 추가할 수 있습니다. 디펜던시 체크는 애플리케이션 실행 전에 필요한 요구 사항을 확인하는 기능입니다. 헬스 체크는 컨테이너가 실행 중에 수행되고, 디펜던시 체크는 컨테이너 실행 전에 수행됩니다.

디펜던시 체크가 성공하면 애플리케이션이 실행되고, 실패하면 애플리케이션이 실행되지 않습니다. 디펜던시 체크는 별도의 인스트럭션으로 구현된 것은 아니고 애플리케이션 실행 명령에 로직을 추가하는 방법으로 구현합니다.

아래는 디펜던시 체크를 추가한 백엔드 Dockerfile 스크립트 입니다. CMD 인스트럭션을 보면 애플리케이션 실행 전에 DB가 사용 가능한지 확인합니다.

FROM gradle:7.6.1 as build

WORKDIR /src
COPY . .
RUN gradle bootJar

FROM amazoncorretto:17-alpine3.17

RUN apk update && apk add curl && apk add netcat-openbsd

WORKDIR /app
EXPOSE 8080
CMD nc -z db 3306 && java -jar app.jar
HEALTHCHECK CMD curl --fail http://localhost:8080/health

COPY --from=build /src/build/libs/*.jar app.jar

netcat 유틸리티를 이미지에 추가하고 nc -z db 3306 명령을 통해 DB가 사용 가능한지 확인합니다. 그 이후에 &&를 통해 명령이 성공하면 app.jar 애플리케이션을 실행합니다.

https://github.com/nefertirii/docker-example/tree/f9c048799262631f4834bea458220b7a78cef2c5 프로젝트를 docker compose up -d 명령어로 실행하면 DB 컨테이너가 준비되기 전에 백엔드 컨테이너가 실행되어 디펜던시 체크에서 실패하여 실행되지 않는 것을 확인할 수 있습니다.

일정 시간 이후 DB가 준비되고 다시 docker compose up -d 명령어를 실행하면 백엔드 컨테이너, 프론트엔드 컨테이너가 디펜던시 체크에서 성공하여 애플리케이션이 정상적으로 실행됩니다.

도커 컴포즈에 헬스 체크와 디펜던시 체크 정의하기

도커 컴포즈에는 애플리케이션의 상태에 이상이 생겼을 때 어느 정도 복원할 수 있는 기능이 있습니다. 하지만 도커 컴포즈도 이상이 생긴 컨테이너를 새 컨테이너로 교체하지는 않습니다. 하지만 종료된 컨테이너를 재시작하거나 이미지에 정의되지 않은 헬스 체크 로직을 추가할 수는 있습니다.

도커 컴포즈 파일에서 헬스 체크 옵션을 아래와 같이 세세하게 설정할 수 있습니다. 백엔드 Dockerfile 스크립트에는 HEALTHCHECK 인스트럭션이 적용되어 있는 상태입니다. https://github.com/nefertirii/docker-example/tree/343f8f8ff59255193215588c376d80c30063fd35 를 참고합니다.

version: '3.7'

services:

  ...

  backend:
    build:
      context: ./backend
    image: spring-backend:1.0.0
    healthcheck:
      interval: 5s
      timeout: 1s
      retries: 2
      start_period: 5s
    ports:
      - "8080"
    networks:
      - app-net
    depends_on:
      - db

  ...

networks:
  app-net:
  • interval은 헬스 체크 실시 간격을 의미합니다.
  • timeout은 그 때까지 응답을 받지 못하면 실패로 간주하는 제한 시간을 의미합니다.
  • retries는 컨테이너 성태를 이상으로 간주할 때까지 필요한 연속 실패 횟수를 의미합니다.
  • start_period는 컨테이너 실행 후 첫 헬스 체크를 실시하는 시간 간격을 의미합니다. 애플리케이션을 시작하는 데 시간이 오래 걸리는 경우 필요합니다.

이미지에 헬스 체크가 정의되지 않았다면 컴포즈 파일에서 정의하는 방법도 있습니다. 아래의 컴포즈 파일에서 test 필드가 헬스 체크를 위해 실행하는 명령입니다. https://github.com/nefertirii/docker-example/tree/46de63d34030e7545137c46945133cc5c473524e 를 참고합니다.

version: '3.7'

services:

  ...
  
  backend:
    build:
      context: ./backend
    image: spring-backend:1.0.0
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 5s
      timeout: 1s
      retries: 2
      start_period: 5s
    ports:
      - "8080"
    networks:
      - app-net
    depends_on:
      - db

   ...

networks:
  app-net:

이미지에 디펜던시 체크가 포함되어 있을 경우 컨테이너 실행시 디펜던시 체크에 실패하면 컨테이너가 실행되지 않고 종료됩니다. 도커 컴포즈에 restart 설정을 추가하여 컨테이너가 종료되면 재시작하게 설정할 수 있습니다. 따라서 위의 프로젝트에서 docker compose up -d 명령 실행시 DB 컨테이너가 준비되지 전까지 백엔드 컨테이너는 디펜던시 체크에 실패해 종료되고 컨테이너가 다시 실행되는 것을 반복하다가 DB 컨테이너가 준비되었을 때 디펜던시 체크에 성공해 애플리케이션이 제대로 동작하게 됩니다.

아래는 restart 설정을 추가한 도커 컴포즈 파일입니다. https://github.com/nefertirii/docker-example/tree/3021a5cac264318ff34b519ed16daefe6017aa08 를 참고합니다.

version: '3.7'

services:

  ...

  backend:
    build:
      context: ./backend
    image: spring-backend:1.0.0
    restart: on-failure
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 5s
      timeout: 1s
      retries: 2
      start_period: 5s
    ports:
      - "8080"
    networks:
      - app-net
    depends_on:
      - db

   ...

networks:
  app-net:

헬스 체크와 디펜던시 체크로 복원력 있는 애플리케이션을 만들 수 있는 이유

여러 개의 요소로 구성된 분산 시스템으로 동작하는 애플리케이션은 유연성과 기민성 면에서 뛰어납니다. 하지만 반대로 관리가 그만큼 어려워집니다.

구성 요소 간의 복잡한 의존 관계를 보면, 각 구성 요소를 시작하는 순서를 제어하면 좋을 것 같다는 생각이 듭니다.
예를 들어 DB 컨테이너를 실행하고, 그 이후에 백엔드 컨테이너를 실행하고, 그 이후에 프론트엔드 컨테이너를 실행하도록 말입니다.

물리 서버가 한 대 뿐이라면 도커 컴포즈에 프론트엔드 컨테이너보다 백엔드 컨테이너를 먼저 실행시키라고 지시할 수 있습니다. 하지만 여러 대의 서버로 이루어진 환경에서 많은 컨테이너로 이루어져 있다면 복잡해집니다. 예를 들어 백엔드 컨테이너 20개 중 19개는 실행되었는데 마지막 한 개가 실행이 되지 않았다면 프론트엔드 컨테이너는 실행되지 않을 것입니다. 따라서 애플리케이션은 정상 동작 하지 않을 것입니다. 하지만 백엔드 컨테이너가 하나 부족하더라도 프론트엔드 컨테이너를 실행하는데는 문제가 없습니다.

디펜던시 체크와 헬스 체크를 도입하면 플랫폼이 실행 순서를 보장하게 할 필요가 없습니다. 가능한 한 빨리 컨테이너를 실행하게 하면 됩니다. 일부 컨테이너가 의존 관계를 만족하지 못한 상태라면 재실행되거나 다른 컨테이너로 교체될 것입니다. 이런 상황에서 모든 컨테이너가 동작하기 전에 일부 컨테이너라도 동작 중이라면 사용자 요청을 처리할 수 있습니다.

하지만 헬스 체크와 디펜던시 체크에도 주의가 필요합니다. 헬스 체크는 주기적으로 자주 실행되므로 시스템에 부하를 주는 내용이어서는 안됩니다. 자원을 너무 많이 소모하지 않으면서 애플리케이션이 실질적으로 동작 중인지 검증할 수 있는 핵심적인 부분을 테스트 해야합니다.

디펜던시 체크는 애플리케이션 시작 시에만 실행되므로 리소스에 너무 크게 신경 쓸 필요는 없습니다. 하지만 테스트 대상이 빠짐없이 정확하도록 주의해야 합니다.

0개의 댓글