최근에 GeekNews에서 재밌는 글을 봤다. SaaS를 운영하는 1인 개발자가 설계한 아키텍처에 대한 설명인데, GeekNews에 공유해주신 분이 요약 해주신 내용만으로도 굉장히 퀄리티 높은 글인 것을 느낄 수 있었다. 그래서 나도 원문을 읽으며 나름대로 정리를 해볼려고 한다.

참고로 본인이 1인 기술 스타트업을 만들 생각이 없어도 개발자라면 한 번쯤 읽어보면 도움이 되는 글이라고 생각한다.

원문 글: https://anthonynsimon.com/blog/one-man-saas-architecture/

서론

  • 원문 글의 필자는 독일에서 1인 기업을 운영하고 있고 스트레스 없이 자기 자본으로만 천천히 운영하고 있다고 한다.

  • 혼자서 운영하는 만큼 다양한 오픈 소스와 서비스들을 사용하게 되었는데, 이것 없이는 목표를 이룰 수 없었고 이걸 사용함에 있어서 거인의 어깨에 서있는 느낌을 받았다고 한다.(개인적으로 이 표현이 좋았다.)

  • 상황에 따라 기술적 선택은 다르다. 이 글은 본인이 선택한 기술들을 공유하는 것 뿐이니, 진리라고 생각하지 말자.

  • 글쓴이는 AWS에서 Kubernetes를 사용하는데, 이것은 단지 이전 회사와 팀에서 몇 년동안 삽질을 하며 내공을 쌓았기 때문에 쓴 것이다. 이 글을 보는 사람들은 자신에게 익숙한 기술을 사용해서 생산성을 저하시키는 방향을 가지 않도록 하자.

프로젝트의 구조

  • 글쓴이가 만든 PanelBear라는 서비스로 프로젝트 구조를 설명함.

  • django monolith 구조, app DB는 Postgres, analytics data는 ClickHouse, 캐싱은 Redis, 테스크 스케줄링은 Celery를 사용하고 쿠버네티스(EKS)에서 운영

  • Monolithic 구조이므로 Django는 Rails나 Laravel 등으로 대체 가능하다.

  • 여기서 흥미로운 부분은 autoscaling, ingress, TLS certificates, failover, logging, monitoring 같은 것들이 서로 결합되고 자동화 되는 부분이다.

  • 이 세팅은 다양한 프로젝트에서 사용했다. 이 세팅 덕분에 비용을 줄이고 검증을 쉽게 할 수 있었다.(Dockerfile을 작성하고 git push만 하면 된다.)

  • 실제로 인프라를 관리하는 시간은 한 달에 0~2시간 뿐이다. 덕분에 피처 개발, CS, 사업의 성장에 집중 할 수 있었다.

  • “Kubernetes makes the simple stuff complex, but it also makes the complex stuff simpler”

Automatic DNS, SSL, and Load Balancing

  • 첫 번쨰 주제: 클러스터에서 트래픽을 어떻게 받을 것인가?

  • 클러스터는 private 네트워크에 있다.

  • NLB(AWS L4 Network Load Balancer)로 트래픽을 향하게 하는 Cloudflare proxying이 존재한다. 이 로드 밸런서는 public internet과 private network의 브릿지 역할을 한다.

  • 요청이 들어오면 로드밸러스가 쿠버네티스 클러스터 노드 중 하나로 포워드한다.

  • ingress-nginx를 사용해서 쿠버네티스가 어느 서비스로 포워드 할지 정해준다. ingress-nginx는 클러스터의 입구다.

  • NGINX는 요청을 일치하는 컨테이너(해당 글에서는 Uvicorn으로 서빙되는 django)로 전달하기 전에 rate-limiting과 traffic shaping 규칙을 적용한다.

  • 몇 개의 terraform/kubernetes 파일로 전체 프로젝트에 적용 가능하기 때문에 한 번 설정하고 잊어버릴 수 있다.

  • 새로운 프로젝트를 배포 할 때, 필수적인 설정을 20줄만 적어주면 된다.

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
 namespace: example
 name: example-api
annotations:
 kubernetes.io/ingress.class: "nginx"
 nginx.ingress.kubernetes.io/limit-rpm: "5000"
 cert-manager.io/cluster-issuer: "letsencrypt-prod"
 external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
spec:
tls:
- hosts:
   - api.example.com
 secretName: example-api-tls
rules:
- host: api.example.com
 http:
   paths:
     - path: /
       backend:
         serviceName: example-api
         servicePort: http

