본문 링크 (Original Link)

미스터리한 힙 커럽션 오류 해결하기

2018.03.19

#

by Agnes Vasarhelyi, translated by pilgwon

얼마 전, 저희 앱의 버그가 갑자기 증가하는 것을 알게되었습니다. 그 버그들은 힙 커럽션(Heap Corruption) 이라고 기록돼있었습니다. 이런 버그는 디버그하기 어려운 것 중 하나인데 왜냐하면 추적기가 알려주는 위치는 실제로 버그가 난 위치나 시점과 거리가 떨어져 있을 가능성이 높기 때문입니다.

여러가지 경로를 통해 조사를 해 나가다 보니, 그 오류는 스위프트 자체의 이슈라고 보여졌습니다. 트위터에서 이것에 대해 얘기를 나눴는데, 너무 많은 사람들이 자세히 알고싶어하여서, 이렇게 글을 쓰게 되었습니다. 이제 힙 커럽션 이슈에 대해 공유해보겠습니다.

앞으로 할 이야기는 슬프고, 좌절감으로 시작해서 끝에선 구원에 이르는 이야기가 될 것입니다. 준비되셨습니까? 혹은 다른 iOS 팀이 버그를 어떻게 조사하고 추적해나가는지 궁금하십니까? 잘 오셨습니다!

먼저, 힙 커럽션이 무엇인지 알아봅시다

힙 커럽션은 메모리 동적 할당이 적절히 이루어지지 않았을 경우에 발생합니다. 보통의 힙 커럽션 문제는 할당된 메모리의 영역 바깥에서 읽기나 쓰기가 이뤄졌거나 메모리 해제를 이중으로 했을 경우에 발생합니다. 그것에 대한 결과(여기선 앱이 꺼지는 버그)는 프로그램이 할당된 메모리를 적절하지 않은 방법으로 조작하려고 했을 떄 바로 나타나지 않기 때문에, 그 이슈를 야기한 근본적인 이유는 여러분의 눈에는 보이지 않을 수 있습니다.

버그 리포팅이 준 신호

모든 것은 Crashlytics의 힙 커럽션 이슈가 증가하는 것에 대한 리포트에서 시작됐습니다. 이슈에 있는 내용들은 실제로 버그가 일어난 곳과는 거의 관련이 없었기 때문에 도움이 되지않았습니다.

이러한 이슈들이 점점 쌓이면서 저희는 긴장되기 시작했습니다. 몇 달만에 크래시-프리 유저 세션이 100%에서 96%까지 내려갔기 때문입니다. 😨

image1

Crashlytics SDK 업데이트 후에 저희가 받는 크래시 수가 더 증가해서, 저는 이것에 대해 문의를 해봤습니다:

@crashlytics Hi there, we're seeing an increased number of crash reports in an iOS app in the past week, all coming from system libraries. Is there any recent change on your side that might make fabric send more system lib crashes? (could be just the latest iOS broke smthg, too)

— Agnes Vasarhelyi (@vasarhelyia) 2018년 1월 22일

안녕하세요, 지난 주 업데이트 이후에 시스템 라이브러리에서 오는 오류 수가 더 늘어났습니다. 혹시 패브릭이 시스템 라이브러리 크래시를 보내는 쪽을 수정하셨나요? (또는 최신 iOS가 어딘가 고장났나요?)

이슈 트래커가 업데이트된걸까요? 그들이 어떤 기능을 켰고 그래서 더 많은 예외들이 시스템 라이브러리에서 발생하는 걸까요?

그들은 아주 빠르게 답장을 주었고 그들의 대답은 절대로 아니다 였습니다. 그 문제는 오롯이 저희의 문제였고 Crashlytics의 문제는 없었습니다. ✅

이슈 재현해보기

버그 리포트를 보다보니 저는 오래됐으며 iOS 11인 기기(아이폰 6, 6 플러스, 5S, SE, 아이팟 터치 6 그리고 아이패드 미니)에서만 그 버그가 발생한다는 사실을 깨달았습니다.

불행히도 저희가 가진 오래된 테스트 기기들은 전부 iOS 10이었습니다! 그 기기들이 iOS 11일때를 테스트해본적이 없었다는 것입니다.

크래시를 애널리틱스 이벤트를 통해 나열해보니 앱이 꺼지는 경우는 사용자가 Try On 뷰를 켜고 우리의 Topology 안경을 AR뷰로 써보려고 할 때 였습니다. 이건 매우 그럴싸해보였고, 왜냐하면 이 화면은 SceneKit과 Metal이 가득 담겨있으며, 앱의 다른 부분들에 비해 더 많은 메모리를 할당하는 가장 무거운 화면 중 하나였습니다.

