Post

Release의 모든 것

Release의 모든 것

이 책은 개발자에게 현실에서 잘 작동하는 프로그램을 만드는 방법을 알려준다. 또한 해결할 문제가 무엇이고 어떻게 그 문제를 해결할 수 있는지 설명한다. 책에서 가장 중요한 개념 하나를 고르면 운영 고려 설계라고 할 수 있다.


안정성 구축


1. 운영 환경의 현실


계획을 아무리 철저하게 세우더라도 안 좋은 일이 생긴다는 사실을 받아들여야 한다. 할 수 있는 만큼의 조치를 취하고 예방하면서, 정말 심각하고 예상치 못한 피해가 발생하더라도 전체 시스템이 복구될 수 있게 만들어야 한다.


1.1. 올바른 목표 설정


이 책에서는 적은 비용으로 고품질의 제품을 생산할 수 있도록 설계하는 공학적 접근법인 제조 고려 설계와 유사한 운영 고려 설계라는 개념을 내세운다.


1.2. 도전의 범위


오늘날엔 활성 사용자가 매우 많으면서 고가용성의 시스템에 대한 요구가 증가하고 있다.


1.3. 여기도 백만 달러, 저기도 백만 달러


설계와 아키텍처 결정은 재무적인 결정이기도 하다. 따라서 구현 비용뿐만 아니라 파생 비용까지 고려해서 결정해야 한다.


1.4. ‘포스’를 사용하라


초기에 내리는 결정은 시스템의 최종 모습에 가장 큰 영향을 미친다. 각 의사 결정이 가용성, 처리량, 유연성에 미치는 영향을 고려하는 것이 중요하다.


1.5. 실용주의 아키텍처


실용주의 아키텍트는 메모리 사용량, CPU 소모량, 대역폭 요구량, 하이퍼스레딩과 CPU 바인딩의 장단점 같은 문제에 관해 논의할 가능성이 높다.


2. 사례 연구: 항공사를 멈추게 한 예외


CF(Core Facility, 핵심 지원) 기능을 제공하는 데이터베이스 클러스터가 계획된 대체 시스템 전환 과정을 시작했다. CF 시스템은 고가용성 시스템으로 구축됐다.

이 시스템은 애플리케이션 서버의 클러스터에서 작동했으며 여분의 복구용 데이터베이스를 가지고 있었다. 모든 데이터는 외장 디스크에 저장되었고, 하루에 두 번 원격지 백업이 이루어졌으며, 별도의 위치에 있는 디스크로 늦어도 5분 이내에 데이터가 복제되는 것이 보장됐다.

앞단에서는 한 쌍의 하드웨어 부하 분산기가 들어오는 트래픽을 애플리케이션 서버 중 한 대로 전달해주었다.

랙 하나가 손상되거나 망가질 때를 대비해 서버들을 여러 다른 랙으로 분할까지 했다.


2.1. 변경 시간대


1분 안에 베리타스는 데이터베이스 1에 있는 오라클 서버를 내리고, RAID 어레이에서 파일 시스템을 마운트 해제하고, 데이터베이스 2에서 이것을 다시 마운트하고, 이 곳의 오라클 서버를 실행시킨 후, 가상 IP 주소를 데이터베이스 2에 재할당한다.


2.2. 작동 중단


어떤 경우라도 서비스 복원이 최우선이다. 작동 중단 시간을 더 늘리지 않고 사후 분석을 위한 약간의 데이터를 수집할 수 있다면 더 좋다.

우선적으로 문제가 있는 서버들을 재시작하여 문제를 해결했다.


2.4. 사후 분석


사후 조사를 수행하고 몇 가지 질문에 대한 답을 찾아야 한다.

  1. 예비 데이터베이스 교체 작업이 장애를 유발했는가? 아니라면 무엇이 원인인가?
  2. 클러스터 구성에는 문제가 없었나?
  3. 운영 팀은 유지 보수를 올바르게 수행했나?
  4. 어떻게 서비스가 중단되기 전에 장애를 감지할 수 있었나?
  5. 이런 일이 다시는 일어나지 않도록 할 방법은 무엇인가?


