본문 바로가기
프로젝트 일지/BoWS

자체 제작 KaaS에서 배포된 애플리케이션의 상태 보여주기

by 데브겸 2024. 8. 12.

지금 제작하는 애플리케이션은 배포에 필요한 최소한의 리소스(BE, FE 앱 이미지, DNS에 등록된 도메인, DB Schema 등)을 제출하면 홈서버 k8s 클러스터에 애플리케이션을 배포해주는 Kubernetes as a Service의 일종이라고 볼 수 있다.

 

이 서비스의 사용자는 자신의 애플리케이션 배포 후 무엇을 궁금해할까? 우선 최소한으로 아래 사항들을 궁금할 것이라 생각하였다. (실제로 내가 애플리케이션을 배포했을 때 궁금했던 내용이기도 하다)

  1. 자신이 어떤 애플리케이션을 배포했는지
    1. 애플리케이션의 이름, 설명, 언제 배포했는지 등의 배포에 대한 메타데이터
    2. 어떤 도메인 FE, BE 이미지로 배포를 했는지 등의 배포 관련 세부 데이터
  2. 자신의 애플리케이션이 ‘잘 배포되었는지’, 현재 ‘정상적으로 동작하는지’ 등 애플리케이션의 상태

이 요소들을 보여줄 때 가장 크게 고려한 점은 사용자가 kubernetes를 잘 모르고, 알 필요도 없다는 점이다. 복잡한 인프라에 대한 지식을 가감 없이 넣는 것은 개발자가 간편하게 자신의 애플리케이션을 배포한다는 목적을 가진 배포 플랫폼 애플리케이션의 목표와 모순된다고 생각했다. 따라서 배포와 애플리케이션 관리, 상황 파악에 필요한 최소한의 정보만최대한 직관적으로 이해할 수 있을만하게 추상화하여 보여주는 것을 목표로 삼았다.

 

 

사용자 자신이 어떤 애플리케이션을 배포했는지 보여주기

1번의 경우 사용자가 제출한 form을 DB에 저장하고, 그것을 보여주면 쉽게 보여줄 수 있다. 현재 BoWS에서는 하나의 배포 단위를 ‘프로젝트’라는 이름으로 묶고, 이 프로젝트를 생성하기 위해 아래와 같은 정보들을 제출하게끔하고 있다. 제출된 데이터는 1차적으로 DB(MySQL)에 저장된다.

 

 

 

위 form에서 제출한 내용을 바탕으로 프로젝트가 k8s 클러스터에 배포가 된다. 프로젝트가 배포되면 (자신이 배포한) 전체 프로젝트 목록에서 아래와 같이 확인할 수 있다. 가장 대표적인 프로젝트 이름, 도메인, 생성 시간 등이 표시되게 하였다. (프로젝트 이름은 영어 소문자와 숫자, _ - . 특수문자만 가능한데, 이는 프로젝트 이름이 k8s 리소스에 label로 붙어 리소스의 식별자로서 역할해야 하기 때문이다. label 영어 소문자, 숫자와 일부 특수문자만 지원하기 때문에 그에 대한 제한이 프로젝트 이름에도 적용되었다. 물론 DB에서 생성되는 project의 id값도 라벨과 리소스 식별자로 붙기 때문에 프로젝트 이름을 label에서 제거, 한글도 가능하게 만들 수 있을 것 같다. 이는 우선순위가 밀리긴 하지만, 추후 업데이트 해보면 좋을 것 같다)

 

 

 

프로젝트가 배포되면 기본적인 구조는 Backend, Frontend, DB에 대한 Deployment와 Service가 각각 생성된다. 이를 일종의 tier 별로 나눠 관리하게 되는데, 이 tier에 대한 정보(혹은 배포된 애플리케이션의 배포 구조) 등에 대해 사용자가 궁금해할 것이라고 생각했다. 이 tier 구조 각각의 ‘서비스’라는 단위로 묶어 관리하고 보여준다. k8s의 service와 살짝 다른, BoWS 애플리케이션 내에서 배포된 프로젝트의 각 tier를 보여주는 단위라고 생각하면 좋을 것 같다.

 