드디어 저희는 해결의 첫 걸음인 버그 재현에 성공했습니다. 이제 코드의 어떤 부분이 문제인지 조사할 수 있게 되었고 조사를 시작했습니다.

과거로 돌아가봅시다

버그를 찾는 가장 클래식한 방법 중 하나는 이진 탐색을 사용하는 것입니다. 지난 달에 업데이트 했던 앱 버전으로 가서 버그가 나오는 지 확인하고, 그 버전에서 버그가 난다면 그 이전으로, 아니라면 그 후로 가는 방법을 사용해서 버그가 어디서 나오는지 찾는 방법입니다. 충분한 시도를 한다면 여러분은 정확히 어떤 커밋이 이 버그를 내뿜는지 확인할 수 있습니다.

저는 iOS 11이 나오기 전인 8개월 전의 커밋으로 돌아가서 버그를 내뿜고 있는 기기에서 실행해보았습니다. ✅

8개월 전의 앱은 iOS 10에선 잘 작동헀지만 iOS 11에선 잘 작동하지 못했습니다. 결론은 iOS 11에서 바뀐 무언가가 버그를 발생시키는 것이었습니다. 우리 팀이 생각하던 가설은 iOS 11이 iOS 10보다 메모리를 많이 사용하고 많아진 메모리 사용량이 과거 기기들에서 버그를 발생하게 했다는 가설이었습니다.

모든 가정을 시도해봅니다

저는 그 가설이 맞는지 확인하기로 했고, iOS 11이 설치된 아이폰 7에서 실행해봤습니다. 하지만 결과는 크래시였습니다.

만약 너무 많은 메모리 때문이라면 메모리 덩어리를 malloc 을 해서 해결하고 크래시가 없었어야 했습니다.

결국 저희가 세운 가설은 틀렸습니다. 하지만 아주 큰 가능성 중 하나를 삭제했기 때문에 실패는 아니었습니다. ✅

코드를 쪼개고 분석하기

이 시점에서, 저희는 이 문제가 어떻게 처음부터 일어날 수 있는지 생각해보기로 했습니다.

저희는 앱의 어떠한 부분에서 메모리를 제대로 핸들링하지 못하는 경우가 발생해서 힙 커럽션이 일어난다는 사실을 알게되었습니다.

어디가 문제인지 알기 위해 몇 가지 이슈로 조사해보았습니다.

  1. 잘못된 포인터 조작 (예. 이중 해제)

저희는 코드 중에 로우 포인터 핸들링을 하는 모든 코드를 확인했습니다. 저희 코드 중엔 C++이나 로우 레벨 그래픽스 코드도 있었는데, 이 두 코드는 잘못된 포인터 사용을 하기 좋은 곳이었지만 모두 괜찮은 것을 확인했습니다. ✅

  1. 쓰레드 데이터 레이스

Xcode에는 Thread Sanitizer(쓰레드 살균기)라는 좋은 툴이 있는데, 이 툴은 앱에서 데이터 레이스가 일어나는 부분을 찾는 것을 도와줍니다. 불행히도 Thread Sanitizer는 시뮬레이터에서만 돌아가며 우리 앱의 대부분의 기능은 시뮬레이터에서는 작동하지 않았습니다. 시뮬레이터에서 작동하는 부분에서는 어떠한 Thread Sanitizer 경고를 일으키지 않고 잘 작동했습니다.

저희는 수동으로 동시에 작동하는 코드들을 전부 확인했고, 모두 안전 또는 점검 필요 와 같은 마킹을 해두었습니다. 하지만 모두 잘 작동하는 것으로 확인됐습니다. ✅

이제 어쩌죠? 😳

쉬는 시간

“iOS 11의 버그일수도 있잖아?” - 에릭, 우리 대표님

플랫폼이나 시스템 프레임워크를 비난하는 것은 쉬운 일입니다. 하지만, 그런건 거의 없는 일이나 마찬가지입니다. 저는 우리 대표님에게 그저 웃으며 “저는 그렇게 생각안할래요” 라고 했습니다. 그때의 전 몰랐습니다. 우리 대표님이 진실과 가장 가까웠다는 사실을요.

만약 저희가 그것이 스위프트 버그라는 결론으로 바로 뛰어넘었더라도, 여전히 발생하는 버그의 이유를 알아내야만 했습니다.

Brute Force

“의심스러울 땐 모두 다 해보면 돼.” - Ken Thompson