Automated rollouts and rollbacks

  • GitHub의 마스터 브랜치에 프로젝트를 push하면 GitHub Actions의 CI 파이프라인이 실행 된다.

  • CI 파이프라인은 아래와 같은 과정을 거친다.

    1. 코드베이스로 체크한다.(Format, Lint, Check types)
    2. Docker-compose로 완벽한 환경을 만들어서 end-to-end test를 거친다.
    3. 1, 2번이 통과되면 새로운 Docker image를 만들어서 ECR로 push 한다.
  • ECR로 push 되면 flux라는 컴포넌트가 현재 클러스터에서 실행되고 있는 이미지와 최근 등록된 이미지를 동기화 시켜준다.

  • Flux는 새로운 Docker image가 생겼을 때 자동으로 incremental rollout을 하고 “Infrastructure Monorepo"에 기록한다.

Let it crash

  • 쿠버네티스는 비정상 적인 pod이 고쳐질 동안 트래픽을 정상적인 pod으로 옮기는 것에 뛰어나다.

Horizontal autoscaling

  • 컨테이너들은 CPU와 Memory 사용량에 따라 오토스케일링 된다.

  • 노드당 많은 Pods이 클러스터에 생기면 자동적으로 클러스터 용량을 늘리고, 아닐 경우 축소한다.

  • 오토스케일링은 아래와 같이 한다

    apiVersion: autoscaling/v1
    kind: HorizontalPodAutoscaler
    metadata:
    name: panelbear-api
    namespace: panelbear
    spec:
    scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: panelbear-api
    minReplicas: 2
    maxReplicas: 8
    targetCPUUtilizationPercentage: 50
    
  • PanelBear는 API pod을 CPU 사용량에 따라 2개에서 9개로 복제본을 조정한다.

Static assets cached by CDN

  • Cloudflare를 사용해서 DNS, 그리고 요청에 대한 CDN, DDoS 보호를 함.

  • 단순하게 HTTP cache headers를 사용해서 Cloudflare 캐시를 컨트롤 하게 한다.

    # Cache this response for 5 minutes
    response["Cache-Control"] = "public, max-age=300"
    
  • Whitenoise를 사용해서 app container로 부터 정적 파일을 서빙한다.

  • 이걸 사용하면 Nginx/Cloudfront/S3에 정적파일을 업로드 할 필요가 없어서 간단하다.

  • 지금까지 이런 방식은 문제가 없었고 성능도 뛰어나면서 간단했다.

  • 정적 웹사이트로는 NextJS를 사용했다. Cloudfront/S3 또는 Netlify/Vercel을 통해 서빙 할 수도 있지만, 클러스터의 하나의 컨테이너로 실행되게 하고 정적 assets을 Cloudflare에 캐싱하는게 더 쉬웠다.

Application data caching

  • 파이썬에서 제공하는 LRU(Least Recently Used) cache를 사용해서 네트워크 콜을 제로로 할 수 있었다.

  • 대부분의 endpoints는 인 클러스터 Redis로 캐싱 했다. Redis는 모든 Django instance가 공유 했다. 인스턴스가 재배포 되면 메모리 캐시를 모두 지웠다.

Per endpoint rate-limiting

  • Kubernetes의 nginx-ingress로 전역적인 rate limits를 설정하긴 했지만, 특정 endpoint나 메서드에도 limit을 설정하기 위해 Django Ratelimit 라이브러리를 사용했다.

  • user의 ip를 각 request에 따라 Redis에 저장해서 조건에 맞게 요청을 제한한다. 아래 예제는 한 유저가 1분에 요청을 5번 넘게 보내면 HTTP 429 Too Many Requests status 상태 코드를 반환하는 예제이다

    class MySensitiveActionView(RatelimitMixin, LoginRequiredMixin):
        ratelimit_key = "user_or_ip"
        ratelimit_rate = "5/m"
        ratelimit_method = "POST"
        ratelimit_block = True
    
        def get():
            ...
    
        def post():
            ...
    

App administration

  • Django가 기본으로 제공해주는 admin을 사용합니다.

Running scheduled jobs

  • 고객용 데일리 리포트, 15분 마다 사용 통계 계산, 스태프용 이메일 보내기 등 다양한 예약 작업이 있다.

  • Celery workers와 Celery beat를 cluster에서 실행한다. Task queue로는 Redis를 사용한다.

  • 만약 예약 된 작업이 정상적으로 작동하지 않으면 Healthchecks를 통해 slack/sms/email로 알림을 받는다.

  • 아래는 예약 된 작업의 상태를 체크하는 모니터링 코드다.

    def some_hourly_job():
        # Task logic
        ...
    
        # Ping monitoring service once task completes
        TaskMonitor(
        name="send_quota_depleted_email",
        expected_schedule=timedelta(hours=1),
        grace_period=timedelta(hours=2),
        ).ping()
    