(거의 대부분 FE-BE-DB 구조로 되어 있겠지만, 그래도 사람 심리상 전체 구조를 궁금해할 것이라 생각했다). 각 tier가 Container Image로 되어 있다는 것을 알 것이기 때문에 그 구조 내 IP, Port에 대한 정보를 궁금해할 것이라고 생각하여 이를 추가하였다. 사실 External IP는 사용자 입장에서 궁금하지 않을 내용일 것 같으나(그리고 그게 정확히 무엇을 의미하는지 모를 것 같으나) AWS, GCP 등의 서비스 등에 익숙한 이들이라면 궁금해하고, 빠르게 이해할 것이라고 생각하여 추가하였다. (생각해보니 어짜피 traefik 사용할거면 각 서비스의 ip는 필요없는 것 같기도 하고… 그냥 알면 만족할 것 같아 넣었다가 가장 정확하겠다) 배포된 프로젝트를 상세조회하면 아래와 같은 화면을 볼 수 있다.

 

 

 

부연설명하자면 프로젝트의 tier인 ‘서비스’를 k8s의 Service의 데이터 구성하여 보여준 것은 Service가 외부와의 통신을 담당하기 때문이다. Pod로 보여줘야 하는거 아닌가도 고민했는데, Pod는 언제든지 뜨고 죽고 새로 띄워지기 때문에 IP, Port, 시작시간 등에 있어 크게 유의미한 정보를 제공해주지 못할 것이라 생각했다.

 

java code는 아래와 같다

@Service
@RequiredArgsConstructor
public class KubeExecutor {

    private final CoreV1Api coreV1Api;

    public List<ServiceMetadata> getServiceMetadataOf(String projectName) {
        try {
            List<V1Service> services = coreV1Api
                    .listNamespacedService(namespace)
                    .labelSelector("projectName" + "=" + projectName)
                    .execute().getItems();

            return services.stream()
                    .map(service -> ServiceMetadata.of(service, getServiceStateFrom(service)))
                    .toList();

        } catch (ApiException | NullPointerException e) {
            log.error(e.getMessage());

            throw new KubeException();
        }
    }
}

// ServiceState와 관련된 설명은 포스트 아래에 있다. 지금은 그냥 그렇구나 하고 넘어가자
public record ServiceMetadata(
        String serviceName,
        List<String> externalIps,
        List<Integer> ports,
        String age,
        String imageName,
        String state,
        String stateReason,
        String stateMessage
) {

    public static ServiceMetadata of(V1Service service, ServiceState serviceState) {

        String name = service.getMetadata().getName();
        List<String> externalIPs = service.getSpec().getExternalIPs();
        List<V1ServicePort> v1ServicePorts = service.getSpec().getPorts();
        List<Integer> ports = v1ServicePorts.stream()
                .map(V1ServicePort::getPort)
                .toList();
        String creationTimestamp = service.getMetadata().getCreationTimestamp().toString();

        return new ServiceMetadata(name, externalIPs, ports, creationTimestamp,
                serviceState.imageName(), serviceState.state(), serviceState.reason(), serviceState.message());
    }
}

 

 


 

배포한 애플리케이션의 상태 보여주기

 

이 부분에서 고민이 많았다. ‘애플리케이션의 상태’란 무엇일까부터 고민했어야 했기 때문에. (고민하고 구현하는데 한 일주일은 쓴듯)

기획 의도는 ‘애플리케이션이 비정상적이어서 접속이 안 된다면, 그 접속이 안 되는 (인프라 단)의 이유를 보여주고 싶다’였다. 따라서 우선 application layer에서 log 등으로 run time error 등을 보여주는 것은 제외했다.

 

