최근에 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 파이프라인은 아래와 같은 과정을 거친다.
- 코드베이스로 체크한다.(Format, Lint, Check types)
- Docker-compose로 완벽한 환경을 만들어서 end-to-end test를 거친다.
- 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을 사용한다. 쿠버네티스 클러스터를 생성할 때 세팅한다.
과정을 나타내면 아래와 같다.
- 암호화 할 환경 변수를 쿠버네티스 manifest에 작성한다.
- 커밋하기 전에 암호화를 하고 푸시한다.
- 암호화 된 변수는 배포되고, 클러스터는 자동으로 컨테이너를 실행하기 전에 암호화 된 변수를 복호화 한다.
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
- cProfile, snakeviz, Django debug toolbar를 통해 profiling을 했다.
마치면서 (개인적인 생각)
저자도 앞에서 언급한 것과 같이 위에서 소개 된 도구와 방법은 정답이 아니다. 각자의 상황 또는 프로젝트의 특성에 따라 정답은 바뀔 수 있다.
그러나 프로젝트가 진행 될 때 공통적으로 필요한 기능들은 위에서 대부분 언급했고 그에 대한 하나의 선택지를 알려줬다고 생각한다.
이 글을 쓰는 나를 포함해서 글을 읽는 여러분들이, 원문의 저자가 공유해준 경험과 선택지를 참고 삼아 자신만의 정답을 찾았으면 좋겠다.
Reference