안녕하세요 힐링페이퍼에 백엔드 엔지니어로 재직 중인 Manggo입니다. 저번 글에서 저희 서비스가 운영중인 Private EKS Cluster와 배포 pipeline인 Github actions에 대해서 서술했습니다. 이번 글에서는 저희가 어떻게 Multi Tenancy환경을 구축했고 엔터프리이즈 환경으로 어떻게 쉽게 확장할 수 있는지에 대해서 서술하고자 합니다.

Background

서비스 구성

저희는 Multi Tenancy환경을 구축하면서 두 종류의 서비스를 정의하였습니다.

  1. 전체 tenant에 적용되어야 하는 common 서비스
  2. 각 tenant별로 제공되는 tenant 서비스

예를 들어서 SaaS제품에 회원가입, 로그인 등 을 담당하는 서비스는 common service로 지정하였고, 병원별로 직원관리, 병원별 시술 관리등을 담당하는 서비스들은 tenant service로 지정하였습니다.

위의 그림은 모든 테넌트들이 엔터프라이즈 환경을 갖게 될때의 그림입니다. tenant service들과 common service로 분리한 이유는 tenant service의 경우 엔터프라이즈 플랜으로 변경될때 해당 엔터프라이즈 만의 서비스로 가동이 되어야 하기 때문입니다.

Kubernetes Manifest 관리

저희 SaaS 제품 팀은 kubernetes manifest관리를 각 서비스 repository에서 코드로 관리하고 있습니다. kustomize, Argo CD 등 여러가지 툴들이 존재하지만 저희 팀이 택한 방법은 하나의 yaml template file에서 gomplate라는 기술을 이용하여 manifest yaml file을 생성하는 방법 입니다.

이 방법을 택한 이유는, Kubernetes Manifest들을 한눈에 파악하여 해당 서비스의 구성을 쉽게 볼 수 있기 때문입니다.

{{ $service_name := "tenant-service-1" }}
{{ $service_port := "3000" }}

{{ $namespace := getenv "NAMESPACE" }}

{{ $service_account_arn := getenv "AWS_SERVICE_ACCOUNT_ARN" }}
{{ $should_init := conv.ToBool (getenv "SHOULD_INIT" "false") }}
{{ $env := (getenv "ENV" "development") }}
{{ if $should_init }}
---
kind: Namespace
apiVersion: v1
metadata:
  name: {{ $namespace }}
  labels:
    name: {{ $namespace }}
{{ end }}

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: {{ $service_name }}
  namespace: {{ $namespace }}
  labels:
    app: {{ $service_name }}
spec:
  ...
  selector:
    matchLabels:
      app: {{ $service_name }}
  template:
    metadata:
      labels:
        app: {{ $service_name }}
		...
    spec:
      serviceAccount: {{ $service_name }}-sa
      ...
      containers:
        - name: {{ $service_name }}
          image: ...:{{ getenv "IMAGE_TAG" }}
					...
          ports:
            - containerPort: {{ $service_port }}
              protocol: TCP
          env:
						...
          resources:
            ...
          livenessProbe:
						...
          readinessProbe:
            ...
          imagePullPolicy: Always
      ...
  strategy:
    ...
  revisionHistoryLimit: 10
  progressDeadlineSeconds: 600

---
kind: Service
apiVersion: v1
metadata:
  name: {{ $service_name }}
  namespace: {{ $namespace }}
  labels:
    app: {{ $service_name }}
spec:
  ports:
    - protocol: TCP
      port: 80
      targetPort: {{ $service_port }}
  selector:
    app: {{ $service_name }}
  type: NodePort
  sessionAffinity: None
---
kind: ServiceAccount
apiVersion: v1
metadata:
  name: {{ $service_name }}-sa
  namespace: {{ $namespace }}
  annotations:
    eks.amazonaws.com/role-arn: {{ $service_account_arn }}

위와 같이 각 서비스별로 필요한 매니페스트들을 정의하였고 적절하게 파라미터화하여 관리하고 있습니다.

서비스 별로 필요한 resource request, limit은 어떻게 되는지, 해당 서비스의 Deployment는 어떤 container들로 구성되어 있는지, container의 image repository와 image tag는 어떻게 되어있는지 등을 셋팅 후 gomplate를 이용하여 k8s.yaml을 만들고 EKS클러스터에 apply하는 방식으로 관리를 하고 있습니다.

ex)

cat k8s.yaml.tmpl | \
  docker run --rm -i -e SHOULD_INIT -e NAMESPACE -e AWS_SERVICE_ACCOUNT_ARN -e IMAGE_TAG -e ENV hairyhenderson/gomplate:v3.5.0-slim -f - | \
  kubectl apply -f -