2.5. 단서 수색


클러스터에 생기는 일반적인 문제가 있는지 찾아봤다.

  1. 클러스터 내의 노드가 정상 작동 중임을 확인하는 신호가 충분한가?
  2. 정상 작동 확인 작업이 서비스 트래픽을 처리하는 스위치를 통해 이루어졌는가?
  3. 서버가 가상 IP 주소가 아닌 물리 주소를 사용하도록 설정되었나?
  4. 잘못된 패키지가 설치되었나?


다음은 애플리케이션 서버의 구성을 살펴볼 차례였다. 필자는 작동이 멈춘 애플리케이션을 디버깅할 때 자바 스레드 덤프를 애용한다. 스레드 덤프로 다음 내용들을 추론할 수 있다.

  1. 애플리케이션이 사용하는 외부 라이브러리
  2. 사용하는 스레드 풀의 유형
  3. 스레드 풀마다 가지고 있는 스레드 수
  4. 애플리케이션이 뒷단에서 수해아는 작업
  5. 애플리케이션이 사용하는 프로토콜 (각 스레드의 스택 추적 정보에서 클래스와 메서드를 살펴봄으로써 확인 가능)


스레드 덤프 획득

JVM에 직접 연결하는 것이 허용된다면 jcmd 를 사용해서 JVM을 실행시킨 터미널이 아니더라도 JVM의 스레드 덤프를 받을 수 있다.

1
jcmd 18835 Thread.print


직접 연결할 수 있다면 jconsole 이 대상 JVM을 가리키도록 하고 GUI로 스레드를 살펴볼 수 있다.


스레드 덤프를 통해 스레드의 실행 상태를 확인할 수 있고 스레드 풀로 관리되고 있는지를 알 수 있다.



스레드 덤프 확인을 통해 CF 시스템 내에 문제가 있다는 것을 알 수 있었다. 외부 요청에 할당된 40개의 스레드 전체가 자바 소켓 라이브러리 내부의 네이티브 메서드인 SocketInputStream.socketRead0() 안에서 블록되어 있었다. 모든 스레드는 절대 오지 않을 응답을 읽기 위해 헛된 노력을 기울이고 있었다.


스레드 덤프를 확인해보니 40개의 스레드가 FilghtSearch.lookupByCity() 를 실행하고 있는 것을 알 수 있었다. 이는 RMI(Remote method invocation, 원격 함수 호출)와 EJB(Enterprise JavaBeans) 메서드를 참조하고 있었다.


RMI는 서로 다른 컴퓨터 간의 통신을 내부 코드를 호출하는 것처럼 만들어준다. 모든 내부 호출이 그렇듯 RMI 호출은 응답을 무한정 기다리기 때문에 위험한 상황이 발생할 수 있다. 결국 호출한 측은 원격 서버의 문제에 취약하다.


2.6. 결정적 단서


CF 애플리케이션 서버는 별도의 스레드 풀을 사용해서 EJB 호출과 HTTP 요청을 처리했다. 이 때문에 CF 서비스에 장애가 발생했음에도 모니터링 애플리케이션에 응답할 수 있었다. 사실 모든 애플리케이션 서버의 모든 스레드는 하나 같이 정확히 동일한 코드에서 블록되었다. 바로 자원 풀에서 데이터베이스 연결을 확인하려는 코드였다.


소스 코드에 접근할 방법이 없었기 때문에 운영에서 이진 코드를 가지고 와서 역컴파일했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.cf.flightsearch;
// ...
public class FlightSearch implements SessionBean {
  private MonitoredDataSource connectionPool;
  public List lookupByCify(...) throws SQLException, RemoteException {
	  Connection conn = null;
	  Statement stmt = null;
	  try {
	    conn = connectionPool.getConnection();
	    stmt = conn.createStatement();
	    // 조회 로직 수행
	    // 결과 리스트 반환
	  } finally {
	    if (stmt != null) {
	      stmt.close();
	    }
	    if (conn != null) {
	      conn.close();
	    }
	  }
  }
}


