
Tailscale의 iOS 네트워크 확장 프로그램은 메모리 사용량이 15MB를 넘으면 종료될 수 있었다. Tailscale 팀은 Go 런타임과 링커를 손봐 메모리를 줄였지만, 개선을 확인하는 사이 출시된 iOS 15가 사용 가능한 메모리를 35MB 늘렸다.
일반 iOS 앱은 약 5GB까지 메모리를 쓸 수 있지만, 네트워크 확장 프로그램은 15MB 제한을 받았다. 기본 동작만으로도 10~12MB를 사용해 여유가 몇 MB에 불과했다. 메모리를 초과하면 VPN 연결이 끊겼고, 팀은 http2와 UPnP 지원 같은 기능 추가도 주저했다. 특히 기기가 많아 데이터가 크고 갱신도 잦은 tailnet에서 문제가 두드러졌다.
팀은 Go 메모리 할당을 분석하고 패킷마다 발생하는 할당을 없애거나 버퍼를 재사용하는 등 코드를 최적화했다. 가비지 컬렉터를 더 자주 실행하도록 debug.SetGCPercent를 조정하고, 스레드 수와 메모리 할당기 부담을 줄이기 위해 GOMAXPROCS=1도 설정했다. 이런 조치만으로는 충분하지 않았다.
팀은 iOS의 메모리 측정 도구 footprint와 vmmap으로 사용량을 살폈다. 당시 프로세스는 iOS 기준 13MB를 사용했고, 그중 상당 부분은 Go 런타임의 일반 메모리 할당이었다. 다음으로 큰 항목인 __DATA_CONST에는 실행 파일이 메모리에 적재될 때 주소를 고치는 재배치(relocation) 정보가 포함돼 있었다. iOS는 재배치가 적용된 페이지를 변경된 메모리로 계산해, 상수 데이터라도 다시 버릴 수 없는 페이지로 취급했다.
Tailscale 팀은 Go 실행 파일의 프로그램 카운터와 소스 코드 줄을 연결하는 pclntab에 재배치가 집중된 것을 찾아냈다. 재배치 대신 오프셋만 저장하고 실행 중 계산하도록 Go 링커를 수정하면, 재배치의 약 절반을 없애 메모리를 거의 1MB 줄일 수 있다고 분석했다. 팀은 Go 팀과 협력해 변경 사항을 반영했다.
이 변경으로 __DATA_CONST는 1568KB에서 708KB로 감소했다. Tailscale 팀은 메모리 여유가 약 1MB 늘 것으로 예상했지만, 실제로는 36MB 증가했다. Go 링커 작업을 마치는 사이 출시된 iOS 15가 조용히 35MB를 더 제공했기 때문이다.
링커 변경은 iOS 메모리 문제에만 영향을 주지 않았다. Go가 위치 독립 실행 파일을 만드는 darwin/arm64에서는 바이너리 크기가 거의 5%, linux/amd64에서는 거의 3% 줄었다. 링크 작업은 darwin/arm64에서 30% 빨라지고 RAM 사용량은 55% 감소했으며, linux/amd64에서는 4% 빨라지고 RAM 사용량이 10% 줄었다. 이 개선은 Go 1.18에 포함될 예정이라고 당시 Tailscale 팀은 밝혔다.
Tailscale 팀은 Go 도구 체인 자체를 수정한 선택이 다른 스타트업에도 적합하다는 뜻은 아니라고 강조했다. Go 표준 라이브러리부터 런타임과 도구 체인까지 다룰 수 있는 팀의 역량이 있었기에 링커 수정이 합리적인 해결책이었다는 설명이다. 이 사례는 제한된 메모리 환경에서 Go 코드를 최적화하는 것뿐 아니라, 운영체제가 메모리를 어떻게 계산하는지도 살펴볼 필요가 있음을 보여준다.
관련 자료: Tailscale의 iOS 메모리 사용 로깅에 쓰인 API, Go pprof 소개, 패킷별 할당 제거 사례와 추가 사례, 버퍼 재사용 사례와 추가 사례, 계층을 분리한 사례, Go의 가비지 컬렉터 제안, Go 할당기의 크기 구간 설명, Go 스케줄러 설명, 크기 구간 변경 논의, Go 메모리 분석 관련 이슈, Apple의 메모리 진단 도구 영상, 재배치와 위치 독립 코드 설명, 주소 공간 배치 난수화, mprotect, dyldinfo, 재배치 분석 스크립트, go tool nm, pclntab, runtime.Callers, Go 도구 체인 자체 관리에 관한 글과 기술 선택에 관한 글.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요