App configuration

  • 쿠버네티스의 configmap을 사용하여 환경 변수를 오버라이딩하고 django의 settings.py에 환경 변수를 정의 한다.
apiVersion: v1
kind: ConfigMap
metadata:
 namespace: panelbear
 name: panelbear-webserver-config
data:
 INVITE_ONLY: "True"
 DEFAULT_FROM_EMAIL: "The Panelbear Team <support@panelbear.com>"
 SESSION_COOKIE_SECURE: "True"
 SECURE_HSTS_PRELOAD: "True"
 SECURE_SSL_REDIRECT: "True"
INVITE_ONLY = env.str("INVITE_ONLY", default=False)

Keeping secrets

  • 쿠버네티스의 kubeseal을 사용해서 키들을 암호화한다. kubeseal은 비대칭 암호화를 한다. 그리고 오직 클러스터만 복호화 키에 접근할 권한을 가지고 있다. 암호화하면 아래와 같이 보인다.

    apiVersion: bitnami.com/v1alpha1
    kind: SealedSecret
    metadata:
        name: panelbear-secrets
        namespace: panelbear
    spec:
        encryptedData:
            DATABASE_CONN_URL: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq...
            SESSION_COOKIE_SECRET: oi7ySY1ZA9rO43cGDEq+ygByri4OJBlK......
    
  • 클러스터 안의 Secret을 보호하기 위해 AWS KMS을 사용한다. 쿠버네티스 클러스터를 생성할 때 세팅한다.

  • 과정을 나타내면 아래와 같다.

    1. 암호화 할 환경 변수를 쿠버네티스 manifest에 작성한다.
    2. 커밋하기 전에 암호화를 하고 푸시한다.
    3. 암호화 된 변수는 배포되고, 클러스터는 자동으로 컨테이너를 실행하기 전에 암호화 된 변수를 복호화 한다.

Relational data: Postgres

  • 처음에는 바닐라 postgres 컨테이너를 쿠버네티스 클러스터에서 실행했다.

  • 프로젝트가 성장함에 따라 AWS RDS로 이전했다.

  • RDS를 사용함으로써 지속적인 보안 업데이트, 암호화 된 백업 같은 장점을 누릴 수 있었다.

Columnar data: ClickHouse

  • 프로젝트의 분석 데이터를 실시간으로 쿼리하고 효율적이게 저장하기 위해 ClickHouse를 이용했다.

  • 이 서비스는 엄청 빠르고, 데이터 구조를 잘 잡음으로써 압축률만 잘 잡으면 저장 비용이 낮아짐. 이건 수익률을 높임.

  • 현재 ClickHouse 인스턴스를 쿠버네티스 클러스터에 호스트해서 사용하고 있다.

  • 쿠버네티스 CronJob을 사용해서 주기적으로 효율적인 columnar format으로 백업한 데이터를 S3로 보냄.

  • 재난 상황이 일어났을 때 복원 할 수 있게 스크립트를 짜놓음

  • 유일하게 써보지 않은 툴이였는데, 문서가 잘 정리되어 있어서 금방 사용하게 됨.

DNS-based service discovery

  • 각 컨테이너끼리 쿠버네티스 내에서 서로 통신은 DNS 레코드 기반으로 이루어진다.

    redis://redis.weekend-project.svc.cluster:6379
    
  • DNS 레코드는 쿠버네티스가 자동으로 관리한다.

  • 쿠버네티스는 자동으로 DNS 레코드를 건강한 Pod으로 동기화 한다.

  • 위의 내용의 원리를 알고 싶으면, 잘 설명하고 있는 글 하나를 추천한다.

Version-controlled infrastructure

  • version-controlled를 간단한 커맨드로 하고 싶어서 Docker, Terraform 그리고 Kubernetes manifest를 하나의 레포에 두었다.

  • 이 레포는 여러 프로젝트에 대한 인프라를 가지고 있다. 즉, 여러 프로젝트들은 서로 다른 레포에 저장되어 있지만, 각 프로젝트의 인프라에 대한 관리는 이 단일 레포에서 하는 것이다.

  • The Twelve-Factor App에 대해 알고 있다면 이 구조가 꽤 괜찮아 보일 것이다.

  • 이렇게 해서 단순한 커맨드로 인프라로 관리할 수 있게 되었다. 아래는 인프라 단일 레포의 구조를 예시로 나타냈다.

    # Cloud resources
    terraform/
        aws/
            rds.tf
            ecr.tf
            eks.tf
            lambda.tf
            s3.tf
            roles.tf
            vpc.tf
        cloudflare/
            projects.tf
    
    # Kubernetes manifests
    manifests/
        cluster/
            ingress-nginx/
            external-dns/
            certmanager/
            monitoring/
    
        apps/
            panelbear/
                webserver.yaml
                celery-scheduler.yaml
                celery-workers.yaml
                secrets.encrypted.yaml
                ingress.yaml
                redis.yaml
                clickhouse.yaml
            another-saas/
            my-weekend-project/
            some-ghost-blog/
    
    # Python scripts for disaster recovery, and CI
    tasks/
    ...
    
    # In case of a fire, some help for future me
    README.md
    DISASTER.md
    TROUBLESHOOTING.md
    