엔터프라이즈별로 namespace를 분리하고 service들을 다르게 제공하며 Multi tenancy및 엔터프라이즈 환경을 구축하였습니다.

Namespace를 왜 정의하였는가?

  • 엔터프라이즈별 격리 환경을 namespace로 분리하였습니다.

ServiceAccount는 왜 정의하였는가?

  • 우리의 인프라들은 AWS에 구축되어있고, AWS 서비스들을 이용하기 때문에 ServiceAccount를 통해 각 서비스가 사용할 AWS 서비스들의 권한을 들고 있는 IAM Role(IRSA)을 부여합니다.

Multi tenancy 환경에서의 DB관리 전략

각 서비스의 각 tenant별로 DB 클러스터를 부여하여 관리하고 싶었지만, 초기 비용이 많이 들기 때문에 우선적으로는 통합 DB를 이용하는 전략을 선택했습니다.

이후 data를 별도로 격리하고 격리된 DB에 데이터를 마이그레이션 가능하며 격리시 데이터 스키마에 변화가 적을 수 있도록 하는 기준을 가지고 DB를 선택했는데 마침 최적의 도구가 NoSQL기반 DB인 MongoDB라고 생각하여 선택하게 되었습니다.

최적이라 생각한 이유는 새로운 tenant에 대해서 DDL을 실행 시킬 필요 없이 스키마관리는 application code로만 관리를 하고, 논리적 collection 이름에 tenantId suffix를 붙여서 물리적인 collection을 만들수 있으며, MongoDB의 collection은 동적으로 생성되고 연결할 수 있기 때문에 위와 같은 전략을 통해서 Multi tenancy환경의 데이터를 관리 할 수 있기 때문입니다.

또한 추후에 데이터가 격리된다면, MongoDB에는 유저별 컬렉션 접근제한을 넣을 수 있기 때문에 같은 디비를 이용한다 하여도 컬렉션별 격리를 시킬 수 있습니다.

  • enterprise(or shared) DB
    • service_a_collection_{tenantId}
    • service_b_collection_{tenantId}

위와 같이 데이터를 관리하게 된다면 추후 별도의 엔터프라이즈 DB로 이관할때 tenantId suffix인 collection들만 추출하여 새로운 DB에 dump하기만 한다면 바로 이관이 가능하기 때문에 위와 같은 전략을 택했습니다.

멀티버전 서비스

파트너들 마다 같은 서비스라도 각기 다른 니즈들을 갖고 있기 때문에 같은 서비스여도 파트너 별로 다른 버전을 제공해 주어야 합니다. 그렇기 때문에 저희는 service image들에 semantic version 관리 전략을 택하기로 했고, 해당 version의 image는 git tag를 참조하여 image build하고 deploy하도록 전략을 취했습니다.

k8s deployment의 container image tag만 변경한다면 즉시 반영을 할 수 있기 때문에 미리 만들어둔 deploy action과 해당 action을 trigger할 수 있는 deploy서비스를 직접 구축하여 서비스의 버전관리를 진행하고 있습니다.

ex)

To be continued

지금까지 대략적으로 Multi Tenancy환경을 어떻게 구현하고 관리하고 있는지, 앞으로의 전략은 어떻게 되는지 살펴보았습니다. 아직 발전해야 하는 부분들이 많이 남아있지만, 추후 다양한 서비스 구조변화에 대해서 충분히 유연한 구조를 가져갈 수 있도록 노력하였습니다.

다음 글 에서는 실제 서비스들을 배포하여 운영하기 위해서 런타임 비밀정보(database connection configuration, authorization key, etc…)들을 어떻게 관리하는지에 대해서 서술하고자 합니다. 다음글도 기대해 주세요!

SaaS 시리즈
프론트엔드
Micro Frontends를 위해 Module Federation 적용하기
[TBA] 복잡한 웹 애플리케이션을 위해 복잡하지 않은 구조 만들기
[TBA] TDD로 개발하기
백엔드
Private AWS EKS Cluster With Github Actions Enterprise 환경으로 확장하기 쉬운 Multi-Tenancy 서비스 구축하기 시간여행이 가능한 시스템 아키텍처
[TBA] Enterprise 서비스 비밀 정보 관리하기
[TBA] SaaS 서비스 이벤트 소싱하기
[TBA] TDD로 개발하기
Manggo
Software Engineer
해결해야 하는 문제가 있으면 최대 효율로 최대 효과를 볼 수 있도록 해결합니다