etcd
고친 사람 github-actions[bot]
etcd 는 여러 서버가 함께 믿어야 하는 작은 값을 맡아 지켜 주는 저장소입니다. 서버 몇 대가 같은 값을 나눠 들고 있어서 한 대가 죽어도 값이 남습니다. 값이 바뀌면 지켜보던 프로그램에 대개 곧 알려 줍니다. 쿠버네티스가 클러스터의 상태를 여기에 적어 둡니다.
쉽고 빠른 이해
etcd 는 여러 서버가 같이 보는 설정 수첩입니다. 「지금 데이터베이스 주소는 무엇인가」·「지금 주 서버는 어느 것인가」 같은 값을 적어 두면 모든 서버가 같은 답을 읽습니다.
이런 값을 서버 한 대에만 두면 그 서버가 죽는 순간 모두가 길을 잃습니다. 여러 대에 복사해 두면 이번에는 서로 다른 값을 들고 있을 수 있습니다. etcd 는 값을 여러 대에 둡니다. 그래도 모두가 같은 값을 봅니다.
- 서버 여러 대가 한 무리를 이룹니다. 홀수로 꾸리기를 권합니다
- 쓰기는 리더 한 대가 받아 나머지에게 돌립니다
- 절반 넘는 서버가 받아 적어야 쓰기가 끝납니다
- 값이 바뀌면 지켜보던 프로그램에 알림이 갑니다
대가가 있습니다. 믿고 담을 수 있는 크기는 몇 기가바이트까지입니다. 그보다 큰 데이터는 다른 데이터베이스에 맡겨야 합니다. 서버를 늘려도 담을 수 있는 양은 안 늘어납니다. 서버가 절반 넘게 끊기면 쓰기를 멈춥니다. 디스크 쓰기가 오래 걸리면 리더가 자꾸 바뀔 수 있습니다.
상세
etcd 는 분산 시스템을 위한 키-값 저장소입니다. 서버 여러 대에 띄워 한 무리로 묶어 씁니다. 담는 것은 설정 값, 서버 목록, 어느 서버가 어떤 일을 맡았는지 같은 작은 메타데이터입니다. 담을 데이터가 메모리에 전부 들어갈 만큼 작다고 보고 설계했습니다.
이름은 유닉스의 /etc 폴더에 분산(distributed)의 d 를 붙인 것입니다. /etc 는 컴퓨터 한 대의 설정 파일을 두는 폴더입니다. etcd 는 그 일을 서버 여러 대의 규모로 합니다. 발음은 「엣시디」입니다.
프로그램은 gRPC 로 etcd 에 요청을 보냅니다. gRPC 는 원격 서버의 함수를 부르듯 요청을 보내는 통신 방식입니다. 사람은 딸려 오는 명령줄 도구 etcdctl 을 씁니다.
이 절은 먼저 이런 저장소가 왜 따로 필요한지 봅니다. 그다음 키와 값의 모양, 여러 대가 같은 값을 맞추는 방법, 서버를 몇 대로 꾸리는지를 봅니다. 이어서 변경 번호인 리비전, 변경 알림인 워치, 시간이 지나면 끝나는 리스, 조건부 쓰기인 트랜잭션을 봅니다.
설정을 한곳에 두기 어려운 까닭
서버 열 대가 같은 데이터베이스 주소를 알아야 한다고 해 봅시다. 주소를 서버마다 설정 파일에 적으면 바꿀 때 열 군데를 고쳐야 합니다. 고치는 사이에 어떤 서버는 옛 주소를 씁니다. 다른 서버는 새 주소를 씁니다.
주소를 한 서버에만 두고 모두가 물어보게 하면 고칠 곳은 하나가 됩니다. 대신 그 서버가 죽으면 열 대가 동시에 주소를 잃습니다. 하나가 죽어 전체가 멈추는 이런 곳이 단일 장애점입니다.
etcd 는 이 둘 사이를 메웁니다. 값을 여러 대에 복사해 두니 한 대가 죽어도 값이 남습니다. 그리고 모든 복사본이 같은 순서로 같은 변경을 받게 합니다. 복사본마다 변경이 다른 순서로 적히는 일이 없습니다.
이 성질 덕에 etcd 는 설정 관리, 서비스 디스커버리, 분산된 작업의 조정에 쓰입니다. 서비스 디스커버리는 지금 살아 있는 서버의 주소를 찾아내는 일입니다. 분산된 작업의 조정은 여러 대 가운데 누가 어떤 일을 맡을지 정하는 일입니다. 리더 선출과 분산 락이 그런 조정입니다.
키와 값
etcd 에 넣는 데이터는 키 하나에 값 하나입니다. 키는 바이트열입니다. /app/db 처럼 폴더 모양으로 이름을 지어도 안에서는 평평한 목록 하나입니다.
키는 바이트 순서로 정렬된 색인을 갖습니다. 그래서 「/app/ 으로 시작하는 키 전부」처럼 범위로 읽는 비용이 적게 듭니다.
여러 대가 같은 값을 갖는 방법
etcd 서버 한 대가 멤버입니다. 멤버의 무리가 클러스터입니다. 한 클러스터의 멤버들은 같은 키와 값을 저마다 한 벌씩 들고 있습니다.
멤버들은 Raft 라는 합의 알고리즘으로 값을 맞춥니다. 합의 알고리즘은 여러 서버가 「다음 변경은 이것」이라는 결정 하나에 함께 동의하게 하는 절차입니다.
Raft 는 리더를 두는 방식입니다. 멤버 가운데 한 대가 리더로 뽑힙니다. 나머지는 팔로워가 됩니다. 합의가 필요한 요청은 전부 리더가 처리합니다. 팔로워에 들어온 쓰기는 리더에게 저절로 넘어갑니다. 클라이언트는 누가 리더인지 몰라도 됩니다.
리더는 변경을 로그에 적고 팔로워들에게 보냅니다. 멤버 과반이 이 변경을 디스크에 적어야 변경이 확정됩니다. 이 확정이 커밋(commit)입니다.
멤버가 n 대면 과반은 n 을 2 로 나눈 몫에 1 을 더한 수입니다. 이 과반을 영어로 쿼럼(quorum)이라고 합니다.
sequenceDiagram
participant 앱
participant 리더
participant 팔로워1 as 팔로워 1
participant 팔로워2 as 팔로워 2
앱->>리더: /app/db 를 새 주소로 써라
리더->>리더: 자기 로그에 적는다
리더->>팔로워1: 이 변경을 적어라
리더->>팔로워2: 이 변경을 적어라
팔로워1-->>리더: 적었다
Note over 리더: 리더와 팔로워 1 로 셋 중 둘이 적었다
리더-->>앱: 성공
팔로워2-->>리더: 늦게 적었다
그림에서 팔로워 2 의 답을 기다리지 않고 성공이 돌아갔습니다. 셋 중 둘이면 과반이기 때문입니다. 답이 늦는 멤버 한 대가 쓰기 전체를 붙잡지 않습니다.
리더는 살아 있다는 신호인 하트비트를 팔로워들에게 주기적으로 보냅니다. 팔로워는 정해진 시간 동안 하트비트를 못 받으면 리더가 죽었다고 보고 선거를 엽니다. 이 시간이 선출 시간 제한(election timeout)입니다.
새 리더를 뽑는 동안 클러스터는 쓰기를 처리하지 못합니다. 선출이 시작된 뒤에 들어온 쓰기 요청은 줄을 서서 새 리더가 설 때까지 기다립니다.
이 선출은 etcd 멤버끼리의 일입니다. etcd 를 쓰는 애플리케이션 서버들이 담당 한 대를 고르는 일과는 다릅니다.
멤버 수와 견디는 장애 수
과반이 살아 있어야 클러스터가 돕니다. 그래서 멤버 수가 몇 대까지 죽어도 되는지를 정합니다.
| 멤버 수 | 과반 | 죽어도 되는 수 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
| 6 | 4 | 2 |
| 7 | 4 | 3 |
표에서 넷은 셋과 견디는 수가 같습니다. 과반만 커져서 쓰기마다 받아 적을 멤버가 하나 더 필요합니다. 짝수로 늘려도 견디는 수는 안 늘어납니다. 멤버를 홀수로 꾸리기를 권하는 까닭입니다.
리비전
etcd 는 클러스터 전체에 하나뿐인 64비트 카운터를 둡니다. 키 공간이 바뀔 때마다 이 수가 커집니다. 이 수가 리비전입니다. 처음 만든 저장소의 리비전은 1 입니다. 리비전은 클러스터가 사는 동안 줄어들지 않습니다.
한 번의 변경이 키 여럿을 바꿔도 리비전은 하나만 커집니다. 트랜잭션 하나에 요청 여러 개가 들어 있어도 같습니다. 그래서 리비전은 모든 변경에 순서를 매기는 논리 시계 노릇을 합니다. 리비전이 큰 값이 작은 값보다 나중에 바뀐 것입니다.
etcd 는 값을 덮어쓰지 않습니다. 새 값을 새 리비전으로 따로 남기고 옛 값도 지우지 않습니다. 그래서 etcdctl get --rev=2 처럼 리비전을 지정해 과거 값을 읽을 수 있습니다. 이렇게 여러 판을 쌓아 두는 방식이 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)입니다.
옛 값을 끝없이 쌓으면 저장 공간이 찹니다. 그래서 어느 리비전보다 오래된 값을 걷어 내는 컴팩션을 돌립니다. 걷어 낸 리비전은 다시 읽을 수 없습니다. 컴팩션을 언제 어떻게 돌리는지는 운영 절이 다룹니다.
워치
워치는 키를 지켜보다가 바뀌면 알려 달라는 요청입니다. 프로그램이 키 하나를 워치해 두면 값이 바뀔 때마다 변경이 흘러옵니다. 몇 초마다 다시 읽어 바뀌었나 확인하는 폴링이 필요 없습니다.
워치는 리비전과 맞물려 돕니다. 지금부터가 아니라 지나간 리비전부터 지켜보라고 요청할 수도 있습니다. 연결이 끊겼던 프로그램은 마지막으로 받은 리비전 다음부터 다시 받습니다. 다만 컴팩션으로 걷힌 리비전은 다시 못 받습니다.
리스
리스는 클라이언트가 살아 있는지 알아내는 장치입니다. 클러스터는 리스를 내줄 때 TTL(Time To Live, 살아 있는 시간)을 붙입니다. 클라이언트가 그 시간 안에 연장 신호(keepAlive)를 보내지 않으면 클러스터가 리스를 끝냅니다. 클라이언트가 revoke 요청으로 직접 거둘 수도 있습니다.
키 하나는 리스 하나에 매달 수 있습니다. 리스가 끝나거나 거둬지면 매달린 키가 전부 지워집니다. 지워질 때마다 삭제 이벤트가 이력에 남습니다. 워치하던 쪽도 키가 사라진 것을 압니다.
서버가 켜질 때 자기 이름 키를 리스에 매달아 넣고 계속 연장한다고 합시다. 서버가 죽으면 연장이 끊기고 키가 사라집니다. 이 목록을 워치하는 쪽은 살아 있는 서버만 보게 됩니다. 서비스 디스커버리를 이렇게 만들 수 있습니다.
트랜잭션
트랜잭션은 「조건을 비교해서 맞으면 이것, 틀리면 저것」을 한 번에 실행하는 요청입니다. 중간에 다른 쓰기가 끼어들지 못합니다.
조건은 비교 여러 개를 「그리고」로 묶은 것입니다. 비교 하나는 키 하나를 봅니다. 값이 있나 없나를 보거나, 값이 무엇인가를 봅니다.
비교는 그 키의 리비전도 볼 수 있습니다. 키의 리비전은 그 키를 마지막으로 바꾼 연산의 리비전입니다. 리비전이 조건으로 쓰이는 까닭입니다.
비교가 전부 참이면 성공 쪽 요청 묶음을 실행합니다. 하나라도 거짓이면 실패 쪽 묶음을 실행합니다. 같은 키를 트랜잭션 하나 안에서 두 번 고칠 수는 없습니다.
흔한 쓰임은 「이 키의 리비전이 내가 읽은 값일 때만 쓴다」입니다. 그 사이 누가 고쳤으면 실패 쪽으로 갑니다. 다른 시스템에서 비교 후 교환(compare-and-swap)이라고 부르는 동작이 이것입니다. 리더 선출과 분산 락 같은 조정 기능도 이 위에 짓습니다. 그 기능이 무엇을 약속하고 무엇을 못 하는지는 보장과 가정 절이 다룹니다.
포기한 것
etcd 는 설계로 세 가지를 안 하기로 정했습니다. 분단 때의 가용성, 데이터 나누기, 큰 데이터입니다. 각각 무엇을 얻으려고 내려놓았는지를 봅니다.
분단 때의 가용성
가용성은 요청을 받아 처리할 수 있는 상태를 뜻합니다. 네트워크가 끊겨 멤버들이 두 무리로 갈리는 일이 분단(partition)입니다. 분단된 두 무리가 저마다 다른 값을 확정해 버리는 일은 스플릿 브레인(split-brain)입니다.
etcd 는 스플릿 브레인이 아예 일어나지 않게 하는 쪽을 골랐습니다. 그 값으로 분단 때의 가용성을 내줍니다. 분단이 나면 과반을 쥔 쪽만 클러스터로 남아 요청을 받습니다. 소수 쪽은 멤버가 살아 있어도 쓸 수 없는 상태가 됩니다.
flowchart TD
subgraph 과반쪽["과반 쪽 · 멤버 셋"]
M1["멤버 1"]
M2["멤버 2"]
M3["멤버 3"]
end
subgraph 소수쪽["소수 쪽 · 멤버 둘"]
M4["멤버 4"]
M5["멤버 5"]
end
과반쪽 --> W1["클러스터로 남아 요청을 받는다"]
소수쪽 --> W2["쓸 수 없는 상태가 된다"]
얻는 것은 분단이 끝났을 때 진실이 하나라는 점입니다. 멤버가 홀수면 분단이 나도 과반을 쥔 쪽이 언제나 하나 있습니다. 그쪽이 확정한 것만 남습니다. 네트워크가 회복돼 과반이 다시 모이면 etcd 는 저절로 안전하게 다시 돕니다.
멤버를 넣고 빼는 일도 그때의 과반이 승인해야 합니다. 그래서 두 쪽이 저마다 과반이라고 우기는 일이 없습니다.
리더가 죽을 때도 같은 값을 치릅니다. 새 리더를 뽑는 동안 쓰기가 멈춥니다. 장애를 시간 초과로 알아채므로 선출에는 대략 선출 시간 제한만큼이 걸립니다. 선출 시간 제한의 기본값은 1000ms 입니다.
데이터 나누기
복제 그룹은 같은 데이터를 한 벌씩 들고 서로 맞추는 멤버의 묶음입니다. etcd 의 클러스터 하나가 복제 그룹 하나입니다. etcd 는 모든 데이터를 이 그룹 하나에 담습니다. 데이터를 여러 그룹에 나눠 담는 샤딩을 하지 않습니다. 멤버를 늘려도 수평으로 확장되지 않습니다.
얻는 것은 순서입니다. 그룹이 하나라서 변경 하나는 Raft 를 한 번만 거치면 확정됩니다. 그 변경에 클러스터 전체에서 하나뿐인 리비전을 바로 붙입니다. 합의를 한 그룹 안으로 묶어 단순한 절차로 분산 일관성을 얻습니다.
반대를 고른 쪽이 NewSQL 데이터베이스입니다. NewSQL 은 SQL(Structured Query Language) 질의를 받는 데이터베이스입니다. 데이터를 여러 복제 그룹에 나눠 담아 대개 테라바이트 이상을 담습니다.
대신 변경마다 전역 번호를 붙이려면 그룹끼리 한 번 더 조정해야 합니다. 이 조정에서 두 그룹이 같은 전역 번호를 집으면, 번호를 기다리던 요청은 조정을 다시 거쳐 번호를 새로 받아야 할 수 있습니다. 요청이 거절되는 것이 아니라 번호를 받는 일을 다시 하는 것입니다. 그래서 엄격한 순서가 필요한 일에서는 대개 etcd 보다 성능이 떨어진다는 것이 etcd 팀의 판단입니다.
큰 데이터
etcd 는 메타데이터처럼 작은 키-값을 다루도록 설계했습니다. 요청 하나와 저장 공간 전체에 상한이 있습니다. MiB(메비바이트)는 2의 20제곱 바이트입니다. GiB(기비바이트)는 2의 30제곱 바이트입니다.
| 상한 | 기본값 | 바꾸는 설정 |
|---|---|---|
| 요청 하나의 크기 | 1.5 MiB | --max-request-bytes |
| 저장 공간 전체 | 2 GiB (보통 환경의 권장 최대는 8 GiB) | --quota-backend-bytes |
상한보다 큰 요청도 상한을 올리면 처리는 됩니다. 다만 다른 요청의 지연이 늘어날 수 있습니다.
얻는 것은 예측할 수 있는 동작입니다. 저장 상한이 없으면 키 공간이 너무 커져 성능이 떨어질 수 있습니다. 저장 공간이 바닥나 클러스터가 어떻게 움직일지 알 수 없는 상태가 될 수도 있습니다. 상한은 그 전에 쓰기를 막아 클러스터를 지킵니다.
그래서 etcd 에는 메타데이터와 분산 애플리케이션의 조정에 쓰는 값을 둡니다. 몇 기가바이트를 넘게 담아야 하거나 SQL 질의가 필요한 데이터는 NewSQL 데이터베이스의 몫입니다.
예시
etcd 를 쓰는 시스템 셋을 봅니다. 쿠버네티스는 클러스터 상태 전부를 맡깁니다. Patroni 는 데이터베이스 주 서버의 자격을 맡깁니다. Container Linux 는 재부팅 순번을 맡깁니다.
쿠버네티스
쿠버네티스는 여러 기계에 컨테이너를 나눠 띄우고 관리하는 시스템입니다. 쿠버네티스에서 etcd 는 모든 클러스터 데이터를 담는 저장소입니다. 쿠버네티스 객체가 전부 etcd 에 들어 있습니다.
etcd 를 직접 다루는 것은 쿠버네티스의 API(Application Programming Interface) 서버입니다. API 서버는 클러스터 상태를 etcd 에 적습니다. 그리고 etcd 의 워치로 클러스터를 지켜보다가 중요한 설정 변경을 퍼뜨립니다.
고른 이유는 일관성입니다. 어느 기계에 무엇을 띄울지 정하는 스케줄링과 서비스 운영이 올바르려면 etcd 의 일관성이 꼭 필요합니다. 거꾸로 etcd 가 흔들려 리더가 없으면 클러스터는 지금 상태를 하나도 바꾸지 못합니다. 새 파드도 못 띄웁니다. 파드는 쿠버네티스가 띄우는 컨테이너 묶음의 단위입니다.
그래서 쿠버네티스는 etcd 를 조심해서 다룹니다. etcd 데이터의 백업 계획을 세우라고 합니다. 백업은 스냅숏 파일로 뜹니다. 스냅숏은 어느 한때의 etcd 키 공간을 전부 떠낸 파일입니다. 쿠버네티스 상태 전부가 들어 있으므로 암호화합니다. etcd 에 닿을 수 있으면 클러스터의 루트 권한을 쥔 것과 같습니다. 되도록 API 서버만 etcd 에 닿게 합니다.
Patroni
Patroni 는 PostgreSQL 서버 여러 대 가운데 어느 것이 주 서버(primary)를 맡을지 정하는 도구입니다. 주 서버는 쓰기를 받는 단 한 대입니다. Patroni 는 리더 선출과 분단 감지를 DCS(Distributed Configuration Store, 분산 설정 저장소)에 맡깁니다. etcd 는 Patroni 가 쓰는 DCS 가운데 하나입니다. ZooKeeper·Consul·쿠버네티스도 DCS 로 쓸 수 있습니다.
Patroni 가 돌보는 서버 한 대를 노드라고 부릅니다. 규칙은 하나입니다. DCS 안의 리더 락(leader lock)을 갱신할 수 있는 노드만 PostgreSQL 을 주 서버로 돌립니다. 리더 락은 DCS 에 두는 값 하나입니다. TTL 이 붙어 있어서 갱신하지 않으면 만료됩니다. 갱신에 실패하면 그 노드의 PostgreSQL 은 즉시 강등돼 읽기 전용으로 다시 뜹니다.
갱신 실패의 원인은 분단일 수도 있습니다. DCS 가 죽은 것일 수도 있습니다. 노드 한 대에서는 둘을 가를 수 없습니다. 그래서 Patroni 는 피해가 큰 쪽인 분단으로 가정합니다.
분단이면 다른 노드가 리더 락을 잡고 주 서버가 될 수 있습니다. 옛 주 서버는 리더 락이 만료되기 전에 스스로 내려갑니다. 주 서버가 둘이 되는 스플릿 브레인을 이렇게 막습니다.
이 「만료되기 전에 내려간다」를 지키려면 시간 값들이 서로 맞아야 합니다.
| 설정 | 뜻 | 기본값 |
|---|---|---|
ttl |
리더 락을 잡는 TTL. 자동 장애 조치가 시작되기까지의 시간으로 보면 됩니다 | 30초 (최소 20초) |
loop_wait |
Patroni 의 반복 주기가 쉬는 시간 | 10초 (최소 1초) |
retry_timeout |
DCS 요청을 다시 시도하는 시간 | 표의 두 값과 함께 아래 규칙으로 묶입니다 |
세 값 가운데 하나를 고치면 loop_wait + 2 * retry_timeout <= ttl 을 지켜야 합니다. 한 주기가 쉬는 시간과 재시도 두 번을 합한 값이 리더 락의 TTL 을 넘지 않아야 한다는 뜻입니다.
etcd 를 Patroni 전용으로 두면 이 갱신 실패를 겪을 가능성이 0 에 가깝습니다. etcd 를 뒤에 둔 쿠버네티스 API 를 DCS 로 쓰면 더 자주 겪을 수 있습니다. Patroni 가 etcd 를 고를 이유는 밝혀져 있지 않습니다(미확인).
Container Linux
Container Linux 는 CoreOS 의 리눅스 배포판입니다. 그 위에서 도는 애플리케이션은 리눅스 커널 업데이트를 멈춤 없이 자동으로 받습니다. 업데이트의 조정은 locksmith 가 맡습니다.
locksmith 는 etcd 위에 분산 세마포어를 구현합니다. 세마포어는 정해진 수만큼만 동시에 들어가게 하는 장치입니다. 그래서 클러스터 가운데 일부만 한 번에 재부팅합니다.
보장과 가정
이 절은 etcd 가 클라이언트에 하는 약속과 그 약속이 기대는 가정을 봅니다. 가정이 깨지면 무엇이 무너지는지도 봅니다. 마지막 소절은 락 API 가 이 가정 위에서 어디까지 약속하는지 봅니다.
키-값 요청의 보장
etcd 는 키-값 요청에 셋을 약속합니다.
| 보장 | 뜻 |
|---|---|
| 지속성(durability) | 완료된 연산은 사라지지 않습니다. 읽기는 영구히 적히지 않은 데이터를 돌려주지 않습니다 |
| 엄격한 직렬화 가능성(strict serializability) | 키-값 연산은 하나의 전체 순서를 이룹니다. 그 순서는 실제 시간의 순서와 어긋나지 않습니다 |
| 선형화 가능성(linearizability) | 키-값 밖의 연산에도 기본으로 걸립니다. 연산은 요청과 응답 사이 어느 한순간에 한꺼번에 일어난 것처럼 보입니다 |
엄격한 직렬화 가능성을 풀면 둘입니다. 하나는 원자성입니다. 키-값 연산은 다 되거나 하나도 안 됩니다. 다른 하나는 순서입니다. 연산 전체에 하나의 순서가 있고 그 순서를 리비전이 매깁니다.
리비전이 2 인 쓰기는 리비전이 3 인 쓰기보다 먼저 일어난 것으로 모든 클라이언트에게 보입니다. 엄격한 직렬화 가능성은 분산 트랜잭션 데이터베이스가 주는 격리 보장 가운데 가장 강한 것입니다.
etcd 가 보장을 따로 적어 둔 요청은 키-값·워치·리스 셋입니다. 그 가운데 워치는 선형화 가능성에서 빠집니다. 그 까닭은 아래 「워치의 보장」이 다룹니다. 키-값 밖의 어느 연산까지 선형화 가능성이 걸리는지는 밝혀져 있지 않습니다(미확인).
연산이 완료되려면 둘이 필요합니다. 합의를 거쳐 커밋돼야 합니다. 그리고 저장 엔진에 영구히 적혀야 합니다. 저장 엔진은 etcd 가 값을 디스크에 적는 부분입니다.
클라이언트는 서버의 응답을 받아야 완료를 압니다. 응답 전에 시간이 다 되거나 네트워크가 끊기면 클라이언트는 그 연산이 됐는지 모를 수 있습니다.
선형화 가능한 읽기에는 비용이 있습니다. 읽기마다 Raft 합의를 거쳐야 합니다.
읽기 요청의 일관성 모드를 serializable 로 바꾸면 합의를 건너뜁니다. 지연이 줄고 처리량이 늘어납니다. 대신 과반이 이미 확정한 값보다 낡은 값을 읽을 수 있습니다. 이 모드의 읽기는 아무 멤버나 처리합니다.
serializable 은 앞 표의 엄격한 직렬화 가능성과 이름이 닮았지만 그보다 약한 선택입니다. etcdctl 에서는 --consistency=s 가 이 모드입니다. 기본은 선형화 가능한 l 입니다.
워치의 보장
워치는 이벤트에 대해 여섯을 약속합니다.
| 보장 | 뜻 |
|---|---|
| 순서 | 이벤트는 리비전 순서로 옵니다 |
| 유일 | 같은 이벤트가 두 번 오지 않습니다 |
| 빠짐없음 | 남아 있는 이력 안에서는 중간 이벤트를 빠뜨리지 않습니다 |
| 원자성 | 한 리비전에서 키 여럿이 바뀌면 그 변경을 쪼개 보내지 않습니다 |
| 다시 잇기 | 끊긴 워치는 마지막으로 받은 리비전 다음부터 다시 걸 수 있습니다. 그 리비전이 이력 안에 남아 있을 때만입니다 |
| 진행 알림 | 어느 리비전까지의 이벤트를 다 보냈는지 알려 주는 이벤트가 있습니다 |
워치는 선형화 가능성을 약속하지 않습니다. 제대로 도는 클러스터라면 변경 뒤 10ms 안팎에 이벤트가 나타나리라 볼 수 있습니다.
그러나 상한은 없습니다. 건강하지 않은 클러스터에서는 이벤트가 끝내 안 올 수도 있습니다. 워치 이벤트와 다른 연산의 순서는 클라이언트가 이벤트의 리비전을 보고 맞춰야 합니다.
과반 생존이라는 가정
이 보장들은 멤버 과반이 살아 있고 서로 닿는다는 가정 위에 섭니다.
과반이 죽으면 클러스터가 멈추고 쓰기를 더 받지 않습니다. 과반이 돌아와야 회복합니다. 과반이 돌아오지 못하면 운영자가 재해 복구를 시작해야 합니다.
리더 한 대가 죽을 때도 잃는 것이 있습니다. 쓰기가 어느 때 들어왔는지로 갈립니다. 선출이 시작된 뒤에 들어온 쓰기는 줄을 서서 새 리더가 처리합니다.
옛 리더에 이미 보냈지만 커밋되지 않은 쓰기는 사라질 수 있습니다. 새 리더가 그 로그 항목을 고쳐 쓸 수 있기 때문입니다. 로그 항목은 리더가 변경을 한 줄로 적어 팔로워에게 보내는 것입니다.
클라이언트에게는 그런 쓰기가 시간 초과로 보입니다. 선출 중에 etcd 가 중단한 연산에는 중단 응답도 가지 않습니다. 커밋된 쓰기는 하나도 잃지 않습니다.
시계로 재는 리스
리스의 TTL 은 물리 시간의 길이입니다. 서버와 클라이언트는 이 시간을 저마다의 시계로 잽니다. 그래서 서버가 리스를 거둔 뒤에도 클라이언트는 아직 쥐고 있다고 믿을 수 있습니다.
리더가 바뀌어도 리스가 일찍 끝나지는 않습니다. 새 리더는 모든 리스의 만료 시간을 저절로 늘려 줍니다. 옛 리더가 내준 리스도 약속한 TTL 전에는 만료되지 않습니다.
락 API 와 상호 배제
etcd 는 리스 위에 지은 락 API 를 줍니다. 클라이언트가 거둬지지 않은 리스를 쥐고 있는 동안 그 리스에 딸린 자원을 자기 것이라고 주장하는 방식입니다. etcd 에서 그 자원은 키입니다.
그런데 락 API 는 그것만으로는 상호 배제 장치가 못 됩니다. 상호 배제는 한 번에 한 클라이언트만 자원을 쓰게 하는 것입니다. 이름이 락인 것은 역사적인 이유일 뿐입니다. 앞 소절의 시계 문제 때문에 리스를 쥐었다고 자원의 락을 쥐었다는 보장이 없습니다.
etcd 의 키를 지킬 때 상호 배제를 보장하는 것은 버전 번호 검증입니다. etcd 가 이 검증에 쓰는 번호는 리비전 번호입니다. 트랜잭션 소절의 비교 후 교환이 이것입니다.
Put 이나 Txn 요청에 리비전 번호와 리스 ID 의 조건을 붙입니다. 조건이 안 맞으면 요청이 실패합니다. 요청이 성공하면 클라이언트는 그때 락을 얻은 것을 압니다.
리스의 몫은 최적화입니다. 상호 배제는 버전 번호 검증이 지킵니다. 리스는 중단되는 요청의 수를 줄입니다. 어떻게 줄이는지는 밝혀져 있지 않습니다(미확인).
etcd 밖의 자원을 지킬 때가 문제입니다. etcd 의 락 기능만으로는 바깥 자원을 지키지 못합니다. 그 자원 스스로 etcd 의 키처럼 둘을 갖춰야 합니다. 버전 번호 검증과 복사본 사이의 일관성입니다.
바깥 자원이 확인하는 이 번호가 펜싱 토큰(fencing token)입니다. etcd 에서는 리비전 번호가 그 펜싱 토큰에 해당하는 것으로 봅니다.
sequenceDiagram
participant A as 앱 A
participant E as etcd
participant B as 앱 B
participant R as 외부 저장소
A->>E: 락을 얻는다
E-->>A: 성공 · 리비전 r1
E->>E: TTL 이 지나 A 의 리스를 거둔다
Note over A: 아직 리스를 쥐었다고 믿는다
B->>E: 락을 얻는다
E-->>B: 성공 · 리비전 r2 (r1 보다 크다)
B->>R: r2 를 붙여 쓴다
A->>R: r1 을 붙여 쓴다
Note over R: r2 를 이미 봤으니 r1 쓰기를 거절한다
그림에서 A 는 리스가 거둬진 줄 모르고 씁니다. 외부 저장소가 리비전을 확인하지 않으면 A 의 쓰기가 B 의 쓰기 위에 덮입니다. 외부 저장소가 리비전을 확인해야 둘이 섞이지 않습니다.
운영
이 절은 etcd 를 굴릴 때 만지는 설정 키와 기본값, 지켜볼 지표를 모읍니다. 대상은 넷입니다. 저장 공간, 옛 리비전 걷기, 디스크와 리더, 백업입니다.
저장 상한과 NOSPACE 알람
저장 상한은 --quota-backend-bytes 로 정합니다. 단위는 바이트입니다. 기본값은 2 GiB 입니다. 보통 환경의 권장 최대는 8 GiB 입니다. 이보다 크게 잡으면 etcd 가 켜질 때 경고합니다. 스왑이나 메모리 부족을 피하려면 기계의 메모리가 이 상한을 덮을 만큼은 있어야 합니다.
멤버마다 키 공간을 담는 파일이 하나 있습니다. 이 파일이 백엔드 데이터베이스입니다. 아래에서 데이터베이스 파일이라고 부르는 것도 같은 파일입니다.
멤버 하나라도 백엔드 데이터베이스가 상한을 넘으면 클러스터 전체에 알람이 걸립니다. 그때부터 클러스터는 키 읽기와 삭제만 받는 유지 보수 모드에 들어갑니다. 쓰기에는 mvcc: database space exceeded 오류가 돌아갑니다. etcdctl alarm list 에는 alarm:NOSPACE 가 찍힙니다.
stateDiagram-v2
state "정상 운영" as 정상
state "유지 보수 모드 · 읽기와 삭제만 받는다" as 유지보수
정상 --> 유지보수: 한 멤버라도 저장 상한을 넘는다
유지보수 --> 정상: 컴팩션 · 모든 멤버 조각 모음 · 알람 해제
그림처럼 정상으로 돌아오려면 셋이 다 필요합니다. 이력을 컴팩션해 공간을 비웁니다. 모든 멤버를 조각 모음합니다. 마지막으로 알람을 해제합니다.
조각 모음은 컴팩션이 데이터베이스 파일 안에 남긴 빈틈을 걷어 파일을 줄이는 일입니다. 아래 「조각 모음」 소절이 자세히 다룹니다.
미리 알아채려면 지표 둘을 같이 봅니다.
| 지표 | 무엇을 재나 |
|---|---|
etcd_mvcc_db_total_size_in_use_in_bytes |
컴팩션 뒤 데이터베이스가 쓰고 있는 크기 |
etcd_mvcc_db_total_size_in_bytes |
조각 모음을 기다리는 빈 공간까지 넣은 크기. v3.4 전에는 이름에 debugging 이 들어 있었습니다 |
첫째 값은 컴팩션 뒤에 실제로 쓰고 있는 크기입니다. 둘째 값은 거기에 빈틈을 더한 파일 크기입니다. 새 쓰기는 빈틈부터 채우므로 파일은 빈틈이 다 찬 뒤에야 커집니다. 그래서 둘째 값은 첫째 값이 자기에게 가까워질 때만 커집니다. 둘 다 상한에 가까우면 알람이 걸리기 전에 컴팩션이 필요합니다.
flowchart TD
subgraph S1["1 · 컴팩션 전"]
A1["쓰는 칸 · 쓰는 칸 · 쓰는 칸 · 쓰는 칸"]
A2["in_use = total = 파일 크기"]
end
subgraph S2["2 · 컴팩션 뒤"]
B1["쓰는 칸 · 빈틈 · 쓰는 칸 · 빈틈"]
B2["in_use 가 줄고 total 은 그대로"]
end
subgraph S3["3 · 새 쓰기가 빈틈을 채운다"]
C1["쓰는 칸 · 쓰는 칸 · 쓰는 칸 · 빈틈"]
C2["in_use 가 total 에 다가간다"]
end
subgraph S4["4 · 빈틈이 다 차서 파일이 커진다"]
D1["쓰는 칸 넷 + 새로 늘린 칸"]
D2["total 이 그제서야 커진다"]
end
subgraph S5["5 · 조각 모음 뒤"]
E1["쓰는 칸 · 쓰는 칸 · 쓰는 칸"]
E2["빈틈이 파일 시스템으로 돌아가 total 이 in_use 까지 줄어든다"]
end
S1 --> S2
S2 --> S3
S3 --> S4
S4 --> S5
그림에서 파일이 커지는 때는 4단계뿐입니다. 그리고 파일을 다시 줄이는 것은 5단계의 조각 모음뿐입니다.
컴팩션
etcd 는 키 공간의 이력을 빠짐없이 남깁니다. 이 이력을 주기적으로 걷지 않으면 성능이 떨어지고 결국 저장 공간이 바닥납니다. 손으로는 etcdctl compact 3 처럼 리비전을 지정해 걷습니다. 걷힌 리비전을 읽으려 하면 mvcc: required revision has been compacted 오류가 돌아옵니다.
자동 컴팩션은 --auto-compaction-mode 와 --auto-compaction-retention 으로 겁니다. 모드는 둘입니다.
| 모드 | 무엇을 남기나 | 언제 걷나 |
|---|---|---|
periodic (기본) |
시간으로 잰 창. --auto-compaction-retention=1h 면 한 시간치 |
보존 기간이 1시간보다 길면 한 시간마다 걷습니다. 1시간 이하면 보존 기간마다 걷습니다 |
revision |
리비전 개수. --auto-compaction-retention=1000 이면 최근 1000 개 |
5분마다 확인해 「최신 리비전 − 1000」까지 걷습니다. 최신이 30000 이면 29000 까지입니다 |
periodic 의 보존 값은 쓰임에 따라 권장이 갈립니다. 같은 키를 자주 고치면 1h 나 30m 를 권합니다. 드물게 고치면 24h·48h·72h 를 권합니다. 일반 용도로 권하는 값은 10h 입니다.
보존 기간은 늦게 따라오는 워치가 따라잡을 여유이기도 합니다.
조각 모음
컴팩션은 데이터베이스 파일 안에 빈틈을 남깁니다. 이 빈 공간은 etcd 가 다시 쓸 수 있습니다. 하지만 파일 시스템에는 돌아가지 않습니다. 애플리케이션 데이터를 지워도 디스크 공간이 안 줄어드는 까닭입니다.
조각 모음(defragmentation)이 이 공간을 파일 시스템에 돌려줍니다. 앞 그림의 5단계가 이것입니다.
조각 모음은 멤버마다 따로 겁니다. 클러스터 전체의 지연이 한꺼번에 튀지 않게 하려는 것입니다. 살아 있는 멤버에 조각 모음을 걸면 상태를 다시 짓는 동안 그 멤버는 읽기와 쓰기를 막습니다.
디스크와 리더 교체
디스크 지연 때문에 리더를 잃는 것은 의도된 동작입니다. etcd 는 디스크 지연도 리더가 살아 있는지의 일부로 봅니다. 리더가 하트비트는 제때 보내도 로그를 디스크에 적는 데 오래 걸리면 새 변경을 커밋하지 못합니다. 그런 리더는 사실상 쓸 수 없는 리더입니다.
과반의 멤버가 요청마다 디스크에 기록해야 합니다. 그래서 etcd 는 디스크 쓰기 지연에 아주 민감합니다. 같은 디스크를 쓰는 다른 프로세스 때문에 fsync 가 길어질 수 있습니다. fsync 는 파일에 쓴 내용을 디스크에 확실히 내려 적게 하는 호출입니다. fsync 가 길어지면 하트비트를 놓쳐 요청 시간 초과와 일시적인 리더 상실이 올 수 있습니다.
시간을 정하는 설정은 둘입니다.
| 설정 | 기본값 | 정하는 법 |
|---|---|---|
| 하트비트 간격(Heartbeat Interval) | 100ms | 멤버 사이 왕복 시간 근처로 잡기를 권합니다 |
| 선출 시간 제한(Election Timeout) | 1000ms | 왕복 시간의 10배 이상이어야 합니다. 왕복 시간이 10ms 면 100ms 이상입니다 |
두 값은 클러스터의 모든 멤버가 같아야 합니다. 멤버마다 다르면 클러스터가 불안정해질 수 있습니다.
디스크는 순차 쓰기 기준으로 잽니다. 순차 IOPS(Input/Output Operations Per Second, 초당 입출력 횟수)는 줄을 세워 하나씩 보낼 때의 초당 처리 횟수입니다. 보통은 초당 50 회가 필요합니다. 분당 7200 회전 하드디스크가 그 예입니다. 부하가 큰 클러스터에는 500 회를 권합니다. 일반 로컬 SSD(Solid State Drive)가 그 예입니다.
클라우드 업체가 내거는 값은 대개 동시 IOPS 입니다. 여러 요청을 한꺼번에 보낼 때의 값이라 순차 IOPS 의 10배까지 될 수 있습니다. 업체 숫자를 그대로 믿지 말고 순차 기준으로 다시 재야 합니다.
지켜볼 지표는 넷입니다.
| 지표 | 무엇을 재나 | 아픈 신호 |
|---|---|---|
etcd_disk_wal_fsync_duration_seconds |
로그 항목을 적용하기 전에 디스크에 내려 적는 fsync 의 지연 분포 | 요청 100개 가운데 99개가 끝나는 지연(p99)이 10ms 를 넘습니다 |
etcd_disk_backend_commit_duration_seconds |
최근 변경만 모은 스냅숏을 멤버가 자기 디스크에 적는 지연 분포 | 높으면 대개 디스크 문제입니다 |
has_leader |
이 멤버에 리더가 있나 | 리더가 없는 멤버는 아예 못 씁니다 |
leader_changes_seen_total |
멤버가 켜진 뒤 본 리더 교체 횟수 | 빠르게 늘면 리더가 불안정합니다 |
첫 지표의 로그가 WAL(Write-Ahead Log, 선행 기록 로그)입니다. 변경을 적용하기 전에 먼저 적어 두는 로그입니다.
리더가 하트비트 간격 두 번을 건너뛰면 etcd 는 failed to send out heartbeat on time 경고를 남깁니다. 이 경고도 대개 디스크가 원인입니다.
백업
정기 스냅숏이 etcd 키 공간의 백업입니다. 스냅숏이 있으면 알려진 정상 시점으로 클러스터를 되돌릴 수 있습니다. 운영 실수로 지우거나 망가뜨린 데이터를 되살리는 수단입니다.
$ etcdctl snapshot save backup.db
$ etcdutl --write-out=table snapshot status backup.db
+----------+----------+------------+------------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| fe01cf57 | 10 | 7 | 2.1 MB |
+----------+----------+------------+------------+
첫 명령이 스냅숏 파일을 만듭니다. 둘째 명령은 etcdctl 이 아니라 etcdutl 입니다. 이 명령이 그 파일의 해시, 리비전, 키 개수, 크기를 표로 찍습니다.
관련 항목
etcd 가 값을 맞추는 합의 방식
Raft · 합의 알고리즘 · 리더 · 팔로워 · 리더 선출 · 정족수 · 하트비트 · 선출 시간 제한 · 복제 · 복제 로그 · 학습자 멤버
etcd 가 데이터를 쌓고 걷는 구조
키-값 저장소 · 리비전 · MVCC · 컴팩션 · 조각 모음 · 스냅샷 · 선행 기록 로그 · fsync · B+ 트리 · bbolt
etcd 가 클라이언트에 주는 기능
워치 · 리스 · TTL · 트랜잭션 · 비교 후 교환 · 분산 락 · 분산 세마포어 · 서비스 디스커버리 · gRPC · etcdctl · etcdutl
etcd 가 지키는 일관성 성질
선형화 가능성 · 직렬화 가능성 · 엄격한 직렬화 가능성 · 지속성 · 일관성 · CAP 정리
락을 바깥 자원까지 지키는 장치
펜싱 토큰 · 상호 배제 · Chubby · 시퀀서
etcd 가 막는 장애
etcd 에 상태를 맡기는 시스템
Kubernetes · kube-apiserver · kubelet · 컨트롤 플레인 · 파드 · Patroni · PostgreSQL · Container Linux · locksmith
etcd 와 같은 역할을 두고 겨루는 조정 서비스
ZooKeeper · Consul · Zab · Paxos
etcd 가 담지 않는 큰 데이터를 받는 데이터베이스
NewSQL · CockroachDB · TiDB · Cloud Spanner · 샤딩
etcd 를 굴릴 때 재는 지표와 도구
Prometheus · fio · IOPS · SSD · p99
etcd 가 속하는 상위 분류
다른 이름: 엣시디