jaav.sql.Statement.close()SQLException 을 던질 수 있다. 오라클 드라이버는 연결을 닫으려는 IOException 을 만날 때 이렇게 작동한다. 예를 들어 데이터베이스 서버를 대체 서버로 교체했을 때 말이다.


앞서 살펴본 코드에서 Statement 를 닫을 때 예외가 발생하면 연결이 닫히지 않아서 결국 자원 누수가 발생한다. 이런 호출이 40번 발생하면 자원 풀은 고갈되고 향후 모든 호출은 connectionPool.getConnection() 에서 블록될 것이다. 이것이 바로 CF의 스레드 덤프에서 본 것이다.


2.7. 외양간 고치기?


이 지점에서 가장 심각한 문제는 한 시스템의 버그가 관련이 있는 다른 모든 시스템으로 전파될 수 있다는 사실이다. 버그를 예방할 방법을 찾는 것보다 더 나은 질문은 ‘한 시스템의 버그가 다른 시스템에 영향을 미치지 않게 하는 방법은 무엇인가?’ 이다. 문제가 확산되지 않게 막는 설계 패턴을 살펴보자.


3. 시스템 안정화


엔터프라이즈 소프트웨어는 냉소적이어야 한다. 냉소적인 소프트웨어는 나쁜 일이 일어날 것이라고 예상하며 내부에 장벽을 세워 장애로부터 자신을 지킨다. 게다가 피해를 입을까봐 다른 시스템과 지나치게 친밀해지는 것을 거부한다.


높은 안정성을 얻기 위해 반드시 많은 비용이 드는 것은 아니다. 아키텍처를 수립하고, 설계하고, 세부 시스템을 구현할 때의 많은 의사 결정 시점이 시스템의 궁극적인 안정성에 큰 영향을 미친다.


3.1. 안정성 정의


안정성에 관해 말하려면 몇 가지 용어를 정의해야 한다. 트랜잭션은 시스템이 처리하는 추상적인 작업 단위다. 이는 데이터베이스 트랜잭션과는 다르다. 작업 단위 하나에 많은 데이터베이스 트랜잭션이 포함될 수 있다. 혼합 작업 부하는 시스템에서 처리되는 서로 다른 트랜잭션 유형의 조합이다.

시스템이라는 단어는 완결적이면서 상호 의존적인 하드웨어, 애플리케이션, 서비스의 집합을 의미하며, 사용자의 트랜잭션을 처리하는 데 필요하다.

강건한 시스템은 일시적인 충격, 영구적인 변형력, 구성 요소의 장애가 정상적인 처리를 방해하더라도 계속 트랜잭션을 처리하는 시스템이다. 이것이 대부분의 사람이 말하는 안정성이다. 안정성은 개별 서버나 애플리케이션이 계속 작동하는 것뿐만 아니라 사용자가 여전히 작업을 정상적으로 처리할 수 있음을 뜻한다.


3.2. 수명 연장


시스템의 장수를 위협하는 주요 원인은 메모리 누수와 데이터 증가다.

이런 버그는 어떻게 찾아야 할까? 자체적으로 장기 안정성 테스트를 시행하는 것이다. 가능하다면 개발용 컴퓨터를 따로 두고, 이 컴퓨터에 JMeter, 마라톤, 그 밖의 부하 테스트 도구가 돌아가게 하자.


3.3. 장애 모드


피해 결과를 포함하여 원래의 계기와 균열이 여타 시스템으로 번지는 방식을 장애 모드라고 한다.