결국 저는 마지막 방법인 브루트 포스(다 해보는 방법)를 사용하기로 했습니다. “앱을 조각조각 잘라내기”로 알려져 있기도 한 방법이죠. 🔪

써드 파티 코드 지워보기

저는 문제가 우리 코드에 있지 않을 가능성을 대비해 모든 써드 파티 의존 코드를 삭제했습니다. 운 좋게도 저희는 써드 파티 라이브러리를 최대한 추가하지 않는 규칙을 가지고 있었고 있더라도 SSZipArchive와 같이 분리가 용이한 코드들이 대부분이었습니다.

여전히 크래시가 발생했습니다. ✅

다음으로, 저는 컴포넌트들에 대부분 쓰이는 프레임워크를 삭제해보았습니다.

여전히 크래시가 발생했습니다. ✅

의심되는 조각들을 빈 프로젝트로 옮기기

저는 무거운 AR뷰가 꽤 의심되었고, 그래서 그것만의 프로젝트로 밀어버렸습니다.

여전히 크래시가 발생했습니다, 좋네요! ✅

저에게 아이디어가 있어서 모든 AR뷰를 삭제해버렸습니다.

화면이 두번째로 나올때마다 모두 크래시가 발생했습니다. ✅ 😳

혼란은 극에 달했습니다. 🤯

앞이 막혔다면, 새로운 각도로 보아라

AR뷰가 문제라고 생각했던 저의 생각이 완전히 박살났고, 아예 다른 시각으로 바라보기로 했습니다.

3D 모델을 모든 종류의 데이터 구조로 파싱하는 과정의 코드는 전체적으로 얇았습니다. 컨커런트한 부분도 없었고 모든 것이 비동기로 돌아갔습니다. 저는 크래시가 일어나는 부분을 다시 보았습니다. 힙 커럽션을 일으키는 위치는 대부분 다르지만 특정한 한 곳에선 꾸준히 일어나고 있었습니다. 저는 이 부분을 자세히 보기로 했습니다.

힙 커럽션 오류가 일어나던 패턴은 Dictionary 가 발생시키고 언제나 double3 과 같은 simd 타입이 딕셔너리에 들어있었습니다.

This heap corruption issue is driving me crazy. Too many times in the past three days I felt like I'm almost there and then suddenly nowhere close to solving it. There's simd involved, crash is iOS 11 & low-memory-device exclusive, very tricky. Send help. 🧠

— Agnes Vasarhelyi (@vasarhelyia) 2018년 2월 9일

이 힙 커럽션 이슈는 저를 미치게 해요. 지난 사흘동안 제가 도착했다고 생각하면 사라지는 것을 반복했어요. 분명히 메모리가 적은 상태에서의 iOS 11의 simd가 일으키는 어떠한 버그가 있을거라고 생각합니다. 도와주세요.

이 땐 일주일이 넘게 이어진 지치는 사냥을 그만둘 준비가 돼있었습니다. 🏹🐞

image2

요약: 앱의 버그가 아니라 스위프트의 버그일 수 있다..

만약에.. 정말 스위프트의 버그라면요? 🙀

저는 제 맥북을 다시 열고 살짝 미친 짓을 하기 시작했습니다.

image3

10분 후:

image4

Found the source of the simd heap corruption issue. ⚠️

Apparently, creating even a few instances of Dictionary<String, double3> on iOS 11 on iPhone6-ish devices results in heap corruption. Reproducible in five lines of code. Radar on the way, Apple folks. https://t.co/WCAP9qMfbd

— Agnes Vasarhelyi (@vasarhelyia) 2018년 2월 10일

simd 힙 커럽션 이슈에 대한 근본적인 이유를 찾았습니다. ⚠️ iOS 11을 설치한 아이폰 6과 같은 기기에서 Dictionary<String, double3> 인스턴스를 만들고 있었는데 힙 커럽션이 발생했습니다. 다섯 줄의 코드로 말이죠. 애플 보고있나?

그 후에

무엇이 문제인지를 찾는 것은 작업하는 데 있어서 아주 중요한 부분입니다. 하지만 여정의 끝은 아니죠. 무엇이 잘못됐는지 알게된 후에 해야 할 일이 있습니다.

제2의 해결책

사용자들은 여전히 크래시를 일으키는 앱을 사용하고 있습니다. 😬

저희가 선택한 해결책은 모든 double3 값들을 float3 으로 바꾸는 것이었습니다. double3 에서 float3 으로 바꾼 후에 딕셔너리를 스트레스 테스트 해봤고 문제가 없는 것을 확인했습니다.

