마이크로서비스가 언제 좋은 선택인지 묻는 질문에 답하기 위해 모듈성, 격리, 배포 단위의 경계를 역사와 함께 살펴본다.
최근 들어 마이크로서비스가 언제 좋은 생각인지 묻는 사람이 많습니다. systems design explains the world에서 저는 세컨드 시스템 효과, 혁신가의 딜레마 같은 큰 그림의 이슈들을 이야기했습니다. 시스템 설계는 마이크로서비스 질문에도 답할 수 있을까요?
그렇습니다. 다만 그 답이 마음에 들지 않을 수도 있습니다. 우선 약간의 역사가 필요합니다.
인터넷에는 여러 정의가 있습니다. 제가 내린 정의는 이렇습니다: 마이크로서비스는 _모놀리스_에 대한 가능한 한 가장 극단적인 반동입니다.
모놀리스는 앱 전체에 필요한 모든 것을 하나의 거대한 프로그램으로 링크하고, 하나의 큰 덩어리로 배포할 때 생깁니다. 모놀리스에는 CGI, Django, Rails, PHP 같은 프레임워크까지 거슬러 올라가는 긴 역사가 있습니다.
바로 여기서, 모놀리스와 마이크로서비스 무리(fleet)가 유일한 두 선택지라는 가정은 버립시다. “모든 걸 하는 거대한 하나의 서비스”에서 “각각은 거의 아무것도 하지 않는 무한히 작은 서비스들”까지는 넓고 미묘한 연속체가 있습니다.
유행을 따르면, 적어도 한 번은 모놀리스를 만들었을 겁니다(일부러든, 전통적인 프레임워크가 그렇게 하도록 부추겼기 때문이든). 그리고 모놀리스의 문제 몇 가지를 발견한 다음, 마이크로서비스가 해답이라는 이야기를 듣고, 모든 것을 마이크로서비스로 다시 설계하기 시작하죠.
하지만 유행을 따르지 마세요. 그 극단들 사이에는 많은 지점이 있고, 그중 하나가 아마 당신에게 맞습니다. 더 나은 접근은 _인터페이스_를 어디에 둘지에서 시작합니다.
인터페이스는 모듈 사이의 연결입니다. 모듈은 관련된 코드의 묶음입니다. 시스템 설계에서는 “박스와 화살표” 엔지니어링을 이야기합니다: 모듈이 박스이고, 인터페이스가 화살표입니다.
그러면 더 깊은 질문은 이것입니다: 박스는 얼마나 커야 할까? 각 박스에는 얼마나 많은 것이 들어가야 할까? 하나의 큰 박스를 언제 두 개의 작은 박스로 나눌지 어떻게 결정할까? 박스들을 연결하는 최선의 방법은 무엇일까? 이 모든 것에는 많은 접근이 있습니다. 무엇이 최선인지는 아무도 확실히 모릅니다. 소프트웨어 아키텍처에서 가장 어려운 문제 중 하나입니다.
수십 년에 걸쳐 우리는 다양한 종류의 “박스”를 거쳐 진화해 왔습니다. Goto statements were "considered harmful"라는 말이 나온 이유는, goto가 어떤 계층 구조도 불가능하게 만들었기 때문이 큽니다. 그다음 우리는 함수나 프로시저를 추가했는데, 이것들은 매우 단순한 박스이며 그 사이의 인터페이스는 (매개변수와 반환 코드 같은) 것들입니다.
프로그래밍의 어느 가지를 타느냐에 따라, 그다음에는 재귀 함수, 컴비네이터, 정적 함수 프로토타입, 라이브러리(정적 링크 또는 런타임 링크), 객체(OOP), 코루틴, 보호된 가상 메모리, 프로세스, 스레드, JIT, 네임스페이스, 샌드박스, chroot, jail, 컨테이너, 가상 머신, 슈퍼바이저, 하이퍼바이저, 마이크로커널, 그리고 unikernels 같은 것들을 만나게 됩니다.
그리고 그건 박스에 불과합니다! 박스들이 서로 격리되면, 이제 화살표로 연결해야 합니다. 이를 위해 ABI, API, syscall, 소켓, RPC, 파일시스템, 데이터베이스, 메시지 패싱 시스템, “가상화된 하드웨어” 같은 것들이 있습니다.
현대적인 Unix 시스템의 완전한 박스-화살표 다이어그램을 그리려고 한다면(저는 안 하겠습니다), 정말 난장판일 겁니다: 스레드 안의 함수, 프로세스 안의 스레드, 컨테이너 안의 프로세스, 유저스페이스 안의 컨테이너, 그 아래의 커널, VM 안의 커널, 랙의 하드웨어 위에서, 데이터센터에서, 클라우드 제공자 안에서, 오케스트레이션 시스템으로 묶여 있고… 등등.
각 추상화 계층의 박스들은 어떤 방식으로든 서로 격리되어 있으면서도, 같은 계층 또는 다른 계층의 어떤 박스들과는 연결되어 있습니다. 어떤 것들은 다른 것들 안에 들어 있습니다. 이런 그림을 2차원에 정직하게 그리려면 선들이 절망적으로 교차할 수밖에 없을 겁니다.
이 모든 것은 수십 년에 걸쳐 진화했습니다. 멋진 사람들은 이를 “path dependence”라고 부릅니다. 저는 그냥 엉망이라고 부릅니다. 그리고 분명히 해둡시다: 이 엉망의 대부분은 더 이상 큰 가치를 제공하지 않습니다.
매우 못생긴 진화의 결과에 집중하는 대신, 사람들이 그런 것들을 발명하면서 하려고 했던 일이 무엇인지 이야기해 봅시다.
모듈 시스템의 최상위 목표는 늘 같습니다:
컴퓨터 산업은 이 모든 모듈성 문제들 사이에서 완벽한 균형을 찾으려고, 동시에 개발을 가능한 한 고통 없고 쉽게 유지하려고, 정말 어마어마한 시간을 허비합니다.
요컨대 우리는 성공하지 못하고 있습니다.
특히 우리가 가장 못하는 부분은 #1, 격리입니다. 코드 한 조각을 다른 조각으로부터 진짜로, 그리고 효율적으로 격리할 수만 있다면, 나머지 목표들은 대부분 저절로 따라올 겁니다. 하지만 우리는 그 방법을 פשוט히 모릅니다.
격리는 엄청나게 어려운 문제입니다. 사람들이 얼마나 시도했는지는 두말할 필요도 없죠. 그럼에도 브라우저 샌드박스 탈출은 여전히 정기적으로 발생하고, 탐지되지 않은 권한 상승 공격은 모든 OS에 존재한다고 가정되며, iOS는 여전히 주기적으로 탈옥되고, DRM은 (좋든 나쁘든) 절대 제대로 동작하지 않으며, 가상 머신과 컨테이너에서는 취약점이 계속 발견되고, k8s 같은 시스템은 기본적으로 컨테이너 네트워킹이 안전하지 않게 설정되어 있기도 합니다.
사람들은 심지어 인터넷을 통해 원격 서버에 타이밍을 맞춘 패킷을 보내는 것만으로 원격 서버의 암호화 키를 알아내는 방법을 찾아내기도 했습니다. 한편 최근 기억 속에서 가장 충격적인 격리 실패는 Meltdown과 Spectre 공격이었습니다. 이는 컴퓨터의 어떤 프로그램이든(웹 브라우저 안의 자바스크립트 앱조차도) 같은 컴퓨터의 다른 프로그램 메모리를, 심지어 샌드박스나 가상 머신을 넘어 읽을 수 있게 했습니다.
새로운 격리 기술은 대체로 낙관에서 절망으로 이어지는 다음과 같은 사이클을 겪습니다:
예를 들어, 현재 보안 전문가들은 다음 어떤 것(각각 그 시점의 최고의 기술)이든 완전히 안전하다고는 믿지 않습니다:
제가 알기로 최첨단, 즉 현재 가능한 최고의 격리는 Chrome 샌드박스나 gVisor 같은 것입니다. 대형 브라우저 벤더와 클라우드 제공자들은 모두 이런 도구를 씁니다. 도구는 여전히 불완전하지만, 제공자들은 새로운 침해를 가능한 한 빠르게 추적해 막고, 새로운 결함이 나오는 속도는 꽤 느린 편입니다.
격리는 예전 어느 때보다 낫습니다… 다만 모든 격리를 가상 머신(VM) 수준에 두고, 클라우드 제공자가 대신 해주게 만들었을 때에 한해서요. 다른 누구도 그 방법을 모르거나, 충분히 자주 업데이트하지 않기 때문입니다.
클라우드 제공자의 VM 격리를 신뢰한다면, 알려진 문제들은 모두 완화되었다는 희망을 가질 수 있습니다. 하지만 더 많은 문제가 발견될 거라고 생각할 충분한 이유도 있습니다.
그럼에도… 종합적으로 보면 꽤 괜찮습니다. 적어도 동작하는 _무언가_는 있으니까요.
잠깐만요. 모든 작은 모듈마다 격리된 VM을 띄우는 건 고통입니다. 그리고 모듈은 얼마나 큰 걸까요?
아주 오래전, Java가 처음 나왔을 때의 꿈은, 같은 애플리케이션 바이너리 안의 객체들 사이에서도 CPU가 강제하는 메모리 보호가 필요 없도록, 모든 객체의 모든 함수의 모든 줄에 권한을 강제할 수 있다는 것이었습니다. 이제는 그게 가능하다고 믿는 사람은 없습니다. 그리고 “cloud functions” 같은 마케팅 주장과는 별개로, 누구도 그걸 시도해야 한다고 진지하게 생각하지 않습니다.
현재 알려진 격리 방법들 중 어느 것도 완벽하게 동작하지는 않지만, 각자 _어느 정도_는 동작합니다. 공격자가 점점 더 숙련되거나 표적이 점점 더 가치 있어질수록, 더 강력하고 더 성가신 격리가 필요해집니다. 지금 우리가 아는 최선의 격리는 1티어 클라우드 제공자들이 제공하는 VM 간 샌드박싱입니다. 최악은, 뭐, 0까지 내려가죠.
또한(증거는 일단 건너뛰고) 대부분의 시스템은 결합도가 너무 높아서 상당히 숙련된 공격자라면 모듈 간 횡적 이동(lateral movement)을 통해 뚫고 들어갈 수 있다고 가정해 봅시다. 예를 들어 누군가 당신의 Go나 C++ 프로그램에 악성 라이브러리를 링크할 수 있다면, 그 사람은 아마 전체 프로그램을 장악할 수 있을 겁니다.
마찬가지로, 프로그램이 데이터베이스에 쓰기 권한이 있다면, 공격자는 아마 데이터베이스의 어디든지 쓸 수 있게 만들 수 있습니다. 네트워크에 접속할 수 있다면, 아마 네트워크의 어디든지 접속할 수 있게 만들 수 있습니다. 임의의 Unix 명령이나 시스템 콜을 실행할 수 있다면, 아마 Unix root 권한을 얻을 수 있습니다. 컨테이너 안에 있다면, 아마 컨테이너를 탈출해 다른 컨테이너로 들어갈 수 있습니다. 악성 데이터가 png 디코더를 크래시시킬 수 있다면, 그 디코더 프로그램이 허용된 다른 어떤 일이라도 하게 만들 수 있을 겁니다. 등등.
특히 강력한 공격 형태 중 하나는 코드 커밋 권한을 얻는 것입니다. 그 코드는 결국 개발자 머신에서 실행될 것이고, 어딘가의 개발자 또는 프로덕션 머신은 당신이 하려는 일을 할 수 있는 접근 권한을 가지고 있을 가능성이 높기 때문입니다.
위 내용은 다소 비관적일 수 있지만, 그런 가정은 실제 보안을 개선하지 않으면서 시스템을 과도하게 복잡하게 만드는 일을 피하는 데 도움을 줄 수 있습니다. Some thoughts on security after ten years of qmail 1.0에서 Daniel J. Bernstein은 (제가 많이 의역하자면) qmail에 추가한 많은 방어책들, 특히 chroot와 서로 다른 Unix uid를 이용해 구성 요소들을 서로 격리한 것들이, 가치가 없었고 한 번도 투자 대비 효과를 내지 못했다고 지적합니다.
어쨌든, 코드 실행 능력이 있는 공격자는 거의 어떤 모듈 격리 기법에서도 결합된 모듈들 사이를 “대체로” 횡적으로 이동할 수 있다고 받아들여 봅시다. 그러면 모듈 경계는 두 종류뿐입니다:
여기서 저는 대단히 통찰력 있는 말을 하고 있는 게 아닙니다. 인기 있는 현대 플랫폼들은 이미 이 구분을 중심으로 만들어져 있습니다.
예를 들어 Chrome은 랜덤한 웹 자바스크립트를 강하게 격리된 샌드박스 VM에서 실행합니다. 웹 페이지는 신뢰할 수 없기 때문입니다.
대부분의 OS는 네이티브 앱을 (샌드박스 없이) 단순한 프로세스로 실행하고, 파일시스템과 네트워크 네임스페이스 등을 공유하게 둡니다. 우리는 한때 그것들이 상대적으로 신뢰할 수 있다고 생각했기 때문입니다. (그리고 그게 바이러스가 생긴 방식이죠.)
전문가들은 더 이상 멀티 유저 Unix 시스템을 신뢰하지 않습니다. 프로세스 격리가 약하다는 것이 드러났기 때문입니다. 클라우드 VM은 기본값으로 패스워드 없는 sudo를 사용합니다. root vs non-root 격리가 약하다는 것이 드러났으니, 애초에 왜 귀찮게 구분하느냐는 거죠.
(우리는 여전히 사람들이 sudo를 치게 만드는데, 그건 모든 파일을 삭제한다든지 하는 인간 실수의 영향을 줄이기 위해서입니다.)
여러 벤더의 공유 라이브러리와 DLL이 다른 벤더의 앱에 링크되는 것은 모든 코드가 신뢰 가능하다고 가정하기 때문입니다. (이것은 오픈 소스 라이브러리 벤더를 통한 공급망 공격의 길을 엽니다. 저는 이런 일이 훨씬 더 자주 일어나지 않는다는 것이 아직도 놀랍습니다. 냉소적인 순간에는, 아마 실제로는 더 자주 일어나지만 잘 탐지되지 않을 뿐이라고 생각합니다.)
폰 OS는 탈옥됩니다. 앱 스토어 제한이 앱 샌드박스를 충분히 신뢰 가능하게 만들어줄 거라고 기대하지만, 격리는 결국 언제나 너무 약하다고 드러나기 때문입니다.
Kubernetes와 Docker는 하나의 머신 또는 VM에서 충분히 격리되지 않은 여러 컨테이너를 실행합니다. 암묵적으로 컨테이너들은 모두 신뢰 가능하다고 간주됩니다. 컨테이너 격리가 약하다는 것이 드러났기 때문에, 서로 신뢰하지 않는 별개의 사용자들을 대신하는(즉, 신뢰 불가 앱들이 섞이는) “멀티 테넌트” Kubernetes 클러스터를 운영하려 하지 말라고 강하게 권고합니다.
아, 그리고 gVisor 같은 강한 격리를 써서 서비스마다 VM을 띄운다 해도, 코드 자체가 강하게 격리된 툴체인으로 빌드되지 않았다면 도움이 되지 않습니다. 어떤 사람이 라이브러리를 업데이트할 수 있고 그 라이브러리가 여러 앱에 링크된다면, 그 앱들이 어떻게 실행되든 서로 진짜로 격리된 게 아닙니다.
그렇게 많은 격리 계층이 약하다면, 왜 우리는 그런 것들을 쓰는 걸까요?
대부분은 역사 때문입니다. 보안 측면에서는 큰 영향이 없고 단순성은 좋아질 것이므로, 이런 계층들의 대부분을 버린다면 더 낫습니다. 저는 시간이 지나면서 그렇게 될 거라고 예상합니다. 이미 그런 추세가 보입니다. 멀티 유저 Unix 시스템은 거의 멸종했고, “serverless” 서버는 가장 강한 종류를 제외한 모든 격리 방식을 버리고, 그김에 클라우드 제공자에 락인시키려 친절하게 시도하죠.
하지만 역사는 일단 제쳐둡시다. 제가 저 많은 격리 개념을 소개한 이유는 더 간단한 말을 하기 위해서입니다: 보안 이유로 모듈 경계를 정의하는 경우는 거의 없습니다.
대신, 모듈 경계는 보통 Conway's law를 따릅니다. 사람들은 팀에서 개발 업무를 어떻게 분할하고 싶은지에 따라 모듈을 쪼개고, 모듈들은 팀과 팀원들이 소통하는 방식에 따라 서로 통신하게 됩니다. (Conway의 법칙은 흥미롭고 실제로도 잘 맞지만, 다른 곳에서도 많이 읽을 수 있으니 여기서는 넘어가죠.)
모듈 경계가 하지 않는 일은 배포 단위의 크기를 정의하는 것입니다.
예를 들어 운영체제를 봅시다:
(사람들은 “데스크톱 리눅스”의 신뢰성에 대해 농담을 하곤 합니다. 그들이 말하는 건 언제나 두 번째, 즉 틈새이고 테스트하기 어려운 종류이지, 첫 번째의 주류이고 테스트하기 쉬운 종류가 아닙니다. 저는 사람들이 느끼는 품질 차이가 기업의 자본 vs 오픈 소스 때문에 생긴다고는 생각하지 않습니다. 차이는 배포 모델에서 옵니다.)
두 시스템 모두 수많은 패키지(모듈)를 포함하고, 수많은 개발자가 팀으로 조직되어 개발하며, 모듈 사이에는 인터페이스가 있습니다. 각 시스템의 박스-화살표 다이어그램을 그린다면, 커널, 드라이버, 윈도잉 시스템, 샌드박스, 웹 브라우저 등 꽤 비슷해 보일 겁니다.
그런데도, 이것들이 OS가 아니라 백엔드 클라우드 서비스였다면, 우리는 이 두 모델을 배포 모델 때문에 각각 _모놀리스_와 _마이크로서비스_라고 부를 것입니다. 하나는 배포된 “서비스”가 하나뿐이고, 다른 하나는 서비스가 많고 각각 따로 배포되기 때문이죠. 같은 모듈 아키텍처인데도요! 무슨 일이죠?
모듈 경계와 서비스 경계는 서로 다른 것입니다.
처음의 모듈성 목표를 다시 봅시다:
서비스 경계를 고를 때 생각해볼 만한 것들은 다음과 같습니다:
사실, 위 대부분은 서비스 간 경계를 만들기 위한 이유로는 대개 그리 설득력이 없습니다. 모듈이나 팀 사이에 경계를 만들기에는 훌륭한 이유가 될 수 있죠! 하지만 모듈들을 다시 합쳐 하나 또는 몇 개의 모놀리스로 만든 다음에 롤아웃할 수 있습니다.
기억하세요. ChromeOS는 모놀리스입니다. iOS는 모놀리스입니다. 당신의 팀은 아마 그 팀들보다 훨씬 작을 겁니다. 당신이 원하는 것을 얻기 위해 수많은 마이크로서비스를 저글링할 필요가 없습니다. 정말로 어쩔 수 없이 어려운 방법을 써야 하기 전까지는, 쉬운 방법으로 아키텍처를 만드세요. 우리가 하는 방식도 그렇습니다.
업데이트 2021-02-24: 아 맞다, also combinators