안전한 장애 모드를 만들어 피해를 억제하고 시스템의 다른 부분을 보호할 수 있다. 이러한 유형의 자기 보호는 전체 시스템의 복원탄력성을 결정짓는다.

이런 보호 잘치를 균열 차단기라고 부른다. 장애 모드를 설계하지 않으면 예측하지 못한 위험한 일이 발생하게 될 것이다.


3.4. 균열 확산 차단


CF 프로젝트는 장애 모드를 계획하지 않았다. SQLException 을 적절하게 처리하지 않은 것에서 균열이 시작되었지만 않은 다른 지점에서 이를 멈출 수 있었다. 만약 커넥션이 일정 시간 동안만 블록되도록 구성했으면 균열이 확산되지 않게 차단했을 것이다.

한 단계 위에서 보면, CF의 호출 하나에서 발생한 문제가 다른 호스트에 있는 애플리케이션에 문제가 발생하도록 만들었다. 호출에 대한 시간 제한이 없으므로 계속 대기하여 아무 일도 못하게 된 것이다.

좀 더 넓은 관점에서 보면 CF 서버들은 하나 이상의 서비스 그룹으로 분할될 수 있다. 이렇게 하면 CF를 사용하는 모든 시스템이 중단되는 대신 한 서비스 그룹에만 문제를 묶어둘 수 있다.

더 관점을 넓혀 아키텍처 문제를 살펴보자. 아키텍처가 강하게 결합될수록 코딩 오류가 전파될 가능성이 높아진다. 반대로 느슨하게 결합된 아키텍처는 완충기와 같은 역할을 하므로 오류의 영향을 감소시킨다.

이러한 방법 중 어느 것이든 SQLException 문제가 항공사의 다른 시스템으로 확산 되지 않게 막을 수 있었다. 안타깝게도 이 공용 시스템의 설계자들은 시스템을 만들 때 균열의 가능성을 염두에 두지 않았다.


3.5. 장애 사슬


모든 시스템 장애의 이면을 보면 사건이 사슬처럼 꼬리에 꼬리를 물고 연쇄적으로 일어난다. 한 지점이나 계층의 장애는 사실 다른 부분에 장애가 발생할 확률을 증가시킨다. 데이터베이스가 느려지면 애플리케이션 서버는 메모리 부족 사태에 빠질 가능성이 더 높다. 매우 복잡하고 강하게 결합되어 있는 시스템은 균열이 확산되는 경로가 더 많으며 오류가 발생할 가능성이 더 높다.


발생할 수 있는 모든 장애에 대비하는 방법은 모든 외부 호출, 모든 입출력, 모든 자원 사용, 모든 예상 결과를 보고 ‘이것이 잘못될 수 있는 모든 경우에는 어떤 것이 있는가?’ 라고 질문하는 것이다.


가해질 수 있는 충격과 변형력의 다양한 유형에 관해 생각해보자.

  1. 초기 연결을 맺지 못한다면?
  2. 연결되는 데 10분이 걸린다면?
  3. 연결되고 나서 끊어지면?
  4. 연결은 되었지만 상대편에서 응답이 오지 않는다면?
  5. 전송한 요청의 응답을 받는 데 2분이 걸린다면?
  6. 만 개의 요청이 동시에 들어온다면?
  7. 웜 같은 악성코드 때문에 네트워크가 끊겨 SQLException 이 발생했는데, 애플리케이션이 오류 메시지를 로그에 남기려고 하니 디스크가 가득 찼다면?


결함은 결코 완벽히 방지할 수 없으며 결함이 오류가 되지 않게 막아야 한다.


4. 안정성 안티 패턴


시스템에 작동하는 부품이 일정 수준 이상으로 많고 눈에 보이지 않을 때 고도의 상호 작용 복잡성이 발생한다. 이런 연결 고리는 ‘문제 인플레이션’의 원인이 되어 작은 결함을 큰 장애로 만든다.