저흰 여전히 왜 double3 이 딕셔너리에서 문제를 일으키는지는 모르지만, 빨리 새로운 버전을 앱 스토어에 제출하고 싶었습니다. 그렇게 바꾼 후에 저희는 다시 크래시 프리 세션이 100%로 올라가는 해피 엔딩을 맞이했습니다. 😎

File a radar

image5

애플 사람들은 이 버그에 대해 모르고 있을 것이고 또 다른 많은 사람들이 같은 버그로 인해 고통받고 있을 것입니다. 그들에겐 우리의 도움이 필요했습니다.

저는 운 좋게도 스위프트 표준 라이브러리에 참여중인 Karoly Lorentey라는 친구를 알고 있었습니다. 그는 제가 이 문제에 대해서 트윗하기 시작헀을 때부터 진행사항에 대해 듣고있었습니다. 그리고 그는 이 버그를 제보했고 Erik Eckstein이 그 문제를 수정했습니다! 이 수정본은 Swift 4.1과 Xcode 9.3 베타 4에 배포됩니다.

저는 저희 앱이 새롭게 업데이트된 Xcode 베타에서 빌드하면 문제가 생기지 않을 것이라고 확신합니다. 🎊

또한 Karoly는 친절하게 문제가 무엇인지 알려주었습니다. 그러니 왜 그러한 힙 커럽션이 일어났는지 알아봅시다.

진짜 문제를 알아봅시다

진짜 문제는 “오래된” 기기에 국한되지 않았습니다. ❌ 모든 플랫폼, 그리고 모든 기기에 영향을 끼쳤습니다. ✅

진짜 문제는 Dictionarydouble3 이 아니었습니다. ❌ 모든 컬렉션 타입이 영향을 받으며, 16 바이트보다 큰 스트롱 타입을 저장할 때 발생합니다. ✅

그들의 요소가 이례적으로 광범위하게 정렬되었을 때, 표준 라이브러리의 컬렉션 타입은 항상 올바르게 정렬되도록 할당할거란 보장을 가지고 있지 않습니다. 만약 저장소의 시작점이 적절한 주소에서 시작하지 않았을 경우, 딕셔너리는 그것을 반올림해서 가장 가까운 정렬 바운더리로 옮깁니다. 이것은 올바른 정렬을 보장하지만 또한 마지막 딕셔너리 요소의 일부분이 할당된 버퍼의 바깥쪽에 있을 수 있다는 것을 의미합니다. 이것은 오버플로우로 이어집니다. OS/언어/기기 파라미터는 이러한 이슈를 더 자주 발생하도록 만들었을 것이며, 이것이 iOS 11을 실행하는 특정 장치에서 두드러지게 된 이유일 것입니다.

정렬이 뭔가요?

Alignment(정렬)은 데이터를 “짝수” 메로리 주소에 정렬하는데 필요한 패딩의 양을 정의합니다. 프로세서는 여러분의 데이터가 적절히 정렬돼있을 때 메모리 접근의 최대 효율을 보여줍니다. 몇몇은 정렬되지 않은 데이터로 작동할수도 있지만 퍼포먼스 이슈가 있을 것입니다. 예를 들면, ARM은 이 부분에 대해 엄격하며, 허락되지 않았을 경우의 정렬되지 않은 접근은 정렬 결함을 발생시킵니다.

실제로 대부분의 스위프트 유형이 16바이트보다 큰 정렬이 없습니다. 이 문제가 우리 앱에서 발생한 이유는 simd 타입을 광범위하게 사용하고 있기 떄문입니다. simd 타입은 광범위하게 정렬되는 경향이 있습니다.

저희는 Address Sanitizer와 Instruments 메모리 인스펙터로 문제를 디버깅했는데도 잡지 못했습니다. 이유가 궁금하네요. 🤷🏻‍♀️

배운 점

버그를 찾고 고치는 일은 소프트웨어 엔지니어의 업무 중 큰 부분입니다. 우리 모두는 그것을 어떻게 하는지 알지만, 가끔씩은 큰 그림을 보며 리뷰하는 것이 더 나을 때도 있다는 것을 알게되었습니다.

마지막으로, 저는 애플에게 버그 리포트를 하기에 충분할 정도의 풍부한 노트와 함께 버그를 발생하는 간단한 프로젝트를 만들었습니다.

저는 체계적인 접근과 약간의 무식함이라면 어떤 문제든 이겨낼 수 있다는 것을 배웠습니다.

그리고 마지막으로, 저는 때때로 우리 대표님이 맞을 때도 있다는 것을 배웠습니다. 죄송해요 대표님. 😉