Terraform for cloud resources

  • Terraform을 사용해서 대부분의 클라우드 리소스를 관리한다.

  • 문서화와 리소스를 추적하고 인프라를 설정하는 것에 도움을 주었다.

Kubernetes manifests for app deployments

  • 쿠버네티스 manifest들은 모든 YAML 파일로 기술되어 있고 인프라 단일 레포에 위치해 있다.

  • 두 개의 레포로 분리 했다: cluster & app

  • cluster 디렉토리 안에는 nginx-ingress, encryped secrets, premotheus scaper 같은 클러스터 전체 서비스의 설정에 대한 내용이 있다.

  • app 디렉토리 안에는 프로젝트와 관련된 내용이 있다.

Subscriptions and Payments

  • Stripe Checkout으로 결제 기능을 관리한다.

  • 결제 정보에 접근할 필요가 없으므로 프로덕트 개발에 더 집중할 수 있게 됐다.

  • 새로운 고객 세션을 만든 뒤 Stripe가 호스트한 페이지로 리다이렉트 시킨 후 webhook을 통해 결제의 결과에 따라 나의 데이터베이스를 업데이트 하기만 하면 된다.

  • 물론 webhook과 관련되서 중요한 부분이 있지만, Strip 문서가 잘 다뤄주고 있다.

Logging

  • logging을 하기 위해 특별한 agent 같은 건 사용하지 않음.

  • stdout을 통해 찍힌 로그를 쿠버네티스가 자동으로 수집하게 하고, 로그를 rotate 함.

  • ElasticSearch/Kibana 같은 자동으로 로그를 모아주는 툴을 쓸 수도 있지만, 지금은 간단하게 유지하고 싶어함.

  • 로그를 추적하기 위해 쿠버네티스 CLI 툴인 stern을 사용함.

Monitoring and alerting

  • 처음에는 Prometheus/Grafana를 자체 호스팅해서 클러스터와 애플리케이션 수치를 모니터링 했다.

  • 하지만 클러스터가 문제가 생기면 모니터링 툴의 alerting system도 다운되서 좋지 않다고 느꼈다.

  • 그래서 New Relic로 툴을 변경했다.

  • 클러스터 내의 모든 서비스는 Prometheus integration을 가지고 있어서 자동으로 기록을 New Relic으로 보낼 수 있었다. 그래서 New Relic으로 마이그레이션 할 때 단순히 Prometheus Docker image만 사용하면 됐었다.

  • 장고 앱의 수치를 기록하는 방법으로는 django-prometheus 라이브러리를 사용했다.

Error tracking

  • Error tracking 툴로는 Sentry를 사용했다.

  • 장고 앱에서 샌트리를 사용하는 방법은 간단하다.

    SENTRY_DSN = env.str("SENTRY_DSN", default=None)
    
    # Init Sentry if configured
    if SENTRY_DSN:
        sentry_sdk.init(
            dsn=SENTRY_DSN,
            integrations=[DjangoIntegration(), RedisIntegration(), CeleryIntegration()],
            # Do not send user PII data to Sentry
            # See also inbound rules for special patterns
            send_default_pii=False,
            # Only sample a small amount of performance traces
            traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.008),
        )
    
  • 또한 Slack #alerts 채널을 사용해서 모든 알림(downtime, cron job failures, security alerts, performance regressions, application exceptions, and whatnot.)을 이 채널로 오도록 했다.

Profiling and other goodies

마치면서 (개인적인 생각)

저자도 앞에서 언급한 것과 같이 위에서 소개 된 도구와 방법은 정답이 아니다. 각자의 상황 또는 프로젝트의 특성에 따라 정답은 바뀔 수 있다.

그러나 프로젝트가 진행 될 때 공통적으로 필요한 기능들은 위에서 대부분 언급했고 그에 대한 하나의 선택지를 알려줬다고 생각한다.

이 글을 쓰는 나를 포함해서 글을 읽는 여러분들이, 원문의 저자가 공유해준 경험과 선택지를 참고 삼아 자신만의 정답을 찾았으면 좋겠다.


Reference