강한 결합은 시스템의 한 부분의 균열이 계층과 시스템 경계를 넘어 확산되도록 만든다. 시스템에서 강한 결합은 애플리케이션 코드 내부, 시스템 간 호출, 공유 자원이 있는 위치에 나타날 수 있다.


4.1. 통합 지점


대부분의 프로젝트는 HTML 외관, 프런트엔드 앱, API, 모바일 앱 중 일부 또는 전부가 조합된 것이다. 이러한 프로젝트의 구조는 나비 또는 거미줄 패턴에 속한다.

나비 패턴은 많은 입력과 연결이 중앙 시스템으로 몰려들고 다른 쪽에서는 출력이 크게 번져 나가는 형태다.

또 다른 방식으로는 거미줄 패턴이 있다. 거미줄 패턴은 여러 상자와 의존 관계로 되어 있다. 부지런히 정리한다면 계층 간에 호출이 이루어지는 형태로 정리될 것이다. 그렇지 않으면 무질서한 모습이 된다.

이러한 모든 연결은 통합 지점으로 각 연결은 시스템의 안정성을 떨어뜨린다. 시스템 안정성은 서비스를 더 작세 많이 만들수록, SaaS와 더 많이 통합할수록, API 우선 전략으로 더 나아갈수록 더 악화된다.

통합 지점은 시스템에서 일급 살인자다. 데이터 전송 하나하나가 안정성 문제를 일으킬 위험이 있다.


4.1.1. 소켓 기반 프로토콜


많은 고수준 통합 프로토콜이 소켓 위에서 작동한다.

TCP가 연결을 시도할 때 정의한 three-way handshaking을 수행한다. 연결은 호출하는 측에서 SYN 패킷을 원격 서버의 포트에 보내는 것으로 시작된다. 아무도 해당 포트에서 수신을 대기하지 않는다면 RESET 패킷을 즉시 돌려보내서 받을 준비가 되지 않았다고 알려준다. 그러면 호출하는 애플리케이션에서는 예외 또는 잘못되었다는 반환값을 받는다. 이 모든 작업은 1/100초도 걸리지 않는 시간만큼 매우 빠르게 진행된다.

대상 포트에서 수신 대기하는 애플리케이션이 있다면 원격 서버는 SYN/ACK 패킷을 돌려보내 연결 수락 의사를 표시한다. 호출하는 측에서는 SYN/ACK를 받고 다시 자신의 ACK를 보낸다. 이렇게 세 패킷으로 연결이 이루어졌고 애플리케이션은 데이터를 주고받을 수 있게 된다.

원격 애플리케이션이 포트를 수신하지만 연결 요청이 한꺼번에 몰려서 더는 들어오는 연결을 서비스할 수 없게 되었다고 가정해보자. 이 포트는 자체 수신 대기열이 있으며 이에 따라 네트워크 스택에 의해 처리되지 않은 연결을 얼마나 허용하는지 정해진다. 일단 수신 대기열이 가득차면 새로 들어오는 연결 시도는 바로 거부된다.

수신 대기열은 최악의 장소다. 소켓이 완전히 연결되지 않아 중간 상태에 머물며 원격 애플리케이션이 마침내 연결을 수락하거나 그 연결 시도가 시한을 초과할 때까지 open()을 호출한 모든 스레드가 운영체제 커널 내부에서 블록된다. 연결 초과 시간은 운영체제 마다 매우 다르지만 일반적으로 분 단위 값을 갖는다.

비슷한 경우가 또 있다. 호출하는 측에서는 연결해서 요청을 보낼 수는 있지만 서버가 요청을 읽고 응답하는 데 오랜 시간이 걸려도 대부분 같은 일이 일어난다. read() 호출은 그 서버가 응답할 때까지 블록된다.

느린 네트워크 장애는 예외가 발생하기 전 수 분 동안 스레드를 블록시킨다. 모든 스레드가 결국 블록되면, 그 서버는 모든 실질적인 목적을 수행할 수 없으니 중지된 것과 같다.