‘인프라 단에서 상태’는 아까 위 기획에서 연역하여 ‘Pod 내부에서 어떤 상황인가’로 그 범위를 좁혔다. ‘Pod가 어떤 상황이냐’로 좁혀도 어려운 것은 동일했다. ‘Pod의 상황’에 대해서도 여러가지로 지표가 있기 때문에. Pod의 Phase, Condition, Status, probe 등 여러 후보지가 있었다.

 

 

가장 직접적으로 상태를 알려주는 것은 probe였다. readiness, liveness probe등으로 k8s에서 애플리케이션을 호출하고 상태를 알아낸 후 그 결과를 알려주는 것이 직빵이긴 할 것 같으나, probe를 알아내기 위해서는 내가 애플리케이션에서 health check api 등을 미리 알고 있어야 한다는 점이 걸렸다. 사용자 특성상, 그리고 BoWS 애플리케이션 특성 상 언제 누가 어떤 소스코드로 배포할지 모르며, 높은 확률로 health check api가 구현되어 있지 않을 가능성이 많았다.

 

(다시 공식문서를 천천히 읽어봤더니 httpGet외에도 tcp 소켓 연결이나, 지정된 명령어 실행 등으로 검증할 수 있는 방법이 있긴 하다… 물론 이렇게 검증할 경우 한 번에 받아볼 수 있는 message에 내용을 사용하긴 애매할 것 같다는 판단이다. 자세한 이유는 아래에. 그래도 앞으로 공식 문서 조금 더 잘 읽어보자)

 

Probe의 결과 등을 받아볼 수 있는 Pod Condition의 status, reason, message의 경우에도 사용하긴 애매했다. 이 플랫폼을 사용하는 입장에서 Pod의 Condition과 같이 Pod 자체에서 일어나는 일을 알고 싶을까? (혹은 알아야 할까?) 파드에 대한 지식을 활용해서 디버깅해야 하는 상황이라면 그건 사용하는 개발자가 아니라 인프라 담당자, 플랫폼 제공자가 뜯어봐야 하는 이슈 아닐까 라는 생각이 들었기 때문이다.

 

 

그래서 선택한 것이 Container Status이다. 이 플랫폼을 사용하여 프로젝트를 배포한 사람이 책임을 져야 하고, 관심있어 하는 것은 Pod가 아니라 Container라고 생각했다. 잘못된 이미지 이름을 적었거나, 버전이 맞지 않는 이미지를 사용하거나 등의 이슈는 온전히 플랫폼 사용자가 컨트롤하고 궁금해하고 책임져야 하는 내용이라고 생각했다. 따라서 이에 대한 모니터링 기능을 제공하는 것이 가장 취지에 맞겠다 생각했다.

 

각 Service의 정보와 그 Service에 할당되어 있는 Pod의 Container Status 정보를 보여주기 위해 아래 와 같이 java 코드를 구성하였다(java k8s client)

 

@Service
@RequiredArgsConstructor
public class KubeExecutor {

    private final CoreV1Api coreV1Api;

		// Service 당 Application 실행을 위한 Pod 한개, Pod 1개에 Container 1개로 전제
    public ServiceState getServiceStateFrom(V1Service service) {
        try {
            String labelSelector = getLabelSelector(service);
            V1Pod pod = coreV1Api.listNamespacedPod("my-app")
                    .labelSelector(labelSelector)
                    .execute()
                    .getItems()
                    .get(0);

            return ServiceState.from(pod.getStatus().getContainerStatuses().get(0));

        } catch (ApiException | NullPointerException e) {
            log.error(e.getMessage());

            throw new KubeException();
        }
    }

    private String getLabelSelector(V1Service service) {
        try{
            return service.getSpec().getSelector().entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue())
                    .collect(Collectors.joining(","));
        } catch (NullPointerException e) {
            log.error(e.getMessage());

            throw new KubeException();
        }
    }
}