4.1.2. 오전 5시 문제


한 사이트에 매일 새벽 5시 정각마다 완전히 정지되는 불쾌한 패턴이 발생했다. 애플리케이션 서버 중 하나에서 스레드 덤프를 확인했다. 그 인스턴스는 작동하고 있었지만 모든 요청 처리 스레드가 오라클 JDBC 라이브러리 안, 정확히는 OCI 호출 안에서 블록되었다. 실제로 직렬화된 메서드에 진입하려고 블록된 스레드를 제거하니 활성 스레드 전부가 저수준 소켓을 읽거나 쓰는 중인 것으로 보였다.

tcpdump 를 확인해봤을 때 소수의 패킷이 애플리케이션 서버에서 데이터베이스 서버로 전송되었지만 응답이 없었다. 게다가 데이터베이스에서 애플리케이션 서버로 아무것도 들어오지 않았다.

스레드들은 JDBC 드라이버 안에서 멈춰 있었다. 데이터베이스의 네트워크 트래픽을 살펴봤을 때 트래픽이 전혀 없었다.

일단 연결되고 나면 TCP 연결은 아무런 패킷 전송 없이도 며칠이고 존재할 수 있다.

방화벽은 특별한 형태의 라우터일 뿐이다 한쪽 물리 포트에서 다른 편으로 패킷을 전달하기만 한다. 방화벽 안에는 어떤 연결을 허용할지 정하는 규칙이 접근 제어 목록 형태로 정의되어 있다. 이 규칙들은 ‘192.0.2.0/24 에서 출발해서 192.168.1.199의 80번 포트로 들어가는 연결은 허용한다’ 같은 식이다.

핵심은 방화벽 내부에 기록된 연결 정보였다. 이 정보는 일정 기간만 보관되었다. 따라서 방화벽은 무한정 유지되는 연결을 허용하지 않았다. TCP 자체는 이것을 허용하는 데도 말이다. 방화벽은 연결의 양 끝단과 함께 패킷이 마지막으로 전송된 시간도 보관한다. 어떤 연결이 패킷 전송 없이 너무 많은 시간이 흘렀다면 방화벽은 연결의 끝단이 죽었거나 사라진 것으로 간주한다.

이런 상황에서 발신 측에 목적지 호스트에 연결될 수 없다고 알리지 않고 패킷을 삭제하기만 했다. 해당 애플리케이션에서 소켓에 데이터를 전송하는 호출 한 줄이 30분 동안 실행을 블록할 수 있다. 소켓에서 읽는 상황은 더 심각해서 영원히 블록될 수 있다.


오라클에는 종료된 연결을 찾아내는 DCD(Dead Connection Detection)란 기능이 있어서 클라이언트가 급작스럽게 작동이 중지되었는지 찾아낼 수 있다. 이 기능을 활성화하면 오라클 서버는 일정한 간격으로 클라이언트에 핑 패킷을 보낸다. 클라이언트가 응답하지 않으면 연결에 할당된 자원을 모두 정리한다.


이처럼 문제를 이해하려면 해당 문제가 드러난 추상화 수준에서 한두 단계를 파고 들어가 실체를 밝혀내는 방법을 알아야 한다.


4.1.3. HTTP 프로토콜


모든 HTTP 기반 프로토콜은 소켓을 사용하기 때문에 앞서 설명한 모든 문제에 취약하다.

시간 초과와 응답 처리를 정밀하게 제어할 수 있는 클라이언트 라이브러리를 사용하자. 응답이 기대한 내용과 같은지 확인하기 전에는 응답을 데이터로 다루자.


4.1.4. 업체 제공 API 라이브러리


업체 API 라이브러리에서 안정성을 해치는 주요 요인은 전부 블로킹과 관련되어 있었다.


Reference


Release의 모든 것

This post is licensed under CC BY 4.0 by the author.