public record ServiceState(
        String imageName,
        String state,
        String reason,
        String message
) {
    public static ServiceState from(V1ContainerStatus status) {
        String state = getState(status.getState());
        String reason = getReason(status.getState());
        String message = getMessage(status.getState());
        return new ServiceState(status.getImage(), state, reason, message);
    }

    private static String getState(V1ContainerState state) {
        if (state.getRunning() != null) return "Running";
        if (state.getWaiting() != null) return "Waiting";
        if (state.getTerminated() != null) return "Terminated";
        return "Unknown";
    }

    private static String getReason(V1ContainerState state) {
        if (state.getWaiting() != null) return state.getWaiting().getReason();
        if (state.getTerminated() != null) return state.getTerminated().getReason();
        return "";
    }

    private static String getMessage(V1ContainerState state) {
        if (state.getWaiting() != null) return state.getWaiting().getMessage();
        if (state.getTerminated() != null) return state.getTerminated().getMessage();
        return "";
    }
}

 

 

위와 같이 구성한 ServiceState를 바탕으로 애플리케이션의 Frontend에서는 조금 더 직관적인 UX/UI를 제공하고자 하였다. Running, Waiting, Terminate 라는 상태 요약을 초록색, 노란색, 빨간색 원으로 표현하여 사용자가 직관적으로 상태를 알 수 있게 하였다.

 

 

const ServiceItem = ({isLast, service }) => {
    const [expanded, setExpanded] = useState(false);

    const getStatusColor = (serviceState) => {
        switch (serviceState) {
            case 'Running': return 'bg-green-500';
            case 'Waiting': return 'bg-yellow-500';
            case 'Terminated': return 'bg-red-500';
            default: return 'bg-gray-500';
        }
    }

    const formattedAge = calculateAge(service.age);

    return (
        <>
            <div
                className={`h-[90px] serviceList-grid 
                items-center w-full bg-white border-gray-300 relative cursor-pointer ${isLast ? 'rounded-b-lg border-b-0' : ''}`}
                onClick={() => setExpanded(!expanded)}
            >
                ... 일부 생략...
            </div>
            {expanded && <ServiceStateToggle service={service}/>}
        </>
    )
};

 

 

Container Status (BoWS 플랫폼 안에서는 ‘서비스의 상태’로 표현)을 자세하게 알고 싶을 경우, 각 서비스를 클릭하여 토글 형태로 정보를 제공하도록 하였다. 어떤 container image가 쓰였는지는 기본으로 제공하고, 만약 서비스가 정상적인 상태라면 ‘서비스가 정상 작동 중’이라는 메세지를, 비정상적인 경우 그 이유와 요약 메시지를 함께 제공한다.

 

 

const ServiceStateToggle = ({ service }) => {
    return (
        <div className="p-4 bg-gray-50 border-t min-h-[90px]">
            <div className="mb-2 flex gap-2 items-center">
                <span className="text-sm font-semibold">이미지 이름:</span>
                <span className="text-sm text-gray-700">{service.imageName}</span>
            </div>
            {service.state === 'Running' && (
                <span className="text-sm text-gray-700">서비스가 정상 작동 중입니다 🚀</span>
            )}
            {service.state !== 'Running' && (
                <>
                    <div className="mb-2 flex gap-2 items-center">
                        <span className="text-sm font-semibold">상태:</span>
                        <span className="text-sm text-gray-700">{service.state}</span>
                    </div>
                    {service.stateReason && (
                        <div className="mb-2 flex gap-2 items-center">
                            <span className="text-sm font-semibold">이유:</span>
                            <span className="text-sm text-gray-700">{service.stateReason}</span>
                        </div>
                    )}
                    {service.stateMessage && (
                        <div className="mb-2 flex gap-2 items-center">
                            <span className="text-sm font-semibold">메시지:</span>
                            <span className="text-sm text-gray-700">{service.stateMessage}</span>
                        </div>
                    )}
                </>
            )}
        </div>
    );
};