# Daniel Kim 의 기술 블로그 > Kubernetes 내부 구조, Istio, etcd, AWS 인프라 운영과 MCP·AI 인프라 구축 경험을 깊이 있게 기록하는 DevOps 엔지니어의 기술 블로그입니다. Public Ghost content for AI and LLM tooling. This file includes a bounded export of public pages first, then recent public posts. Append `.md` to any post or page URL to get the content in Markdown (for example, `/example-post.md`). ## Pages ### About URL: https://www.kimsehwan96.com/about/ Last updated: 2026-07-21T12:38:22.000Z 6년차 DevOps Engineer 김세환입니다. 현재는 ABLY corporation 에서 근무중입니다. 저는 현재 DevOps Engineering, AI Agent(platform), MCP, K8s, CI/CD, Data platform(Trino, Starrocks, Spark/EMR)등에 관심을 갖고, 관련 업무도 진행하고 있습니다. ## Posts ### Trino 에 접근제어 붙이기 : Google Workspace Secure LDAP 을 Ranger 에 연결하기 URL: https://www.kimsehwan96.com/trino-access-control-with-apache-ranger-2/ Last updated: 2026-08-11T23:35:58.000Z 💡 이 글은 Trino 접근제어 시리즈의 2편입니다. Apache Ranger 가 무엇이고 어떻게 동작하는지는 \[1편\](https://www.kimsehwan96.com/trino-access-control-with-apache-ranger-1/)에서 다뤘습니다. Apache Ranger 2.7.0 기준으로 작성되었습니다. ## 들어가며 1편에서 Ranger 는 자기 DB 에 있는 유저와 그룹에 대해서만 정책을 판정 할 수 있고, 그 목록은 usersync 라는 컴포넌트가 LDAP 을 보고 채워준다는 이야기를 했다. 이번 편은 usersync 에 Google Workspace Secure LDAP 을 소스로 붙이는 과정을 다룬다. 작업은 크게 네 가지였다. - usersync 컨테이너 이미지 빌드 (공식 이미지가 없다) - mTLS 클라이언트 인증서 구성 - install.properties 주입 - delta sync 관련 소스 패치 ## 왜 Google Workspace Secure LDAP 인가 사내 유저 정보의 원천은 Google Workspace 다. 입사하면 계정이 생기고 팀을 옮기면 그룹이 바뀌고 퇴사하면 비활성화되는 흐름이 전부 거기서 일어난다. 이 상황에서 OpenLDAP 같은 디렉터리 서버를 새로 세우면 이번에는 그 서버와 Google Workspace 사이의 동기화가 또 필요해지는데, 접근제어를 붙이려다 디렉터리 서버 운영이라는 일을 하나 더 만드는 구조라 제외했다. Google Workspace 에는 [Secure LDAP](https://knowledge.workspace.google.com/admin/apps/about-the-secure-ldap-service?ref=kimsehwan96.com) 이라는 기능이 있는데, LDAP 서버를 직접 프로비저닝하지 않아도 Workspace 에 등록된 유저와 그룹 정보를 LDAP 프로토콜로 조회 할 수 있다. 추가 인프라 없이 기존 유저 정보의 단일 출처를 그대로 이어받는 셈. 대신 표준 LDAP 이라면 있을 운영 속성 몇 가지를 제공하지 않는다. 이 이야기는 delta sync 섹션에서 이어진다. ## Secure LDAP 는 mTLS 가 필수다 Google Admin 콘솔에서 LDAP 클라이언트를 만들면 접속 자격이 두 겹으로 나온다. - 클라이언트 인증서 (cert + key) : 연결 자체를 mTLS 로 맺는 데 필요하다. 이것이 없으면 `ldaps://ldap.google.com:636` 연결이 성립하지 않는다. - bind 자격증명 (username + password) : LDAP bind 용 계정 정보다. 공식 문서는 stunnel 같은 프록시에 인증서를 물려 mTLS 를 대신 처리하게 하는 구성을 안내하는데, usersync 는 JVM 에서 돌기 때문에 프록시 없이 Java keystore 로 직접 처리 할 수 있다. 다만 Google 이 내려주는 것은 PEM 형식의 cert 와 key 두 파일이고 Java 쪽은 PKCS12 keystore 를 원해서 변환이 필요한데, openssl 로 변환한 p12 파일을 시크릿으로 넣으면 인증서를 갱신 할 때마다 누군가 로컬에서 변환을 반복해야 한다. 우리는 시크릿 관리를 ExternalSecrets 로 하고 있어서 템플릿 함수 `pemToPkcs12Pass` 를 사용했다. ```yaml apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: ranger-ldap-keystore spec: target: name: ranger-ldap-keystore template: engineVersion: v2 data: # PEM cert + key -> PKCS12 ldap-keystore.p12: | {{ pemToPkcs12Pass .ldap_cert .ldap_key "" | b64dec }} data: - secretKey: ldap_cert remoteRef: key: ops/ranger property: ldap_cert # PEM 인증서 - secretKey: ldap_key remoteRef: key: ops/ranger property: ldap_key # PEM 개인키 ``` Secrets Manager 에는 Google 이 준 PEM 두 개를 그대로 넣어두고, 쿠버네티스 시크릿으로 렌더링되는 시점에 PKCS12 로 변환되는 구성. 인증서 갱신이 Secrets Manager 값 교체로 끝난다. ![인증서가 usersync 까지 가는 경로. PEM 에서 PKCS12 로의 변환은 ExternalSecrets 컨트롤러가 담당한다](https://www.kimsehwan96.com/content/images/2026/08/ranger2-cert-flow.png) 인증서가 usersync 까지 가는 경로. PEM 에서 PKCS12 로의 변환은 ExternalSecrets 컨트롤러가 담당한다 이렇게 만든 keystore 를 usersync 파드에 마운트하고 JVM 옵션으로 지정해주면 mTLS 쪽은 끝난다. ```yaml env: - name: JAVA_OPTS value: >- -Djavax.net.ssl.keyStore=/ldap-cert/ldap-keystore.p12 -Djavax.net.ssl.keyStoreType=PKCS12 -Djavax.net.ssl.keyStorePassword= -Djavax.net.ssl.trustStore=/opt/java/openjdk/jre/lib/security/cacerts -Djavax.net.ssl.trustStorePassword=fooBarBaz ``` trustStore 는 별도로 만들지 않고 JDK 에 기본으로 들어있는 cacerts 를 그대로 썼다. `ldap.google.com` 의 서버 인증서는 공인 CA 체인이라 기본 truststore 로 검증이 된다. usersync 를 띄우기 전에 자격이 유효한지 ldapsearch 로 먼저 확인해보기로 했다. 커맨드라인에서는 클라이언트 인증서를 `LDAPTLS_CERT` / `LDAPTLS_KEY` 환경변수로 물릴 수 있는데, 인증서까지 제대로 넣고도 bind 가 계속 이렇게 실패했다. ```bash $ LDAPTLS_CERT=~/foo/ldap_cert.crt \ LDAPTLS_KEY=~/foo/ldap_cert.key \ ldapsearch -x -LLL -H ldaps://ldap.google.com:636 \ -b "ou=Users,dc=example,dc=com" \ -D "" -W \ '(mail=user@example.com)' Enter LDAP Password: ldap_bind: Invalid credentials (49) additional info: Incorrect password ``` 비밀번호를 다시 확인하고, bind DN 형식을 이리저리 바꿔보고, Secure LDAP 서비스 상태가 OFF 인 것 아닌가? 하는 의심까지 했는데 전부 아니었다. 원인은 클라이언트 쪽으로, macOS 에 기본 설치된 ldapsearch 는 SNI 를 지원하지 않는 빌드라 Google 쪽과의 인증이 성립하지 않는다. `brew install openldap` 으로 설치한 ldapsearch (`/opt/homebrew/opt/openldap/bin/ldapsearch`) 를 쓰면 같은 명령이 그대로 통과한다. [Google 문서](https://knowledge.workspace.google.com/admin/apps/secure-ldap-connectivity-testing?ref=kimsehwan96.com)의 연결 테스트 안내에도 OpenLDAP 클라이언트 요구사항이 정리되어 있다. ## usersync 공식 이미지는 없다 Ranger Admin 은 Docker Hub 에 [apache/ranger](https://hub.docker.com/r/apache/ranger?ref=kimsehwan96.com) 로 공식 이미지가 있는데 usersync 는 없다. apache/ranger-usersync 라는 저장소 자체가 없어서, 소스에서 직접 빌드해야 한다. 빌드에 필요한 것들은 레포의 [dev-support/ranger-docker](https://github.com/apache/ranger/tree/release-ranger-2.7.0/dev-support/ranger-docker?ref=kimsehwan96.com) 디렉토리에 모여있다. 이번 작업과 관련된 부분만 추리면 이런 구조다. ``` dev-support/ranger-docker/ ├── Dockerfile.ranger-usersync # usersync 이미지 정의 ├── docker-compose.ranger-usersync.yml ├── scripts/ │ ├── ranger-usersync.sh # 컨테이너 ENTRYPOINT │ └── ranger-usersync-install.properties └── dist/ └── ranger-2.7.0-usersync.tar.gz # Maven 빌드 산출물을 여기에 둔다 ``` 빌드는 2단인데, 먼저 Maven 으로 usersync tar.gz 를 만들어 `dist/` 에 두면 [Dockerfile.ranger-usersync](https://github.com/apache/ranger/blob/release-ranger-2.7.0/dev-support/ranger-docker/Dockerfile.ranger-usersync?ref=kimsehwan96.com) 가 그 tar.gz 를 풀어서 이미지로 만든다. ```dockerfile ARG RANGER_BASE_IMAGE ARG RANGER_BASE_VERSION FROM ${RANGER_BASE_IMAGE}:${RANGER_BASE_VERSION} ARG USERSYNC_VERSION COPY ./dist/ranger-${USERSYNC_VERSION}-usersync.tar.gz /home/ranger/dist/ RUN tar xvfz /home/ranger/dist/ranger-${USERSYNC_VERSION}-usersync.tar.gz --directory=${RANGER_HOME} && \ ... ``` Maven 빌드 커맨드는 최종적으로 이렇게 되었다. ```bash $ mvn clean install -DskipTests -Denunciate.skip=true ``` 포인트는 `-Denunciate.skip=true` 쪽인데. enunciate 는 REST API 문서를 생성하는 Maven 플러그인으로, 빌드 중 duplicate class 에러를 내면서 실패했다. 로컬 `~/.m2` 캐시 상태와 겹치면 나는 문제로 보이는데, 문서 생성은 이번 목적과 무관한데도 `-DskipDocs` 로는 꺼지지 않았고 플러그인 자체를 skip 해야 통과했다. 베이스 이미지는 업스트림이 제공하는 [apache/ranger-base](https://hub.docker.com/r/apache/ranger-base/tags?ref=kimsehwan96.com) 를 그대로 썼다. 태그가 `20251023-1-8`, `20251023-1-11`, `20251023-1-17` 같은 식으로 나뉘는데, 끝자리가 JDK 버전. 2.7.0 의 런타임 스크립트들은 아직 Java 8 전제가 남아있어서 `-8` 태그를 골랐다. 노드 그룹에 arm64 와 amd64 가 섞여있는 환경이라 buildx 로 멀티 아키텍처 빌드해서 사내 레지스트리에 올렸다. ```bash docker buildx build \ --platform linux/arm64,linux/amd64 \ --tag /ranger-usersync:2.7.0 \ -f Dockerfile.ranger-usersync \ --build-arg RANGER_BASE_IMAGE=apache/ranger-base \ --build-arg RANGER_BASE_VERSION=20251023-1-8 \ --build-arg USERSYNC_VERSION=2.7.0 \ --push . ``` ## install.properties 는 ConfigMap 과 시크릿을 합쳐서 만든다 usersync 의 설정은 `install.properties` 파일 한 장이다. 기동 시 [setup.py](https://github.com/apache/ranger/blob/release-ranger-2.7.0/unixauthservice/scripts/setup.py?ref=kimsehwan96.com) 가 이 파일을 읽어 실제 런타임이 읽는 XML 설정으로 변환하는데, 쿠버네티스에 올리면서는 세 가지 처리가 필요했다. 첫번째는 비밀값 분리다. install.properties 에는 LDAP bind 비밀번호 같은 민감한 값이 섞여 들어간다. 파일 전체를 ConfigMap 으로 두면 비밀값이 git 에 남고, 파일 전체를 손으로 시크릿으로 만들면 나머지 설정의 변경 이력이 사라진다. ExternalSecrets 의 `templateFrom` 을 쓰면 설정 본문은 ConfigMap 템플릿에 두고 민감값만 Secrets Manager 에서 가져와 렌더링 할 수 있다. ```yaml apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: ranger-usersync-install-properties-secret spec: target: name: ranger-usersync-install-properties template: engineVersion: v2 templateFrom: - configMap: name: ranger-usersync-install-properties-template items: - key: install.properties data: - secretKey: ldap_user remoteRef: key: ops/ranger property: ldap_user - secretKey: ldap_password remoteRef: key: ops/ranger property: ldap_password ``` 설정 파일은 git 에서 리뷰되고 비밀값은 git 에 없는 형태가 된다. 두번째는 쓰기 가능한 마운트다. 이 시크릿을 subPath 로 `install.properties` 자리에 마운트하면 끝 아닌가? 싶었지만, setup.py 가 기동 과정에서 이 경로에 쓰기를 시도한다. 시크릿 마운트는 read-only 라 그대로는 기동이 실패해서, init container 가 시크릿 내용을 emptyDir 로 복사하고 본 컨테이너는 그 복사본을 마운트한다. ```yaml initContainers: - name: prepare-config command: - sh - -c - | cp /config-template/install.properties /workdir/install.properties chmod 644 /workdir/install.properties ``` 세번째는 install.properties 에 항목이 없는 설정이다. 삭제된 유저를 soft delete 처리하는 `ranger.usersync.deletes.enabled` 같은 값은 install.properties 에 대응 항목이 없어서, usersync 가 읽는 XML 설정 파일인 `ranger-ugsync-default.xml` 에 직접 넣어야 한다. setup.py 가 기동 시 `conf.dist` 디렉토리의 설정 파일들을 `conf` 로 복사하기 때문에, `conf.dist/ranger-ugsync-default.xml` 자리에 우리 파일을 마운트해뒀다. ```xml ranger.usersync.deletes.enabled true ranger.usersync.deletes.frequency 10 ``` Google Workspace 라서 표준 LDAP 과 다른 부분도있고, 우리 상황에 맞게 설정하다보니 LDAP 관련 설정은 아래와 같이 하게되었다. - `SYNC_LDAP_USER_NAME_ATTRIBUTE=mail` : 유저명 속성으로 흔히 쓰는 `cn` 이나 `uid` 대신 이메일을 쓴다. Trino 가 인증에서 넘겨주는 아이덴티티가 이메일이라, Ranger 에 등록되는 유저명도 이메일이어야 정책 판정이 같은 키로 맞아떨어진다. - `SYNC_LDAP_USER_OBJECT_CLASS=person` : Google 쪽 유저 엔트리의 objectClass 가 person 이다. - `SYNC_GROUP_MEMBER_ATTRIBUTE_NAME=member` : POSIX 그룹의 `memberUid` 가 아니라 groupOfNames 의 `member` 속성으로 멤버십이 표현된다. 값도 uid 가 아니라 멤버의 DN 이다. - `SYNC_LDAP_USER_SEARCH_FILTER=` : memberOf 기반 필터가 기대대로 동작하지 않는 경우가 있어서 필터 없이 전체를 받고 있다. - `SYNC_LDAP_USERNAME_CASE_CONVERSION=lower` : LDAP 은 대소문자를 구분하지 않지만 Ranger 의 유저명은 구분한다. 어느 쪽 표기가 들어와도 한 유저로 모이도록 소문자로 통일한다. ## delta sync 는 두 곳에서 동작하지 않았다 1편에서 이야기한 delta sync 문제를 마저 정리한다. usersync 의 delta sync 는 `uSNChanged`(Active Directory) 나 `modifyTimestamp`(표준 LDAP 운영 속성) 를 검색 필터에 넣어 변경분만 가져오는 방식인데, Google Workspace Secure LDAP 은 이 두 속성을 제공하지 않는다. - [LdapUserGroupBuilder.java](https://github.com/apache/ranger/blob/release-ranger-2.7.0/ugsync/src/main/java/org/apache/ranger/ldapusersync/process/LdapUserGroupBuilder.java?ref=kimsehwan96.com) 의 `getUsers()` 는 delta sync 가 꺼져있어도 검색 필터에 해당 조건을 무조건 붙인다. 두 속성이 없는 서버에서는 매칭되는 엔트리가 0건이 되어 유저가 한 명도 동기화되지 않는다. - [setup.py#L259](https://github.com/apache/ranger/blob/release-ranger-2.7.0/unixauthservice/scripts/setup.py?ref=kimsehwan96.com#L259) 는 LDAP 소스이기만 하면 `ranger.usersync.ldap.deltasync` 를 무조건 `true` 로 덮어쓴다. install.properties 에 `SYNC_LDAP_DELTASYNC=false` 를 적어도 반영되지 않는다. 포크에 패치를 두 개 넣었다. 필터 쪽은 delta sync 가 꺼져있으면 해당 조건을 붙이지 않도록 분기를 넣었고, setup.py 쪽은 당장은 `false` 하드코딩으로 대체했다. 업스트림에는 설정값을 존중하는 형태로 정리해서 [RANGER-5466 (#827)](https://github.com/apache/ranger/pull/827?ref=kimsehwan96.com) 로 올려뒀는데, PR 의 변경은 두 부분이다. `LdapUserGroupBuilder.java` 쪽은 delta sync 가 꺼져있으면 검색 필터를 objectclass 조건만으로 만든다. (그룹을 읽는 `getGroups()` 에도 같은 패턴의 분기가 하나 더 있다) ```diff - extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")(|(uSNChanged>=" + deltaSyncUserTime + ")(modifyTimestamp>=" + deltaSyncUserTimeStamp + "Z))"; + if (config.isDeltaSyncEnabled()) { + extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")(|(uSNChanged>=" + deltaSyncUserTime + ")(modifyTimestamp>=" + deltaSyncUserTimeStamp + "Z))"; + } else { + extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")"; + } ``` `setup.py` 쪽은 무조건 덮어쓰던 값을, 사용자가 지정하지 않았을 때만 기본값 `true` 로 채우도록 바꿨다. ```diff - ret['ranger.usersync.ldap.deltasync'] = "true" + if ('ranger.usersync.ldap.deltasync' not in ret or len(str(ret['ranger.usersync.ldap.deltasync'])) == 0): + ret['ranger.usersync.ldap.deltasync'] = "true" ``` 이 글을 쓰는 시점 기준 리뷰 대기 중이고, 우리는 해당 변경이 꼭 필요하니까 포크/빌드해서 사용중이다. ## 돌아가는지 어떻게 아는가 usersync 는 60분 주기로 Workspace 전체 유저와 그룹을 full sync 한다. 매 사이클이 무엇을 몇 건 만들고 바꿨는지는 Ranger Admin 의 audit 화면에 남고, 동기화가 살아있는지는 거기서 확인한다. delta sync 를 못 쓰는 대가로 매번 전체를 훑지만, 우리 조직 규모에서는 한 사이클이 부담스러운 수준은 아니었다. 사이클 하나가 끝날 때의 로그는 이런 모습이다. (실제 로그에서 이메일만 바꿨다) ``` INFO o.a.r.u.p.PolicyMgrUserGroupBuilder [UnixUserSyncThread] - user alice@example.com already marked for delete INFO o.a.r.u.p.PolicyMgrUserGroupBuilder [UnixUserSyncThread] - user bob@example.com already marked for delete INFO o.a.r.u.p.PolicyMgrUserGroupBuilder [UnixUserSyncThread] - No. of users marked for delete = 0 INFO o.a.r.l.p.LdapUserGroupBuilder [UnixUserSyncThread] - deltaSyncUserTime = 0 and highestdeltaSyncUserTime = 0 INFO o.a.r.l.p.LdapUserGroupBuilder [UnixUserSyncThread] - deltaSyncGroupTime = 0 and highestdeltaSyncGroupTime = 0 INFO o.a.r.u.UserGroupSync [UnixUserSyncThread] - End: update user/group from source==>sink INFO o.a.r.u.UserGroupSync [UnixUserSyncThread] - Sleeping for [3600000] milliSeconds ``` 읽을 만한 지점이 셋 있다. - `deltaSyncUserTime = 0` : delta sync 를 끈 상태가 로그에 그대로 드러난다. 변경분 조회의 기준 시각이 epoch(0) 으로 고정되어 매번 전체를 읽는다. - `already marked for delete` : LDAP 에서 사라진 (퇴사한) 유저들이 soft delete 상태로 유지되고 있다는 표시다. - `Sleeping for [3600000] milliSeconds` : `SYNC_INTERVAL=60` 그대로, 다음 사이클까지 60분 대기한다. 참고로 이 로그가 `kubectl logs` 로 바로 보이는 것부터가 포크에서 손본 결과다. 1편에서 이야기했듯 원래는 기동 스크립트가 로그를 파일로 리다이렉트하고 있어서 파드 안에 들어가야 볼 수 있었다. 동기화 경로는 LDAP -> usersync -> Ranger Admin(DB) -> 엔진 플러그인(userstore 캐시) 로 이어지는데, "유저의 그룹이 정책에 반영이 안 되는것 같다" 는 문의가 오면 Ranger Admin 의 API 두 개를 비교해서 문제 구간을 좁힐 수 있다. 참고로 usersync 가 저장한 결과는 Ranger DB 의 `x_user` , `x_group` , `x_group_users` 테이블에 들어가고, 플러그인에 내려줄 userstore 의 버전은 `x_ranger_global_state` 가 관리한다. ![동기화 경로와 문제 확인 지점. 두 API 의 응답을 비교하면 어느 구간이 문제인지 갈린다](https://www.kimsehwan96.com/content/images/2026/08/ranger2-sync-path.png) 동기화 경로와 문제 확인 지점. 두 API 의 응답을 비교하면 어느 구간이 문제인지 갈린다 ```bash # Ranger DB 에 저장된 유저 정보 (usersync 가 넣은 결과) curl -u "admin:$PWD" "http://ranger:6080/service/xusers/users/userName/{user}" # 엔진 플러그인이 내려받는 userstore (버전 기반 캐시) curl -u "admin:$PWD" "http://ranger:6080/service/xusers/secure/download/{serviceName}" ``` | xusers API (DB) | userstore API (플러그인용) | 문제 위치 | | --------------- | --------------------- | ----------------------------------------- | | 그룹 없음 | 그룹 없음 | usersync 이전. LDAP 값과 동기화 로그부터 확인 | | 그룹 있음 | 그룹 없음 | Ranger Admin 의 userstore 캐시. 버전 갱신이 안 된 것 | | 그룹 있음 | 그룹 있음 | 엔진 쪽 플러그인 캐시. 엔진이 새 버전을 아직 안 받은 것 | 가운데 행은 실제로 겪은 케이스다. 그룹 멤버십이 바뀌어도 userstore 버전이 올라가지 않아 캐시가 이전 상태로 남는 버그였고, 1편 말미의 표에 있던 [RANGER-5463 (#824)](https://github.com/apache/ranger/pull/824?ref=kimsehwan96.com) 이 이것이다. ## 마치며 - Google Workspace Secure LDAP 은 디렉터리 서버를 새로 세우지 않고 기존 유저 단일 출처를 이어받는 선택지다. 대신 mTLS 클라이언트 인증서가 필수고, 표준 LDAP 운영 속성 일부가 없어서 delta sync 를 쓸 수 없다. - PEM 에서 PKCS12 로의 변환은 ExternalSecrets 의 `pemToPkcs12Pass` 로 컨트롤러에 맡길 수 있다. 인증서 갱신이 Secrets Manager 값 교체로 끝난다. - usersync 는 공식 이미지가 없어 소스 빌드가 필요하다. Maven 빌드는 `-Denunciate.skip=true` 가 필요했고, 베이스 이미지는 JDK 8 태그를 쓴다. - 민감한 값이 섞인 설정 파일은 ExternalSecrets `templateFrom` 으로 ConfigMap 템플릿과 시크릿 값을 합쳐 렌더링하면 git 리뷰와 비밀 관리를 둘 다 챙길 수 있다. - delta sync 는 코드와 설치 스크립트 두 곳에서 막혀 있었고, 포크 패치로 풀어서 업스트림 PR 로 올렸다. - 동기화 문제는 xusers API 와 userstore API 의 비교로 문제 구간을 좁힐 수 있다. ### Trino 에 접근제어 붙이기 : Apache Ranger 는 무엇이고 어떻게 동작하는가 URL: https://www.kimsehwan96.com/trino-access-control-with-apache-ranger-1/ Last updated: 2026-08-02T23:17:01.000Z 💡 이 포스팅은 Apache Ranger 2.7.0, Trino 479, Google Workspace Secure LDAP 환경을 기준으로 작성하였다. 이번 글은 개념과 구조를 다룬다. ## 들어가며 작년 말부터 전사 쿼리 엔진을 Athena 에서 Trino 로 옮기는 작업을 진행하게 되었는데, 스캔량이 많아질수록 Athena 대비 비용 이점이 커지는 구조라 사용량이 늘어날수록 효과가 컸고 지금은 사내 분석 쿼리 대부분이 Trino 위에서 돌고있다. 그런데 쿼리 엔진을 우리 손으로 운영한다는것은 관리형 서비스가 대신 해주던 것들까지 같이 떠안는다는 뜻이기도 한데, 그중 하나가 접근제어였다. Athena 를 쓸 때는 IAM 이 알아서 해주던 일인데 Trino 로 넘어오면서 "누가 어떤 데이터를 조회 할 수 있는가" 를 직접 설계해야 하는 상황이 되었다. 사실 무슨 특별한 사건이 있어서 시작한 일은 아니다. 쿼리 엔진을 직접 운영하기로 한 이상 인증과 인가는 당연히 갖춰야 하는 축이었고, 데이터 엔지니어 쪽에서도 테이블이나 컬럼 단위로 접근을 나눠야 한다는 이야기가 나오던 참이었는데. 인증이야 Trino 가 자체적으로 여러 방식을 제공하니 고르면 되는 문제였고 남는 것은 인가였는데, Trino 에서 인가를 붙이는 현실적인 선택지가 사실상 Ranger 아니면 OPA 둘 중 하나라 그 둘을 놓고 검토를 시작하게 되었다. Trino 인증 타입 : [https://trino.io/docs/current/security/authentication-types.html](https://trino.io/docs/current/security/authentication-types.html?ref=kimsehwan96.com) Trino 접근 제어 - [OPA](https://trino.io/docs/current/security/opa-access-control.html?ref=kimsehwan96.com) - [Ranger](https://trino.io/docs/current/security/ranger-access-control.html?ref=kimsehwan96.com) ## Trino 는 상태를 거의 남기지 않는다 접근제어 방식을 고르기 전에 Trino 라는 엔진의 성격을 먼저 짚어야 하는데, 여기서 갈리는것이 꽤 많은편이다. **Trino 는 자기 상태를 저장할 별도의 데이터베이스를 두지 않는다**. MySQL 이나 PostgreSQL 처럼 유저 테이블이 있고 권한 테이블이 있는 구조가 아니라서, 좀 과장해서 말하면 거의 stateless 에 가깝게 동작한다고 볼 수 있다. - **쿼리 기록** 은 **코디네이터의 메모리**에만 들고 있다. 웹 UI 에서 최근 쿼리를 볼 수 있지만 그건 메모리에 남아있는 동안 뿐이고, 코디네이터가 재시작되면 그냥 사라진다. - **인증에 쓰는 정보** 도 마찬가지인데, 어딘가에 유저를 저장해두는 것이 아니라 파일이나 외부 시스템에서 그때그때 읽어들여 메모리에 올리는 형태다. 그래서 Trino 를 운영하려면 "남겨야 하는 것" 들을 바깥으로 빼내는 작업이 따라붙게 되는데, 우리는 쿼리 기록을 event listener 로 Kafka 에 흘려보내고 그걸 다시 테이블로 적재해서 쓰고있다. ```properties event-listener.name=kafka kafka-event-listener.created-event.topic=trino_queryEvent_created kafka-event-listener.completed-event.topic=trino_queryEvent_completed ``` Trino helm chart 내 eventListenerProperties 의 리스트 값 예시 이렇게 해두면 누가 언제 어떤 쿼리를 돌렸고 얼마나 스캔했는지를 나중에 SQL 로 조회 할 수 있는데, 반대로 이 설정을 안 해두면 코디네이터가 재시작되는 순간 해당 기록은 그냥 없어진다. 다만 이벤트를 그대로 다 흘려보내지는 않는데. Trino 가 내보내는 완료 이벤트에는 쿼리 텍스트나 스캔량처럼 우리가 실제로 들여다보는 것들 말고도 실행 계획 전문이나 오퍼레이터별 통계, 태스크 통계 같은것이 함께 들어있어서, 쿼리가 무거워질수록 이벤트도 같이 커지게 된다. 그래서 조회에 쓰지 않는 무거운 필드는 아래와 같이 아예 제외하고 보내고 있다. ```properties kafka-event-listener.excluded-fields=jsonPlan,payload,operatorSummaries,taskStatistics,plan,planNodeStatsAndCosts,outputBufferMetrics,optimizerRulesSummaries,routines,catalogMetadataMetrics ``` Trino helm chart 내 eventListenerProperties 의 리스트 값 예시 전부 남겨두면 언젠가 쓸 데가 있을것 같기도 한데, 실제로는 Kafka 와 적재 테이블 용량만 늘고 조회도 같이 느려지기 때문에, 감사 목적으로 필요한 필드만 골라 적재하고 실행 계획 같은것은 필요할 때 `EXPLAIN` 으로 다시 뽑는 편이 낫다고 보고있다. 권한도 같은 맥락인데, Trino 안에 권한을 저장할 곳이 없으니 접근제어 역시 외부에 두고 참조하는 형태가 될 수밖에 없다. ## Trino 는 인증까지만 자기가 하고, 인가는 넘긴다 인증(authn)과 인가(authz)가 어떻게 다른지를 새삼 설명할 필요는 없을것 같고, 대신 Trino 안에서 이 둘의 담당자가 어디서 갈리는지는 짚어두는것이 뒤의 이야기에 필요하다. - **인증** 은 Trino 가 자체적으로 처리하는데, 방식을 설정으로 고르면 되고 결과물은 principal 하나가 Trino user 로 확정되는것까지다. - **인가** 는 Trino 가 스스로 판단하지 않고 `system access control` 이라는 확장 지점으로 넘기는데, 여기에 무엇을 끼우느냐에 따라 판정 주체가 통째로 바뀐다. 처음부터 갈아끼우도록 설계되어있는 셈이다. Trino 는 인증 방식을 여러 가지 제공한다. 우리가 실제로 쓰는 것은 둘인데. ```yaml authenticationType: "OAUTH2,PASSWORD" ``` Trino helm chart 내 값 예시 OAuth2 는 사람이 쓰는 경로다. 브라우저나 클라이언트에서 회사 계정으로 로그인하면 그 principal 이 그대로 Trino user 가 된다. Password 쪽은 사람이 아닌 것들이 쓰는 경로인데, Redash 나 Airflow, 각종 배치 잡처럼 브라우저 로그인을 할 수 없는 서비스 계정에는 username/password 를 발급해서 쓰고있다. 이 둘 말고도 [Trino 공식 문서](https://trino.io/docs/current/security.html?ref=kimsehwan96.com)를 보면 LDAP 직접 인증이나 Kerberos, 클라이언트 인증서, JWT, Salesforce 등 선택지가 꽤 많으니 환경에 맞는것을 고르면 된다. 그리고 여기서 인증은 끝이다. Trino 는 "이 사람이 누구인지" 까지만 알려주고, "그래서 무엇을 할 수 있는지" 는 접근제어 시스템 쪽으로 넘겨버린다. Ranger 가 담당하는 부분이 정확히 이 뒷단이다. ## 왜 Ranger 였나 Trino 가 제공하는 인가 방식은 크게 셋인데, JSON 파일 기반과 Apache Ranger 기반, 그리고 OPA(Open Policy Agent) 기반이다. | 방식 | 정책을 어디에 쓰나 | 판단 | | ---------- | ---------------------- | ---------------------------------- | | **file** | JSON 파일 (k8s manifest) | 권한 하나 바꾸는 데 매니페스트 수정과 배포가 필요 | | **OPA** | REGO 로 작성한 policy | DSL 을 새로 배워야 하고, 그래도 결국 매니페스트를 거친다 | | **Ranger** | Ranger 자체 DB (웹 UI) | 컴포넌트를 따로 운영해야 함 | 앞의 두 방식을 제친 이유는 사실 하나로 수렴하는데, 권한을 바꾸려면 매번 DevOps 엔지니어가 개입해야 한다는 점이다. "이 테이블 권한 좀 주세요" 라는 요청이 올 때마다 매니페스트를 고치고 PR 을 올리고 배포를 기다려야 한다면, 권한 관리라는 일이 통째로 인프라 팀의 큐에 쌓이게 된다. 지금 누가 무슨 권한을 갖고 있는지 한눈에 보여줄 방법도 없고. (플랫폼 엔지니어링으로 풀어낼수는 있겠다) OPA 는 정책 표현력이 훨씬 뛰어나지만 REGO 라는 DSL 을 배워야 하는데다, 배운다 한들 정책이 매니페스트에 사는 이상 개입 문제는 그대로 남는다. 반면 Ranger 를 고른 이유는 이랬다. - 웹 UI 로 정책을 만들고 감사 로그까지 확인 할 수 있다. DevOps 엔지니어뿐 아니라 데이터 엔지니어나 보안 담당자도 직접 권한을 관리 할 수 있게 된다. - LDAP 과 연계해서 유저·그룹 매핑을 그대로 가져올 수 있다. - Hadoop 시절부터 오래 쓰여온 프로젝트라 성숙도가 있다. 물론 대가도 명확한데, 컴포넌트를 여러 개 띄우고 운영해야 한다는 것. 그리고 정책에 대한 GitOps 가 되지 않는것이 있다. (에이전트로 운영을 자동화하거나 DevOps 엔지니어링 일부를 위임하는경우에는 GitOps 가 되지 않는게 크리티컬 할수는 있겠다. 이건 개선해야 할 점으로 생각하고있다) ## Apache Ranger 는 무엇인가 한 줄로 요약하면 여러 데이터 엔진에 걸친 인가 정책을 한곳에서 관리하는 도구다. Ranger 는 Hadoop 생태계에서 태어났는데, HDFS 나 Hive, HBase, Kafka 같은 컴포넌트마다 권한 체계가 제각각이라 이걸 하나의 정책 모델과 하나의 UI 로 묶으려는 시도였고, 지금도 지원 플러그인 목록을 보면 그 계보가 그대로 보인다. 다만 목록이 Hadoop 에서 멈춰있지는 않아서 Elasticsearch, Kafka Schema Registry 같은것들도 있고 그 안에 Trino 와 Presto 도 들어있다. 즉 Hadoop 컴포넌트를 하나도 쓰지 않고 Trino 플러그인만 붙여서 쓰는 구성도 성립한다는 뜻인데, 우리가 한 것이 정확히 그것이다. 우리 환경에는 HDFS 도 YARN 도 Hive 서버도 없고, 오브젝트 스토리지 위에 Iceberg 테이블이 있고 그걸 Trino 가 읽는 형태다. ![Ranger Service Manager. Hadoop 생태계 컴포넌트들이 나열되어 있지만 우리가 등록해서 쓰는것은 TRINO 하나뿐이다](https://www.kimsehwan96.com/content/images/2026/07/ranger-service-manager.png) Ranger Service Manager. Hadoop 생태계 컴포넌트들이 나열되어 있지만 우리가 등록해서 쓰는것은 TRINO 하나뿐이다 그런데 그런 구성을 설명해주는 문서가 마땅치 않았다. [공식 사이트](https://ranger.apache.org/?ref=kimsehwan96.com)는 사용법 대부분을 [Confluence 위키](https://cwiki.apache.org/confluence/display/RANGER/Index?ref=kimsehwan96.com)로 넘기고 있는데, 그 위키에서 가장 먼저 눌러보게 되는 [Ranger Installation Guide](https://cwiki.apache.org/confluence/spaces/RANGER/pages/49579132/Ranger+Installation+Guide?ref=kimsehwan96.com) 는 마지막 수정이 2015 년이고 대상 버전이 0.4.0 이다. 우리가 설치한 것은 2.7.0 이고 지금 최신 릴리스는 2.8.0 인데. 문서가 다루는 플러그인도 HDFS, Hive, HBase, Knox, Storm 다섯 개라 Trino 는 아예 등장하지 않고, 딸려나오는 전제 버전들이 Hadoop 2.5.2 나 JDK 7 인 것을 보면 지금 환경과는 거리가 꽤 있다. 소스 트리를 봐도 사정이 비슷한데, `plugin-trino` 디렉터리에는 README 한 장이 없다. 결국 설정 항목 하나하나를 코드와 다른 플러그인의 사례에서 유추해가며 맞추는 식이 되었고, 문서가 도와주지 않는 구간이 꽤 길었다. 공식 Helm chart 도 없어서 최소한의 구성으로 매니페스트를 직접 작성해서 배포했다. ## 세 개의 컴포넌트 Ranger 를 설치한다고 할 때 실제로는 여러 개의 독립된 프로세스를 띄우는 일이 되는데. 이름이 다 비슷비슷해서 처음에 좀 헷갈렸지만, 역할로 나누면 셋이다. | 컴포넌트 | 무엇인가 | 어디에 사는가 | | ----------------- | -------------------------------- | ------------------ | | **Ranger Admin** | 정책 저장소 + 웹 UI + REST API | 독립 서버 (백엔드 DB 필요) | | **Ranger Plugin** | 실제로 허용/거부를 판정하는 라이브러리 | **데이터 엔진 프로세스 내부** | | **UserSync** | LDAP 등에서 유저·그룹을 읽어 Admin 으로 밀어넣음 | 독립 프로세스 | 여기에 감사 로그를 저장할 곳이 하나 더 필요한데, Ranger 는 audit store 로 Solr 또는 Elasticsearch 를 지원해서 우리는 Solr 를 골랐다. ![Ranger 의 구성요소와 데이터 흐름. LDAP 에서 유저가 들어오고 정책은 Admin 에 쌓이지만, 판정은 Trino 안의 plugin 이 ](https://www.kimsehwan96.com/content/images/2026/07/ranger-architecture.png) Ranger 의 구성요소와 데이터 흐름. LDAP 에서 유저가 들어오고 정책은 Admin 에 쌓이지만, 판정은 Trino 안의 plugin 이 한다 이 중에서 가장 오해하기 쉬운것이 Plugin 인데. 이름만 보면 Ranger 쪽에 붙는 무언가처럼 들리지만 실제로는 반대다. Plugin 은 데이터 엔진 쪽에 설치되는 라이브러리이고, Trino 의 경우 배포판에 jar 이 이미 들어있기 때문에(`/usr/lib/trino/plugin/ranger/`) 따로 설치할 것 없이 설정만 얹으면 된다. ```properties access-control.name=ranger ranger.service.name=trino ranger.plugin.config.resource=/etc/trino/ranger-trino-security.xml,/etc/trino/ranger-trino-audit.xml ``` Trino helm chart 내 accessControl 값 예시 Trino 코디네이터가 쿼리를 받아 접근 권한을 확인해야 할 때 이 plugin 이 호출되어 허용 여부를 반환하는 형태인데, 즉 판정 자체는 Trino 프로세스 안에서 일어난다. 이 사실이 다음 이야기로 이어지는데. ## 정책 평가는 Admin 이 아니라 엔진 안의 로컬 캐시에서 일어난다 Ranger 를 처음 접했을 때는 이렇게 동작할 것이라고 짐작했었다. 유저가 쿼리를 던지면 Trino 가 Ranger Admin 에게 "이 사람 이거 봐도 되냐" 고 물어보고 답을 받아서 처리하는것 아닌가? 라는 그림이었는데, 실제로는 전혀 그렇지 않았다. Trino 안의 plugin 은 주기적으로 Ranger Admin 을 폴링해서 정책 전체를 통째로 내려받아 로컬 파일에 캐싱해두고, 쿼리가 들어올 때는 그 로컬 캐시만 보고 판정한다. Admin 에게 물어보지 않는다. ```properties ranger.plugin.trino.policy.pollIntervalMs = 1000 ranger.plugin.trino.policy.cache.dir = /tmp/ranger/policycache ``` additionalConfigFiles 내 xml 값중 일부 예시 우리는 폴링 주기를 1초로 두고 있는데, 코디네이터 파드에 들어가서 캐시 디렉터리를 보면 아래와 같이 파일이 떨어져 있다. ``` /tmp/ranger/policycache/ trino_trino.json ← 정책 캐시 trino_trino_userstore.json ← 유저/그룹 캐시 ``` 폴링이라고 해서 1초마다 정책 전체를 다시 받아오는것은 아니다. plugin 은 자기가 알고 있는 버전 번호를 함께 보내고 Admin 은 버전이 그대로면 변경 없음만 응답하기 때문에, 실제로 데이터가 오가는 것은 버전이 올라갔을 때 뿐이다. ![쿼리 처리 경로와 정책 동기화 경로는 완전히 분리되어 있다](https://www.kimsehwan96.com/content/images/2026/07/ranger-authz-flow.png) 쿼리 처리 경로와 정책 동기화 경로는 완전히 분리되어 있다 이 설계에는 분명한 장점이 있는데. 쿼리 경로에 네트워크 왕복이 없으니 판정이 빠르고, Ranger Admin 이 죽어도 Trino 는 마지막으로 받아둔 정책으로 계속 동작한다. 권한 시스템이 쿼리 엔진의 단일 장애점이 되지 않는다는것은 꽤 중요한 성질이다. 대신 대가가 있다. - 정책을 바꿔도 즉시 반영되지는 않는다. 폴링 주기만큼의 시차가 존재한다 (우리는 1초라 체감은 거의 없다). - 그리고 더 중요한 것으로, 변경 감지가 오직 버전 번호 비교로만 이루어진다. 다시 말해 **Admin 이 버전을 올려주지 않으면 plugin 은 그 변경을 영원히 알지 못한다.** 두 번째 항목은 뒤에서 다룰 Ranger 운영 과정의 문제들과 이어지는 부분이다. ## user, group, role 그리고 Ranger DB 라는 관문 Ranger 정책에서 권한을 부여받는 주체는 세 종류인데, user 와 group, 그리고 role 이다. - **user** : 개별 사용자. 우리는 이메일 주소를 그대로 유저명으로 쓴다. - **group** : 사용자의 묶음. LDAP 그룹이 그대로 동기화되어 들어온다. - **role** : user 와 group 을 담을 수 있는 Ranger 고유의 개념이다. LDAP 에는 대응하는것이 없고 Ranger 안에서만 존재한다. "분석가", "일반 사용자" 처럼 권한 묶음에 이름을 붙이고 싶을 때 쓴다. ![role 은 user 와 group 을 묶어두는 단위다. 개인에게 직접 권한을 주는 대신 role 과 group 에 정책을 붙이는 편이 관리하기 좋았다](https://www.kimsehwan96.com/content/images/2026/07/ranger-roles-v2.png) role 은 user 와 group 을 묶어두는 단위다. 개인에게 직접 권한을 주는 대신 role 과 group 에 정책을 붙이는 편이 관리하기 좋았다 여기서 반드시 걸리는 함정이 하나 있는데. 📌 Ranger 가 어떤 유저에 대해 판정을 하려면, 그 유저가 ****Ranger DB 에 존재해야 한다.** Trino 가 인증을 통과시킨 멀쩡한 유저라 하더라도 Ranger 쪽에 해당 유저 레코드가 없으면 정책의 user 필드와 매칭 될 일이 없고, 애초에 정책을 만들 때 UI 에서 그 유저를 선택 할 수조차 없다. 그룹도 마찬가지다. 인증을 통과했으면 권한도 당연히 따라오는것 아닌가? 싶지만, 인증과 인가가 별개라는 앞의 이야기가 여기서 실감나게 다가오는 지점이다. 그래서 UserSync 라는 별도 컴포넌트가 존재하는 것인데, 이 친구의 유일한 임무는 **LDAP 에 있는 유저와 그룹을 읽어서 Ranger Admin 의 DB 로 계속 밀어넣는 일**이고, 단방향이라 반대 방향으로는 아무것도 흐르지 않는다. 그런데 이 구조에는 사각지대가 하나 있는데, LDAP 에 존재하지 않는 유저다. **Redash 나 Airflow 같은 서비스 계정은 회사 구글 계정이 아니라 Trino 자체 password 인증**으로 접속하는데, 이런 계정은 usersync 가 알 방법이 없다. 그래서 해당 유저들은 **Ranger UI 나 REST API 로 직접 만들어줘야 한다.** (UserSync 와 동기화하는 **LDAP 레벨에서 이런 서비스어카운트가 존재하도록 하면 우아하게 풀어낼 수 있다.** 지금은 Google Workspace Secure LDAP 을 무임승차중이라, 해당 방안은 적용하지 않았다) ```bash curl -u admin:{admin_password} -X POST \ "https://ranger.example.com/service/xusers/secure/users" \ -H "Content-Type: application/json" \ -d '{ "name": "some_batch_job", "userSource": 1, "status": 1, "isVisible": 1, "userRoleList": ["ROLE_USER"] }' ``` `userSource: 1` 이 외부 소스 유저라는 표시인데, UI 에서 그냥 만들면 Internal 로 생성된다. 여기서 password 를 뭘로 넣든 상관없는것이, Ranger 는 이 유저로 로그인시킬 생각이 없고 오직 username 매칭만 하기 때문이다. 인가 전용 레코드인 셈이다. 그룹과 관련해서는 선택지가 하나 더 있었는데. Trino 에도 자체 group provider 가 있어서 유저를 그룹에 매핑 할 수 있고, Ranger plugin 은 이 둘을 어떻게 조합할지 설정으로 정하게 되어있다. ```xml ranger.plugin.trino.use.rangerGroups true ranger.plugin.trino.use.only.rangerGroups true ``` 우리는 둘 다 `true` 인데, 즉 그룹 정보의 출처를 Ranger 하나로 단일화하고 Trino 쪽 그룹 매핑은 아예 보지 않는 형태. 권한의 근거를 한곳으로 모으는 편이 관리하기 명확하다고 판단해서 이렇게 정했는데, 이 선택은 나중에 usersync 가 그룹 멤버십을 잘못 건드렸을 때 완충장치가 하나도 없다는 뜻이기도 했다. ## 유저 라이프사이클은 LDAP 이 관리한다 UserSync 를 조금 더 이야기해두는것이 좋겠다. 왜 하필 LDAP 인가? 사내 유저의 소속과 권한, 그리고 재직 여부까지를 관리하는 단일 출처가 어딘가에는 있어야 하는데, 디렉터리 서비스가 원래 그 역할을 하도록 만들어진 것이다. 입사하면 계정이 생기고 팀을 옮기면 그룹이 바뀌고 퇴사하면 비활성화되는 흐름이 한곳에서 일어나면, 그걸 참조하는 시스템들은 각자 유저 목록을 따로 들고 있을 필요가 없어진다. 그래서 사내 도구들은 대체로 디렉터리에서 유저와 그룹 정보를 주기적으로 받아와 자기 쪽 계정의 라이프사이클을 맞추는 형태로 동작한다. Ranger 의 UserSync 도 정확히 그 일을 하는 컴포넌트인데, 앞에서 이야기한 Ranger DB 에 유저가 있어야 판정이 된다는 제약을 자동으로 메꿔주는 장치라고 보면 된다. 정책은 사람이 UI 에서 만들지만 그 정책이 가리킬 유저와 그룹은 UserSync 가 계속 채워넣는 형태. 우리는 이미 회사 계정으로 쓰고 있던 Google Workspace 의 Secure LDAP 을 그대로 붙였다. 디렉터리 서버를 새로 세우지 않아도 되고 유저와 그룹의 원본이 이미 거기에 있으니, 추가 인프라 없이 기존 단일 출처를 그대로 이어받을 수 있다는 점이 컸다. 설정 자체는 install.properties 한 장으로 끝난다. 표준 LDAP 을 가정하고 만들어진 항목들을 Google Workspace 쪽 스키마에 맞춰 바꿔주는 일인데, 어떤 속성이 다른지가 여기서 그대로 드러난다. (bind 계정이나 keystore 비밀번호처럼 민감한 값은 ExternalSecrets 로 주입해서 템플릿 자리만 남겨뒀다) ```properties SYNC_SOURCE=ldap SYNC_INTERVAL=60 SYNC_LDAP_URL=ldaps://ldap.google.com:636 SYNC_LDAP_BIND_DN={{ .ldap_user }} SYNC_LDAP_BIND_PASSWORD={{ .ldap_password }} SYNC_LDAP_SEARCH_BASE=dc=example,dc=com # 표준 LDAP 운영 속성이 없어서 delta sync 를 끈다 SYNC_LDAP_DELTASYNC=false # user: objectClass 는 person, 유저명은 mail 속성을 그대로 쓴다 SYNC_LDAP_USER_SEARCH_BASE=ou=Users,dc=example,dc=com SYNC_LDAP_USER_OBJECT_CLASS=person SYNC_LDAP_USER_NAME_ATTRIBUTE=mail SYNC_LDAP_USER_GROUP_NAME_ATTRIBUTE=memberOf # group: memberUid 가 아니라 member 속성을 본다 SYNC_GROUP_SEARCH_BASE=ou=Groups,dc=example,dc=com SYNC_GROUP_OBJECT_CLASS=groupOfNames SYNC_GROUP_NAME_ATTRIBUTE=cn SYNC_GROUP_MEMBER_ATTRIBUTE_NAME=member SYNC_GROUP_USER_MAP_SYNC_ENABLED=true SYNC_LDAP_USERNAME_CASE_CONVERSION=lower SYNC_LDAP_GROUPNAME_CASE_CONVERSION=lower SYNC_PAGED_RESULTS_ENABLED=true SYNC_PAGED_RESULTS_SIZE=500 # mTLS 클라이언트 인증서. Secure LDAP 은 이게 필수다 SYNC_LDAP_SSL_KEYSTORE=/ldap-cert/ldap-keystore.p12 SYNC_LDAP_SSL_KEYSTORE_PASSWORD={{ .keystore_password }} # LDAP 그룹 멤버십으로 Ranger 관리자 권한을 부여할 수도 있다 GROUP_BASED_ROLE_ASSIGNMENT_RULES=ROLE_SYS_ADMIN:g:platform-team ``` 대신 대가가 있었는데, 표준 LDAP 이 정의하는 운영 속성 일부를 제공하지 않아서 변경분만 가져오는 delta sync 를 쓸 수 없고 그래서 매 사이클마다 전체를 다 훑는 full sync 로 돌고 있다. 왜 그런지는 아래에서 코드까지 들여다볼 예정인데, 여기서는 결론만 적어두면 동기화 대상 규모가 한 사이클을 부담스럽게 할 정도는 아니라고 판단해서 지금은 이렇게 두고 있다. 언젠가 규모가 커지거나 동기화 주기를 더 촘촘히 가져가야 할 때가 오면 표준 속성을 온전히 제공하는 디렉터리로 옮기는것을 검토하게 될 텐데, 다행히 그때 드는 비용은 크지 않다. Ranger 쪽에서 바뀌는것은 LDAP URL 과 검색 필터 정도이고 유저와 그룹의 이름 체계를 그대로 유지하면 정책은 손댈 필요도 없으니, 지금은 구현 편의성과 추가 인프라가 필요하지 않다는 이점이 delta sync 를 못 쓰는 제약보다 크다고 봤다. 동기화는 1시간 주기로 돌고 있고, 각 사이클이 무엇을 얼마나 바꿨는지는 감사 화면에 그대로 남는다. ![UserSync 감사 화면. 사이클마다 새로 생긴 유저와 그룹, 변경된 유저와 그룹의 수가 기록된다](https://www.kimsehwan96.com/content/images/2026/07/ranger-usersync-audit.png) UserSync 감사 화면. 사이클마다 새로 생긴 유저와 그룹, 변경된 유저와 그룹의 수가 기록된다 사이클 하나를 열어보면 어떤 조건으로 LDAP 을 훑었는지까지 확인 할 수 있다. ![사이클 하나의 상세. Incremental sync 가 null 인것이 위에서 이야기한 delta sync 를 쓰지 못하는 상태다](https://www.kimsehwan96.com/content/images/2026/07/ranger-usersync-details.png) 사이클 하나의 상세. Incremental sync 가 null 인것이 위에서 이야기한 delta sync 를 쓰지 못하는 상태다 ## 우리 구성 거창할 것은 없고, Ranger Admin 과 UserSync 를 각각 파드로 띄우고 정책 저장소로 관리형 PostgreSQL, 감사 로그 저장소로 Solr 를 붙인 형태다. LDAP 소스는 Google Workspace Secure LDAP 이고, Trino 쪽은 배포판에 내장된 plugin 을 설정으로 켜둔 형태. 몇가지 결정만 짚어두면 이렇다. **Solr 는 단일 파드로 띄웠다.** 감사 로그 조회가 주 용도라 처음부터 고가용성이 필요하지 않다고 봤고, ZooKeeper 를 끼워넣는 SolrCloud 모드는 운영 부담이 훨씬 커지는데 얻는 것이 없었다. 필요해지면 그때 관리형 검색 서비스로 옮기는 편이 낫다고 판단했다. 감사 로그를 Solr 로 보내는 설정은 Ranger Admin 이 아니라 Trino 쪽 plugin 이 들고 있다. 판정을 plugin 이 하니 기록을 남기는 주체도 plugin 인 셈. ```xml xasecure.audit.is.enabled true xasecure.audit.solr.is.enabled true xasecure.audit.solr.solr_url http://ranger-solr.ranger.svc.cluster.local:8983/solr/ranger_audits ``` **Ranger UI 로그인도 LDAP 으로 붙여뒀다.** `authentication_method=LDAP` 으로 두면 회사 구글 계정의 아이디와 비밀번호로 그대로 로그인이 되기 때문에, 권한을 관리할 사람들에게 별도 계정을 발급하고 관리할 필요가 없어진다. 다만 Secure LDAP 이 클라이언트 인증서를 요구해서 keystore 를 만들어 마운트해줘야 하는데, 이 부분은 삽질이 꽤 있었다. **Admin 과 UserSync 는 둘 다 컨테이너 이미지를 직접 빌드해서 쓴다.** 왜 그래야 했는지는 아래에서 이야기하겠다. ### SSO 는 붙이지 못했다 사실 Ranger UI 접근도 SSO 로 통일하고 싶었다. 사내 다른 도구들은 대부분 SSO 로 붙어있어서 Ranger 만 아이디와 비밀번호를 따로 입력하는것이 계속 걸렸는데, 결론부터 이야기하면 방법을 찾지 못했다. Ranger 에 SSO 설정 자체는 있는데. ```xml ranger.sso.enabled false ranger.sso.providerurl https://127.0.0.1:8443/gateway/knoxsso/api/v1/websso ranger.sso.publicKey ``` 그런데 `providerurl` 의 기본값을 보면 이 SSO 가 무엇을 전제하고 있는지가 그대로 드러난다. Apache Knox 다. 인증 필터 구현을 열어보면 Knox 가 발급한 `hadoop-jwt` 쿠키를 읽어서 설정에 박아둔 RSA public key 하나로 서명을 검증하는 구조로 되어있고, 쿠키가 없으면 `providerurl` 로 리다이렉트시킨다. OIDC discovery 도 없고 JWKS 엔드포인트를 조회하지도 않기 때문에 키 로테이션을 따라갈 방법도 없다. 다시 말해 **일반적인 IdP 를 Ranger 에 직접 물리는 경로는 사실상 없다.** 그러니까 SSO 를 쓰려면 앞단에 Knox 를 세우고 Knox 가 IdP 와 붙게 한 다음 Knox 가 발급한 JWT 를 Ranger 가 받는 형태가 되는것인데, Hadoop 생태계 컴포넌트를 하나 더 운영해야 한다는 뜻이기도 하다. Ranger UI 를 실제로 쓰는 사람이 몇 명 되지 않는 지금 상황에서는 굳이 싶어서 LDAP 인증으로 남겨뒀다. (Knox 를 실제로 붙여보지는 않았으니 이건 코드와 문서만 보고 내린 판단이다) ## 정책은 어떻게 생겼나 Ranger 의 정책은 리소스 기반인데, Trino 서비스를 Ranger 에 등록하면 기본 정책 13개가 자동으로 만들어진다. 해당 목록을 보는것만으로 Trino 에서 통제 가능한 대상이 무엇인지 대략 파악이 되는편이다. 데이터 계층은 catalog → schema → table → column 으로 내려가는데, 여기에 각각 부여 할 수 있는 권한이 조금씩 다르다. | 리소스 | 사용 가능한 권한 | | ------- | ------------------------------------------------------- | | catalog | select, create, drop, show, all | | schema | select, create, drop, show, all | | table | select, insert, update, delete, show, drop, create, all | | column | select, all | 데이터 계층 말고도 `function`, `procedure`, `sessionproperty`, `systemproperty`, `role` 같은 통제 대상이 더 있는데, 그중 두 개가 특히 눈에 띄었다. - `**queryid**` : 쿼리를 실행하고 중지하는 권한이다. 다른 권한과 다르게 `execute` 하나만 존재한다. - `**trinouser**` : `impersonate` 권한이 붙는 리소스인데, 말 그대로 다른 사용자로 가장 할 수 있는 권한이다. 이게 있어야 웹 UI 에서 남의 쿼리를 볼 수 있고, 없는 유저는 자기가 돌린 쿼리만 보이고 자기 쿼리만 kill 할 수 있다. 운영자 입장에서는 `trinouser` 의 impersonate 를 모든 유저에 대해 열어둬야 클러스터 전체 쿼리를 들여다볼 수 있고, 반대로 일반 사용자에게는 이걸 주면 안된다. 처음에는 "왜 나는 쿼리 목록이 텅 비어보이지?" 라는 생각에 한참을 헤맸던 부분이다. impersonate 가 실제로 어디에 쓰였는지 하나만 적어두면 BI 도구 연동인데, Redash 처럼 데이터소스마다 계정 하나로 엔진에 붙는 도구가 문제가 된다. 그대로 두면 Trino 입장에서는 모든 쿼리가 redash 라는 서비스 계정 하나로 들어오는 것처럼 보이기 때문에, 누가 무엇을 조회했는지 감사 로그에 남지 않고 개인이나 팀 단위로 권한을 나눌 방법도 없어진다. Trino 에는 이걸 풀 수 있는 impersonation 이 있는데. 접속 자체는 서비스 계정으로 하되 이 쿼리는 누구 것이라고 별도로 알려주면 Trino 가 그 사람 권한으로 처리하는 방식인데, 정작 Redash 쪽에 그 옵션이 없었다. 그래서 query runner 에 impersonation 설정을 추가하는 [Redash#7605](https://github.com/getredash/redash/pull/7605?ref=kimsehwan96.com) 를 올렸고 머지되었다. 데이터소스 설정에서 켜고 끌 수 있고 어떤 값을 넘길지도 고를 수 있게 해뒀는데, 기본은 이메일이고 API 키처럼 이메일이 없는 주체는 이름으로 떨어진다. 이제 Redash 에서 실행한 쿼리는 로그인한 회사 계정 이름으로 Trino 에 도착한다. Ranger 쪽에서는 거기에 맞춰 정책을 하나 만들어두면 되는데, redash 서비스 계정에게 회사 도메인 유저를 impersonate 할 권한을 주는 형태. 정책에는 allow 조건과 deny 조건을 각각 넣을 수 있고 각 조건에서 예외(exclude)를 지정하는것도 가능하다. 그리고 각 정책에는 우선순위가 있어서 Override 로 지정한 정책이 일반 정책을 이기게 되는데, 예컨대 그룹 전체에 allow 를 주고 그중 한 명만 막고 싶다면 일반 deny 로는 안 되고 Override deny 여야 한다. ![trinouser 리소스에 impersonate 를 허용하는 정책. 리소스 쪽에 impersonate 대상을 지정하고, Allow Conditions 에서 그 권한을 실제로 쓸 주체(redash 서비스 계정)를 고르는 구조다](https://www.kimsehwan96.com/content/images/2026/07/ranger-policy-impersonate-v3.png) trinouser 리소스에 impersonate 를 허용하는 정책. 리소스 쪽에 impersonate 대상을 지정하고, Allow Conditions 에서 그 권한을 실제로 쓸 주체(redash 서비스 계정)를 고르는 구조다 컬럼 단위 제어에는 알아둬야 할 부작용이 하나 있는데. 특정 컬럼을 막아두면 그 테이블에 `SELECT *` 를 날렸을 때 에러가 나기 때문에, 막힌 컬럼을 빼고 명시적으로 컬럼을 나열해줘야 한다. 생각해보면 당연한 동작이지만 쓰는 사람 입장에서는 갑자기 `SELECT *` 가 안 되는 것이라 별도 안내가 필요했다. 실무에서는 정책을 유저 단위로 만들기보다 role 과 group 단위로 만드는 편이 훨씬 관리하기 좋았다. 개인에게 직접 권한을 주기 시작하면 퇴사자 정리나 팀 이동 때마다 정책을 일일이 손봐야 하는데, 그룹 기반이면 LDAP 쪽에서 그룹만 옮겨주면 자동으로 따라온다. 다만 이 편리함은 usersync 가 그룹을 정확히 동기화해준다는 전제 위에 서 있다. 그리고 정책이 실제로 어떻게 작동했는지는 감사 로그에서 확인하는데. 허용이든 거부든 판정 결과가 남기 때문에, 권한 때문에 쿼리가 실패했다는 문의가 오면 여기부터 열어보게 된다. ![정책에 걸려 거부된 요청은 감사 로그에 Denied 로 남는다. Access Enforcer 가 ranger-acl 인것이 Ranger 정책에 의한 판정이라는 표시다](https://www.kimsehwan96.com/content/images/2026/07/ranger-audit-denied.png) 정책에 걸려 거부된 요청은 감사 로그에 Denied 로 남는다. Access Enforcer 가 ranger-acl 인것이 Ranger 정책에 의한 판정이라는 표시다 ## 도입하면서 알게 된 것들 여기까지가 Ranger 라는 도구의 생김새다. 그림만 보면 단순하고 개념 자체도 딱히 어렵지 않은데, 문제는 이걸 Hadoop 이 없는 환경에 붙이려고 할 때 문서가 도와주지 않는다는 점이었다. 실제로 부딪힌 것들을 적어보면 이렇다. **UserSync 는 공식 컨테이너 이미지가 없다.** Admin 은 도커 허브에 이미지가 올라와 있는데 usersync 는 없어서 소스에서 직접 빌드해야 했고, 그 빌드가 순순히 되지 않았다. **Google Workspace Secure LDAP 은 delta sync 를 쓸 수 없다.** usersync 는 매 사이클마다 전체를 다 훑지 않으려고 변경분만 가져오는 delta sync 를 지원하는데, 문제는 이것이 어떻게 구현되어 있느냐인데. 2.7.0 의 [\`LdapUserGroupBuilder.getUsers()\`](https://github.com/apache/ranger/blob/7ad06dc3c4c4376de1045e084153ecd775a47722/ugsync/src/main/java/org/apache/ranger/ldapusersync/process/LdapUserGroupBuilder.java?ref=kimsehwan96.com#L462-L468) 를 보면 이렇게 되어있다. ```java if (groupUserTable.rowKeySet().size() != 0 || !config.isDeltaSyncEnabled() || (computeDeletes)) { // Fix RANGER-1957: Perform full sync when there are updates to the groups or when incremental sync is not enabled deltaSyncUserTime = 0; deltaSyncUserTimeStamp = dateFormat.format(new Date(0)); } extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")(|(uSNChanged>=" + deltaSyncUserTime + ")(modifyTimestamp>=" + deltaSyncUserTimeStamp + "Z))"; ``` delta sync 가 꺼져있으면(`!config.isDeltaSyncEnabled()`) 기준 시각을 epoch 으로 되돌려서 전체를 다 가져오겠다는 의도까지는 좋은데, 바로 다음 줄에서 **필터 자체는 무조건 붙인다.** 그러니까 delta sync 를 끄더라도 검색 필터에는 `(|(uSNChanged>=0)(modifyTimestamp>=19700101...Z))` 가 그대로 들어간다. `uSNChanged` 는 Active Directory 의 속성이고 `modifyTimestamp` 는 표준 LDAP 운영 속성인데, Google Workspace Secure LDAP 은 이 두 개를 다 내주지 않는다. 속성이 아예 없으니 저 OR 조건을 만족하는 엔트리도 하나도 없고 검색 결과가 0건이 되는데, 유저가 한 명도 동기화되지 않는것이다. 그러면 delta sync 를 끄면 되지 않나 싶은데, `install.properties` 에 `SYNC_LDAP_DELTASYNC=false` 를 넣어도 증상이 그대로였는데. 설치 스크립트인 [\`setup.py\`](https://github.com/apache/ranger/blob/7ad06dc3c4c4376de1045e084153ecd775a47722/unixauthservice/scripts/setup.py?ref=kimsehwan96.com#L256-L259) 를 따라가보니 이런 코드가 있었다. ```python elif (syncSource == SYNC_SOURCE_LDAP): ret['ranger.usersync.ldap.deltasync'] = "true" ``` install.properties 를 XML 설정으로 변환하는 지점인데, **LDAP 소스이기만 하면 사용자가 무슨 값을 넣었든 무조건 \`true\` 로 덮어쓴다.** 설정 파일에 적어둔 `false` 는 애초에 읽히지도 않았던 것이다. 결국 이 줄이 install.properties 의 값을 존중하도록 고치고, 필터 쪽도 delta sync 가 꺼져 있으면 저 조건을 아예 붙이지 않도록 분기를 넣었다. (사내에 별로 포크버전을 만들어서 직접 빌드해서 사용중이다) **정상적으로 재직 중인 유저가 어느 날 갑자기 권한을 잃는다.** 조직 개편으로 LDAP 의 OU 가 바뀌면 usersync 가 그 사람을 "삭제된 유저" 로 오판하는데, Ranger 의 오래된 설계 결함이고, 업스트림에 [RANGER-5110](https://github.com/apache/ranger/pull/516?ref=kimsehwan96.com) 이 올라와 있지만 1년 반째 머지되지 않고 있다. 우리가 해당 버그를 정면으로 맞게 되었다. ### 고친 것들, 그리고 업스트림 이런 것들이 쌓이다 보니 업스트림 2.7.0 을 그대로 쓰지 못하고 별도 브랜치를 유지하게 되었는데, 크게 보면 세 갈래였다. - **delta sync 를 실제로 끌 수 있게** 하는 것. 위에서 이야기한 설치 스크립트의 하드코딩과 LDAP 검색 필터 분기다. - **usersync 가 놓치고 있던 것들.** 그룹 멤버십이 바뀌어도 userstore 버전이 올라가지 않던 문제, 삭제된 그룹이 hidden 으로 마킹되지 않던 조건 오타 같은것들. 우리만 겪을 문제가 아니라고 생각해서 업스트림에도 올려봤다. | 내용 | PR | 상태 | | ------------------------------------ | ---------------------------------------------------------------------------- | ----------------- | | 삭제된 그룹이 hidden 으로 마킹되지 않던 조건 오타 | [RANGER-5461](https://github.com/apache/ranger/pull/822?ref=kimsehwan96.com) | 머지됨 | | 그룹 멤버십 변경 시 userstore 버전이 갱신되지 않던 문제 | [RANGER-5463](https://github.com/apache/ranger/pull/824?ref=kimsehwan96.com) | 머지됨 (master, 2.9) | | delta sync 하드코딩과 불필요한 LDAP 필터 | [RANGER-5466](https://github.com/apache/ranger/pull/827?ref=kimsehwan96.com) | 리뷰 대기중 | 두 번째 건은 우리 환경이 특이해서 겪는 문제인가 싶었는데, 같은 버그를 맞고 있다는 다른 사용자의 코멘트가 PR 에 달리기도 했다. 메인테이너가 master 와 2.9 브랜치에 반영해주었다. (PR 자체는 closed 로 보이지만 코드는 들어가 있다) ## 정리 - **Trino 는 상태를 거의 남기지 않는다.** 쿼리 기록도 인증 정보도 메모리에만 있어서, 남겨야 하는 것은 event listener 로 빼내야 하고 권한 역시 외부에 두고 참조하는 구조가 된다. - **인증과 인가는 다른 일이다.** Trino 가 "누구인가" 까지 알려주면 Ranger 가 "무엇을 할 수 있는가" 를 판단한다. - **정책을 매니페스트 밖으로 꺼내는 것이 Ranger 를 고른 이유다.** file 이나 OPA 는 권한 변경마다 인프라 팀이 개입해야 하지만 Ranger 는 UI 로 위임 할 수 있다. - **정책 평가는 Ranger 에 물어보는 것이 아니라 엔진 안의 로컬 캐시에서 일어난다.** 빠르고 장애에 강한 대신 변경 전파가 전적으로 버전 번호에 의존한다. - **Ranger 가 모르는 유저와 그룹에는 정책을 적용 할 수 없다.** LDAP 에 없는 서비스 계정은 직접 만들어줘야 한다. - **업스트림을 그대로 쓰지는 못했다.** Google Workspace Secure LDAP 조합에서는 delta sync 가 성립하지 않는데 그 옵션이 설치 스크립트에서 하드코딩되어 있었고, 컨테이너 환경을 전제하지 않은 로그 처리도 손을 봐야 했다. 고친 것 중 남들도 겪을 만한 것들은 업스트림에 올렸고 두 건은 반영되었다. - **정책은 그룹과 role 기준으로 설계하는 편이 좋다.** ### 간헐적인 502 는 어디서 오는가? (feat: NLB draining 과 istio 의 terminationDrainDuration) URL: https://www.kimsehwan96.com/intermittent-502-nlb-istio-draining/ Last updated: 2026-07-26T02:23:21.000Z 💡 이 포스팅은 Istio 1.26(당시 1.26.4), AWS NLB + IP target group 환경을 기준으로 작성하였다. ## 들어가며 외부에서 우리 API 를 호출하는 쪽에서 문의가 하나 들어왔다. "API 를 호출했는데 502 를 받았습니다. 그런데 처리 상태는 바뀌어 있습니다." 로그를 확인해보니 상황이 묘했다. 클라이언트는 분명히 502 를 받았다고 한다. 그런데 우리 쪽 서버 앞단의 istio-proxy 액세스 로그에는 이렇게 남아 있었는데. ```json { "method": "PUT", "path": "/v1/foo/bar", "response_code": 0, "response_code_details": "downstream_remote_disconnect", "response_flags": "DC", "duration": 5899 } ``` `response_flags: DC`. Envoy 입장에서는 "다운스트림(클라이언트 방향)이 연결을 끊었다"는 기록이다. 클라이언트는 서버가 502 를 줬다고 하고 서버는 클라이언트가 끊었다고 하는, 양쪽 다 자기는 잘못이 없다고 말하는 상황이었다. 더 큰 문제는 따로 있었는데. 첫 요청에서 백엔드는 작업을 **정상적으로 완료**했다. 응답만 전달되지 못했을 뿐이다. 502 를 받은 호출자가 재시도하자 이번에는 "이미 처리된 요청"이라는 응답이 내려갔고 호출자 입장에서는 상태 불일치로 보였다. 인프라 계층의 커넥션 문제가 비멱등 API 를 만나 실제 문제가 된 케이스다. 이 간헐적인 502 의 범인을 시간을 꽤나 들여서 확인하였는데, 결론부터 이야기하면 원인은 한 줄이지만 그 과정에서 NLB 의 draining 이 실제로 어떻게 동작하는지, Envoy 는 커넥션을 어떻게 정리하는지, 그리고 "Terminate connections on deregistration" 같은 옵션이 정확히 어느 구간을 끊는지를 실측으로 확인하게 되어서 해당 과정을 공유해보고자 한다. ## 단서: 레이어마다 다른 증언 트래픽 경로는 다음과 같다. ``` Client → WAF → CloudFront → NLB → istio-ingressgateway → istio-proxy(sidecar) → app ``` NLB 는 IP target group 모드로 istio-ingressgateway 파드들을 직접 타겟으로 바라보는 구성이다. 각 레이어의 로그를 모아보면 이렇게 읽힌다. | 레이어 | 기록 | 해석 | | -------------------- | --------------------- | ---------------------- | | CDN | 502 수신 | CloudFront 가 502 를 내려줌 | | CloudFront | AbortedOrigin | origin(NLB) 쪽에서 응답이 끊김 | | istio-ingressgateway | (일부 요청은 로그 자체가 없음) | | | istio-proxy(app) | DC, response\_code: 0 | 다운스트림이 끊었다 | 모두가 "내 뒤쪽" 혹은 "내 앞쪽"을 가리키고 있어서, 이 상황에서 로그만 계속 들여다보는것은 답이 안 나올 것 같았고 관점을 바꿔서 **언제** 터지는지를 보기로 했다. ## 타이밍의 발견 CloudFront 액세스 로그를 Athena 로 쿼리해서 502 가 발생한 시각을 뽑고 그 옆에 istio-ingressgateway 파드 개수 메트릭을 나란히 놓았다. ```promql sum(kube_pod_info{namespace="istio-system", pod=~"istio-ingressgateway-.+"}) ``` ![스케일 인으로 파드가 줄어드는 구간에 502 가 집중된다 (당시 메트릭을 다시 조회해 재구성한 그래프)](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-scalein-pods-vs-502.png) 스케일 인으로 파드가 줄어드는 구간에 502 가 집중된다 (당시 메트릭을 다시 조회해 재구성한 그래프) 5초 이상 걸린 요청 + `AbortedOrigin` 조합의 발생 시각이 **파드 개수가 줄어드는 시점과 정확히 일치**했다. 우연의 일치인가? 싶었지만 5\~6건을 대조해보니 전부 맞았다. 다시 말해 istio-ingressgateway 가 scale-in 으로 종료될 때마다, 그 파드를 통과하던 요청 일부가 502 로 죽고 있었다. 파드 종료가 원인이라면 이야기는 graceful shutdown 으로 좁혀지는데, istio 의 종료 설정을 덤프해보니 이상한 값이 하나 보였다. ![istio-ingressgateway 의 config dump. terminationDrainDuration 이 기본값인 5s 로 잡혀 있다](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-config-dump-5s.png) istio-ingressgateway 의 config dump. terminationDrainDuration 이 기본값인 5s 로 잡혀 있다 `terminationDrainDuration: 5s`. Envoy 가 SIGTERM 을 받은 뒤 in-flight 요청을 **5초까지만** 처리하고 남은 커넥션을 강제로 끊는다는 뜻이다. 기본값이다. ## 왜 종료 중인 파드로 요청이 계속 들어오는가 그런데 5초가 짧긴 해도, 종료 중인 파드라면 애초에 새 요청이 안 들어와야 하는것 아닌가? 라는 의문이 생긴다. **일반적인 애플리케이션 파드에 붙는** istio-proxy(sidecar)는 실제로 그렇다. 파드가 Terminating 상태가 되면 EndpointSlice 에서 엔트리가 제거되는것이 아니라 해당 엔드포인트가 ready=false 로 마킹되고, EndpointSlice 를 watch 하던 istiod 가 이 엔드포인트를 EDS 응답에서 제외한 채 프록시들에 push 한다. 그래서 트래픽이 더 이상 그 파드로 가지 않는다. preStop sleep 동안 이 전파가 끝나기 때문에, SIGTERM 이후 5초 drain 으로도 문제가 없다. (그리고 native sidecar 를 사용하는 경우 application container 가 모두 종료된 이후에서야 sidecar 에 SIGTERM 신호가 전달되니 in-flight 요청 처리에 대한 부분도 신경 쓸 필요가 없다. 참고 : [sidecar-containers k8s 문서](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/?ref=kimsehwan96.com#:~:text=Upon%20Pod%20termination%2C%20the%20kubelet%20postpones%20terminating%20sidecar%20containers%20until%20the%20main%20application%20container%20has%20fully%20stopped)) 그런데 istio-ingressgateway 는 사정이 다르다. 이 파드는 **NLB target group 이 IP 모드로 직접** 바라보고 있다. 파드가 Terminating 상태가 되면 NLB 타겟은 deregistration(draining) 상태로 전이되는데, 실측해보니 이 상태에서도 **요청이 계속 들어온다**. 처음에는 버그인가 싶었지만 여러 번 테스트한 결론은 이렇다. 📌 NLB 의 draining(deregistration)은 "신규 연결을 안 받는다"는 뜻일 뿐, 이미 맺어진 TCP 연결과는 아예 무관하다. CloudFront 와 NLB, NLB 와 ingressgateway 사이의 커넥션은 keep-alive 로 재사용된다. 기존 커넥션 위로는 draining 이 시작된 뒤에도 HTTP 요청이 계속 흘러들어온다. 심지어 "신규 연결 차단" 도 칼같지 않았다. 게이트웨이의 downstream 활성 연결을 지켜보면 draining 시작 후 1\~2분까지는 새 커넥션이 간간이 생기는 것도 관찰됐다. 나중에 보니 이건 [AWS 문서](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/edit-target-group-attributes.html?ref=kimsehwan96.com#deregistration-delay)에도 있는 이야기였다. draining 중에는 신규 연결을 받지 않는 것이 맞지만 "configuration propagation delay 로 인해 타겟이 여전히 연결을 받을 수 있다"고 명시되어있다. 그래서 타임라인이 이렇게 완성된다. 1. istio-ingressgateway 파드가 scale-in 으로 Terminating 진입, preStop sleep 시작 (기존 설정 180초) 2. NLB 타겟은 draining 상태가 되지만, 기존 커넥션으로 요청은 계속 유입 3. preStop 이 끝나고 Envoy 에 SIGTERM 4. Envoy 는 5초(terminationDrainDuration) 동안만 in-flight 를 처리 5. 5초 안에 응답 못 한 요청의 커넥션이 강제 종료 → istio-proxy 에는 DC, CloudFront 에는 AbortedOrigin, 클라이언트에는 502 ![기본 5초 drain 이 502 를 만드는 타임라인과 개선 후 비교](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-termination-timeline-v2.png) 기본 5초 drain 이 502 를 만드는 타임라인과 개선 후 비교 응답이 5초 이상 걸리는 요청일수록 당첨 확률이 높아진다. 문의가 들어온 API 는 한 번에 다루는 데이터가 많아 처리 시간이 길었고 그래서 유독 자주 걸렸다. ## 재현하기 가설이 섰으니 실제로 재현을 해볼 차례다. 프로덕션 트래픽에 영향을 주지 않도록 격리된 임시 ingressgateway 를 하나 더 만들고 뒤에 httpbin 을 붙였다. httpbin 의 `/delay/10` 엔드포인트는 10초 뒤에 응답을 주기 때문에 "느린 요청"을 만들기에 딱 좋다. ``` 부하: 0.1초 간격으로 /delay/10 호출 (aiohttp, 비동기 스크립트) 조작: istio-ingressgateway-temp 파드를 삭제 관찰: 간헐적으로 downstream_remote_disconnect 발생 ``` 재현이 되었는데, 파드를 죽일 때마다 in-flight 였던 요청 일부가 DC 로 끊기는것을 확인 할 수 있었다. 💡 여담: httpbin 의 delay 는 최대 10초로 잘려 있다 (`delay = min(float(delay), 10)`). 더 긴 지연을 테스트하려면 이 한 줄을 고쳐서 쓰거나 별도 테스트 앱이 필요하다. ## 1차 조치: 5초를 30초로 istio-ingressgateway 에 한해 `terminationDrainDuration` 을 5초에서 30초로 올렸다. 적용 후 같은 부하 테스트에서 DC 가 더 이상 발생하지 않았다. 적용하고 나서는 프로덕션 ingressgateway 전체를 의도적으로 rollout restart 하면서 지켜봤다. 롤링으로 파드 42대가 전부 교체되는 동안 502 는 발생하지 않았다. ![rollout restart 로 파드 42→102→42 전체 교체가 일어나는 동안 502 는 배경 수준을 벗어나지 않았다](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-rollout-restart-check.png) rollout restart 로 파드 42→102→42 전체 교체가 일어나는 동안 502 는 배경 수준을 벗어나지 않았다 여기까지가 1차전인데, "5초 이상 걸리는 요청의 502"는 잡았지만 로그를 다시 보니 이상한것이 남아 있었다. **0.x 초 만에 AbortedOrigin 으로 끊긴 요청들**이다. 이건 drain 시간과는 무관해 보였다. ## 2차전: 영원히 안 죽는 커넥션 drain 시간을 늘리는 것 말고 더 깔끔한 방법이 없을까? 싶어서 `EXIT_ON_ZERO_ACTIVE_CONNECTIONS` 를 실험했다. 이 옵션을 켜면 Envoy 가 SIGTERM 이후 신규 연결을 거부하면서 기존 active connection 이 **전부 소진될 때까지 기다렸다가** 종료한다. | | terminationDrainDuration | EXIT\_ON\_ZERO\_ACTIVE\_CONNECTIONS | | -- | ---------------------------------------- | ------------------------------------- | | 동작 | 지정 시간이 지나면 active connection 이 있어도 강제 종료 | active connection 이 0 이 될 때까지 대기 후 종료 | | 장점 | 종료 시점이 예측 가능 | in-flight 유실이 원천적으로 없음 | | 단점 | 시간 안에 못 끝낸 요청은 유실 | 종료 시점을 예측할 수 없음 | 재현에 썼던 격리된 임시 게이트웨이에서는 훌륭하게 동작해서, 20\~30초면 active connection 이 모두 정리되고 Envoy 가 깨끗하게 내려가는것을 확인했다. 그런데 실제 프로덕션 트래픽에서는 달랐다. 이번에는 프로덕션 게이트웨이와 동일한 레이블을 갖는 임시 게이트웨이를 하나 더 띄웠다. 같은 Service 오브젝트의 셀렉터에 걸리기 때문에 NLB 타겟으로 함께 등록되고, 실제 프로덕션 트래픽의 일부를 받는다. 여기에 terminationGracePeriodSeconds 를 600초까지 늘려서 지켜봐도, active connection 메트릭이 천천히 줄다가 **1\~4개가 10분 넘게 살아남았고** 결국 SIGKILL 로 강제 종료됐다. 파드 안에 들어가 `ss` 로 열린 소켓을 직접 세어보니, 남아 있는 커넥션의 상대방 주소는 전부 NLB 였다. 누가 이 커넥션을 물고 있는 걸까. ## connection id 로 범인 잡기 프로덕션 트래픽 일부를 받고 있는 그 임시 게이트웨이에 Envoy admin API 로 커넥션 레벨 로그를 켰다. 이렇게 하면 프로덕션 게이트웨이 전체에 trace 레벨 로그를 켜지 않고도 실제 트래픽을 관찰 할 수 있다. ```bash curl -X POST localhost:15000/logging?conn_handler=debug curl -X POST localhost:15000/logging?http=trace curl -X POST localhost:15000/logging?http2=trace ``` 이렇게 하면 모든 HTTP 요청 로그에 그 요청이 탄 **connection id** 가 태그로 남는다. connection id 는 시간이 지남에 따라 증가하는(incremental) 값이다. 즉 draining 시작 후 5\~10분이 지나서야 끊기는 커넥션이 있다면, 그 커넥션의 낮은 id 를 로그에서 역추적해 **과거에 어떤 요청들이 이 커넥션을 썼는지** 알아낼 수 있다. 추적 결과, 끝까지 살아남는 커넥션은 전부 한 곳을 가리켰다. **모바일 클라이언트가 주기적으로 이벤트를 전송하는 엔드포인트로 가는 요청**이었다. 이 요청들은 CDN 을 거치지 않고 NLB 에 직결되는 경로였고 `Connection: keep-alive` 헤더 덕분에 하나의 TCP 연결을 계속 재사용하고 있었다. 유저가 앱을 쓰는 동안 이벤트가 계속 발생하니 커넥션은 영원히 idle 이 되지 않고 Envoy 는 "active connection" 으로 계속 카운트한다. 커넥션의 수명이 우리 손이 아니라 유저 클라이언트의 행동에 달려있다면 "0 이 될 때까지 대기"는 종료 시점을 보장 할 수 없는것이고, `EXIT_ON_ZERO_ACTIVE_CONNECTIONS` 를 채택 할 수 없는 이유가 여기서 확정되었다. 그리고 격리된 임시 게이트웨이에서는 이 옵션이 깔끔하게 동작했던 이유도 같이 설명이 되는데, 그쪽을 지나던 커넥션은 전부 CloudFront 경유였고 CloudFront 는 origin(NLB) 쪽 keep-alive 커넥션을 60초 이상 유휴 상태면 알아서 끊어준다. 앞단에 커넥션 수명을 관리해주는 프록시가 있는 경로는 active connection 이 자연히 0 으로 수렴하지만, NLB 에 직결된 keep-alive 클라이언트가 하나라도 있는 경로는 영원히 수렴하지 않는것이다. 결국 옵션 자체의 문제라기보다는 트래픽이 어떤 경로로 들어오느냐의 문제였다. ![같은 옵션이 트래픽 경로에 따라 성립하기도, 성립하지 않기도 한다](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-exit-on-zero-paths.png) 같은 옵션이 트래픽 경로에 따라 성립하기도, 성립하지 않기도 한다 ## NLB 의 draining 을 오해하고 있었다 이 과정에서 NLB 옵션 하나도 실측으로 정리하게 되었는데. NLB target group 에는 "Terminate connections on deregistration" 옵션이 있다. 이름만 보면 draining 이 끝날 때 관련 커넥션을 정리해줄 것 같은데. 실제로 켜고 테스트해보면: ![옵션이 끊는 구간은 Client 와 NLB 사이뿐이다](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-terminate-connections-scope.png) 옵션이 끊는 구간은 Client 와 NLB 사이뿐이다 - 이 옵션이 끊는 것은 실측상 **(A) Client↔NLB 구간뿐**이다 (client 가 다음 요청을 보낼 때 NLB 로부터 RST 를 받는다) - **(B) NLB↔target 구간은 건드리지 않는다**. 80/443 리스너 모두 적용해도 마지막 active connection 은 줄지 않았다 - 그리고 이 옵션 자체가 deregistration delay(기본 300초)가 **다 지난 뒤에야** 동작한다 정리하면, (B) 구간의 커넥션은 client 가 끊거나 TCP keepalive/idle timeout 이 발생하지 않는 한 **아무도 끊어주지 않는다**. Envoy 는 스스로 active connection 을 먼저 끊지 않고 NLB 도 안 끊는다. "설마 아무도 안 끊겠어"의 답은 "정말 아무도 안 끊는다"였다. 그러면 저 keep-alive 커넥션 위의 요청들은 파드 종료 때 어떻게 되는 걸까. 해당 서비스의 스테이징 환경을 격리된 임시 게이트웨이에 붙여 마지막 실험을 해보았는데. 결과는 다행히 안전했다. Envoy 는 draining 중에 요청을 받으면 "이 커넥션은 곧 닫힌다"는 신호를 응답에 실어 보낸다. HTTP/1.1 이면 `connection: close` 헤더, HTTP/2 면 GOAWAY 프레임. 이걸 받은 브라우저는 다음 요청에서 자연스럽게 새 커넥션을 맺었다. Envoy 가 완전히 내려간 뒤에야 다음 요청을 보내는 경우도 마찬가지였다. 기존 커넥션이 없으면 DNS lookup 부터 TCP, TLS 핸드셰이크까지 새로 하고 아무 일 없다는 듯 전송된다. 네트워크 탭에서 어떤 에러도 발생하지 않았다. ## 최종 설계 2주의 실측 끝에 "모든 커넥션을 완벽하게 graceful 하게 종료한다"는 목표 자체를 버리기로 했다. EXIT\_ON\_ZERO\_ACTIVE\_CONNECTIONS 는 채택하지 않고, 대신 각 구간의 시간을 실측에 맞춰 다시 배치하는 형태로 정리했다. | 설정 | 기존 | 최종 | 근거 | | ----------------------------- | ---- | ---- | ---------------------------------------- | | preStop sleep | 180초 | 120초 | NLB deregistration delay 와 동일하게 | | NLB deregistration delay | 300초 | 120초 | 실측상 draining 1\~2분이면 신규 연결 유입이 끝남 | | terminationDrainDuration | 5초 | 90초 | CloudFront 의 keep-alive timeout 60초 + 여유 | | terminationGracePeriodSeconds | 210초 | 240초 | preStop 120 + drain 90 + 여유 | ![최종 설계의 종료 타임라인](https://www.kimsehwan96.com/content/images/2026/07/nlb-502-final-timeline.png) 최종 설계의 종료 타임라인 **preStop 과 deregistration delay 를 120초로 맞췄다.** draining 이 시작되고도 1\~2분은 신규 연결이 생길 수 있기 때문에, 해당 유입이 끝난 뒤에 Envoy 의 draining 이 시작되도록 preStop 이 deregistration delay 구간을 온전히 덮게 했다. 기본값 300초짜리 delay 는 실측을 근거로 120초까지 줄였다. **drain 90초의 근거는 CloudFront 의 keep-alive timeout 이다.** 주력 트래픽은 CloudFront 를 경유하는데, CloudFront 는 origin 커넥션을 60초 유휴면 끊기 때문에 drain 이 60초를 넘으면 CF 경유 커넥션이 자연스럽게 소진되는것을 기대 할 수 있다. 실제로 60초로 두고 게이트웨이를 수동으로 내려보니 drain 이 끝나갈 무렵에도 NLB 직결 경로의 POST 가 드문드문 들어와서 여유를 더해 90초로 정했다. 그러면 90초가 지나도 남아있는 커넥션은? NLB 직결 keep-alive 커넥션은 90초 뒤 강제 종료되는데, 이건 받아들이기로 했다. draining 90초 동안 요청을 한 번이라도 보낸 클라이언트는 connection: close 나 GOAWAY 를 받고 이미 새 커넥션으로 옮겨탄 뒤라서, 실제로 끊기는 것은 90초 내내 요청이 없던 유휴 커넥션뿐이다. 이 이벤트 전송 클라이언트에는 전송 실패 시 localStorage 에 쌓아뒀다 재전송하는 로직도 있다. 1차전에서 남겨뒀던 0.x 초 만의 AbortedOrigin 은 결국 따로 잡지 않았다. 몇 건을 백엔드 액세스 로그와 대조해보니 이 요청들은 백엔드에 아예 도달하지 않았고, 수 초 안에 재시도가 성공하는 것을 확인 할 수 있었다. CloudFront 가 NLB 와의 keep-alive 커넥션을 재사용하는 순간 하필 그 커넥션이 반대편에서 닫혀버린 케이스인데, CloudFront 는 비멱등 요청을 재시도하지 않기 때문에 이 레이스는 POST 에서만 502 로 표면화된다. 발생량도 전체 요청의 0.0002% 수준인데다 백엔드가 처리한 적 없는 요청이라 상태 불일치를 만들지 않으므로, 이건 클라이언트 재시도에 맡기기로 했다. 정말 위험했던 것은 반대로 백엔드는 요청을 처리했는데 클라이언트만 502 를 받는 케이스였고, 그게 이번에 처리한 502 다. `terminationDrainDuration` 값을 늘리는 변경점 적용 후 문의의 원인이었던 간헐 502 는 재발하지 않았고, 그렇게 이 문제를 해결 할 수 있었다. ## 정리 - **L4 와 L7 의 "draining" 은 다른 말이다.** NLB 의 deregistration 은 신규 연결 차단일 뿐 기존 연결과 무관하고 Envoy 의 drain 은 기존 커넥션 정리다. 두 계층의 시간이 어긋나는 지점에서 502 가 발생한다. - **NLB 를 IP target 으로 직접 바라보는 파드는 sidecar 와 종료 시나리오가 완전히 다르다.** EndpointSlice 에서 빠지면 트래픽이 멈추는 sidecar 에서의 처리 방식으로 ingressgateway 를 다루면 안 된다. - **"Terminate connections on deregistration" 은 Client↔NLB 구간만 끊는다.** 옵션 이름만 보고 동작을 유추하면 안된다. - **"active connection 이 언제 0 이 되는가"는 옵션이 아니라 트래픽 구성이 결정한다.** CloudFront 처럼 유휴 커넥션을 관리해주는 프록시 뒤에서는 `EXIT_ON_ZERO_ACTIVE_CONNECTIONS` 가 성립하지만 로드밸런서에 직결된 keep-alive 클라이언트가 하나라도 있으면 성립하지 않는다. - **connection id 는 훌륭한 추적 도구다.** Envoy admin API 로 커넥션 레벨 로깅을 켜면, "어떤 요청이 이 커넥션을 만들었나"를 역추적할 수 있다. - **레이어들이 서로를 가리킬 때는 로그 대조가 아니라 재현이 답이다.** 임시 gateway 워크로드 + 지연 응답 + 파드 kill 조합이면 이런 부류의 문제는 대부분 재현할 수 있다. (아닌 경우에는 당연히 tcpdump 등으로 더 심층 조사를 해야한다) - 그리고 이 모든 것과 별개로, **비멱등 API 는 언젠가 이런 인프라 이슈를 만나 상태 불일치로 표면화된다.** 인프라를 고치는 것과 별개로 멱등성 설계는 필요하다. ### 사내 단일 MCP Gateway 도입기 (feat: IBM mcp-context-forge) URL: https://www.kimsehwan96.com/mcp-context-forge-adoption/ Last updated: 2026-07-29T03:14:22.000Z 💡 이 포스팅은 mcp-context-forge v1.0.x 버전을 기준으로 작성하였습니다. ## 들어가며 올해 초부터 사내 AI 인프라를 구성하는 일을 맡고 있다. LLM 게이트웨이, 에이전트 런타임, 트레이싱 같은 것들을 하나씩 쌓아왔는데, 돌이켜보면 도입 효과도 가장 컸던 것은 따로 있었다. MCP Gateway, 정확히는 MCP Federation 패턴이다. MCP Gateway 는 조직에 흩어진 MCP 서버들을 한 곳에 모아두고, 인증과 권한 관리를 게이트웨이가 대신 맡는 구조다. 구체적인 요구사항들은 아래와 같았다. - BI 도구(Redash)를 에이전트에서 실행하고 싶어요 - 쿼리 도구(Trino)를 에이전트에서 실행하고 싶어요 - 개발 직군이 아닌데도 쓸 수 있나요? - 이런이런 MCP 사용해도 되나요? 하나하나 떼어놓고 보면 MCP 서버 하나 붙이거나 호스팅 하면 되는 일이다. 문제는 이게 조직 규모로 곱해질 때 생긴다. 사용자 수십 명 x MCP 서버 십수 개가 되는 순간, 각자의 로컬 머신에 mcp.json 을 배포하고 서버마다 인증 정보(API 키, OAuth 앱)를 나눠주고 버전을 관리하는 일은 현실적으로 불가능해진다. 시크릿이 개인 머신 곳곳에 흩어지는 것도 보안 관점에서도 좋은 구성은 아니다. 그래서 개별 MCP 서버를 하나씩 붙여주는 대신, 게이트웨이를 한 번 제대로 구축하기로 했다. 이 글에서는 왜 게이트웨이 패턴이었는지, 후보 중에 왜 IBM 의 mcp-context-forge 를 골랐는지, 그리고 도입하면서 만난 버그들을 업스트림 컨트리뷰션으로 메꿔온 과정을 정리해보려고 한다. ## MCP Gateway 는 무엇을 해결하나 MCP Gateway 는 여러 업스트림 MCP 서버를 게이트웨이 뒤에 모아두고 사용자에게는 단일 MCP 엔드포인트 하나만 노출하는 패턴이다. 게이트웨이가 업스트림 서버들의 tool 을 연합해서 하나의 카탈로그처럼 제공하기 때문에 Federation 이라고도 부른다. ![Context Forge 를 중심으로 한 사내 MCP 게이트웨이 구조. 클라이언트는 게이트웨이 하나에만 연결하고, 게이트웨이가 SSO·RBAC·](https://www.kimsehwan96.com/content/images/2026/07/cf-architecture.png) Context Forge 를 중심으로 한 사내 MCP 게이트웨이 구조. 클라이언트는 게이트웨이 하나에만 연결하고, 게이트웨이가 SSO·RBAC·감사·토큰 위임을 맡아 각 업스트림 MCP 로 per-user 토큰으로 연합한다. 이 패턴이 해결해주는 것은 네 가지다. 1. **단일 엔드포인트**: 사용자는 게이트웨이 주소 하나만 등록하면 된다. 새 MCP 서버가 추가되어도 클라이언트 설정은 바뀌지 않는다. 2. **인증의 중앙화**: 각 업스트림 MCP 의 OAuth 인증을 게이트웨이가 대신 수행하고, 발급받은 토큰도 게이트웨이가 위임 관리한다. 사용자의 로컬 머신에는 업스트림 시크릿이 내려가지 않는다. 3. **큐레이션과 권한**: 어떤 팀에게 어떤 tool 을 노출할지 관리자가 중앙에서 결정할 수 있다. tool 이 수백 개가 되면 "다 주는 것"이 오히려 독이 되기 때문에, 용도별로 묶어서 제공하는 기능이 꼭 필요하다. 4. **감사(audit)**: 누가 어떤 tool 을 호출했는지 한 곳에 남는다. 사실 "왜 엔터프라이즈 환경에서는 CLI 가 아니라 MCP 인가"는 할 말이 더 있는데, 그건 별도의 글로 다루려고 한다. 요약하면 개인 로컬 환경에서는 CLI 가 더 나은 경우가 많지만 "수백 명이 사내 시스템에 에이전트로 접근한다"는 전제가 붙는 순간 중앙에서 관리되는 MCP 쪽으로 저울이 기운다. (물론 그럼에도 CLI 가 주는 장점, 예를들자면 파이프라이닝을 통한 출력 제어로 토큰 사용량 절감이라든지.. 하는것은 CLI 쪽이 여전히 우세하긴 하다) ## 왜 mcp-context-forge 였나 2026년 초 시점에 검토했던 후보는 크게 두 갈래였다. | | agentgateway (solo.io) | mcp-context-forge (IBM) | | --------- | ---------------------- | ----------------------- | | 성격 | MCP/A2A 데이터플레인(프록시) | 관리 UI 를 갖춘 게이트웨이 플랫폼 | | 강점 | 트래픽 처리 계층으로서의 완성도 | 팀/RBAC/토큰 위임 같은 조직 기능 | | 당시 아쉬웠던 점 | 관리자 UI, 셀프서비스 온보딩 부재 | 자잘한 버그, 미완성 기능 | | 라이선스 | 오픈소스 | 오픈소스 (별도 유료 플랜 없음) | **agentgateway** 는 solo.io 가 주도하는 오픈소스 프로젝트로, MCP/A2A 트래픽을 다루는 데이터플레인으로서는 인상적이었다. 다만 당시 시점에는 사내 서비스로 만들 때 필요한 관리자 UI, 팀 단위 관리, 셀프서비스 온보딩 같은 것들이 부족해서 도입하기엔 아직 이르다고 판단했다. 프록시로서는 훌륭하지만 우리에게 필요한 건 프록시가 아니라 플랫폼이었다. **mcp-context-forge** 는 IBM 이 공개한 오픈소스 MCP 게이트웨이다. 처음 봤을 때 눈에 띈 건 두 가지였다. 첫째, 별도의 엔터프라이즈 플랜이 없다. 오픈소스 중에는 SSO, RBAC, 감사 로그 같은 핵심 기능을 유료 에디션에 묶어두는 open-core 모델이 많은데, mcp-context-forge 는 그런 구분 없이 전부 열려 있었다. 둘째, 조직 규모 확장을 처음부터 감안한 설계였다. 팀(Team) 개념, 역할 기반 접근 제어(RBAC), 리소스 소유자(resource owner) 모델, 그리고 OAuth 토큰 위임(token delegation)까지, 게이트웨이에 기대했던 "플랫폼" 기능이 어느 정도 이미 구현되어 있었다. 여러 업스트림 MCP 를 용도별로 묶어 노출하는 **Virtual Server** 개념도 있어서 큐레이션 요구사항과 정확히 맞아떨어졌다. (Virtual Server 는 일종의 단일 MCP 엔드포인트를 제공해주면서, MCP servers / tools / A2A Agents 등을 논리적으로 묶어서 제공해주는 개념이다) 그래서 mcp-context-forge 로 결정하고 사내 IdP(SSO) 연동과 함께 쿠버네티스 위에 올렸다. ![Context Forge 관리 화면 개요. 게이트웨이를 거친 실행 건수·성공률과 연결된 MCP 서버·에이전트·리소스가 한눈에 들어온다.](https://www.kimsehwan96.com/content/images/2026/07/admin-ui-v2.png) Context Forge 관리 화면 개요. 게이트웨이를 거친 실행 건수/성공률과 연결된 MCP 서버/에이전트/리소스가 한눈에 들어온다. ## 토큰 위임은 어떻게 동작하나 이 글에서 계속 나올 개념이라, 토큰 위임(token delegation)이 대략 어떻게 돌아가는지만 짚고 넘어가려고 한다. 먼저 관리자가 업스트림 MCP 를 게이트웨이에 등록할 때, 해당 서비스의 OAuth 클라이언트 정보(client id/secret)도 함께 등록한다. 이 시크릿은 게이트웨이에만 존재하고 사용자에게는 내려가지 않는다. 앞에서 "로컬 머신에 시크릿이 흩어지지 않는다"고 했던 것이 이 구조 덕분이다. 사용자 쪽 흐름은 평범한 OAuth authorization code 플로우다. ![OAuth authorization code 방식의 토큰 위임 흐름. 최초 1회 인증으로 발급받은 access·refresh 토큰을 게이트웨이가](https://www.kimsehwan96.com/content/images/2026/07/oauth-token-delegation-flow.png) OAuth authorization code 방식의 토큰 위임 흐름. 최초 1회 인증으로 발급받은 access·refresh 토큰을 게이트웨이가 암호화해 저장해두고, 이후 tool 을 호출할 때마다 그 토큰으로 대리 인증한다. 핵심은 토큰을 저장해두고, 이후 호출마다 게이트웨이가 대신 인증하는 부분이다. 발급받은 access token 과 refresh token 은 게이트웨이 DB 에 암호화되어 저장되고, 이후 그 사용자가 해당 업스트림의 tool 을 호출할 때마다 게이트웨이가 저장해둔 토큰을 꺼내 업스트림 MCP 에 전달한다. 토큰이 만료되면 함께 저장해둔 refresh token 으로 다시 발급받는다. 인증의 단위가 "사용자 x 업스트림"이라는 점도 운영에서 꽤 중요했다. Virtual Server 는 업스트림 MCP 들을 용도별로 묶은 뷰이기 때문에, 같은 업스트림을 포함하는 Virtual Server 가 여러 개 있어도 사용자는 그 업스트림에 한 번만 인증하면 된다. 사용자는 큐레이션된 업스트림마다 최초 1회 동의 화면만 거치면 되고 그 뒤로는 어떤 Virtual Server 에서 쓰든 인증이 따라온다. 여기까지는 게이트웨이가 업스트림 MCP 에 대신 인증하는 쪽 이야기다. 반대로 사용자가 게이트웨이 자체에 인증하는 "진입점" 쪽은 조금 뒤에서 따로 다룬다. ## 처음부터 완전하게 잘 동작하는건 아니었다 여기까지만 보면 순탄한 도입기 같지만 실제로는 그렇지 않았다. 쓰기 시작하자마자 버그와 부족한 기능이 줄줄이 나왔다. 몇 가지만 꼽아보면 이렇다. 1. OAuth 토큰 갱신(refresh)이 특정 프로바이더에서 실패했다. 토큰 엔드포인트가 JSON 이 아니라 form-encoded 로 응답하는 프로바이더(GitHub 등)가 있는데, 게이트웨이가 JSON 만 가정하고 파싱했기 때문이다. 2. 관리 UI 의 토큰 폐기(revoke) 버튼이 빈 토큰 ID 로 DELETE 요청을 보내는 버그가 있었다. 3. 팀 스코프의 viewer 역할에는 tool 실행 권한이 아예 없어서 "조회만 가능한 사용자에게도 tool 실행은 허용"하는 우리 정책을 표현할 수 없었다. 선택지는 둘이었다. 이슈를 올려두고 기다리거나, 직접 고치거나. 나는 후자를 택했다. 어차피 사내에서는 fork 를 빌드해서 쓰고 있었으니, 급한 버그는 fork 에 먼저 패치해서 배포하고 같은 패치를 업스트림 PR 로 올리는 사이클을 돌렸다. ## 고칠 거면 업스트림에도 돌려주기 이렇게 진행한 업스트림 기여가 쌓이면서 위에서 언급한 버그 상당수는 지금 본가(upstream)에 머지되어 있다. 대표적인 것만 나열하면 이렇다. - `feat(auth)`: Virtual Server 진입점의 OAuth access token 검증(JWKS). 바로 아래에서 자세히 다룬다 ([#3715](https://github.com/IBM/mcp-context-forge/pull/3715?ref=kimsehwan96.com)) - `fix(oauth)`: refresh\_token 의 form-encoded 응답 파싱 지원 ([#4259](https://github.com/IBM/mcp-context-forge/pull/4259?ref=kimsehwan96.com)) - `fix(ui)`: 토큰 revoke 버튼이 빈 ID 로 DELETE 를 보내던 버그 ([#4047](https://github.com/IBM/mcp-context-forge/pull/4047?ref=kimsehwan96.com)) - `feat(rbac)`: 팀 스코프 viewer 역할에 tools.execute 권한 추가 ([#3882](https://github.com/IBM/mcp-context-forge/pull/3882?ref=kimsehwan96.com)) - `feat(ui)`: 소유자가 아닌 사용자도 접근 가능한 OAuth 게이트웨이에 인증할 수 있도록 개선 ([#3935](https://github.com/IBM/mcp-context-forge/pull/3935?ref=kimsehwan96.com)) - `feat(admin)`: A2A 에이전트 등록 폼에 protocol version 선택 추가 ([#4761](https://github.com/IBM/mcp-context-forge/pull/4761?ref=kimsehwan96.com)) - `fix(ui)`: OAuth 게이트웨이 편집 시 issuer 필드가 채워지도록 수정 ([#3756](https://github.com/IBM/mcp-context-forge/pull/3756?ref=kimsehwan96.com)) - `fix`: 관리 UI 편집 시 서버 team\_id 가 유실되던 문제 수정 ([#3780](https://github.com/IBM/mcp-context-forge/pull/3780?ref=kimsehwan96.com)) - `fix`: 게이트웨이 상태 정리 시 진행 중인 auth\_code 를 지우지 않도록 guard 추가 ([#3792](https://github.com/IBM/mcp-context-forge/pull/3792?ref=kimsehwan96.com)) 부산물도 있었다. 게이트웨이에 물릴 업스트림 MCP 서버들을 하나씩 연동하다 보면 그쪽 버그를 밟게 되는데, 그렇게 n8n 의 MCP OAuth audience 불일치 수정이 n8n 업스트림에 머지되기도 했다 ([n8n#30055](https://github.com/n8n-io/n8n/pull/30055?ref=kimsehwan96.com)). MCP 생태계 전체가 아직 젊다 보니, 게이트웨이 도입기가 곧 생태계 디버깅기(?)가 된다. 이 방식의 좋은 점은 fork 부채가 계속 줄어든다는 것이다. fork 로 앞서가되 업스트림에 돌려주면, 다음 버전 업그레이드 때 들고 갈 커스텀 패치가 줄어 있다. 반대로 fork 에만 쌓아두면 업그레이드 때마다 이자를 내야 한다. ## 진입점도 OAuth 로: Virtual Server 인증 게이트웨이의 인증에는 두 개의 면이 있다. 게이트웨이가 각 업스트림 MCP 에 인증하는 면(앞에서 설명한 토큰 위임)과, 사용자(정확히는 MCP 클라이언트)가 게이트웨이에 인증하는 면, 즉 진입점이다. 도입 당시 진입점 인증의 기본 선택지는 게이트웨이가 자체 발급하는 정적 API 토큰이었다. 사용자가 몇 명일 때는 문제가 없지만 조직 규모로 가면 이야기가 달라진다. 토큰을 발급해서 나눠주고 주기적으로 회전시키고 조직을 떠난 사람의 토큰을 정리하는 일이 전부 운영 부담으로 남는다. 무엇보다 MCP 스펙 자체가 인증을 OAuth 로 정리해가는 흐름인데, 게이트웨이 문 앞에서만 정적 토큰을 쓰는 것이 어색했다. 그래서 Virtual Server 진입점에 OAuth 인증을 붙이는 기능을 만들어 업스트림에 올렸고 머지되었다. 동작은 단순하다. 클라이언트가 사내 IdP 에서 발급받은 access token(JWT) 을 들고 오면, 게이트웨이가 IdP 의 공개키(JWKS)로 서명을 직접 검증한다. 검증된 사용자는 SSO 팀 매핑으로 팀과 역할이 자동으로 부여되고 그대로 게이트웨이의 RBAC 으로 이어진다. ![Virtual Server 상세 화면. 여러 업스트림의 tool·resource·prompt 를 하나로 묶고, 진입점 OAuth(Authoriz](https://www.kimsehwan96.com/content/images/2026/07/vs-example.png) Virtual Server 상세 화면. 여러 업스트림의 tool·resource·prompt 를 하나로 묶고, 진입점 OAuth(Authorization Server) 설정도 여기서 한다. 이걸로 통행 구조가 끝까지 OAuth 로 정리되었다. 건물에 비유하면 이렇다. 1. **진입점 OAuth**: 정문에서 사원증을 확인한다 (이 요청이 누구인지 IdP 기준으로 검증) 2. **RBAC**: 층별 출입 권한을 확인한다 (어떤 Virtual Server 와 tool 을 쓸 수 있는지 결정) 3. **토큰 위임**: 각 사무실 문은 그 사람 명의의 열쇠로 연다 (업스트림 MCP 에는 그 사용자가 위임해둔 토큰으로 인증) 물론 진입점에 OAuth 를 붙였다고 인증 이야기가 끝난 것은 아니다. 붙는 클라이언트와 IdP 가 다양해질수록 예상치 못한 문제가 계속 튀어나왔는데, 대표적인 것이 audience 검증이었다. "이 토큰이 원래 누구에게 쓰라고 발급된 것인가"를 게이트웨이가 어떻게 확인할 것인가 하는 문제다. 그런데 이 값을 토큰 어디에 담는지, 검증을 얼마나 엄격하게 기대하는지가 IdP 마다 제각각이었다. OAuth 의 audience 가 이렇게까지 깊고 어려운 주제일 줄은 이때 처음 알았다. (개인적으로 이 작업을 하면서 OAuth 에 대해서 좀 더 알게되기도 하고, Idp 마다 표준에 대한 구현 방식이 조금씩 다르거나 지원되는 범위가 미묘하게 달라서 생기는 문제가 많다는것도 알게되었다. 알고싶진 않았지만..) ## 온보딩은 5분이면 끝난다 도입 후 몇 달이 지난 지금, 새 구성원이 사내 MCP 를 쓰기 시작하는 데 걸리는 시간은 5분이 채 되지 않는다. 개발 직군이 아니어도 똑같다. 실제 온보딩 플로우는 이렇다. 1. 코딩 에이전트에서 온보딩 스킬을 실행한다 (안내와 링크가 출력된다) 2. 게이트웨이에 SSO 로 로그인한다 (사내 계정이면 가입 절차랄 것도 없다) 3. 안내된 링크에서 각 업스트림 MCP(Jira, Slack, Notion 등)의 OAuth 인증을 최초 1회씩 완료한다 4. 끝. 이후 토큰 갱신은 게이트웨이가 알아서 한다 앞서 말한 진입점 OAuth 덕에, 이 플로우 어디에도 "정적 토큰을 발급받아 붙여넣는" 단계가 없다는 점이 핵심이다. ![① 각 업스트림 MCP 의 Authorize 를 눌러 최초 1회 OAuth 인증을 진행한다.](https://www.kimsehwan96.com/content/images/2026/07/auth.png) 1\. 각 업스트림 MCP 의 Authorize 를 눌러 최초 1회 OAuth 인증을 진행한다. ![② 인증이 끝나면 게이트웨이가 토큰을 위임받아 저장한다. 이후로는 재인증 없이 tool 이 바로 호출된다.](https://www.kimsehwan96.com/content/images/2026/07/auth-done.png) 2\. 인증이 끝나면 게이트웨이가 토큰을 위임받아 저장한다. 이후로는 재인증 없이 tool 이 바로 호출된다. 큐레이션된 카탈로그에는 공식(official) MCP 서버와 사내 시스템용으로 직접 만든 커스텀 MCP 서버가 섞여 있는데, 사용자 입장에서는 구분할 필요가 없다. 전부 게이트웨이 하나에서 나오는 tool 이기 때문이다. ![게이트웨이에 등록된 업스트림 MCP 서버들. 공식 MCP(Slack, GitHub 등)와 사내 시스템용 커스텀 MCP(Redash, ArgoCD](https://www.kimsehwan96.com/content/images/2026/07/upstream-mcps.png) 게이트웨이에 등록된 업스트림 MCP 서버들. 공식 MCP(Slack, GitHub 등)와 사내 시스템용 커스텀 MCP(Redash, ArgoCD 등)가 한데 섞여 있다. 기대하지 않았던 효과도 있었다. 코딩 에이전트를 갈아탈 때의 마이그레이션 비용이 사실상 사라졌다. Claude Code 를 쓰다가 Codex 로 옮겨도 게이트웨이 엔드포인트와 본인 인증은 그대로라 인증 정보를 옮기거나 재발급받을 일이 없다. 에이전트 도구가 빠르게 바뀌는 요즘, 도구 선택과 인프라를 분리해둔 것이 생각보다 큰 자유를 줬다. ## MCP 로 시작했는데, 에이전트 인프라가 됐다 mcp-context-forge 에는 A2A(Agent-to-Agent) 게이트웨이 기능도 포함되어 있다. A2A 프로토콜을 지원하는 에이전트를 게이트웨이에 등록하면, 클라이언트 입장에서는 MCP tool 처럼 호출할 수 있다. 우리는 이 기능으로 사내 관측/조회용 에이전트들(메트릭, 로그, 쿠버네티스 리소스 조회 등)을 게이트웨이에 등록해서 쓰고 있다. 재밌게도 순서가 거꾸로였다. MCP Gateway 로 시작했는데, 게이트웨이에 이미 큐레이션된 MCP tool set 이 쌓여 있으니 그 위에 DevOps/SRE 에이전트를 만드는 일이 훨씬 쉬워졌다. 에이전트를 만들 때 가장 손이 많이 가는 "도구 연결과 인증"이 이미 플랫폼에 있기 때문이다. 게이트웨이가 에이전트 인프라의 토대가 된 셈이다. ![게이트웨이에 A2A 에이전트로 등록된 것들. kubernetes·sentry·grafana-loki 같은 조회용 에이전트를 클라이언트에서 too](https://www.kimsehwan96.com/content/images/2026/07/a2a.png) 게이트웨이에 A2A 에이전트로 등록된 것들. kubernetes·sentry·grafana-loki 같은 조회용 에이전트를 클라이언트에서 tool 처럼 호출한다. ## 실제로 얼마나 쓰이나 게이트웨이를 세워두면 좋은 점 하나는, 사내에서 MCP 가 실제로 어떻게 쓰이는지가 한곳에 모인다는 것이다. 지금까지 게이트웨이를 거쳐 나간 tool 호출은 수십만 건 단위가 됐다. 눈에 띄는 건 상위권이 대부분 데이터 조회라는 점이다. Trino 쿼리 실행, 메타데이터 검색, 메트릭/로그 조회 같은 데이터 조회 계열 tool 이 각각 수만 회 이상 불리며 최상위를 차지한다. 사람이든 에이전트든, 결국 "지금 상태가 어떤지 물어보는" 일에 MCP 를 제일 많이 쓰고 있다는 뜻이다. ![게이트웨이가 집계한 tool 사용량 상위 목록. Trino 쿼리·메타데이터 검색·Datadog 메트릭 조회 같은 데이터 조회 계열이 각각 수만 ](https://www.kimsehwan96.com/content/images/2026/07/metrics-v2.png) 게이트웨이가 집계한 tool 사용량 상위 목록. Trino 쿼리/메타데이터 검색/Datadog 메트릭 조회 같은 데이터 조회 계열이 각각 일주일간 수만 회 이상으로 가장 많이 호출된다. (구체 수치는 가렸다.) ## 그런데, MCP 라고 다 같은 MCP 가 아니었다 게이트웨이에 붙일 MCP 를 하나씩 정리하다 보니, MCP 라고 다 같은 게 아니라는 걸 알게 됐다. 게이트웨이 뒤에 자연스럽게 들어오는 MCP 가 있고, 아무리 봐도 잘 맞지 않는 MCP 가 있었다. 기준은 하나였다. 그 MCP 가 다루는 대상이 "네트워크 너머의 공유 자원"인가, 아니면 "사용자의 로컬 환경"인가. - **SaaS/내부 API 를 감싼 MCP**: Grafana, GitHub, Sentry, Datadog, Slack 같은 것들이다. 결국 뒷단이 네트워크로 접근하는 공유 API 라, 게이트웨이가 사용자를 대신해 호출해주는 구조와 완벽하게 맞는다. 지금 우리가 큐레이션한 MCP 는 대부분 여기에 속한다. - **로컬 환경을 제어하는 MCP**: Playwright, Chrome, 파일시스템 같은 것들이다. 이들은 "그 사용자의 브라우저", "그 사람의 파일"처럼 로컬 자원을 다룬다. 중앙 게이트웨이 뒤에 두면 "누구의 브라우저를 열 것인가"부터 답이 없다. 그래서 아직 게이트웨이로는 연동하지 못한 영역이다. 원격 브라우저 풀을 띄우는 식의 우회가 없진 않겠지만, 애초에 게이트웨이가 풀려던 문제와는 결이 다르다. 정리하면 게이트웨이는 "공유 자원형 MCP"에는 강력하지만, "로컬 자원형 MCP"는 처음부터 다른 접근이 필요하다. 이 경계선을 알고 나니 "그건 게이트웨이에 등록하면 됩니다"와 "그건 게이트웨이로는 안 되는 종류입니다"를 구분해서 답할 수 있게 됐다. MCP 를 하나의 균일한 무언가로 보지 않게 된 것, 이것도 도입기의 소득이라면 소득이다. ## 마치며 정리하면 세 가지다. 1. 조직 규모에서 AI 에이전트에 도구를 연결하는 문제는 **개별 연동이 아니라 게이트웨이(Federation) 패턴**으로 푸는 것이 맞았다. 도입 이후 "에이전트에서 xx 쓰고 싶어요"류의 요구사항은 대부분 "게이트웨이에 등록"으로 수렴했다. 2. mcp-context-forge 는 처음부터 완성도가 높진 않았다. 하지만 팀/RBAC/토큰 위임 같은 뼈대가 전부 열려 있는 오픈소스였기 때문에, 부족한 부분은 fork 로 앞서가고 업스트림으로 돌려주는 사이클로 메꿀 수 있었다. 지금 다시 선택하라고 해도 같은 선택을 할 것 같다. (지금은 완성도가 많이 올라왔다고 생각한다) 3. 표준(MCP, A2A) 위에 인프라를 세워두니, 그 위의 도구는 얼마든지 갈아탈 수 있게 되었다. 변화가 빠른 시기일수록 "무엇이 표준으로 남을 것인가"에 인프라를 거는 쪽이 안전했다. ### ECR Pull-Through Cache 적용기(feat: Docker Rate Limit 을 피해서) URL: https://www.kimsehwan96.com/ecr-pull-through-cache/ Last updated: 2026-07-20T12:37:02.000Z ## 들어가며 EKS 환경에서 Helm Chart를 이용해 여러 애플리케이션을 배포하다 보면, Docker Hub(registry-1.docker.io)의 이미지를 그대로 사용하는 경우가 많습니다. 그런데 NAT IP가 고정된 상태에서 노드가 자주 추가/삭제 되는 스케일 아웃/인을 반복하는 환경이라면 Docker Hub 의 Image Pull Rate Limit 에 걸릴 우려가 있습니다. 특히, 2024년 6월 30일부터 시행된 기존 규정에 따르면 인증되지 않은 사용자(anonymous)에 대해서는 6시간 동안 최대 100회의 Pull만 허용되었습니다. 2025년 4월 1일부터는 인증되지 않은 사용자의 Pull 횟수가 ‘시간당 10번’으로 제한되고, 무료 사용자(로그인 상태)도 시간당 100번까지만 이미지를 Pull할 수 있도록 제한이 더욱 강화될 예정입니다. 이런 변화는 Kubernetes 클러스터에서 노드 교체가 잦은 EKS 환경에 큰 영향을 줄 수밖에 없으며, 결과적으로 애플리케이션 배포 및 스케일링 과정에서 이미지 Pull 실패 위험을 한층 높이게 됩니다. [PullsLearn about pull usage and limits for Docker Hub.![](https://docs.docker.com/favicons/docs@2x.ico)Docker Documentation![](https://docs.docker.com/images/thumbnail.webp)](https://docs.docker.com/docker-hub/usage/pulls/?ref=kimsehwan96.com) 따라서 개인 용도로 사용하는 케이스가 아닌 실제 프로덕션에서 Kubernetes 클러스터를 이용 중이며, Docker Hub 에 퍼블리싱된 이미지를 인증(Docker 계정)없이 사용하는 경우에는 Image Pull Rate Limit 을 마주할 확률이 더욱 커진다고 볼 수 있습니다. ## 이미지 캐싱에 대한 오해 "Kubernetes에서는 한 번 Pull한 이미지를 노드 레벨에서 캐싱하기 때문에, 굳이 Rate Limit 걱정을 할 필요가 없지 않을까?"라는 의문이 들 수 있습니다. 실제로 Kubelet은 기존에 Pull된 이미지를 재사용하며(imagePullPolicy 가 Always 가 아닌 이상), 설정에 따라 특정 주기에 오래된 이미지를 정리(pruning)하거나 디스크 공간이 부족하면 이미지를 제거할 수 있습니다. 다만, EKS 환경에서 Karpenter와 같은 오토스케일링 도구를 사용하는 경우 노드 자체의 생명 주기가 짧아집니다. 스팟 인스턴스가 자주 교체되거나 부하 변화에 따라 노드가 빠르게 추가/삭제되면서, 이전에 캐싱해둔 이미지를 재활용할 기회가 크게 줄어들 수밖에 없습니다. 결국 이러한 이유로 인해, Rate Limit에 걸릴 위험성이 더욱 높아지게 됩니다. ## **그렇다면 Docker Hub의 Rate Limit을 우회하기 위한 방법은 무엇이 있을까요?** 해당 문제를 겪었을 때 들었던 방법은 몇가지가 있었습니다. 1. Docker Hub 무료 계정 사용 + `imagePullSecrets` 적용 1. Docker Hub의 무료 계정을 생성하고, 이를 Kubernetes에 `imagePullSecrets`로 등록해 인증된 Pull을 시도하는 방법입니다. - 하지만 무료 계정 역시 Rate Limit이 존재하며, 장기적으로 안정적인 해결책이 아니라 판단되어 제외하였습니다. 2. 직접 ECR Private Repository에 이미지를 업로드 1. 우리가 사용하는 이미지를 ECR Private Repository에 일일이 Push해두고, Helm Chart나 매니페스트에서 해당 Private Repository를 사용하도록 변경하는 방법입니다. 2. 클러스터 컴포넌트나 애드온 이미지를 자주 교체하지는 않으나, 여전히 수동으로 매번 업로드·업데이트해야 한다는 점이 번거로웠습니다. 3. Docker Hub를 미러링하는 서드파티 레지스트리 사용 1. 외부에서 Docker Hub 이미지를 미러링해주는 레지스트리를 찾아서, 해당 레지스트리를 대신 사용합니다. 2. 하지만 서드파티 레지스트리에 대한 신뢰 문제, 장애나 중단 발생 시 치명적인 영향을 받을 수 있다는 점이 우려되었습니다. 3. 신뢰 문제가 아니더라도 모든 퍼블릭 레지스트리에 대해서 미러링하는 서비스는 찾지 못하였습니다. 세가지 방법 중 2번(직접 업로드)를 자동화 한 형태에 가장 가까운 `ECR Pull-Through Cache` 가 가장 적합하다고 판단하였습니다. `ECR Pull-Through Cache` 를 사용하면 ECR에서 퍼블릭 레지스트리마다 캐싱 정책을 설정한 뒤, 퍼블릭 레지스트리별로 생성된 ECR 레포지토리 주소를 통해 이미지를 Pull 할 수 있습니다. 이렇게 하면 처음 이미지를 가져올 때 ECR이 자동으로 원본 퍼블릭 레지스트리에서 이미지를 받아 ECR Private Repository에 저장하고, 이후에는 캐싱된 이미지를 통해 간편하게 운영할 수 있게 됩니다. ## ECR Pull-Through Cache 의 개념 및 동작 방식 `ECR Pull-Through Cache` 는 특정 퍼블릭 레지스트리(예: Docker Hub)에 대해 `프록시(Proxy)` 처럼 동작합니다. ECR에 이미지 요청이 들어오면 먼저 ECR 레포지토리 내부에 해당 이미지가 캐싱되어 있는지 확인하고, **없다면** 퍼블릭 레지스트리에서 이미지를 자동으로 가져와 ECR에 저장합니다. 따라서 우리는 퍼블릭 레지스트리에 직접 접근 할 필요 없이 항상 ECR 레포지토리를 통해 이미지를 Pull할 수 있게 됩니다. 2025년 2월 기준, `ECR Pull-Through Cache` 가 지원하는 퍼블릭 레지스트리 - ECR Public Registry - Quay (quay.io) - Docker Hub (registry.docker.io) - GitHub Container Registry (ghcr) - Azure Container Registry - GitLab Container Registry 일반적으로 대부분의 Helm Chart가 Quay, Docker Hub, GitHub Container Registry 등을 자주 사용하므로 대부분의 유즈케이스를 충분히 커버 할 수 있습니다. ## ECR Pull-Through Cache 설정 방법 (웹 콘솔) 우선 웹 콘솔에서의 사용 방법을 간단히 소개합니다. AWS ECR 서비스의 Private Registry -> Features & Settings 에서 Pull-Through Cache 규칙 설정을 접근합니다. ![AWS ECR 콘솔의 Private Registry 에서 Pull-Through Cache 규칙 설정에 접근하는 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.25.56.png) 이후 규칙 추가를 선택합니다. ![ECR Pull-Through Cache 규칙 목록에서 규칙 추가를 선택하는 콘솔 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.26.30.png) 어떤 퍼블릭 레지스트리(업스트림)에 대해 규칙을 생성할지 선택 할 수 있습니다. ![Pull-Through Cache 규칙에서 업스트림 퍼블릭 레지스트리를 선택하는 콘솔 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.27.22.png) Docker Hub 과 GitHub Container Registry 의 경우 Public Image 에 접근하더라도 인증 정보를 사용해야하는것에 유의해야 합니다. 우선 인증이 필요하지 않은 `quay.io` 에 대해 먼저 설정해봅니다. ![인증이 필요 없는 quay.io 업스트림 규칙을 설정하는 콘솔 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.47.13.png) 위와 같이 네임스페이스를 지정 할 수 있습니다. 웹콘솔로 진행하는경우 위와같이 `quay.io` 에 대한 네임스페이스는 `quay` 로 지정됩니다. 위와 같이 사용해도 무방하지만, 실제 업스트림 레지스트리의 네이밍을 그대로 따라가는것을 추천합니다. (예: `ghcr.io` , `quay.io` , `registry.k8s.io` / `docker.io` \-> 실제 업스트림 레지스트리url 은 `registry-1.docker.io` 입니다) ![ECR 캐시 네임스페이스와 업스트림 레지스트리 URL 을 입력하는 콘솔 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.47.35.png) ![생성된 quay.io Pull-Through Cache 규칙을 확인하는 ECR 콘솔 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.32.38.png) 생성을 하고 나면 위와 같이 캐시 네임스페이스가 생성됩니다. `{ACCOUNT_ID}.dkr.ecr.ap-northeast-2.amazonaws.com/quay.io/{repository}/{image}:{tag}` 와 같은 형태로 이미지 Pull 요청을 하면 실제 업스트림인 `quay.io/{repository}/{image}:{tag}` 에서 이미지를 갖고와 캐싱해서 사용할 수 있게 됩니다. 예를들어 `quay.io` 레지스트리를 사용하는 `argo-cd` 를 테스트해보겠습니다. ![quay.io 의 argo-cd 이미지로 Pull-Through Cache 를 테스트하는 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.35.55.png) 캐시 규칙을 설정해두었지만 현재 ECR Private Repository 에 아무것도 없는 상태입니다. ![캐시 규칙만 있고 아직 이미지가 없는 ECR Private Repository 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.40.00.png) `$ aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin {ACCOUNT_ID}.dkr.ecr.ap-northeast-2.amazonaws.com` 와 같은 형태로 `docker` 에 ecr을 통해 인증을 하고나서 `$ docker pull {ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/quay.io/argoproj/argocd:v2.5.6` 와 같이 ECR Private Repository 를 통해 image pull 을 요청합니다. 우리가 아까 정의한 캐시 네임스페이스를 사용하는것에 유의합니다. 이렇게 하면 실제로는 해당 경로에 아직 이미지가 없지만, 설정한 캐시 규칙을 통해 업스트림 레지스트리에서 이미지를 자동으로 가져와 캐싱 네임스페이스 기반의 ECR Private Repository에 저장하게 됩니다. 이후에는 그 이미지를 곧바로 사용할 수 있습니다. ![업스트림 이미지가 ECR Private Repository 에 캐싱되어 저장된 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.45.29.png) 실제로 우리가 ECR Private Repository 를 직접 생성하지 않았지만. 캐시 규칙에 의해 자동으로 Private Repository 를 생성하고, 이미지를 해당 레포지토리에 저장하게됩니다. 아까 처음에 Docker Hub 및 GitHub Container Registry 는 `ECR Pull-Through Cache` 정책을 사용하기 위해 인증 정보가 필요하다고 이야기 했었습니다. ![Docker Hub·GHCR 캐시를 위한 인증 정보를 설정하는 콘솔 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-27------10.49.09.png) Docker Hub 이나 GitHub Container Registry 를 업스트림으로 하는 캐시를 웹콘솔에서 생성하려고 하면 볼 수 있는 화면입니다. `ecr-pullthroughcache/` 를 접두사로 하는 암호를 `AWS Secrets Manager` 에 생성하고 사용해야 하는것만 더 추가됩니다. 이 때 `AWS Secrets Manager` 에 생성하는 암호에는 `username` 과 `accessToken` 이라는 키를 사용합니다. ## ECR Pull-Through Cache 설정 방법(Terraform) 위와 같이 `aws_ecr_pull_through_cache_rule` 리소스를 사용 할 수 있습니다. Docker Hub 와 GitHub Container Registry 에 대해서는 AWS Secrets Manager 에 정의된 암호를 사용해야하며, 해당 부분을 어떻게 설정하는지는 이 글의 범위를 벗어나니 생략하겠습니다. ## EKS 환경에서 사용하기 위에서 `docker pull` 로 이미지를 pull 할 때에는, 제가 사용한 IAM User 의 역할(IAM Role)에 ECR 관련된 정책이 이미 연결되어 있었기 때문에 별다른 문제 없이 사용이 가능했습니다. 다만 우리가 실제로 사용하게 될 EKS 워커노드 환경에서는 해당 권한들이 기본적으로 세팅되어있지 않기 때문에 아래와 같은 권한을 worker node 가 사용하는 IAM Role 에 추가해야 합니다. ``` statement { effect = "Allow" actions = [ "ecr:BatchImportUpstreamImage", "ecr:CreateRepository", "ecr:ReplicateImage" ] resources = ["*"] } ``` ecr 과 관련된 정책을 추가해야 한다. resources 의 경우 각자의 환경에 맞게 수정해서 사용하면 된다. Terraform EKS 모듈을 사용하고 self managed node group 을 사용한다면 아래와 같이 사용할 수 있습니다. ## Helm Chart에서의 이미지 경로 설정 대부분의 Helm Chart에서는 `values.yaml` 내의 `image.repository` 또는 `image.registry` 값을 조합해 최종 Pull 경로를 결정합니다. 이 로직은 보통 1. `_helpers.tpl` 파일에서 템플릿 함수로 구현되어 있거나 2. `deployment.yaml`, `statefulset.yaml`, `daemonset.yaml` 등 **워크로드 매니페스트** 내부의 `containers[].image` 항목에 인라인으로 정의되어 있습니다. 예를 들어 ArgoCD Helm Chart(예: `argo-cd` 차트의 `argocd-server` 부분) `deployment.yaml` 에서 `deployment.yaml`을 보면 ([link](https://github.com/argoproj/argo-helm/blob/f26595848502480f9427c1365a8313270d9aa3af/charts/argo-cd/templates/argocd-server/deployment.yaml?ref=kimsehwan96.com#L67-L70)) ``` containers: - name: {{ .Values.server.name }} image: {{ default .Values.global.image.repository .Values.server.image.repository }}:{{ default (include "argo-cd.defaultTag" .) .Values.server.image.tag }} imagePullPolicy: {{ default .Values.global.image.imagePullPolicy .Values.server.image.imagePullPolicy }} ``` 여기서 `default A B`라는 Helm 함수는 “B가 설정되어 있으면 B를, **없다면** A를 사용”한다는 의미입니다. 즉, - `.Values.server.image.repository`가 있으면 그 값을 사용, - 없으면 `.Values.global.image.repository`를 사용하게 됩니다. ``` ## Globally shared configuration global: # -- Default domain used by all components ## Used for ingresses, certificates, SSO, notifications, etc. domain: argocd.example.com # -- Common labels for the all resources additionalLabels: {} # app: argo-cd # -- Number of old deployment ReplicaSets to retain. The rest will be garbage collected. revisionHistoryLimit: 3 # Default image used by all components image: # -- If defined, a repository applied to all Argo CD deployments repository: quay.io/argoproj/argocd ``` 따라서 `global.image.repository` 값을 수정하면, ArgoCD 컴포넌트 전반에 걸친 이미지 레포지토리를 일괄적으로 변경할 수 있습니다. 또한 Dex와 Redis는 서브차트가 아닌, ArgoCD 차트의 템플릿 일부로 포함되어 있는데, 이들은 `global.image.repository`를 참조하도록 구현되어 있지 않으므로 **별도**로 `dex.image.repository`, `redis.image.repository` 등을 설정해줘야 합니다. ## 이 포스트에서 다룰 예시 차트들 아래 Helm Chart들을 예시로 들어, 이미지 레지스트리를 ECR Pull-Through Cache 주소로 바꾸는 과정을 살펴보겠습니다. (괄호 안은 테스트 시점의 Chart 버전) - argo-cd (7.8.5) - cert-manager(v1.15.3) - external-secrets(0.9.16) - keda(2.14.0) - istiod(1.21.2) - fluentd(0.5.2) - kube-prometheus-stack(58.3.1) - external-dns(7.2.0) ### argocd `global.image.repository` 를 통해 `argocd` 애플리케이션들의 이미지 레포지토리를 일괄 지정합니다. `repository` 라는 이름으로 쓰이면 pull-path 로 정의해야 합니다. (`{registry}/{repository}/{image}` \-> `{ACCOUNT-ID}.dkr.ecr.ap-northeast-2.amazonaws.com/quay.io/argoproj/argocd`) `global.image.tag` 값을 정의하지 않는 이유는 argocd helm chart 내부에서 처리하기 때문에 정의하지 않았습니다. ([링크 참고](https://github.com/argoproj/argo-helm/blob/f26595848502480f9427c1365a8313270d9aa3af/charts/argo-cd/templates/%5Fcommon.tpl?ref=kimsehwan96.com#L37-L39)) `dex` 와 `redis` 는 Helm 의 sub chart 기능을 활용한것은 아니고 argocd 차트의 템플릿중 일부로 포함되어있습니다. `dex` 와 `redis` 는 `global.image.repository` 의 영향을 받지 않고 별도로 지정하도록 되어있습니다. ([링크 참고](https://github.com/argoproj/argo-helm/blob/f26595848502480f9427c1365a8313270d9aa3af/charts/argo-cd/templates/dex/deployment.yaml?ref=kimsehwan96.com#L70-L76)) ### cert-manager cert-manager 의 helm chart 를 보면 controller 자체는 `image.registry` 로 레지스트리만 변경 할 수 있습니다. (values.yaml 에는 `image.registry` 항목이 없는데 사실 `_helpers.tpl` 쪽에 받아서 처리 할 수 있게 구성되어있다 / [링크 참고](https://github.com/cert-manager/cert-manager/blob/0448418353269ce5ecbb4eaeb0f301c5dcbeb8de/deploy/charts/cert-manager/templates/%5Fhelpers.tpl?ref=kimsehwan96.com#L176-L188)) webhook 과 cainjector 의 경우는 `webhook.image.registry` 와 같은것으로 레지스트리만 변경이 불가능하고 `repository` 를 변경해야 합니다. 여기서도 느끼겠지만 보통 `registry` 의 경우 레지스트리 정보만 주입하면 되는 반면, `repository` 의 경우 `registry` 를 포함해서 레포지토리/이미지 값까지 포함해야 한다는 점이 차이가 있습니다. ### external-secrets `image.repository` 는 external-secrets 메인 컴포넌트의 이미지 레포지토리를 지정하고, `webhook` , `certController` 또한 각각 별도로 지정해줘야 합니다. ### external-dns `registry` 만 변경할 수 있도록 되어있고, external-dns 자체가 여러 컴포넌트가 없기 때문에 매우 단순하게 변경 할 수 있습니다. ### keda keda 의 모든 컴포넌트의 이미지 레지스트리를 일괄 변경 할 수 있도록 `global.image.registry` 값이 존재하므로 그대로 사용하면 됩니다. ### istiod `defaults.pilot.hub` 의 경우 `istiod` 가 사용할 이미지에 대한 registry 를 지정하는 값입니다. `defaults.global.hub` 의 경우 sidecar injection 을 수행할 때 사용할 registry 및 `defaults.pilot.hub` 값이 정의되어있지 않을 때 사용할 기본 값(istiod 의 이미지)입니다. ### fluentd ### kube-prometheus-stack kube-prometheus-stack 은 여러 helm sub chart 가 함께 사용 되는 형태입니다. ![kube-prometheus-stack Helm 차트의 서브차트 구조를 보여주는 화면](https://www.kimsehwan96.com/content/images/2025/02/-----------2025-02-28-------1.07.08.png) kube-prometheus-stack 의 helm chart. charts/ 에 있는 차트들은 서브차트들이다. 따라서 values.yaml 에서도 서브차트 / kube-prometheus-stack 자체의 차트 각각에 쓰이는 값들이 혼재되어있습니다. 서브차트의 경우 서브차트의 이름을 통해서 values 값을 전달해야 합니다.(예: `kube-state-metrics` 에 전달할 값은 `kube-state-metrics.image.registry` \-> 해당 서브 차트의 `.Values.image.registry`) `alertmanager` , `prometheusOperator` , `prometheus` 는 모두 `kube-prometheus-stack` 의 자체 템플릿이고. `prometheusOperator` 는 자체 `deployment` 로 뜨기 때문에 `prometheusOperator.image.registry` 형태로 직접 전달하고. `alertmanager` 와 `prometheus` 는 모두 `CustomResource` 로 생성되기때문에 `Spec` 이라는 postfix 로 value 를 처리합니다. 여기서 유의할점은. `grafana` , `prometheusOperator` 와 같이 하나의 추상적인 컴포넌트도 서브 이미지(사이드카 등)들이 각각 다른 레지스트리를 쓸 수도 있으므로 `values.yaml` 에서 모든 이미지 부분을 확인하는 것이 중요합니다. ### Helm Chart 예시들을 보고 나서 앞서 살펴본 바와 같이, Helm Chart에서 이미지 레지스트리(또는 레포지토리)를 설정하는 방식은 차트마다 제각각입니다. 1. **`image.registry` vs. `image.repository`** 1. 어떤 차트는 단순히 `image.registry`를 변경하면 나머지 경로(리포지토리/이미지명)는 템플릿이 자동으로 붙여주는 구조를 갖습니다. 2. 반면 다른 차트는 `image.repository` 자체에 레지스트리+조직+이미지명을 전부 넣도록 설계되어 있을 수 있습니다. 2. **`global` 설정** 1. 일부 차트는 `global.image.repository`(or `global.image.registry`)만 설정하면 여러 컴포넌트가 일괄적으로 해당 값을 참조하도록 만들어졌습니다. 2. 하지만 서브차트(혹은 같은 차트 내 여러 템플릿)마다 별도의 `image.repository` 값을 사용하도록 구현되어 있으면, 각각 별도로 오버라이드해야 합니다. 3. 서브차트 & 사이드카 1. `kube-prometheus-stack`처럼 복잡한 차트는 서브차트, CRD, 사이드카 이미지가 섞여 있어, 여러 위치에 레지스트리 설정이 흩어져 있을 수 있습니다. 2. 따라서 values.yaml 파일에서 모든 이미지 관련 파라미터를 빠짐없이 확인하고, 필요하다면 **각각** ECR Pull-Through Cache 경로로 바꿔줘야 합니다. ## ## 결론 결국 Docker Hub의 이미지 Pull Rate Limit 이슈는, 스팟 인스턴스나 Karpenter 같은 오토스케일링 환경에서 노드가 자주 교체되는 EKS 클러스터에 특히 취약하게 작용합니다. 이러한 문제를 해결하기 위해 직접 이미지를 업로드하거나, 외부 미러링 레지스트리에 의존하기보다는 AWS ECR Pull-Through Cache를 활용하여 퍼블릭 레지스트리의 이미지를 ECR 프라이빗 레포지토리에 캐싱하는것이 가장 안정적이고 간단한 방법이라고 생각합니다. 물론 Helm Chart마다 `image.registry`, `image.repository`, `global.image.*` 등의 설정 방식이 다르므로, 필요한 컴포넌트·서브차트별로 꼼꼼히 레지스트리 경로를 변경해야 합니다. 하지만 이 작업을 한 번 진행하고 나면, 대부분 Helm Chart에서 image 와 관련된 템플릿 부분이 변경되지는 않기 때문에 더 이상 신경쓰지 않고 여러분의 ECR 프라이빗 레포지토리에 퍼블릭 이미지를 캐싱해서 사용할 수 있습니다. 이를 통해 퍼블릭 레지스트리의 정책 변화에 영향을 받지 않고 안정적으로 프로덕션 환경에서 이미지를 관리할 수 있습니다. ### EMR on EKS 환경에서 spark driver 와 executor 가 같은 AZ 에 스케쥴링 되도록 하는 방법 (가용성까지 확보하면서) URL: https://www.kimsehwan96.com/emr-on-eks-spark-driver-and-executor-same-az/ Last updated: 2026-07-20T12:37:01.000Z 💡 emr-on-eks 환경에서 spark driver pod 와 executor pod 가 서로 다른 AZ(가용영역)에 있으면 AZ 간 통신비용이 많이 발생하게 됩니다. 따라서 spark driver pod 가 쿠버네티스에 의해 특정 노드에 스케쥴링 되었을 때, executor pod 가 해당 driver pod 가 스케쥴링 된 노드의 AZ 와 동일한 AZ에 존재하는 노드에 스케쥴링 하기위해 조치했던 방법을 공유합니다. ## EMR on EKS EMR on EKS 란 EKS 에서 오픈소스 빅데이터 프레임워크를 실행 할 수 있는 아마존의 서비스를 의미한다. EKS 클러스터에서 Amazon EMR 기반 애플리케이션을 실행 할 수 있는데. AWS EMR 에 Spark Job 을 submit 하면 가상클러스터라고 불리는 EKS 환경에 Spark driver 와 Spark Executor 를 실행 할 수 있다. Karpenter 와 함께 사용하면 그 편리함이 극대화 되는데, 무거운 Spark Job 이 수행되는 경우 Karpenter 에 의해 Node 가 자동으로 프로비저닝되고, 작업이 끝난 이후에는 해당 노드를 내리는 등의 작업을 자동화 할 수 있기 때문에 매우 비용 효율적이다. 현재 우리는 Airflow 와 함께 EMR on EKS 를 사용중인데. Airflow 의 EMROperator 클래스를 사용해서 필요한 Spark Job 을 주기적으로 실행하는 형태로 구성하였다. 현재 우리의 구성을 간략하게 그리면 아래와 같다. ![Airflow 로 EMR on EKS Spark Job 을 실행하는 구성도 (job submitter 파드 생략)](https://www.kimsehwan96.com/content/images/2024/12/-----------2024-12-14-------2.01.06.png) 그림에서는 job submitter pod 그림이 생략되었다. EMROperator 클래스를 통해 (정확히는 그것을 상속하여 별도로 구현한 Operator 를 사용중이다.) Spark Job context 와 함께 AWS EMR 에 job 을 submit 하면, AWS EMR 을 통해 EKS 클러스터 내에 Spark Driver Pod 와 Spark Executor Pod 가 생성되게 된다. (Job Submitter Pod 도 존재하는데 이 글에서는 해당 내용을 생략한다) ## 문제 상황 Spark Job 은 대부분 컴퓨팅 리소스(CPU, 메모리 둘다)를 많이 사용하는 작업이므로, Karpenter 를 통해 필요할때만 동적으로 컴퓨팅 리소스를 생성한다고 하더라도 EC2 비용이 매우 많이 발생하게 된다. 따라서 비용 효율성 개선을 위해 Spark Driver Pod 는 on-demand 머신에, Spark Executor Pod 는 spot 머신에 프로비저닝 하도록 `podTemplate` ([https://spark.apache.org/docs/latest/running-on-kubernetes.html#pod-template](https://spark.apache.org/docs/latest/running-on-kubernetes.html?ref=kimsehwan96.com#pod-template))을 통해 설정하였다 위와 같은 PodTemplate 를 AWS EMR 에 job submit 할 때 제출하면, 우리가 지정한 properties (e.g spec.nodeSelector)를 갖고, 그 외 값들을 채워서 EKS 환경에 driver 와 executor 가 생성된다. Spark Driver 의 경우 실패하면 모든 작업을 다시 시작해야 하지만 (executor 도 모두 종료), Spark Executor 의 경우 종료되더라도 전체 작업에 영향을 끼치지 않기 때문에 spot 인스턴스 환경에서 수행하는것이 훨씬 비용 효율적이였다. 문제는, 위와 같이 구성하였을 경우, 그리고 (당연히도) EKS 클러스터 환경의 노드가 여러 가용영역(AZ)에 존재하는 경우. AWS EMR 을 통해 생성되는 driver pod 와 executor pod 가 서로 다른 AZ 에 스케쥴링 되는 경우가 발생하고, 이러한 상황에서는 driver pod 와 executor pod 간의 통신이 모두 비용으로 발생하게 된다. (한국 리전 기준 $0.01 per GB) [https://aws.github.io/aws-emr-containers-best-practices/node-placement/docs/eks-node-placement/](https://aws.github.io/aws-emr-containers-best-practices/node-placement/docs/eks-node-placement/?ref=kimsehwan96.com) 아마존이 제공하는 AWS EMR 환경에 대한 Best Practice 문서를 보면 AWS EKS 기반의 쿠버네티스 클러스터의 워커노드가 여러 AZ 에 걸쳐서 있는 경우, Driver 와 Executor 가 서로 다른 AZ 에 프로비저닝 될 수 있고, 이런 경우 비용이 발생한다는 내용이 나와있다. ![Spark Driver 와 Executor 가 서로 다른 AZ 에 스케줄링돼 통신 비용이 발생하는 다이어그램](https://www.kimsehwan96.com/content/images/2024/12/-----------2024-12-14-------3.12.57.png) Driver 와 Executor 가 서로 다른 AZ 에 스케쥴링 되면 AZ 간 통신 비용이 발생한다. 여기서 제공하는 솔루션은 spark job submit parameter 에 nodeSelector를 `topology.kubernetes.io/zone` 등을 설정하여 job 을 submit 하고 job submitter, driver, executor 등을 단일 AZ 에서 스케쥴링 되도록 하는 내용이다. 당연히 동작하긴 할것이다. 그렇지만 이 문서에서 나와있듯이 특정 AZ 를 하드코딩해서 사용하는 경우 해당 AZ 에 on-demand, spot 인스턴스가 부족한 경우에는 pending 상태에 빠질것이다. (karpenter 도 어떻게 해줄 수 없다. 실제 해당 AZ에 인스턴스가 부족한거니까) 그래서 오히려 job submitter, driver pod 는 아무 AZ 에 존재하는 노드에 프로비저닝 되도록 하고 (Kubernetes Scheduler 에게 전적으로 위임하고), executor pod 만 driver pod 와 동일한 AZ 에 있는 노드에 프로비저닝 되도록 하는것이 더 가용성 향상이 된다고 판단했다. ## 해결 방법 driver pod 가 떠있는 노드의 [topology.kubernetes.io/zone](http://topology.kubernetes.io/zone?ref=kimsehwan96.com) 레이블을 갖는 노드에 executor pod 가 스케쥴링 되면 되는 상황이기때문에, 스케쥴러가 이런 상황을 처리 할 수 있는 `inter-pod-affinity` 를 사용하면 된다고 생각했다. ([https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#inter-pod-affinity-and-anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/?ref=kimsehwan96.com#inter-pod-affinity-and-anti-affinity)) `inter-pod-affinity` 란 해당 노드에서 이미 실행중인 파드의 `레이블` 을 기반으로 파드를 스케줄링 할 수 있는 노드를 제한 할 수 있는 기능이다. driver pod 가 스케쥴링된 node 의 [topology.kubernetes.io/zone](http://topology.kubernetes.io/zone?ref=kimsehwan96.com) 레이블의 값을 갖는 노드에 executor pod 를 프로비저닝 하고자 할 때 `inter-pod-affinity` 를 통해서 구현이 가능한 것이다. > inter-pod-affinity 는 상당한 처리량을 필요로 하는 작업이라 수백대 이상의 노드를 운영하는 클러스터에서는 사용이 권장되지 않는다. 파드가 스케쥴링되기 까지 (이 조건을 계산하느라) 오래 걸릴 수 있다고 한다. 위와 같이 구성하면 `spark-app` 이라는 driver pod 의 label 을 기반으로, 해당 파드가 떠있는 노드의 [topology.kubernetes.io/zone](http://topology.kubernetes.io/zone?ref=kimsehwan96.com) 레이블의 값과 동일한 Node 에 스케쥴링 하도록 강제 할 수 있다. (topologyKey 참고) 이론적으로는 간단한 접근이였지만 현실적으로 아래와 같은 문제가 있었다. 1. spark job 을 submit 하기 전에는 각 job 별로 고유한 job id 를 알 수 없다. 따라서 PodTemplate 은 spark job submit 하기전에 이미 있어야 하기 때문에 job id 기반의 레이블을 선택 할 수 없다. 2. 그렇다면 각 Job 별로 (Airflow 환경에서는 DAG / Task) static 하게 app name 등을 지정하고, 그것을 통해서 레이블을 선택하도록 하면 되지 않을까? → 만약 같은 시간에 중복 실행되고있다면 문제가 발생 할 수 있다. AWS EMR 에 job 을 submit 하기전에 driver 와 pod 에 대한 PodTemplate 을 전달해야 하는데, 즉 이 시점에서 driver 와 pod 를 고유하게 매칭시켜줄 수 있는 키/값을 고정해야만 우리가 하고자 했던 동작을 정확하게 구현 할 수 있다. 따라서 Airflow EMROperator 를 사용하는 우리 환경에, AWS EMR 에 job 을 submit 하기 이전에 해당 작업의 이름(e.g `etl-foo-bar` )과 timestamp 를 조합해서 affinity 를 사용하기 위한 label 과 value 를 고정하였다. 그리고 EMROperator 가 job 을 submit 하기 이전에 PodTemplate 을 동적으로 생성하여 S3 에 업로드하고, 그 S3 오브젝트의 경로를 AWS EMR 에 submit 할 때 같이 넘겨주는 방식으로 구현하였다. `spark.kubernetes.driver.label` 형식으로 spark driver pod 의 레이블을 지정해줄 수 있고. 이것을 사전에 지정하고, PodTemplate 에는 지정된 label 의 value 를 `inter-pod-affinity` 에서 사용 할 수 있게 구현하였다. 동적으로 생성된 PodTemplate 은 S3 에 업로드하고, 그것에 대한 경로를 `spark.kubernetes.executor.podTemplateFile` , `spark.kubernetes.driver.podTemplateFile` 형태로 넘겨주어서 처리하였다. 이렇게 하여서 driver pod 에 대한 고유한 label key:value 를 job submit 하기 전에 핸들링 할 수 있고, 이것을 통해서 PodTemplate 을 생성하면 우리가 원하는 동작을 구현 할 수 있다. 이 케이스에서 label 의 key, value 는 어떤것을 사용해도 상관없다. 다만 value 는 동일한 작업에 대해서 여러번 submit 되거나 동시에 실행되는 상황이 발생해도 conflict 이 나지 않을 수 있게만 지정하면 된다. ## 쉽게 해결 할 수 없을까? 이렇게 특정 파드를 다양한 조건으로 스케쥴링 하는 방법은 Kubernetes 에서의 스케쥴러가 판단 할 수 있는 nodeSelector, affinity , topology spread constraints 등을 상황에 맞게 잘 조합해서 사용해야 한다. 이번 문제상황과 같은 경우에는 `inter-pod-affinity` 를 이용해서 쉽게 해결은 가능했지만. AWS EKS 뿐만 아니라 AWS EMR 이라는 중간 layer 가 들어가 있기 때문에 어느정도 우회법을 찾아서 해결을 해야 했다. 만약 spark on kuberentes 에서 podTemplate 에 대한 템플리팅 변수를 지원해주거나, AWS EMR 에서 그러한 기능을 지원해줬다면 지금과 같이 Airflow Operator (혹은 그외 다양한 여러분의 환경)레벨에서 구현할 필요가 없었을 내용이다. 예를들어 위와 같은 {JOB\_ID} 등을 spark on kuberentes 혹은 AWS EMR 에서 처리를 해준다면 위와 같은 형태로 PodTemplate 만 간단하게 만들어서 구현 할 수 있었을 것이다. 하지만 안타깝게도 spark on kuberentes , AWS EMR 모두 이러한 기능은 제공되지 않기 때문에 환경에 맞게 적절한 우회법을 찾아서 구현해야 한다. 지금 상황의 경우도 mutatingWebhook 등을 커스텀하게 구현해서 affinity 부분을 동적으로 생성되게 구현한다면 더 우아하게 처리 될 수 있다. Spark driver 와 executor Pod 가 `생성 될 때` 에는 downward API 를 사용 할 수 없기 때문에 AWS EMR 이 EKS 클러스터에 생성을 요청한 request 를 mutatingWebhook 에서 한번 가로채서, metadata 혹은 환경변수에 설정된 driver / executor 모두 동일하게 갖고있는 고유한 값(높은 카디널리티를 가지는 id 와 같은 값들이 두 파드 모두 설정되도록 생성된다)을 executor pod 의 `labelSelector.matchLabels` 에서 사용 할 수 있게 Pod Spec 을 조작하는 방식으로 구현도 가능하다. ## 결론 결론적으로 EMR on EKS 환경에서 동일 리전 / 다른 AZ 간 통신 비용을 줄이기 위해서는 driver pod 와 executor pod 가 같은 AZ 에서 실행되면 된다. 만약 정말 고가용성이 필요하지 않다면 자신의 EKS 환경에서 하나의 AZ 를 선택해서 driver / executor pod 의 nodeSelector 에 해당 AZ 를 하드코딩해서 사용하면 쉽게 해결이 가능하다. 하지만 만약 해당 AZ 가 어떠한 이유에서든 문제가 발생한다면 이런 상황에서 자동으로 처리가 되지 않기 때문에, driver pod (job submitter pod 포함) 해서 가능한 아무 AZ 에 스케쥴링 되도록 하고, executor 만 driver pod 가 스케쥴링된 노드의 AZ 와 동일하게 스케쥴링 되도록 하고 싶다면. 1. 동적으로 PodTemplate 을 생성하는 전략 2. mutatingWebhook 을 구현해서 처리하는 전략 두가지 방법이 있고, 이 글에서는 1번을 소개했다. ### Airflow 무중단 이전 URL: https://www.kimsehwan96.com/airflow-mujungdan-ijeon/ Last updated: 2026-07-20T12:37:01.000Z ## 최근 EKS 클러스터 버전 업그레이드를, 노드별로 in-place 업그레이드 하는것이 아닌, 새로운 버전의 EKS 를 준비해서 기존버전 -> 신규버전으로 일종의 rolling update를 하는 작업을 진행하게 되었다. 이 때 여러 Addon(Istio, Argocd 등등), 애플리케이션들을 이전했는데, 특히 Airflow 를 무중단으로 이전하는 방법에 대해서 고민하고, 실제로 무중단로 이전한 과정에 대해서 공유해보고자 한다. ## Airflow 컴포넌트 Airflow 무중단 이전 작업을 설계하기 앞서서, 정확히 Airflow 의 각 컴포넌트가 어떤 역할을 하는지 파악해보았다. Airflow 는 아래와 같은 컴포넌트들로 구성되는데 1. Scheduler : 예약된 워크플로우를 트리거하고, 실행할 작업을 executor 로 제출하는 작업을 처리하는 컴포넌트 2. Webserver : DAG 와 작업의 동작을 확인하고, 실행시키거나, 디버깅 할 수 있는 인터페이스 3. Metadata database : Airflow 의 컴포넌트가 워크플로우 및 작업의 상태를 저장하는데 사용하는 데이터베이스 위 컴포넌트들은 필수적으로 필요한 컴포넌트들이고, 이 외에 triggerer 등이 추가적으로 필요 할 수 있다. 여기서 `scheduler` 는 예약된 작업들을 트리거하고, executor 가 실행 할 수 있도록 하는 중요한 역할을 하는데, 이것은 Metadata database 를 바라보며 작업을 예약하거나, queued 된 예약된 작업을 executor 에 제출하는 역할을 한다. ## 무중단 이전 설계 따라서, 기존 버전의 EKS 와, 신규 버전의 EKS 양쪽에 동일한 Airflow 구성 (Metadata database 포함)을 하고, 점진적으로 기존 버전 EKS의 Airflow 에는 executor 에 작업을 submit 하지 않도록 하면서, 신규 버전 EKS 의 Airflow 에는 executor 에 작업을 submit 하도록 하는 형태로 작업하면 될 것이라고 구상했다. ![Airflow 무중단 이전 작업의 구성도](https://www.kimsehwan96.com/content/images/2024/11/-----------2024-11-19-------1.34.10.png) 설계된 작업의 구성도 위와 같이 구성을 하기 전에, 몇가지 우려되는 점이 있었는데. 1. 동일한 구성, 동일한 Metadata database 를 바라보는 두 Airflow 가 있을 때, 한쪽 클러스터에만 워크플로우가 실행되어야 하는데, 양쪽에 동시에 실행되지 않을까? 2. 기존 버전 EKS 에 존재하는 Airflow 인스턴스들을 내릴경우, 해당 클러스터 안에 존재하는 워크플로우들이 실패하게 될까? 결론부터 이야기하면 1번, 2번 모두 그렇지 않았다. ![구버전·신버전 EKS 의 Airflow 를 병행 운영하는 이전 설계 구성도](https://www.kimsehwan96.com/content/images/2024/11/-----------2024-11-19-------1.43.36.png) 1번의 경우, Metadata database 의 테이블중 `public.task_instance` 를 보면 scheduled 된 워크플로우나, 수동으로 트리거한 워크플로우는 모두 해당 테이블에 `queued` 상태로 들어가게 되는데, 이 때 양쪽에 존재하는 스케쥴러중 하나가 `queued` 된 작업을 executor 에 제출하게 된다. 이 때 두 스케쥴러가 동시에 같은 데이터를 수정하지 않기 때문에 (Lock) 두 스케쥴러중 하나만이 작업을 가져가게 된다. 2번의 경우 실제로 테스트해본결과, Airflow 인스턴스들을 내린다고해서 기존 작업들에 SIGTERM 신호가 전달되거나 하지 않았다. ## 실제 작업 중 겪은 장애 양쪽 클러스터에 동일한 Airflow 인스턴스 (정확히는 동일한 helmchart 와 values 를 사용했다)를 세팅하고 DAG 들이 스케쥴링되고, 실행되는데 문제 없음을 확인했지만. Airflow 에서 사용할 전역 변수를 한쪽 인스턴스(웹서버)에서 수정하면, 반대편 웹서버에서는 해당 변수를 찾지 못하고, 반대로 해도 한쪽은 찾고 한쪽은 못찾는 장애가 발생하였다. 이것은 Airflow 가 데이터를 metadata database 에 저장할때, 특정 데이터들 (전역 변수)을 암호화 해서 저장하는데, Airflow 에서는 fernet key 를 사용한다. [Fernet — Airflow Documentation![](https://airflow.apache.org/docs/apache-airflow/stable/_static/pin_32.png)](https://airflow.apache.org/docs/apache-airflow/stable/security/secrets/fernet.html?ref=kimsehwan96.com) 따라서 기존 Airflow 에서 자동으로 생성된 fernet key 를 양쪽 helm value 에 동일하게 적용하고 실행하니, 완전하게 두 벌의 Airflow 가 문제없이 동작하는걸 확인 할 수 있었다. ![양쪽 Airflow 에 동일한 fernet key 를 적용한 helm values 설정](https://www.kimsehwan96.com/content/images/2025/11/-----------2024-11-19-------1.51.05.png) helm values ## 장애 인 줄 알았던 것 이렇게 두벌의 Airflow 를 구성하고, 어느 한 쪽의 Airflow webserver 를 접근해서 DAG 실행 로그를 확인 할 때, 간헐적으로 로그가 확인이 되지 않아서 설정에 문제가 있나? 장애가 발생한건가? 라는 생각을 하기도 했었다. 결론적으로(당연하게도) 접근한 Airflow 가 속해있는 쿠버네티스 클러스터에 executor 가 실행중인 DAG 의 경우는 로그가 보이지만, 그렇지 않은경우에는 로그가 보이지 않는것에 불과했다. 당연한 동작이기 때문에 Airflow 를 양쪽에 생성하고, 한쪽 Airflow 를 내리고 신규 버전의 EKS 내의 새로 생성한 Airflow 쪽에만 모든 워크플로우가 실행되도록 하고 나서 당연하게도 해당 문제는 해결되었다. ## 결론 Airflow 를 무중단으로 이전하는 방법에 대해서 검색해보고, 적절한 접근법이 있으면 반영해서 작업하려고 했지만 딱히 내가 원하는 정보가 나오지 않아서 결국 뇌피셜(?)로 설계하고, 실제로 프로덕션 작업 이전에 개발 환경에서 Airflow 를 이것저것 만져보고 문제가 없음을 확인하고 프로덕션 Airflow 까지 원활하게 이전 작업을 완료했다. 처음에는 두 벌의 Airflow 를 동시에 존재하게 하는것이 문제가 없는가? 라는 의문이 계속 들었지만, 테스트를 해보니 결국 그런 설정에도 동일한 Metadata database 를 쓰고있다면 스케쥴러가 작업을 가져갈 때 두 스케쥴러중 오직 하나만이 작업을 가져가게 되는 (DB Lock 으로 인하여)구조라는것을 확인할 수 있었다. 그럼에도 (이론적으로) 문제가 없음을 확인했지만 혹시나 중복실행되면 어떡하지? 라는 걱정에 데이터 엔지니어분들과 이야기를 나눠봤는데, DAG 라는것이 결국 멱등성이 보장되어야 하는것이고, 그렇게 구성을 했다고 해서 중복실행되는 것에 대해서도 크게 걱정을 하지 않고 작업을 했다. (물론 중복실행 되지도 않지만!) 검색해서 좋은 레퍼런스를 찾아서 해결하는것도 좋지만, 해당 시스템이 어떤 구성을 갖고있고, 추상적인 레벨에서 어떻게 동작하는지를 확인하고 실제로 테스트해보면서 작업하면 충분히 무리없이 할 수 있다는걸 느끼게 되었다. ### S3 다운로드 작업시 동기, 비동기, 멀티스레드, 멀티프로세스 성능 비교 (python) URL: https://www.kimsehwan96.com/s3-donwload-sync-async-thread-process-perfomance-comparision/ Last updated: 2026-07-20T12:37:01.000Z 최근 많은 파일들 (버킷 자체로 따지면 천만개 이상, Prefix 나 날짜별로 분리해도 수천\~수만 이상)들을 다운로드받고 그것을 tar.gz 으로 아카이브 및 압축을 해서 특정 버킷에 glacier 클래스로 밀어넣어야 하는 요구사항을 처리하게 되었다. 이 때 파일을 다운로드 받아야 아카이브 및 압축을 할 수 있기 때문에 처음에는 `aws cli` 를 통해 다운로드 받고, `tar` 명령어를 통해 아카이브해서 밀어넣는 최대한 단순한 방법을 고려해보았다. 하지만 `c7g.16xlarge` 와 같은 네트워크 성능이 최대 30Gbps(=3750 MB/s) 나오는 인스턴스로도 다운로드 되는 속도는 최대 250MB/s 에 불과했었는데. 다양한 원인이 있겠지만 1. 일단 하나의 `aws cli` 요청 (하지만 내부적으로 스레드 풀을 사용하기때문에 여러 요청을 병렬적으로 하고는 있다)이 EC2 인스턴스 내의 NIC 가 처리 가능한 네트워크 대역폭을 모두 포화시키지는 못한다. 2. gp3 스토리지를 기본값 (IOPS 3000, 처리량 125MB)으로 생성해서 연결한 경우 Disk 쓰기 속도의 제한으로 인해서 병목현상이 발생하기도 한다. [Transfer data between Amazon S3 bucket and Amazon EC2I want to improve the speed when transferring data from my Amazon Elastic Compute Cloud (Amazon EC2) instance to my Amazon Simple Storage Service (Amazon S3) bucket.![](https://repost.aws/apple-touch-icon.png)Amazon Web Services, Inc.AWS Official![](https://a0.awsstatic.com/libra-css/images/logos/aws_logo_smile_1200x630.png)](https://repost.aws/knowledge-center/s3-transfer-data-bucket-instance?ref=kimsehwan96.com) 위 글을 참고하면 S3 ↔︎ EC2 간 성능 향상을 위해서는 “병렬” 적인 작업 부하, 업로드시에는 멀티파트 업로드 설정 최적화, 그리고 가능한 경우 VPC Endpoint 를 사용하라는 등의 내용을 볼 수 있다. 여기서 일단 다른 설정들은 제외하고 “병렬”적인 작업부하와 같은 병렬적인 접근 방식이나, 혹은 비동기 처리등을 통해서 S3 로부터 EC2 까지 데이터를 받아오는데 성능을 올리는 방법을 적용해보고자 테스트 해봤다. 이 때 모든 파일은 “메모리”에 저장해서 SSD 의 쓰기 속도의 제한을 받지 않도록 작업하였다. ## 접근 방법들 우선 크게 아래와 같은 4개의 접근 방법을 고려해보았다. 1. 동기 코드 2. 비동기 모듈 기반 코드 (aioboto3, aiobotocore..) 3. 멀티스레드 코드 (concurrent.future 에서의 ThreadPoolExectutor) 4. 멀티프로세스 코드 (concurrent.future 에서의 ProcessPoolExecutor) 각각의 코드는 아래와 같다. ### 동기 코드 ### 비동기 코드 ### 멀티 스레드 코드 ### 멀티 프로세스 코드 ## 테스트 방법 `$ dd if=/dev/urandom of=50MB.file bs=1M count=50` 와 같은 형태로 여러 용량의 더미 데이터를 생성하고. `$ seq 1 1000 > object_ids` 과 같은 형태로 버킷의 키로 사용할 문자를 미리 생성한다. `$ time parallel --will-cite -a object_ids -j 10 aws s3 cp 50MB.file s3://YOUT_TEST_BUCKET/{}` 형태로 `aws s3 cp` 명령어를 병렬적으로 (동시에 10개) 수행하는 방식으로 테스트 할 데이터를 업로드한다. 레퍼런스 : [https://github.com/aws-samples/maximizing-storage-throughput-and-performance](https://github.com/aws-samples/maximizing-storage-throughput-and-performance?ref=kimsehwan96.com) 이렇게 테스트 할 데이터를 버킷에 올리고 동기, 비동기, 멀티 스레드, 멀티 프로세스 코드로 S3 로부터 파일을 다운로드 받는 테스트를 진행해보았다. ## 테스트 with profiler (viztracer) 각 코드가 실제로 어떤 흐름으로 동작하는지 이해하기 위해서 `viztracer` 라는 프로파일링 툴을 이용해서 각 코드를 테스트해보았다. ### 동기 코드 ![viztracer 로 프로파일링한 동기 다운로드 코드의 실행 흐름](https://www.kimsehwan96.com/content/images/2024/08/8e2a2459-1ec5-406d-854c-7eb103466de4.png) 동기 코드의 경우 각 파일 다운로드에 대해서 프로그램의 메인 실행 흐름에서 순차적으로 진행됨을 알 수 있다. ### 비동기 코드 ![viztracer 로 프로파일링한 비동기 다운로드 코드의 실행 흐름](https://www.kimsehwan96.com/content/images/2024/08/78fcd375-4fe0-437a-bf3d-b0cdf5f8cdaa.png) aioboto3(aiobotocore)를 사용한 코드의 프로파일링 결과다. aioboto3(aiobotocore)는 aiohttp를 래핑해서 사용하는 패키지로 S3 작업에 대해 비동기 작업을 할 수 있다. 위 스크린샷에서 메인 스레드(이벤트 루프)에서 초기에 여러 파일 다운로드에 대한 Future 객체들을 모두 등록하고 await 동작에 따라서 처리 완료된 Network I/O 작업에 대해 메인스레드에서 비동기적으로 처리하게 된다. ![비동기 다운로드에서 Future 객체 등록 흐름을 나타낸 viztracer 화면](https://www.kimsehwan96.com/content/images/2024/08/373f9fc1-f520-4bbb-8ee5-995ac22fbd82.png) 위 스크린샷은 프로그램 실행 초기에 비동기 작업들을 등록하는 작업이다. ![비동기 작업들을 실행 초기에 등록하는 단계를 나타낸 viztracer 화면](https://www.kimsehwan96.com/content/images/2024/08/4d86c960-5e24-4180-a01e-246f88a751ce.png) 이후 처리가 된 HTTP Request 결과에 대해서 메인 스레드에서 준비된 비동기 Task 로부터 데이터를 받는 부분을 확대한 스크린샷이다. 이렇게 메인 스레드에서 Network I/O 작업이 블로킹되지 않고. select / epoll / kqueue 와 같은 비동기 메커니즘을 사용해서 처리된다. 이 작업은 “단일 스레드” 에서 동작하므로 당연하게도 “하나의 CPU 코어” 에만 부하가 발생한다. ### 멀티 스레드 ![viztracer 로 프로파일링한 멀티스레드 다운로드 코드의 실행 흐름](https://www.kimsehwan96.com/content/images/2024/08/0af51855-081c-49a8-92e8-111fd3ab5737.png) 멀티스레드 (ThreadPoolExecutor) 를 사용하는 경우. 메인 스레드는 제어 흐름만 관리하고 실제 S3로부터 데이터를 다운로드 받는 작업은 worker thread 에서 병렬적으로 수행된다. 이 때 멀티코어 CPU 환경에서는 이 작업이 “여러 CPU” 에서 발생 할 수 있고. 또한 스레드간 작업 전환에는 “컨텍스트 스위칭”을 필요로 하기 때문에 특정 상황에서는 이 컨텍스트 스위칭 오버헤드에 의해서 원하는 성능이 나오지 않을 수 있다. 💡 aws cli 또한 내부적으로 `concurrent.futures.ThreadPoolExecutor` 를 사용하게 되어있고. S3 에 관한 설정 중 `max_concurrent_requests` 옵션의 경우 그 스레드 풀의 최대 스레드 개수를 제한하는 옵션이다. [https://github.com/boto/s3transfer/blob/9a168299c932077e665a618bfa5e2d5e39343745/s3transfer/futures.py#L406-L411](https://github.com/boto/s3transfer/blob/9a168299c932077e665a618bfa5e2d5e39343745/s3transfer/futures.py?ref=kimsehwan96.com#L406-L411) ### 멀티 프로세스 ![viztracer 로 프로파일링한 멀티프로세스 다운로드 코드의 실행 흐름](https://www.kimsehwan96.com/content/images/2024/08/5706c24b-2395-4330-a99c-f7ac2da0ec5a.png) 멀티프로세스 (ProcessPoolExecutor)를 사용하는 경우 스레드가 아닌 “프로세스를” 워커로서 사용하는 방법으로 CPU bound 작업 또한 성능 개선을 노려볼 수 있다. 다만 프로세스별로 별도의 메모리 공간을 가져야 하므로 메모리 사용량이 더 크다는 점도 있기 때문에, 머신의 자원이 널널한 경우에만 사용하는게 좋아보인다. ## 상황 별 테스트 테스트를 하기 위한 인스턴스로 `c7g.16xlarge` EC2 인스턴스를 사용했고. 네트워크 성능은 최대 30Gbps 인 인스턴스다. 1KB, 1MB, 50MB 파일에 대해서 각각 10개, 50개, 100개, 500개, 1000개를 받을 때 소요시간을 체크해보았고. 3GB 단일 파일에 대해서도 소요시간을 체크해보았다. ### 1KB 파일 | 방식/개수 | 10 | 50 | 100 | 500 | 1000 | | ------ | ----- | ----- | ----- | ------ | ------ | | 동기 | 0.7s | 3.1s | 5s | 30s | 57s | | 비동기 | 0.15s | 0.25s | 0.37s | 1.4s | 2.73s | | 멀티스레드 | 1.29s | 3.9s | 5.04s | 15.25s | 28.87s | | 멀티프로세스 | 0.23s | 0.25s | 0.38s | 0.66s | 6.1s | 전체적으로 동기 방식은 선형적으로 처리 시간이 늘어난다. 비동기의 경우 전체 케이스에 거쳐서 좋은 성능을 보인다. 멀티 스레드 방식의 경우 그렇게 좋은 성능을 내진 않고, 멀티프로세스 방식의 경우 특정 케이스에서는 더 낫지만 어떤 경우 아니기도 하다. (오버헤드에 의한 부분 차이로 느껴짐) ### 1MB 파일 | 방식/개수 | 10 | 50 | 100 | 500 | 1000 | | ------ | ----- | ----- | ----- | ------ | ------ | | 동기 | 0.75s | 3.3s | 6.1s | 30s | 137s | | 비동기 | 0.18s | 0.33s | 0.53s | 1.4s | 4.4s | | 멀티스레드 | 1.24s | 4.01s | 5.05s | 15.76s | 29.80s | | 멀티프로세스 | 0.24s | 0.27s | 0.39s | 0.82s | 1.34s | ### 50MB 파일 | 방식/개수 | 10 | 50 | 100 | 500 | 1000 | | ------ | ----- | ----- | ------ | ------ | ------- | | 동기 | 0.70s | 3.18s | 6.22s | 46.45s | 113.07s | | 비동기 | 1.21s | 5.01s | 10.30s | 58.33s | 116.80s | | 멀티스레드 | 1.85s | 5.97s | 6.23s | 31.71s | 60.66s | | 멀티프로세스 | 0.78s | 1.32s | 2.99s | 8.6s | 15.48s | 파일 크기가 큰 경우에. 파일 다운로드 자체 I/O 가 긴 작업이기때문에 “비동기”를 이용해 동시성으로 성능을 끌어올리려고 해도. 오히려 I/O 에 의한 오버헤드가 발생해서 동기식 코드보다 느려지는 경향이 있다. 이럴 때에는 병렬 실행이 오히려 네트워크 대역폭도 잘 쓰고 , 네트워크 I/O에 대한 처리가 더 나아진다. ### 3GB 파일 1개 동기 : 0.13s 비동기 : 35s 멀티 스레드 : 0.15s 멀티 프로세스 : 0.2s 전반적으로 작은 파일이 여러개 있는 경우에는 비동기 로직이 더 나은 성능을 보인다. 파일 개수가 많고 각 파일이 큰 경우(크다 라는것이 너무 추상적이지만 대충 수십MB 이상인 경우라고 생각 할 수 있다), 혹은 큰 파일(수 GB 이상의 파일) 하나를 받아오는데에는 비동기 로직이 성능이 나빠진다. 아래는 각 테스트 결과에 대한 그래프로 나타낸 자료이다. 높이가 낮을수록 성능이 좋은것 (소요 시간이 짧은 것)이다. ![1KB 파일 다운로드 방식별 소요 시간 비교 그래프 (비동기가 가장 빠름)](https://www.kimsehwan96.com/content/images/2024/08/sync_r.png) 1KB 파일의 경우 일관적으로 비동기 코드가 가장 효율적 ![1MB 파일 다운로드 방식별 소요 시간 비교 그래프 (비동기가 효율적)](https://www.kimsehwan96.com/content/images/2024/08/thread_r.png) 1MB 파일의 경우도 비동기 코드가 효율적 ![50MB 파일 다운로드 방식별 소요 시간 비교 그래프 (멀티스레드·프로세스가 우세)](https://www.kimsehwan96.com/content/images/2024/08/process_r.png) 50MB 파일의 경우 오히려 동기로직보다 비동기 코드가 비효율적. 멀티스레드,프로세스가 더 나은 성능을 보임 ## 결론 위 테스트 결과를 통해 동기, 비동기, 멀티스레드, 멀티프로세스 어느 하나 “모든”상황에 좋은 성능을 내는 접근 법은 **없다**라는 결론을 얻었다. **다만 작은 파일이 여러개 있다 → 비동기가 유리하다** **파일이 각각이 크기가 크다 → 멀티스레드/프로세스가 유리하다.** **그리고 그것과 별개로 S3 Prefix 구조를 잘 짜놨다 → 병렬 실행해서 성능을 끌어 올릴 수 있다.** **멀티스레드/멀티프로세스가 성능이 향상되는 케이스가 있지만. 그 대가로 자원을 더 많이 사용하게 된다 (여러 CPU 코어에 한 실행시간을 더 쓰게 됨)** 위와 같은 인사이트를 얻을 수 있었다. 결국 모든것이 trade-off 라고 볼 수 있고, 상황에 맞게 끔 적절한 접근 방식을 취해야 하고 결국 어떤 상황에서 퍼포먼스가 가장 나은지를 실제 테스트해보고 결정해야 한다. ## 번외 [GitHub - peak/s5cmd: Parallel S3 and local filesystem execution tool.Parallel S3 and local filesystem execution tool. Contribute to peak/s5cmd development by creating an account on GitHub.![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubpeak![](https://opengraph.githubassets.com/78885e41a12987f28a7070d63d6eb1101d3ad9df2c9c882ec5fd1707bb499c53/peak/s5cmd)](https://github.com/peak/s5cmd?ref=kimsehwan96.com) s5cmd 라는 툴은 Go 로 작성되어있고 동시성과 병렬성을 모두 가져가서 40Gbps(\~4.3GB/s) 정도의 대역폭도 포화 시킬 수 있는 성능을 갖고있다고 한다. Disk 의 I/O 성능이 충분하거나, 그 외 여러 상황에서 직접 코드로 구현하는것보다 나은 성능을 얻을 수 있다. 테스트를 위해 `c7g.16xlarge` 인스턴스에 Ramdisk 를 80GB 할당하고 테스트해보았다. 램디스크의 읽기/쓰기 성능은 약 5.83GB/s 이므로 s5cmd 로 네트워크를 통해 데이터를 받고 디스크에 쓰더라도 병목현상이 크게 생기지 않을 것으로 판단했다. (fio 명령어를 통해 ramdisk 성능을 EC2 인스턴스 내에서 측정한 결과) 50MB 파일 1000개 (총 50GB)를 다운로드 받는것을 아래와 같은 명령어로 테스트해보았다. `$ time s5cmd cp s3://MY_TEST_BUCKET/test/* /mnt/ramdisk` 15초 정도가 소요되었다. 50GB 를 15초만에 다운로드 받았다는것은 네트워크 성능이 약 27.31Gbps 정도였다는것을 의미하고 별다른 설정 없이 `s5cm5d` 명령어 하나만으로 인스턴스(`c7.16xlarge`)의 최대 네트워크 성능정도를 포화시킬 수 있었다. 파이썬 멀티 프로세스 코드를 통해서 똑같이 테스트를 해보았다. (다만 기존 코드와 다르게 램디스크에 동기적으로 파일을 쓰도록 수정해서 테스트 하였다) 이때는 15.48초 정도 소요되었다. `s5cmd` 와 거의 비슷한 성능을 얻을 수 있었다. `s5cmd` 는 동시성 + 병렬성을 모두 반영했다고 하고, 위에서 본 멀티 프로세스 코드 또한 가용한 모든 CPU 를 사용해서 병렬적으로 수행했고. `s5cmd` 및 멀티프로세스 코드 모두 인스턴스의 최대 네트워크 성능에 가깝게 대역폭을 포화 시킬 수 있었다고 추측된다. `s5cmd` 는 큰 노력 없이 가용한 자원을 모두 사용해서 S3 를 이용 할 수 있는것으로 판단된다. 다만 주의할 점은 네트워크 성능과 별개로 파일시스템에 파일을 쓰는 경우 Disk I/O 성능(IOPS) 을 고려해야 할 것이다. ### Istio Sidecar 설정을 통한 Pod 의 egress 제어 URL: https://www.kimsehwan96.com/istio-sidecar-egress-control/ Last updated: 2026-07-20T12:37:01.000Z 💡 Istio 의 Sidecar 설정을 통해 각 워크로드에 붙어있는 사이드카 프록시의 구성 설정을 변경 할 수 있습니다. 이 포스트에서는 이 설정을 통해 특정 서비스가, 지정된 서비스만 호출 할 수 있도록 제어하는 방법을 소개합니다. ## Network Policy 기본적으로 쿠버네티스에서 파드 - 파드 , 파드 - 서비스 등의 모든 통신은 기본적으로 허용이 되어있다. 이 때 Kubernetes 에서 네이티브하게 지원하는 `Network Policy` 로 아래와 같은 네트워크 정책을 잡아 파드-파드 등의 호출을 불가능하도록 막을 수 있다. 1. 다른 **파드** 의 요청을 허용할지 여부 (파드 자체의 트래픽, 즉 파드 안의 컨테이너간의 요청을 제어 할 수는 없음) 2. 다른 네임스페이스에 존재하는 워크로드로부터의 요청을 허용할지 여부 3. IP 블록 기반의 허용 여부 (이 설정과 관계없이 파드가 실행중인 노드와의 트래픽은 항상 허용됨) 아래는 Network Policy 오브젝트의 예시이다. ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: test-network-policy namespace: default spec: podSelector: matchLabels: role: db policyTypes: - Ingress - Egress ingress: - from: - ipBlock: cidr: 172.17.0.0/16 except: - 172.17.1.0/24 - namespaceSelector: matchLabels: project: myproject - podSelector: matchLabels: role: frontend ports: - protocol: TCP port: 6379 egress: - to: - ipBlock: cidr: 10.0.0.0/24 ports: - protocol: TCP port: 5978 ``` ingress 와 egress 로 들어가고 나가는 트래픽에 대해서 파드를 선택하거나, 네임스페이스를 선택하거나, ip 대역을 지정해서 허용 여부를 결정 할 수 있다. 다만 Network Policy 로는 다음과 같은 것을 할 수 없다. 1. 내부 클러스터 트래픽이 특정 게이트웨이를 통과하도록 강제 (서비스 메시나 기타 프록시를 사용하는것을 권장) 2. TLS 와 관련된 모든것 (서비스 메시나 Ingress Controller 사용 권장) 3. 노드 별 정책 4. 서비스 네임(도메인 네임)을 타게팅한 정책 5. 모든 네임스페이스 또는 파드에 반영되는 "기본" 정책 기본적으로 쿠버네티스 환경에서 내부 서비스간 호출은 "서비스" 오브젝트의 도메인을 호출해서 사용하는것이 보편적이다. (e.g `example-service.example.svc.cluster.local`) 하지만 위에서 알 수 있듯이 `서비스 네임` 기반의 네트워크 트래픽 정책을 `Network Policy` 로는 구성 할 수 없다 이 때 `Istio` 를 쓰고있다면 이런 구성을 할 수 있다 ## Istio Sidecar 예를들어서 A 애플리케이션은 오직 B 데이터베이스와, C 서비스(애플리케이션)과만 통신을 하면 되고, 그 외에 것들과는 통신을 할 필요가 없다고 했을 때. Istio Sidecar 를 별도로 설정하지 않은 경우 B 데이터베이스, C서비스를 포함해서 그 외 D,E,F,G,…. 등의 같은 쿠버네티스 클러스터 안에 있는 다른 서비스들을 호출 할 수 있는 상태가 된다. 따라서 Sidecar 설정을 통해서 Egress 트래픽을 제한함으로서 특정 서비스가 허용된 서비스만 호출 할 수 있도록 강제 할 수 있다. ### 어떻게 설정 할 수 있나요? 일단 Istio 의 MeshConfig 부터 손을 봐야하는데. [Global Mesh Options](https://istio.io/latest/docs/reference/config/istio.mesh.v1alpha1/?ref=kimsehwan96.com#MeshConfig-OutboundTrafficPolicy-Mode) Istio 의 서비스 메시 글로벌 옵션인 `outboundTrafficPolicy` 가 기본적으로는 `ALLOW_ANY`로 되어있다. 이것은 대상 포트에 대한 서비스 또는 서비스 엔트리가 없는 경우 무조건적으로 아웃바운드 트래픽을 허용한다는 의미이다. 쉽게 생각해서 내부 서비스를 포함해서 외부 엔드포인트(e.g [www.google.com](http://www.google.com/?ref=kimsehwan96.com))에 대해서 모두 다 아웃바운드 트래픽을 허용한다는 의미이다. `REGISTRY_ONLY` 옵션의 경우 아웃바운트 트래픽으로 서비스 엔트리로 관리하고있거나, 서비스 레지스트리에 등록된 서비스만 아웃바운드 트래픽을 허용한다는 의미이다. 여기서 Sidecar 옵션을 활용하기 위해서는 `outboundTrafficPolicy` 를 `REGISTRY_ONLY` 로 수정하고 진행해야 한다. 💡 Service Registry : Istio 가 인지하고있는 트래픽을 라우팅 할 수 있는 서비스 오브젝트(혹은 가상 서비스), 서비스 엔트리등을 의미한다. 💡 Service Entry : 서비스 엔트리는 Istio 내부 서비스 레지스트리에 Mesh 외부 서비스를 추가해서 서비스 메시 안에서 서비스 엔트리로 수동으로 추가한 서비스에 대해서 Istio 가 트래픽을 라우팅 할 수 있도록 하는 개념이라고 생각 할 수 있다. 예를들어 outboundTrafficPolicy가 ALLOW\_ANY 인 경우 Istio (혹은 쿠버네티스)가 관리하지 않는 서비스 도메인인 경우 Istio 서비스 레지스트리에는 없지만, 어쩄든 outbound traffic 은 허용되는 상태로 트래픽이 넘어 갈 수 있다. 반면 REGISTRY\_ONLY 인 경우 서비스 엔트리로 수동으로 추가하지 않는 경우 트래픽이 라우팅 되지 않는다. (Istio 혹은 쿠버네티스 내부에서 관리하는 서비스 도메인, 서비스 등이 아닌경우) ```yaml meshConfig: outboundTrafficPolicy: mode: REGISTRY_ONLY ``` 위와 같이 Istio 의 meshConfig 의 `outboundTrafficPolicy` mode 를 수정한다. (디폴트는 `ALLOW_ANY`) ```yaml apiVersion: v1 kind: Namespace metadata: name: red labels: istio-injection: enabled --- apiVersion: v1 kind: Namespace metadata: name: blue labels: istio-injection: enabled --- apiVersion: v1 kind: Namespace metadata: name: green labels: istio-injection: enabled --- apiVersion: v1 kind: Pod metadata: name: nginx-red-1 namespace: red labels: name: nginx-red-1 app: nginx-red-1 spec: containers: - name: nginx image: nginx:latest --- apiVersion: v1 kind: Pod metadata: name: nginx-blue-1 namespace: blue labels: name: nginx-blue-1 app: nginx-blue-1 spec: containers: - name: nginx image: nginx:latest --- apiVersion: v1 kind: Pod metadata: name: nginx-green-1 namespace: green labels: name: nginx-green-1 app: nginx-green-1 spec: containers: - name: nginx image: nginx:latest ``` 테스트를위해 red, green, blue 라는 세 네임스페이스에, nginx 파드를 각각 띄우도록 위 yaml 을 반영한다. 이 때 sidecar 는 모두 inject 되도록 Namespace 레벨에서 `istio-injection: enabled` 레이블이 붙어있다. ```yaml apiVersion: v1 kind: Service metadata: name: nginx-red-svc namespace: red spec: selector: name: nginx-red-1 ports: - port: 80 targetPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-blue-svc namespace: blue spec: selector: app: nginx-blue-1 ports: - port: 80 targetPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-green-svc namespace: green spec: selector: app: nginx-green-1 ports: - port: 80 targetPort: 80 ``` 이후 각 파드에 대한 서비스 오브젝트를 생성한다. 이 상태에서 `red` 네임스페이스의 파드에서 `green` 네임스페이스의 파드로, `red` 네임스페이스 파드에서 `blue` 네임스페이스의 파드로 HTTP 요청을 아래와 같이 해본다. ``` $ kubectl exec -it -n red nginx-red-1 -- curl -XGET -v nginx-green-svc.green.svc.cluster.local $ kubectl exec -it -n red nginx-red-1 -- curl -XGET -v nginx-blue-svc.blue.svc.cluster.local ``` `Istio Sidecar` 리소스를 생성해서 egress 를 강제하지 않았기 때문에 요청에 대한 응답이 잘 들어오게 된다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: Sidecar metadata: name: red-sidecar namespace: red spec: egress: - hosts: - "istio-system/*" ``` 이 때 `red` 네임스페이스에 대해서 `egress` 항목에 `hosts` 를 `istio-system/*` 로 지정하여 Sidecar 리소스를 생성해보자. 이것은 `istio-system` 을 제외한 나머지 네임스페이스에 존재하는 서비스 로 트래픽을 outbound 하지 않도록 하겠다는 설정을 의미한다. 💡 이때 `hosts` 내의 항목은 `namespace/dnsName` 포멧으로 작성한다. 위 케이스는 `istio-system` 네임스페이스의 모든 호스트에 대한 egress 를 허용한다는 의미이다. 이후 ``` $ kubectl exec -it -n red nginx-red-1 -- curl -XGET -v nginx-green-svc.green.svc.cluster.local $ kubectl exec -it -n red nginx-red-1 -- curl -XGET -v nginx-blue-svc.blue.svc.cluster.local ``` 을 통해 `red` 네임스페이스에서 `green` , `blue` 네임스페이스의 서비스를 호출 해보면 502 BadGateway 가 뜨면서 트래픽이 넘어가지 않는 것을 확인 할 수 있다. ![Sidecar 로 egress 를 제한해 다른 네임스페이스 호출 시 502 Bad Gateway 가 발생한 화면](https://www.kimsehwan96.com/content/images/2024/07/-----------2024-07-16------5.04.51.png) 502 Bad Gateway 응답 ```yaml apiVersion: networking.istio.io/v1alpha3 kind: Sidecar metadata: name: red-sidecar namespace: red spec: egress: - hosts: - "istio-system/*" - "green/*" - "blue/*" ``` 위와 같이 사이드카 오브젝트를 수정하고 반영한뒤 요청하면, 이 때는 응답이 잘 들어온다. ### REGISTRY\_ONLY 로 meshConfig 내 설정을 변경했더니 외부 도메인에 대한 트래픽 처리가 안되요! 위와 같이 Sidecar 리소스를 설정하고, outboundTrafficPolicy.mode 가 REGISTRY\_ONLY 인 경우 egress 에 등록된 호스트(서비스) 외에는 트래픽이 넘어가지 않게 된다. 만약 `red` nginx 앱에서 `www.google.com` 을 호출하고 싶다면 아래와 같이 설정해야한다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: Sidecar metadata: name: red-sidecar namespace: red spec: egress: - hosts: - "istio-system/*" - "green/*" # green 네임스페이스로 넘어가는 outbound 트래픽 허용 - "blue/*" # blue 네임스페이스로 넘어가는 outbound 트래픽 허용 - "red/*" # 자기 자신의 네임스페이스에 대한 모든 호스트 허용 -> service entry 추가 항목 반영 (ServiceEntry 를 red 네임스페이스에 생성했기 때문에) --- apiVersion: networking.istio.io/v1alpha3 kind: ServiceEntry metadata: name: svc-entry namespace: red spec: hosts: - www.google.com ports: - number: 443 name: https protocol: TLS - number: 80 name: http protocol: HTTP location: MESH_EXTERNAL resolution: DNS ``` 위와 같이 `ServiceEntry` 오브젝트를 생성하고, Sidecar 오브젝트 또한 자기 자신의 네임스페이스를 지정하면 `www.google.com` 에 대한 egress 트래픽도 허용되게 된다. 💡 위 케이스에서 `ServiceEntry` 오브젝트에 대한 네임스페이스가 `red` 이기 때문에 Sidecar 에서도 `red/*` 을 지정했다. 만약 해당 오브젝트가 다른 네임스페이스에 있다면 다른 네임스페이스를 지정하면 된다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: ServiceEntry metadata: name: google namespace: entries spec: hosts: - www.google.com ports: - number: 443 name: https protocol: TLS - number: 80 name: http protocol: HTTP location: MESH_EXTERNAL resolution: DNS ``` 위와 같이 `entries` 라는 네임스페이스에 `ServiceEntry` 를 생성했다면. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: Sidecar ... spec: egress: - hosts: - "istio-system/*" - "entries/www.google.com" ``` 과 같은 형태로 네임스페이스/FQDN 으로 지정해서 `www.google.com` 에 대한 egress 트래픽을 허용 할 수 있다. ### kubelet "서버" 를 위한 인증서 (kubelet.crt, kubelet.key) (feat: Metrics-Server TLS 인증서 오류 조치) URL: https://www.kimsehwan96.com/kubelet-server-certificates/ Last updated: 2026-07-20T12:37:01.000Z ## Kubelet 은 kube-apiserver 를 호출하는 그저 클라이언트 아니였나요? kubelet 은 컨트롤플레인 노드, 워커 노드 관계없이 쿠버네티스의 노드라면 무조건 실행되어야 하는 컴포넌트로, 컨테이너 런타임과 통신하여 컨테이너를 실행하고 관리하는데 이는 static pod 뿐 아니라 API 서버로부터 받은 PodSpec 에 기반해서 컨테이너를 실행하기도 합니다. 이 kubelet 은 기본적으로 kube-apiserver 를 호출하여 노드의 상태를 컨트롤 플레인에 알려주는 역할을 하는데. 이 때 Kubelet 이 kube-apiserver 에 인증하기 위해서는 특별한 인증 모드인 `Node Authorizer` 라는 모드의 인증을 사용합니다. ([Using Node Authorization](https://kubernetes.io/docs/reference/access-authn-authz/node/?ref=kimsehwan96.com) ) 이 때 사용하는 x.509(TLS) 인증서는 CN 에 `system:node:` 형태로 각 kubelet 이 떠있는 노드의 이름이 각 인증서별로 존재합니다. 이렇게 kubelet 이 kube-apiserver 를 호출하고, 인증처리를 하기 위해 사용하는 x.509 인증서는 `/var/lib/kubelet/pki` 디렉터리 내에 `kubelet-client-current.pem (심볼릭 링크)` 형태로 존재합니다. kubelet.conf 등의 kubelet 이 사용할 kubeconfig 파일을 확인해보시면 아시겠지만, kubeconfig 파일은 해당 심볼릭 링크를 인증서로 바라보도록 되어있고, 실제 인증서는 `kubelet-client-2024-05-27-17-07-20.pem` 과 같은 형태로 datetime 이 뒤쪽에 붙는 형태로 되어있습니다. 이건 실제로 kubelet 이 이 인증서를 필요시에 `rotate` 하기 위함이고. kubeconfig 는 심볼릭 링크만 바라보도록 해서 실제 심볼릭 링크만을 인증서가 업데이트 되었을 때 바라보는 경로를 업데이트 하여서 kubeconfig 파일의 수정이 필요하지 않도록 설계가 되었습니다. `kubelet` 은 기본적으로 로 10250/10255 번 포트로 `서버` 를 띄우고 Listen 을 하도록 되어있습니다. 이 kubelet 서버의 엔드포인트 정보는 아래와 같습니다. [kubernetes/pkg/kubelet/server/server.go at 68091805a53c6fdd42553f8ccdbf932ab263fbdd · kubernetes/kubernetesProduction-Grade Container Scheduling and Management - kubernetes/kubernetes![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes![](https://opengraph.githubassets.com/2c7f09fb9cc0374c1464d331c33d06e0da4728ae268e757636d928b494c3700b/kubernetes/kubernetes)](https://github.com/kubernetes/kubernetes/blob/68091805a53c6fdd42553f8ccdbf932ab263fbdd/pkg/kubelet/server/server.go?ref=kimsehwan96.com#L94-L104) 이 엔드포인트를 호출하는 대표적인 케이스가 `metrics-server` 입니다. metrics-server 는 K8s 내의 Pod 의 메트릭을 실시간으로 수집해서 kube-apiserver 에 전달하게되는데요, 이것을 통해 HPA(Horizontal Pod Autocaling), VPA(Vertical Pod Autoscaling) 등을 사용가능하게 됩니다. `metrics-server` 는 각 노드에 있는 kubelet 의 10250 번 포트로 메트릭을 수집하기위해 kubelet 서버에 HTTP Request 를 하게 됩니다. kubelet 은 kube-apiserver 에 대한 클라이언트이자, 파드를 관리하는 오퍼레이터이자, metrics 및 기타 기능을 노출하는 서버로서 동작을 하는 셈입니다. ## Kubelet 의 서버 인증서 (x.509) [kubernetes/cmd/kubelet/app/server.go at 4bb434501d9ee5edda6faf52a9d6d32a969ae183 · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/4bb434501d9ee5edda6faf52a9d6d32a969ae183/cmd/kubelet/app/server.go?ref=kimsehwan96.com#L1100-L1181) kubelet 서버에 대한 TLS 인증서/키를 따로 지정하지 않았을 경우 self-signed certs 를 생성해서 Kubelet 의 서버(10250 번 포트)를 TLS 를 enable 한 서버로 실행합니다. `/var/lib/kubelet/pki` 디렉터리 내의 `kubelet.crt` , `kubelet.key` 는 kubelet self-signed 키, 인증서 입니다. `/var/lib/kubelet/pki` 에는 두가지 종류의 인증서가 들어가게 되는데요. `kubelet-client-xxx.pem` 은 `kubelet` → `kube-apiserver` 인증시 `Node Authorizer` 인증 모드를 사용해 (이건 x509 인증서 내의 CN이 system:nodes: 형태로 서명된 인증서를 사용하는거) apiserver 에 인증하기 위한 인증서입니다. `kubelet.crt` 는 `kubelet` 서버가 TLS 서버를 오픈하기 위한 인증서입니다. 이 글에서는 후자를 다루겠습니다. ![/var/lib/kubelet/pki 디렉터리 구조를 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/06/086bd862-66cc-4231-a05e-c6b31e2141b2.png) `/var/lib/kubelet/pki` 디렉터리의 구조 앞서 이야기 한 것 처럼 `kubelet-client-current.pem` 의 경우 `kubelet -> kube-apiserver` 인증을 위한 인증서입니다. `$ openssl x509 -text -noout -in kubelet-client-current.pem` 을 해보면 ``` Certificate: Data: Version: 3 (0x2) Serial Number: 2796500698555846452 (0x26cf2a919f8d8f34) Signature Algorithm: sha256WithRSAEncryption Issuer: CN = kubernetes Validity Not Before: May 27 08:02:17 2024 GMT Not After : May 27 08:07:19 2025 GMT Subject: O = system:nodes, CN = system:node:node1 Subject Public Key Info: Public Key Algorithm: rsaEncryption Public-Key: (2048 bit) Modulus: 00:b6:9d:8f:31:03:12:47:b6:b9:83:62:ef:96:ba: ... Exponent: 65537 (0x10001) X509v3 extensions: X509v3 Key Usage: critical Digital Signature, Key Encipherment X509v3 Extended Key Usage: TLS Web Client Authentication X509v3 Basic Constraints: critical CA:FALSE X509v3 Authority Key Identifier: 8F:3C:69:8E:62:01:95:BF:FB:D8:8B:41:4A:D7:B8:CF:0F:9C:36:9F Signature Algorithm: sha256WithRSAEncryption Signature Value: 9d:38:83:5b:78:2c:56:53:90:02:cb:e3:1e:b4:b0:d3:61:07: ... ``` `kubelet-client-current.pem` 위와 같이 `CN` 이 `system:node:` 인 `node-authorizer` 인증을 위한 인증서임을 알 수 있습니다. 그러면 kubelet의 TLS 서버를 위한 `kubelet.crt` 는 어떨까요? `$ openssl x509 -text -noout -in kubelet.crt` 를 해보면 ```` Certificate: Data: Version: 3 (0x2) Serial Number: 8001362879639973410 (0x6f0a8d255fefee22) Signature Algorithm: sha256WithRSAEncryption Issuer: CN = node1-ca@1716797154 Validity Not Before: May 27 07:05:54 2024 GMT Not After : May 27 07:05:54 2025 GMT Subject: CN = node1@1716797154 Subject Public Key Info: Public Key Algorithm: rsaEncryption Public-Key: (2048 bit) Modulus: 00:c3:19:c5:5e:71:3f:3c:0e:8b:dc:56:85:2e:7a: ... Exponent: 65537 (0x10001) X509v3 extensions: X509v3 Key Usage: critical Digital Signature, Key Encipherment X509v3 Extended Key Usage: TLS Web Server Authentication X509v3 Basic Constraints: critical CA:FALSE X509v3 Authority Key Identifier: CC:87:5A:94:20:C4:F3:11:C6:06:90:A7:A4:C1:B9:1E:FE:51:CE:9F X509v3 Subject Alternative Name: DNS:node1 Signature Algorithm: sha256WithRSAEncryption Signature Value: 60:7d:e4:4e:23:2d:50:92:1d:78:51:22:43:ab:38:75:82:93: ... ``` kubelet.crt `Issuer` 의 Common Name을 보면 알겠지만 self-sigend 인증서임을 알 수 있습니다. 이 인증서는 `kubelet` 을 실행 할 때 kubelet configuration에 `tlsPrivateKeyFile` , `tlsCertFile` 을 지정하지 않은 경우 `자동` 으로 생성됩니다. [https://kubernetes.io/docs/reference/config-api/kubelet-config.v1beta1/#kubelet-config-k8s-io-v1beta1-KubeletConfiguration](https://kubernetes.io/docs/reference/config-api/kubelet-config.v1beta1/?ref=kimsehwan96.com#kubelet-config-k8s-io-v1beta1-KubeletConfiguration) 참고. ![kubelet 서버 인증서 관련 KubeletConfiguration 공식 문서 화면](https://www.kimsehwan96.com/content/images/2024/06/8f71cfad-36e0-4c5a-85ac-cc0303bf3ccb.png) 만약 `/var/lib/pki/kubelet.crt` , `/var/lib/pki/kubelet.key` 를 삭제하고 kubelet 을 재실행 하더라도 다시 생성하게 됩니다. 대부분의 케이스에서 이 동작이 크게 문제 될일은 없지만. `metrics-server` 의 경우 `kubelet` 서버의 (10250) 엔드포인트를 찔러서 데이터를 수집하는데, 이 때 10250 포트로 열린 kubelet 의 인증서가 self-signed 인증서 이기 때문에 metrics server 를 설치 한 경우 아래와 같은 에러가 발생하게 될것입니다. ![self-signed kubelet 인증서로 인한 metrics-server 오류 메시지](https://www.kimsehwan96.com/content/images/2024/06/43104c64-50c4-497e-a2da-6c74f63b8b7c.png) `"Failed to scrape node" err="Get \"https://192.168.207.7:10250/metrics/resource\\": x509: cannot validate certificate for 192.168.207.7 because it doesn't contain any IP SANs" node="node5"` kubelet TLS 서버를 띄우기 위한 kubelet self-signed 인증서 내에 SANs 필드에 DNS 정보는 있지만, 해당 kubelet이 떠있는 노드에 대한 IP 정보가 없기 때문에 발생하는 문제입니다. 위쪽에서 `kubelet.crt` 인증서 내용을 확인해본 내용을 다시 보면. `DNS:node1` 과 같은 정보는 있지만, IP 정보는 없기 때문에 그렇습니다. 이 때 보통 찾아보면 나오는 방법이. `metrics-server` 내에 `--kubelet-insecure-tls` 를 실행인자로 주는 방법이 있습니다. 하지만 더 올바른 접근법은 아래와 같습니다. ## kubelet 의 TLS 인증서를 kube-apiserver 로 부터 받아오기 (TLS Bootstrap) 이것은 kubelet 실행 인자, 혹은 kubelet configuration 에서 `serverTLSBootstrap: true` 을 추가해서 사용 할 수 있는 방법입니다. ![kubelet configuration 에 serverTLSBootstrap 인자를 추가한 설정 화면](https://www.kimsehwan96.com/content/images/2024/06/1637e004-0492-4e41-9670-1f49c56f30f5.png) serverTLSBootstrap 인자를 kubelet configuration 에 추가한 모습 kube-apiserver / controller-manager 에게 TLS 인증서를 요청하는 방법이 있습니다. 그것을 TLSBootstrap 이라고 부릅니다. 우선 기존 kubelet configuration 에 serverTLSBootstrap: true 를 추가합니다. `/etc/kubernetes/kubelet-config.yaml` 에 보통 `KubeletConfiguration` 이 설정되어있을것입니다. 수정 한 이후 `$ systemctl stop kubelet` `$ systemctl restart kubelet` `$ kubectl get csr -A` 해당 명령어들을 수행해보면 아래와 같을것입니다. ![kubelet 재시작 후 kubectl get csr 로 서명 요청을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/06/8f5ff4cc-c44f-4c3a-a8a2-8408d2c04fc0.png) 위와 같이 `system:node:` 으로부터 `kubernetes.io/kubelet-serving` 형태의 서명 요청이 생겼습니다. 여기서 `$ kubectl certificate approve csr-bvbnx` 와 같이 해당 서명 요청에대해서 `approve` 해봅니다. ![kubelet-serving CSR 을 kubectl 로 approve 하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/06/9eb678b1-351c-48d7-acf7-23a72febd791.png) 이렇게 하면 이제 `kubelet-server-current.pem` 이라는 인증서가 보이게 됩니다. (`kubelet.crt` , `kubelet.key` 는 삭제해도 됩니다.) 이제 해당 인증서로 해당 노드의 `kubelet` 은 10250 번 포트로 TLS 서버를 오픈합니다. `$ openssl x509 -text -noout -in kubelet-server-current.pem` 을 확인해보면 ``` Certificate: Data: Version: 3 (0x2) Serial Number: 21:24:58:0f:29:01:0d:ab:45:c4:10:a1:3b:d3:fc:7d Signature Algorithm: sha256WithRSAEncryption Issuer: CN = kubernetes Validity Not Before: May 27 09:19:33 2024 GMT Not After : May 27 09:19:33 2025 GMT Subject: O = system:nodes, CN = system:node:node1 Subject Public Key Info: Public Key Algorithm: id-ecPublicKey Public-Key: (256 bit) pub: 04:e1:30:d0:21:06:29:4b:0d:5e:4e:45:04:9f:d1: ... ASN1 OID: prime256v1 NIST CURVE: P-256 X509v3 extensions: X509v3 Key Usage: critical Digital Signature X509v3 Extended Key Usage: TLS Web Server Authentication X509v3 Basic Constraints: critical CA:FALSE X509v3 Authority Key Identifier: 8F:3C:69:8E:62:01:95:BF:FB:D8:8B:41:4A:D7:B8:CF:0F:9C:36:9F X509v3 Subject Alternative Name: DNS:node1, IP Address:192.168.207.3 Signature Algorithm: sha256WithRSAEncryption Signature Value: ca:a1:b9:79:7a:8e:9e:83:89:ad:01:4b:d7:34:ea:f2:6d:13: ... ``` kubelet-server-current.pem 위와 같이 Issuer 의 CN 이 kubernetes 임을 알 수 있습니다. 이건 우리가 쿠버네티스 클러스터를 생성하고 인증서를 만들 때, 자체적으로 사용하는 CA 를 의미합니다( `/etc/kubernetes/ssl/{ca.crt,ca.key}` ). 쿠버네티스 내의 각종 인증서의 CA 는 이것입니다. (etcd 및 front-proxy 는 별도의 CA를 두는 편입니다) 또한 기존에 self-signed 인증서에는 없었던 IP Address 항목이 SAN 필드에 들어가있는것을 확인 할 수 있습니다. 이로써 metrics-server 가 해당 `https://해당IP:10250` 로 접근 했을 때 클라이언트 측에서 문제가 생길 것은 없어졌습니다. (기존에는 `https://해당IP:10250` 으로 접근하지만, 정작 인증서 정보에는 `DNS` 정보만 있어서 문제가 생겼습니다.) 이제 이상태에서 metrics-server 를 `insecure` 옵션 없이 써보면 오류 없이 metrics-server 가 잘 kubelet 의 메트릭을 수집하는 것을 확인 할 수 있습니다. 여기서 부가적으로 얻을 수 있는 장점은. kubelet 의 rotateCerts 기능에 이제 current-server-pem 이 들어오게 되는것입니다. (self-signed 인증서는 kubelet 의 해당 기능이 통제하지 않음. 관련한 다양한 이슈 또한 존재. kubeadm 도 그것을 처리하지 않음) ## 이렇게 수동으로 approve 해야 하나요? 노드가 계속 늘어나면 어떻게하죠? 우리가 방금 한 방식은 TLS Bootstrap 요청, CSR 요청에 대해서 수동으로 approve 를 하는 방식입니다. 쿠버네티스는 이런 인증서 서명 요청에 대해 자동화 하기 위해 `Approver` 라는 개념을 두고있습니다. 우리가 컨트롤플레인/워커노드 join 을 할때, 바닐라 혹은 kubeadm 을 써보면 `token` 을 만들어서 join 하는 방법을 해본적이 있을것입니다. 이것은 `token` 을 기반으로 인증된 요청에 대해서 TLS Bootstrap 요청을(CSR) 자동으로 수락하는 Approver 를 설정한 것입니다. `kube-controller-manager` 에서 `controllers` 로 default 로 추가되어있지 않아서 (정확히는 Default 로 disable 되어있어서) 추가해줘야 합니다. kubeadm 으로 클러스터를 프로비저닝 했다면 자동으로 되어있는 사항입니다. ([kube-controller-manager](https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/?ref=kimsehwan96.com) ) ![kube-controller-manager 의 --controllers 실행 인자를 확인하는 화면](https://www.kimsehwan96.com/content/images/2024/06/dcde787f-79f6-42f0-8ea5-0e342d32991c.png) kube-controller-manager 의 실행 인자중 --controllers 💡 `bootstrapsigner`, `tokencleaner` 는 디폴트로 활성화되는 컨트롤러들이 아니기 때문에 추가가 되는것입니다. 이렇게 클러스터 조인시 사용되는 TLS Bootstrap 요청에 대해서 승인 할 수 있는 컨트롤러는 사전에 구현되어있지만. `kubelet` 의 **Server TLS 인증서 요청에 대한 자동 승인 컨트롤러는 kube-controller-manager 에 구현되어있지는 않습니다**. 그래서 과거에는 [GitHub - kontena/kubelet-rubber-stamp: Simple CSR approver for KubernetesSimple CSR approver for Kubernetes. Contribute to kontena/kubelet-rubber-stamp development by creating an account on GitHub.![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkontena![](https://opengraph.githubassets.com/a0aeea0bf74f735f57031d5d14756af05155e12afdfaadd7d214fff7839c4b09/kontena/kubelet-rubber-stamp)](https://github.com/kontena/kubelet-rubber-stamp?ref=kimsehwan96.com) kubelet-rubber-stamp `kubelet-rubber-stamp` 라는 것이 사용되었었지만, 현재 유지보수 되고있는 프로젝트가 아니고, 또한 몇가지 보안 허점이 있는것으로 알려져있습니다. [GitHub - postfinance/kubelet-csr-approver: Kubernetes controller to enable automatic kubelet CSR validation after a series of (configurable) security checksKubernetes controller to enable automatic kubelet CSR validation after a series of (configurable) security checks - postfinance/kubelet-csr-approver![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubpostfinance![](https://opengraph.githubassets.com/ec4ddc87096e73766d33a0346d5843ace02a1ce1af19e35eeffb3955e3ab99c9/postfinance/kubelet-csr-approver)](https://github.com/postfinance/kubelet-csr-approver?ref=kimsehwan96.com) kubelet-csr-approver 이것을 위한 대안으로 `kubelet-csr-approver` 라는 것이 있고 kubespray 또한 kubelet 의 serverTLSBootstrap 옵션을 활성화 하면 이것을 함께 배포하는것으로 개선되었습니다. ## 그럼 kubespray 에서 저친구 어떻게 쓸 수 있어요? 우선 전역변수에 `kubelet_rotate_server_certificates: true` 를 넣어줘야 하고 ![kubespray 의 ansible host.yaml 에 전역변수를 추가한 설정 화면](https://www.kimsehwan96.com/content/images/2024/06/689e80a1-36c9-4c90-a009-fa42f7520970.png) 이렇게 ansible host.yaml 파일에 전역변수로 넣어주면 된다. [kubespray/roles/kubernetes-apps/kubelet-csr-approver/defaults/main.yml at 5616a4a3eedf3462d25346f02afe57bf36ae77c3 · kubernetes-sigs/kubesprayDeploy a Production Ready Kubernetes Cluster. Contribute to kubernetes-sigs/kubespray development by creating an account on GitHub.![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes-sigs![](https://opengraph.githubassets.com/18a8e2cb2b9ed2962e7ff190feb3fd78171247bfd3301bbc77d9a359877b869c/kubernetes-sigs/kubespray)](https://github.com/kubernetes-sigs/kubespray/blob/5616a4a3eedf3462d25346f02afe57bf36ae77c3/roles/kubernetes-apps/kubelet-csr-approver/defaults/main.yml?ref=kimsehwan96.com#L12) 여기의 `kubelet-csr-approver` 의 변수를 설정하는 defaults/main.yml 내에 `kubelet_csr_approver_values` 를 자신의 상황에 맞게 적절하게 채워줘야합니다. 현재 테스트하는 환경의 경우 모든 노드는 `node1`, `node2` 와 같이 `node` 라는 이름을 prefix 로 갖게하고, 모든 노드는 `192.168.207.0/24` CIDR 블록 안에서 프로비저닝 했었기 때문에 아래와 같이 설정했습니다. ![kubelet-csr-approver 설정 예시를 상황에 맞게 수정한 화면](https://www.kimsehwan96.com/content/images/2024/06/4d0096f8-47a0-48c8-a6a0-8d5407a80c03.png) 여러분에 상황에 맞게 수정해야하고 .. 자세한 내용은 kubelet-csr-approver 레포 참고 이렇게 하면 우리가 수동으로 approve 하지 않고 kubelet-csr-appover 가 설정에 맞는 요청에 대해서 자동으로 approve 하도록 사용 할 수 있습니다. ## Kubespray 안쓰면 어떻게 써요? [GitHub - postfinance/kubelet-csr-approver: Kubernetes controller to enable automatic kubelet CSR validation after a series of (configurable) security checksKubernetes controller to enable automatic kubelet CSR validation after a series of (configurable) security checks - postfinance/kubelet-csr-approver![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubpostfinance![](https://opengraph.githubassets.com/ec4ddc87096e73766d33a0346d5843ace02a1ce1af19e35eeffb3955e3ab99c9/postfinance/kubelet-csr-approver)](https://github.com/postfinance/kubelet-csr-approver?ref=kimsehwan96.com) kubelet-csr-approver 는 helm 으로 패키징되어있기 때문에 해당 레포를 참조해서 helm 으로 설치하는것을 권장합니다. ``` helm repo add kubelet-csr-approver https://postfinance.github.io/kubelet-csr-approver helm install kubelet-csr-approver kubelet-csr-approver/kubelet-csr-approver -n kube-system \ --set providerRegex='^node-\w*\.int\.company\.ch$' \ --set providerIpPrefixes='192.168.8.0/22' \ --set maxExpirationSeconds='86400' --set bypassDnsResolution='false' ``` 여기서도 역시나 위에서 이야기한대로 `providerRegex` , `providerIpPrefixes` 등을 여러분의 환경에 맞게 설정하면 됩니다. ## Wrapping up Kubelet 은 10250 번 포트에 TLS 기반의 HTTP 서버(HTTPS)를 열어두고 있습니다. Kubelet 은 kube-apiserver 를 호출하고, 컨테이너 런타임 인터페이스를 통해 파드(컨테이너)를 관리하는것 이외에도 metrics, log 등을 위해 10250번 포트로 서버를 열어두고 있습니다. 이때 별다른 설정을 하지 않고 kubelet 을 실행하면 `kubelet.crt` , `kubelet.key` 와 같은 형태의 `self-signed` 인증서가 `/var/lib/kubelet/pki` 디렉터리 내에 생성되고, 그것을 기반으로 TLS 서버를 실행합니다. 크게 문제될일은 없지만 `metrics-server` 를 실행하는 경우에 `self-signed` 인증서의 문제점으로 인해 `metrics-server` 가 kubelet metrics 를 긁어올 수 없는 문제가 있습니다. 이 때 보통은 `--kubelet-insecure-tls` 옵션을 `metrics-server` 에 추가하여 문제를 해결하기도 하지만 근본적인 문제를 해결한것은 아닙니다. `kubelet` 이 kubernetes-ca 를 통해 서명된 인증서를 kubelet 서버에 사용하기 위해서는 `serverTLSBootstrap` 옵션을 `KubeletConfiguration` (혹은 kubelet 실행 인자에도 넣을 수 있지만 depracted 되어가고있습니다.)에 추가할 수 있습니다. 다만 이 때에도 해당 CSR(Certificate Signing Request)를 자동으로 수락하는 별도의 애드온등을 사용하지 않는 경우에 수동으로 해당 요청에 대해서 `approve` 해줘야 합니다. ### kubeadm 은 CoreDNS 를 어떻게 설치할까요? 파드는 서비스 도메인을 어떻게 처리할까요? 그리고 설정은 어떻게 할까요? (feat: NodeLocal DNSCache) URL: https://www.kimsehwan96.com/core-dns-with-kubeadm/ Last updated: 2026-07-20T12:37:01.000Z 💡 CoreDNS 는 쿠버네티스 “서비스 디스커버리”를 위한 핵심 애드온입니다. kubeadm 에서는 CoreDNS , kube-proxy 두가지의 애드온을 설치합니다. 만약 바닐라 쿠버네티스 클러스터를 프로비저닝 하고 있다면 이러한 애드온은 수동으로 설치해야 합니다. ## 들어가며 Kubeadm 의 경우 CoreDNS 및 kube-proxy 를 addon 으로 부르며, 클러스터 프로비저닝 시점에서 자동으로 설치합니다. Kubeadm 을 wrapping 한 Kubespray 또한 마찬가지입니다. 만약 Kubeadm 과 같은 프로비저닝 툴을 사용하지 않고 클러스터를 프로비저닝 했다면 CNI 설치, CoreDNS , kube-proxy의 설치같은 작업은 관리자가 직접 해야합니다. 바닐라 쿠버네티스 프로비저닝 툴 작업을 진행하면서 Kubeadm 은 과연 어떻게 CoreDNS 를 설치하고, 또 어떻게 `10.96.0.10` 등의 ClusterIP 를 고정해서 사용하게 되는지 궁금하여 Kubeadm 의 코드를 확인해가면서 그 동작을 확인해보았습니다. 그 이후 과연 파드들은 어떻게 nameserver 설정을 디폴트로 갖고 오게 되는지 확인해보고, PodSpec 내의 dnsPolicy, dnsConfig 등의 내용을 확인해보았습니다. 마지막으로는 NodeLocal DNSCache 라는 각 노드별로 설치되는 DNS 캐싱 에이전트에 대해서 설명하고, 설치하는 방법에 대해서 소개합니다. Kubespary로 클러스터를 프로비저닝 하게 되면 NodeLocal DNSCache 는 디폴트로 설치하게 됩니다. 하지만 Kubeadm 으로 클러스터를 프로비저닝하면 수동으로 설치해야합니다. ## CoreDNS CoreDNS 는 Go 로 작성된 DNS 서버입니다. 다양한 환경에서 사용 가능한 DNS 서버로 보통은 쿠버네티스 클러스터에 설치되어서 클러스터내에서 도메인 네임 resolving 을 위해 사용됩니다.(cluster.local) ## Kubernetes Pod 의 쿠버네티스 서비스 디스커버리를 위한 nameserver 설정 쿠버네티스 파드를 띄우면 모든 파드에는 `/etc/resolv.conf` 파일이 있고. 이 파일에는 `nameserver` 정보가 들어있습니다. kubeadm 으로 프로비저닝 한 클러스터 내의 파드의 `/etc/resolv.conf` 를 한번 확인해보겠습니다. `$ kubectl run -i -t --rm nginx --image=nginx --restart=Never – sh` ![파드 내부에서 cat /etc/resolv.conf 를 실행한 결과 터미널 출력](https://www.kimsehwan96.com/content/images/2024/05/b06e9bd7-762e-43ea-af6c-56db32718b9a.png) `$ cat /etc/resolv.conf` 를 수행한 결과 💡 search 는 어떤 Domain 에 대한 요청에 대해서 Domain Name Resolve 가 실패하는경우 해당 도메인 요청에 뒷부분에 default.svc.cluster.local , svc.cluster.local, cluster.local 등을 순서대로 추가하려고 하는 동작이다. 예를들어서 `nginx-0` 에 대한 요청이 들어오면 `nginx-0.default.svc.cluster.local` 에 대해서 resolve 되는지 확인하고, 실패하면 `nginx-0.svc.cluster.local` 로 시도하고 하는 식이다. 확인해보면 `nameserver 10.96.0.10` 이라는 값이 들어가있는 것을 볼 수 있습니다. 이 네임서버는 어디에 있는 것일까요? 클러스터 내에 설치된 `coreDNS` 의 서비스 오브젝트를 확인해보겠습니다. `$ kubectl get svc -n kube-system` ![kube-system 에서 kube-dns 서비스 오브젝트를 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/05/0ce2fbea-b354-4739-a6f1-07218895d2f4.png) `kube-dns`라는 이름의 서비스 오브젝트를 확인 할 수 있습니다. ❓ coreDNS 를 설치했지만 서비스 오브젝트 이름이 kube-dns 인 이유는 [kubernetes/cmd/kubeadm/app/phases/addons/dns/manifests.go at 5732a8bbb44113f7fba40721f0f2e03051999da0 · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/5732a8bbb44113f7fba40721f0f2e03051999da0/cmd/kubeadm/app/phases/addons/dns/manifests.go?ref=kimsehwan96.com#L19-L26) manifests 자체에 coreDNS 이지만 서비스 오브젝트의 이름을 kube-dns 로 생성해서 그렇습니다. 처음에는 쿠버네티스 팀이 놓친것이라고 생각했지만 코드를 잘 보니, node-local-dns-cache 등이 뒷단이 coreDNS 든 kube-dns 든 상관없이 지원하기 위해서라고 쿠버네티스 DNS 서비스 이름은 그냥 kube-dns 로 두려고 한것이 아닐까라고 생각했는데, 실제로 하위호환성을 위해 그렇게 두었다고 합니다. (참고 : ) 위 서비스 오브젝트의 `ClusterIP` 를 확인해보면 아까 `nameserver 10.96.0.10` 에서 본것과 동일하다는 것을 알 수 있습니다. 그럼 이 파드별로 들어가게 될 `/etc/resolv.conf` 는 어떻게 지정이 되었고, kubeadm 을 사용하면서 별도로 지정한적도 없는 coreDNS(혹은 kube-dns)의 ClusterIP 는 어떻게 10.96.0.10 으로 고정이 되었을까요? ## Kubelet 의 clusterDNS 설정 각 파드별로 생성될 `/etc/resolv.conf` 의 정보는 `kubelet configuration` 에서의 `clusterDNS` 항목을 통해 설정이 됩니다. `kubelet config` 파일을 확인해보면 ![kubelet 설정의 clusterDNS 에 10.96.0.10 이 지정된 것을 확인하는 화면](https://www.kimsehwan96.com/content/images/2024/05/cd6c15ff-0c72-4261-b50d-6c46d6370918.png) clusterDNS 에 10.96.0.10 주소가 들어있다. `clusterDNS` 항목에 클러스터 Domain Name 을 처리하기 위한 nameserver 리스트를 넣을 수 있도록 되어있음을 확인 할 수 있습니다. ([Customizing DNS Service](https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/?ref=kimsehwan96.com) ) 기본적으로 node 의 kubelet 의 `clusterDNS` 항목을 상속하지만, 파드별로 별도로 수정해야 할 필요가 있다면 podSpec 내의 `dnsPolicy` 및 `dnsConfig` 를 이용해 변경 가능합니다. [](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/?ref=kimsehwan96.com) [DNS for Services and Pods](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/?ref=kimsehwan96.com) Pod 의 DNS Policy 는 파드별로 설정이 가능합니다. 여기에 설정 가능한 값으로는 `Default`, `ClusterFirst` , `ClusterFirstWithHostNet` , `None` 등이 설정 가능합니다. 기본적으로 명시하지 않고 파드를 생성하면 `ClusterFirst` 로 설정됩니다. `ClusterFirst` 로 지정하면 `cluster.local` 과 같은 suffix 가 아닌 `www.google.com` 과 같은 DNS 쿼리는 DNS 서버의 업스트림 네임서버로 넘어가도록 하는, 기본적으로 CoreDNS(kube-dns) 를 사용하는 옵션입니다. `Default` 로 지정하면 해당 파드가 떠있는 노드의 `/etc/resolv.conf` 설정을 그대로 사용합니다. 따라서 이렇게 지정하고 `cluster.local` suffix 를 가진 DNS 쿼리를 하게 되었을 때, **노드* 에 적절한 네임서버가 설정되지 않은경우 처리되지 않을 수 있습니다.* `ClusterFirstWithHostNet` 은 파드의 `hostNetwork: true` 설정을 했을 때 사용해야하며, 만약 `hostNetwork: true` 설정을 했지만 , `ClusterFirst` 로 설정하거나, 명시적으로 설정하지 않으면 `Default` 설정으로 fallback 하게 됩니다. `None` 의 경우는 해당 파드가 떠있는 쿠버네티스 환경에서 상속받는 모든 DNS 설정을 무시하고, `dnsConfig` 를 통해 DNS 설정을 커스텀 할 때 사용합니다. 만약 Pod 내의 DNS 설정을 직접 하고 싶다면, dnsConfig 를 통해 namserver , searches, options 등을 지정 할 수 있습니다. ```yaml apiVersion: v1 kind: Pod metadata: name: dnsutils namespace: hayden spec: containers: - name: dnsutils image: registry.k8s.io/e2e-test-images/jessie-dnsutils:1.3 command: - sleep - "infinity" imagePullPolicy: IfNotPresent restartPolicy: Always dnsConfig: nameservers: - 10.96.0.10 - 123.123.123.123 - 8.8.8.8 searches: - consul.local ``` 위와 같이 dnsConfig 를 설정하고 실제 생성된 Pod 내에서 `/etc/resolv.conf` 를 확인해보면 ![dnsConfig 를 적용한 파드의 /etc/resolv.conf 를 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/05/04801d32-c259-42d4-8c81-7c0809b0fb33.png) ## CoreDNS(kube-dns)의 IP 주소는 어떻게 결정되었을까요? `kubeadm` 에서는 [kubernetes/cmd/kubeadm/app/phases/addons/dns/manifests.go at 1d5589e4910ed859a69b3e57c25cbbd3439cd65f · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/1d5589e4910ed859a69b3e57c25cbbd3439cd65f/cmd/kubeadm/app/phases/addons/dns/manifests.go?ref=kimsehwan96.com#L38) 여기서 `.DNSIP` 라는 변수를 통해서 받아서 CoreDNS 의 ClusterIP 주소를 지정하는데요. [kubernetes/cmd/kubeadm/app/constants/constants.go at 5732a8bbb44113f7fba40721f0f2e03051999da0 · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/5732a8bbb44113f7fba40721f0f2e03051999da0/cmd/kubeadm/app/constants/constants.go?ref=kimsehwan96.com#L635-L650) 위 코드부분을 보면 알겠지만, Service Object 의 CIDR 블록에서 10번째의 IP 주소를 사용하도록 하고있습니다. 그래서 kubeadm 으로 클러스터를 프로비저닝하면 DNS 를 위한 서비스 오브젝트의 ClusterIP 주소는 10.96.0.10 과 같이 10번째 IP 주소로 고정이 되는것입니다. 이것을 kubelet 의 clusterDNS 항목에도, CoreDNS 의 ClusterIP 항목에도 넣어서 일치시켜주는것입니다. kubeadm 을 사용하지 않고 쿠버네티스 클러스터를 프로비저닝 한 경우(Vanilia) 위와 같은 형태로 사전에 CoreDNS(혹은 kube-dns) 서비스에 대한 ClusterIP 를 미리 계획하고, Kubelet 의 `clusterDNS` 항목에 설정해줘야 별다른 설정을 하지 않은 파드들이 올바르게 서비스 도메인에 대한 DNS 쿼리 Resolve 를 수행 할 수 있습니다. ## Kubeadm 은 어떻게 CoreDNS 를 설치하나요? `$ kubeadm init` 으로 컨트롤 플레인 노드를 최초로 생성하는 시점에서 [https://github.com/kubernetes/kubernetes/blob/bce55b94cdc3a4592749aa919c591fa7df7453eb/cmd/kubeadm/app/cmd/init.go#L106-L192](https://github.com/kubernetes/kubernetes/blob/bce55b94cdc3a4592749aa919c591fa7df7453eb/cmd/kubeadm/app/cmd/init.go?ref=kimsehwan96.com#L106-L192) 혹은 `$ kubeadm init phase addon` 명령을 수행하는 시점에서 ([https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-init-phase/#cmd-phase-addon](https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-init-phase/?ref=kimsehwan96.com#cmd-phase-addon)) kubeadm workflow runner 에 아래 코드와 같은 명령어를 등록하게 됩니다. [https://github.com/kubernetes/kubernetes/blob/1d5589e4910ed859a69b3e57c25cbbd3439cd65f/cmd/kubeadm/app/cmd/phases/init/addons.go#L49-L85](https://github.com/kubernetes/kubernetes/blob/1d5589e4910ed859a69b3e57c25cbbd3439cd65f/cmd/kubeadm/app/cmd/phases/init/addons.go?ref=kimsehwan96.com#L49-L85) 여기서 `runCoreDNSAddon` 은 아래 코드와 같은것을 수행하고 [https://github.com/kubernetes/kubernetes/blob/bce55b94cdc3a4592749aa919c591fa7df7453eb/cmd/kubeadm/app/cmd/phases/init/addons.go#L108-L114](https://github.com/kubernetes/kubernetes/blob/bce55b94cdc3a4592749aa919c591fa7df7453eb/cmd/kubeadm/app/cmd/phases/init/addons.go?ref=kimsehwan96.com#L108-L114) 여기에서의 EnsureDNSAddon는 아래쪽 코드에 정의되어있고 [https://github.com/kubernetes/kubernetes/blob/bce55b94cdc3a4592749aa919c591fa7df7453eb/cmd/kubeadm/app/phases/addons/dns/dns.go#L90-L170](https://github.com/kubernetes/kubernetes/blob/bce55b94cdc3a4592749aa919c591fa7df7453eb/cmd/kubeadm/app/phases/addons/dns/dns.go?ref=kimsehwan96.com#L90-L170) 전반적으로 사용하는 템플릿 manifests 는 아래에 정의되어있습니다. [https://github.com/kubernetes/kubernetes/blob/1d5589e4910ed859a69b3e57c25cbbd3439cd65f/cmd/kubeadm/app/phases/addons/dns/manifests.go](https://github.com/kubernetes/kubernetes/blob/1d5589e4910ed859a69b3e57c25cbbd3439cd65f/cmd/kubeadm/app/phases/addons/dns/manifests.go?ref=kimsehwan96.com) 쉽게 추상화해보면, `kubeadm init` 을 수행하면 여러 `phases` 를 수행하는데, 그 중 `addon phase` 가 있고, 별도로 `kubeadm init phase addon` 으로 해당 `phase` 만 수행이 가능하기도 합니다. `addon phase` 에서는 사전에 정의된 manifests 를 템플리팅하여서 kubeadm 설정에 맞게 manifests를 렌더링하고 그것을 쿠버네티스 클러스터에 반영하는 형태로 `CoreDNS`를 설치하고있습니다. (kube-proxy 도 마찬가지입니다.) ## NodeLocal DNSCache [Using NodeLocal DNSCache in Kubernetes Clusters](https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/?ref=kimsehwan96.com) `kubeadm` 으로 프로비저닝 한 클러스터는 CoreDNS 정도만 설치하는 반면, `kubeadm` 을 wrapping 한 `kubespray` 의 경우 기본적으로 `NodeLocal DNSCache` 를 설치하도록 되어있습니다. `NodeLocal DNSCache` 는 각 노드별로 DNS 캐싱 에이전트를 `DaemonSet` 으로 실행하여 클러스터의 DNS 성능을 향상시키기 위해 사용합니다. 쿠버네티스에서 내부 파드간 통신은 대부분 서비스 오브젝트를 통해서 하게 되는데, 이 때 DNS Query 과정은 `Pod` 가 로컬의 `/etc/resolv.conf` 설정을 보고 네임서버에 DNS Query 를 하게 되고. 해당 설정에는 보통 `CoreDNS` 에 대한 서비스 오브젝트 ClusterIP 가 지정되어있습니다. 이 `ClusterIP` 에 대해서 요청하게되면 내부적으로는 `kube-proxy` 가 추가한 `iptables rule` 에 의해 실제 CoreDNS(kube-dns) 엔드포인트로 변환되는 과정을 거치게 됩니다. 만약 각 노드별로 쿠버네티스 클러스터 도메인에 대한 DNS 정보를 캐싱한 데몬셋이 있는 경우 이 iptables 의 DNAT 규칙, connection tracking 을 피하고 단순히 각 노드의 로컬에 있는 DNS 캐시 서버에 질의를 하는 방식으로 단순화 되고, 만약 각 로컬에 있는 DNS 캐싱 에이전트가 Cache Miss 되는 경우에만 CoreDNS(kube-dns)에 캐싱 에이전트가 대신 DNS 정보를 업데이트 하는 식으로 구조가 간단하게 바뀝니다. ![NodeLocal DNSCache 의 DNS 질의 흐름을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/05/8af9a114-c83e-4547-9eef-e03950ac9ec9.png) 보통 `NodeLocal DNSCache` 는 클러스터의 서비스 오브젝트 IP 대역과 충돌하지 않는 대역의 IP 를 기반으로 사용되고, 권장되는 대역은 `169.254.0.0/16` 입니다. 보통은 `169.254.xx.10` 를 주로 사용합니다. ## NodeLocal DNSCache 설치 방법 [Using NodeLocal DNSCache in Kubernetes Clusters](https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/?ref=kimsehwan96.com) 만약 기존 클러스터에 `NodeLocal DNSCache` 를 설치하고 싶다면 아래와 같은 방식으로 구성해야 합니다. 우선 [kubernetes/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml at master · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml?ref=kimsehwan96.com) 주소의 yaml 파일을 내려받습니다. ```yaml # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # apiVersion: v1 kind: ServiceAccount metadata: name: node-local-dns namespace: kube-system labels: kubernetes.io/cluster-service: "true" addonmanager.kubernetes.io/mode: Reconcile --- apiVersion: v1 kind: Service metadata: name: kube-dns-upstream namespace: kube-system labels: k8s-app: kube-dns kubernetes.io/cluster-service: "true" addonmanager.kubernetes.io/mode: Reconcile kubernetes.io/name: "KubeDNSUpstream" spec: ports: - name: dns port: 53 protocol: UDP targetPort: 53 - name: dns-tcp port: 53 protocol: TCP targetPort: 53 selector: k8s-app: kube-dns --- apiVersion: v1 kind: ConfigMap metadata: name: node-local-dns namespace: kube-system labels: addonmanager.kubernetes.io/mode: Reconcile data: Corefile: | __PILLAR__DNS__DOMAIN__:53 { errors cache { success 9984 30 denial 9984 5 } reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__CLUSTER__DNS__ { force_tcp } prometheus :9253 health __PILLAR__LOCAL__DNS__:8080 } in-addr.arpa:53 { errors cache 30 reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__CLUSTER__DNS__ { force_tcp } prometheus :9253 } ip6.arpa:53 { errors cache 30 reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__CLUSTER__DNS__ { force_tcp } prometheus :9253 } .:53 { errors cache 30 reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__UPSTREAM__SERVERS__ prometheus :9253 } --- apiVersion: apps/v1 kind: DaemonSet metadata: name: node-local-dns namespace: kube-system labels: k8s-app: node-local-dns kubernetes.io/cluster-service: "true" addonmanager.kubernetes.io/mode: Reconcile spec: updateStrategy: rollingUpdate: maxUnavailable: 10% selector: matchLabels: k8s-app: node-local-dns template: metadata: labels: k8s-app: node-local-dns annotations: prometheus.io/port: "9253" prometheus.io/scrape: "true" spec: priorityClassName: system-node-critical serviceAccountName: node-local-dns hostNetwork: true dnsPolicy: Default # Don't use cluster DNS. tolerations: - key: "CriticalAddonsOnly" operator: "Exists" - effect: "NoExecute" operator: "Exists" - effect: "NoSchedule" operator: "Exists" containers: - name: node-cache image: registry.k8s.io/dns/k8s-dns-node-cache:1.23.0 resources: requests: cpu: 25m memory: 5Mi args: [ "-localip", "__PILLAR__LOCAL__DNS__,__PILLAR__DNS__SERVER__", "-conf", "/etc/Corefile", "-upstreamsvc", "kube-dns-upstream" ] securityContext: capabilities: add: - NET_ADMIN ports: - containerPort: 53 name: dns protocol: UDP - containerPort: 53 name: dns-tcp protocol: TCP - containerPort: 9253 name: metrics protocol: TCP livenessProbe: httpGet: host: __PILLAR__LOCAL__DNS__ path: /health port: 8080 initialDelaySeconds: 60 timeoutSeconds: 5 volumeMounts: - mountPath: /run/xtables.lock name: xtables-lock readOnly: false - name: config-volume mountPath: /etc/coredns - name: kube-dns-config mountPath: /etc/kube-dns volumes: - name: xtables-lock hostPath: path: /run/xtables.lock type: FileOrCreate - name: kube-dns-config configMap: name: kube-dns optional: true - name: config-volume configMap: name: node-local-dns items: - key: Corefile path: Corefile.base --- # A headless service is a service with a service IP but instead of load-balancing it will return the IPs of our associated Pods. # We use this to expose metrics to Prometheus. apiVersion: v1 kind: Service metadata: annotations: prometheus.io/port: "9253" prometheus.io/scrape: "true" labels: k8s-app: node-local-dns name: node-local-dns namespace: kube-system spec: clusterIP: None ports: - name: metrics port: 9253 targetPort: 9253 selector: k8s-app: node-local-dns ``` 파일 명은 `nodelocaldns.yaml` 로 합니다. (아래쪽의 sed 명령어의 타겟 파일명으로 사용합니다) ```sh kubedns=`kubectl get svc kube-dns -n kube-system -o jsonpath={.spec.clusterIP}` domain= localdns= ``` 위와 같은 명령어를 수행해서 올바른 값을 넣습니다. 대부분의 경우 kubedns=10.96.0.10 (서비스 오브젝트의 CIDR 대역에서의 10번째 값) domain=cluster.local localdns=169.254.25.10 으로 설정하면 됩니다. 만약 `kube-proxy` 가 IPTABLES 모드로 실행되고있다면 ```sh sed -i "s/__PILLAR__LOCAL__DNS__/$localdns/g; s/__PILLAR__DNS__DOMAIN__/$domain/g; s/__PILLAR__DNS__SERVER__/$kubedns/g" nodelocaldns.yaml ``` IPVS 모드로 실행되고 있다면 ```sh sed -i "s/__PILLAR__LOCAL__DNS__/$localdns/g; s/__PILLAR__DNS__DOMAIN__/$domain/g; s/,__PILLAR__DNS__SERVER__//g; s/__PILLAR__CLUSTER__DNS__/$kubedns/g" nodelocaldns.yaml ``` ⚠️ MacOS 를 사용중이라면 `$ brew install gnu-sed` 를 설치하고. sed 명령어대신 gsed 로 위 명령어를 수행합니다. 위 명령어들은 ConfigMap 을 수정하기 위한 명령어입니다. ```yaml apiVersion: v1 kind: ConfigMap metadata: name: node-local-dns namespace: kube-system labels: addonmanager.kubernetes.io/mode: Reconcile data: Corefile: | __PILLAR__DNS__DOMAIN__:53 { errors cache { success 9984 30 denial 9984 5 } reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__CLUSTER__DNS__ { force_tcp } prometheus :9253 health __PILLAR__LOCAL__DNS__:8080 } in-addr.arpa:53 { errors cache 30 reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__CLUSTER__DNS__ { force_tcp } prometheus :9253 } ip6.arpa:53 { errors cache 30 reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__CLUSTER__DNS__ { force_tcp } prometheus :9253 } .:53 { errors cache 30 reload loop bind __PILLAR__LOCAL__DNS__ __PILLAR__DNS__SERVER__ forward . __PILLAR__UPSTREAM__SERVERS__ prometheus :9253 } ``` 여기서 `__PILLAR__LOCAL__DNS__` 는 각 노드에서 이 DNS Cache 에이전트가 어떤 IP 로 바인딩 될지를 결정하는 부분이고. 우리는 여기서 `169.254.25.10` 으로 대치하게 됩니다. `__PILLAR__DNS__SERVER__` 의 경우 CoreDNS(kube-dns) 의 ClusterIP 로 대치됩니다. `__PILLAR__CLUSTER__DNS__` 및 `__PILLAR__UPSTREAM__SERVERS__` 는 node-local-dns-cache 데몬셋 파드 내부에서 동적으로 대치하는 영역인데, `__PILLAR__CLUSTER__DNS__` 는 이 yaml 파일을 사용해서 생성하는 `kube-dns-upstream` 서비스 오브젝트의 ClusterIP 입니다. 여기서 유의해야하는점은 `kube-dns-upstream` 서비스 오브젝트의 레이블 셀렉터로 `k8s-app=kube-dns` 로 되어있기 때문에 CoreDNS 를 수동으로 설치한 경우 레이블에 `k8s-app=kube-dns` 를 추가해줘야합니다. (만약 CoreDNS 를 helm 으로 설치했다면 values 에 `k8sAppLabelOverride: "kube-dns"` 라는 값을 사용하면 됩니다) `__PILLAR__UPSTREAM__SERVERS__` 의 경우 `__PILLAR__LOCAL__DNS__` 값과 CoreDNS(kube-dns) ClusterIP 값으로 평가되어서 들어가게 됩니다. s**ed 명령어를 사용하지 않고 직접 수정할 경우** 아래 값들을 직접 대치하면 됩니다. `__PILLAR__DNS__DOMAIN__` : cluster.local 등의 클러스터 도메인 `__PILLAR__LOCAL__DNS__` : 169.254.25.10 등의 node local dns cache 데몬셋 파드가 Listen 할 IP 지정 `__PILLAR__DNS__SERVER__` : CoreDNS(kube-dns) 의 ClusterIP 이렇게 해서 `node-local-dns` 라는 이름의 데몬셋이 성공적으로 배포되고 나면, 실제 파드들이 `node-local-dns` 파드로 DNS 쿼리를 하도록 변경해야 합니다. `KubeletConfiguration` 의 `clusterDNS` 정보를 업데이트하고, kubelet 재시작을 함으로서 새로 생성되는 파드들에 대해서 `node-local-dns` 에 클러스터 도메인에 대한 DNS 쿼리를 하도록 설정 할 수 있습니다. ## Wrapping up 쿠버네티스의 Pod 가 어떻게 클러스터 도메인에 대한 DNS Query Resolve 를 하는지 알아보면서, CoreDNS 에 대해서 알아보고, kubeadm 은 그것을 어떻게 설치하는지, 그리고 각 파드별 DNS 설정은 어떻게 하는지, 더 나아가서 NodeLocal DNSCache 까지 다양하게 다루게 되었습니다. kubadm / kubespray 등의 프로비저닝 툴은 기본적으로 kube-proxy, CoreDNS 등의 애드온을 알아서 설치하고 처리해줍니다. 하지만 Vanilia Kubernetes 클러스터를 구축하는 경우에는 그런것들을 모두 직접 설정해줘야 합니다. kubeadm 에서 CoreDNS 를 어떻게 설치하고, CoreDNS 의 ClusterIP 는 어떻게 설정된것인지 알아보면서 왜 `10.96.0.10` 과 같은 IP 주소가 설정되었는지 알 수 있었고. 더 나아가서 kubelet 과도 긴밀히 연계되어있음을 알 수 있었습니다. ### kubelet 이 원하는 커널 파라미터 URL: https://www.kimsehwan96.com/kubelet-expected-kernel-parameters/ Last updated: 2026-07-20T12:37:01.000Z 💡 이 글에서는 Packer 를 통해 가상머신에서 구동할 K8s 클러스터에 대한 골든 이미지를 만드는 작업, 그리고 실제 클러스터를 프로비저닝 하고 테스트 하는 과정에서 겪은 kubelet과 관련된 문제를 다룹니다. 최근 `packer` 를 이용하여 `golden image` 를 생성해서 Kubernetes 클러스터를 생성하는 작업을, 별다른 프로비저닝 툴의 도움 없이 (kubeadm 조차도 쓰지 않고)진행하고 있습니다. ![Packer 골든 이미지 명세를 작성한 HCL 파일 일부](https://www.kimsehwan96.com/content/images/2024/05/2bd5f17c-fc76-450f-b4cb-07d38ef192de.png) packer 에서 사용할 hcl 파일의 일부. 생각보다 간단하게 이미지에 대한 명세 작성이 가능했다! 이 작업을 하는 이유는 `Virtual Machine` 들로 이루어진 쿠버네티스 클러스터를 프로비저닝/운영 하기 위함이고. 전반적인 모습은 아마도 `EKS` 와 매우 비슷한 모습일겁니다. `EKS` 는 각 쿠버네티스 컴포넌트(바이너리)가 사전에 설치된 쿠버네티스용 `ami` 기반으로 가상머신(ec2)를 생성하고, 그것들을 클러스터에 join 시키며, 컨트롤 플레인은 AWS가 managed 로 처리해주는 컨셉입니다. 여기서 `Virtual Machine` 기반의 쿠버네티스 클러스터를 만들어보고 테스트하는 과정에서, **가상머신을 한번 껐다가 켰더니** `kubelet` 이 아래와 같은 에러 로그를 발생시키고 있었습니다. 💡 현재 테스트하는 환경은 M1 Mac(ARM64) + VMWare Fusion 을 사용한 가상머신(ARM64) 환경입니다. 💡 kubelet 은 `systemd` 로 보통 실행되기때문에, 로그를 보기 위해서는 `journalctl -xeu kubelet -f` 와 같은 명령어를 수행하면 됩니다. ![커널 파라미터 불일치로 kubelet 실행이 실패한 journalctl 로그](https://www.kimsehwan96.com/content/images/2024/05/a4550ac5-5724-46c5-8775-612eaa69caac.png) kubelet이 예상하는 커널 파라미터 값과 달라서 kubelet 실행이 실패한다. ($ journalctl -xeu kubelet -f) May 02 03:05:49 control1 kubelet\[1295\]: E0502 03:05:49.409568 1295 kubelet.go:1542\] "Failed to start ContainerManager" err="\[invalid kernel flag: kernel/panic, expected value: 10, actual value: 0, invalid kernel flag: vm/overcommit\_memory, expected value: 1, actual value: 0\]" 위 에러 로그를 해석해보자면 `kubelet` 이 컨테이너 매니저를 실행시키는데 실패했고, 그 이유는 `kernal/panic` 커널 파라미터와 `vm/overcommit_memory` 커널 파라미터가 `kubelet` 이 “예상” 한 값과 다르기 때문에 `kubelet` 의 실행이 실패한것입니다. 우선 잘 알려진 해결 방법은 `kubelet` 을 실행 할 때 `--protect-kernel-defaults=false` 실행 인자를 추가해주는 방법이 소개되고 있습니다. 사실 `--protect-kernel-defaults=true` 가 기본값이기 때문에 위 에러 로그가 발생한것이라고 다시 해석이 가능한데요. 이 실행 인자는 `kubelet` 이 권장/원하는 커널 파라미터와 실제 커널 파라미터 설정값이 차이가 있을 경우 `kubelet` 이 오버라이드 할것이냐 말것이냐를 지정하는 옵션으로. 말 그대로 커널 파라미터 설정값을 보호 하겠냐, 보호하지 않겠냐의 의미로 해석하시면 됩니다. 따라서 `--protect-kernel-defaults=false` 인자를 `kubelet` 에 실행 인자로 주는 경우 커널 파라미터가 변경되면서 잘 동작하게 됩니다. ## kubelet 의 실행 인자 추가/변경하는 방법 💡 kubelet systemd 파일은 kubeadm 으로 프로비저닝했는지, 직접 프로비저닝 했는지 등에 따라서 systemd service 파일의 형태가 다소 다릅니다. service 파일에 직접 인자를 넣어줄수도 있고, kubeadm 으로 배포한 경우 `systemd drop in` 파일에서 해당 인자를 넣어줄수도 있습니다. 각자의 쿠버네티스 환경에 맞게 적용해야 합니다. ### Kubelet systemd 서비스 파일을 직접 만들어서 클러스터 프로비저닝 한 경우 우선 `kubelet` 에 대한 `systemd` `drop-in` 파일이 없는 구조에서, 환경변수를 사용하지 않고 `kubelet` 실행인자를 직접 `service` 파일에 지정한 경우에는 아래와 같습니다. ``` [Install] WantedBy=multi-user.target [Unit] Description=Kubernetes Kubelet Server Documentation=https://github.com/GoogleCloudPlatform/kubernetes After=containerd.service Wants=containerd.service [Service] ExecStart=/usr/local/bin/kubelet \ --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubeconfig \ --config=/etc/kubernetes/kubelet-config.yaml \ --kubeconfig=/etc/kubernetes/kubelet.kubeconfig \ --register-node=true \ --v=2 \ --protect-kernel-defaults=false Restart=always RestartSec=10s [Install] WantedBy=multi-user.target ``` kubelet.service 위와 같이 실행 인자에 직접 `protect-kernel-defaults=false` 를 넣어주면 됩니다. ### Kubeadm 을 통해 프로비저닝한 클러스터 ![kubeadm 으로 프로비저닝한 클러스터의 kubelet systemd 서비스 설정](https://www.kimsehwan96.com/content/images/2024/05/a8007a09-0f64-4d28-8a85-0c61004ee0ba.png) kubeadm 을 통해 프로비저닝한 클러스터의 kubelet systemd service 💡 kubelet 을 운영체제의 패키지 매니저로 설치했을때의 모습입니다. 패키지 매니저를 사용하지 않고 직접 바이너리를 다운로드 받아서 kubeadm 으로 클러스터를 프로비저닝한경우 이 상황과 완전히 일치하지 않을 수 있습니다. 만약 `systemctl status kubelet` 을 했을 때 `drop-in` 파일이 있는경우 해당 파일을 확인합니다. 위 케이스의 경우 `kubeadm` 을 통해 프로비저닝한 클러스터이고. `/usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf` 파일에 지정된 `systemd` 파일의 선언 블록들이 오버라이드 됩니다. 따라서 해당 파일을 확인해봅니다. ``` # Note: This dropin only works with kubeadm and kubelet v1.11+ [Service] Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf" Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml" # This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env # This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use # the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file. EnvironmentFile=-/etc/sysconfig/kubelet ExecStart= ExecStart=/usr/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS ``` 10-kubeadm.conf 위 항목에 여러 환경변수를 지정하는데. `$KUBELET_EXTRA_ARGS` 의 경우`/etc/sysconfig/kubelet` 파일을 통해 로드하고. 해당 파일을 열어보면 ``` KUBELET_EXTRA_ARGS= ``` /etc/sysconfig/kubelet 위와 같이 `KUBELET_EXTRA_ARGS` 를 볼수 있습니다. 여기서 ``` KUBELET_EXTRA_ARGS="--protect-kernel-defaults=false" ``` /etc/sysconfig/kubelet 를 넣어주면 됩니다. ### Kubespray 를 통해 프로비저닝한 클러스터 ![kubespray 로 프로비저닝한 클러스터의 systemctl status kubelet 출력](https://www.kimsehwan96.com/content/images/2024/05/8e6f2c15-8b1f-4613-83e5-a8e2bf0c711b-1.png) kubespray 를 통해 프로비저닝한 클러스터의 systemctl status kubelet 결과 kubespray 를 통해 프로비저닝한 클러스터는 단일 systemd service 파일로 구성되어있습니다. ``` [Unit] Description=Kubernetes Kubelet Server Documentation=https://github.com/GoogleCloudPlatform/kubernetes After=containerd.service Wants=containerd.service [Service] EnvironmentFile=-/etc/kubernetes/kubelet.env ExecStart=/usr/local/bin/kubelet \ $KUBE_LOGTOSTDERR \ $KUBE_LOG_LEVEL \ $KUBELET_API_SERVER \ $KUBELET_ADDRESS \ $KUBELET_PORT \ $KUBELET_HOSTNAME \ $KUBELET_ARGS \ $DOCKER_SOCKET \ $KUBELET_NETWORK_PLUGIN \ $KUBELET_VOLUME_PLUGIN \ $KUBELET_CLOUDPROVIDER Restart=always RestartSec=10s [Install] WantedBy=multi-user.target ``` kubelet.service 환경변수를 `/etd/kubernetes/kubelet.env` 를 통해 로드합니다. 해당 파일을 열어보면 ``` KUBE_LOG_LEVEL="--v=2" KUBELET_ADDRESS="--node-ip=192.168.207.3" KUBELET_HOSTNAME="--hostname-override=node1" KUBELET_ARGS="--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf \ --config=/etc/kubernetes/kubelet-config.yaml \ --kubeconfig=/etc/kubernetes/kubelet.conf \ --container-runtime-endpoint=unix:///var/run/containerd/containerd.sock \ --runtime-cgroups=/system.slice/containerd.service \ " KUBELET_CLOUDPROVIDER="" PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ``` /etd/kubernetes/kubelet.env 위와 같이 되어있기 때문에, 아래와 같이 수정해서 저장하면 된다. ``` KUBE_LOG_LEVEL="--v=2" KUBELET_ADDRESS="--node-ip=192.168.207.3" KUBELET_HOSTNAME="--hostname-override=node1" KUBELET_ARGS="--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf \ --config=/etc/kubernetes/kubelet-config.yaml \ --kubeconfig=/etc/kubernetes/kubelet.conf \ --container-runtime-endpoint=unix:///var/run/containerd/containerd.sock \ --runtime-cgroups=/system.slice/containerd.service \ --protect-kernel-defaults=false \ " KUBELET_CLOUDPROVIDER="" PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ``` /etd/kubernetes/kubelet.env KUBELET\_ARGS 부분의 맨 아래쪽에 `--protect-kernel-defaults=false` 추가됨 ## kubelet 이 어떤 커널 파라미터들이 어떻게 설정되있기를 예상하나요? 위 트러블 슈팅을 하면서 궁금했던건, `kubelet` 은 그럼 어떤 커널 파라미터가 어떻게 설정되있기를 원하느냐가 궁금했습니다. 단순히 `kubelet` 이 커널 파라미터를 오버라이드 하도록 두는것보다, 애초에 VM 의 커널 설정을 그렇게 하는것이 좋겠다고 생각했습니다. 거기다가 kubelet 이 해당 커널 파라미터를 원하는데에는 kubernetes 클러스터를 안정적으로 운영하기 위함이라는 추측이 있었고, 커널 파라미터가 어떻게 설정되있기를 바라는지 아는것 또한 충분히 의미가 있을 것이라고 생각했습니다. [kubernetes/pkg/kubelet/cm/container\_manager\_linux.go at 82cd82aa15aeb5e5ac1201c3b800b8134eb51cb9 · kubernetes/kubernetesProduction-Grade Container Scheduling and Management - kubernetes/kubernetes![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes![](https://opengraph.githubassets.com/74223d6337f888837b89008f0178537fcdeaadf6dde61093427eb57c657d3584/kubernetes/kubernetes)](https://github.com/kubernetes/kubernetes/blob/82cd82aa15aeb5e5ac1201c3b800b8134eb51cb9/pkg/kubelet/cm/container%5Fmanager%5Flinux.go?ref=kimsehwan96.com#L400-L446) ```go // setupKernelTunables validates kernel tunable flags are set as expected // depending upon the specified option, it will either warn, error, or modify the kernel tunable flags func setupKernelTunables(option KernelTunableBehavior) error { desiredState := map[string]int{ utilsysctl.VMOvercommitMemory: utilsysctl.VMOvercommitMemoryAlways, utilsysctl.VMPanicOnOOM: utilsysctl.VMPanicOnOOMInvokeOOMKiller, utilsysctl.KernelPanic: utilsysctl.KernelPanicRebootTimeout, utilsysctl.KernelPanicOnOops: utilsysctl.KernelPanicOnOopsAlways, utilsysctl.RootMaxKeys: utilsysctl.RootMaxKeysSetting, utilsysctl.RootMaxBytes: utilsysctl.RootMaxBytesSetting, } sysctl := utilsysctl.New() errList := []error{} for flag, expectedValue := range desiredState { val, err := sysctl.GetSysctl(flag) if err != nil { errList = append(errList, err) continue } if val == expectedValue { continue } switch option { case KernelTunableError: errList = append(errList, fmt.Errorf("invalid kernel flag: %v, expected value: %v, actual value: %v", flag, expectedValue, val)) case KernelTunableWarn: klog.V(2).InfoS("Invalid kernel flag", "flag", flag, "expectedValue", expectedValue, "actualValue", val) case KernelTunableModify: klog.V(2).InfoS("Updating kernel flag", "flag", flag, "expectedValue", expectedValue, "actualValue", val) err = sysctl.SetSysctl(flag, expectedValue) if err != nil { if inuserns.RunningInUserNS() { if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.KubeletInUserNamespace) { klog.V(2).InfoS("Updating kernel flag failed (running in UserNS, ignoring)", "flag", flag, "err", err) continue } klog.ErrorS(err, "Updating kernel flag failed (Hint: enable KubeletInUserNamespace feature flag to ignore the error)", "flag", flag) } errList = append(errList, err) } } } return utilerrors.NewAggregate(errList) } ``` `kubelet` 의 linux 컨테이너 매니저 코드인 `pkg/kubelet/cm/container_manager_linux.go` 코드를 확인해보면 `VMOvercommitMemory` , `VMPanicOnOOM` 과 같은 값을 볼 수 있는데 이것은 `utilsysctl` 쪽에 아래와 같이 정의되어있습니다. [kubernetes/staging/src/k8s.io/component-helpers/node/util/sysctl/sysctl.go at 82cd82aa15aeb5e5ac1201c3b800b8134eb51cb9 · kubernetes/kubernetesProduction-Grade Container Scheduling and Management - kubernetes/kubernetes![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes![](https://opengraph.githubassets.com/74223d6337f888837b89008f0178537fcdeaadf6dde61093427eb57c657d3584/kubernetes/kubernetes)](https://github.com/kubernetes/kubernetes/blob/82cd82aa15aeb5e5ac1201c3b800b8134eb51cb9/staging/src/k8s.io/component-helpers/node/util/sysctl/sysctl.go?ref=kimsehwan96.com#L26-L65) ```go const ( sysctlBase = "/proc/sys" // VMOvercommitMemory refers to the sysctl variable responsible for defining // the memory over-commit policy used by kernel. VMOvercommitMemory = "vm/overcommit_memory" // VMPanicOnOOM refers to the sysctl variable responsible for defining // the OOM behavior used by kernel. VMPanicOnOOM = "vm/panic_on_oom" // KernelPanic refers to the sysctl variable responsible for defining // the timeout after a panic for the kernel to reboot. KernelPanic = "kernel/panic" // KernelPanicOnOops refers to the sysctl variable responsible for defining // the kernel behavior when an oops or BUG is encountered. KernelPanicOnOops = "kernel/panic_on_oops" // RootMaxKeys refers to the sysctl variable responsible for defining // the maximum number of keys that the root user (UID 0 in the root user namespace) may own. RootMaxKeys = "kernel/keys/root_maxkeys" // RootMaxBytes refers to the sysctl variable responsible for defining // the maximum number of bytes of data that the root user (UID 0 in the root user namespace) // can hold in the payloads of the keys owned by root. RootMaxBytes = "kernel/keys/root_maxbytes" // VMOvercommitMemoryAlways represents that kernel performs no memory over-commit handling. VMOvercommitMemoryAlways = 1 // VMPanicOnOOMInvokeOOMKiller represents that kernel calls the oom_killer function when OOM occurs. VMPanicOnOOMInvokeOOMKiller = 0 // KernelPanicOnOopsAlways represents that kernel panics on kernel oops. KernelPanicOnOopsAlways = 1 // KernelPanicRebootTimeout is the timeout seconds after a panic for the kernel to reboot. KernelPanicRebootTimeout = 10 // RootMaxKeysSetting is the maximum number of keys that the root user (UID 0 in the root user namespace) may own. // Needed since docker creates a new key per container. RootMaxKeysSetting = 1000000 // RootMaxBytesSetting is the maximum number of bytes of data that the root user (UID 0 in the root user namespace) // can hold in the payloads of the keys owned by root. // Allocate 25 bytes per key * number of MaxKeys. RootMaxBytesSetting = RootMaxKeysSetting * 25 ) ``` 위 코드의 내용들을 정리해보면 `kubelet` 이 검증( `--protect-kernel-defaults=false` 인자가 들어온다면 수정)하는 커널 파라미터는 아래와 같았습니다. ``` vm.overcommit_memory = 1 vm.panic_on_oom = 0 kernel.panic = 10 kernel.panic_on_oops = 1 kernel.keys.root_maxkeys = 1000000 kernel.keys.root_maxbytes = 25000000 # root_maxkeys * 25 ``` 제가 작업하는 환경에서는 `packer` 를 통해 쿠버네티스 노드(컨트롤 플레인 노드, 워커노드 모두)의 이미지를 만들기 때문에, 이미지를 빌드할 때에 아래와 같은 `ansible` task 를 추가하여서 반영했습니다. ``` - name: kubelet이 원하는 커널 파라미터 변경 ansible.posix.sysctl: sysctl_file: /etc/sysctl.d/k8s.conf name: "{{ item.name }}" value: "{{ item.value }}" state: present reload: yes with_items: - { name: kernel.keys.root_maxbytes, value: 25000000 } - { name: kernel.keys.root_maxkeys, value: 1000000 } - { name: kernel.panic, value: 10 } - { name: kernel.panic_on_oops, value: 1 } - { name: vm.overcommit_memory, value: 1 } - { name: vm.panic_on_oom, value: 0 } ``` ansible task 실제로 `kubespray` 또한 비슷한 작업을 하는데요. [kubespray/roles/kubernetes/preinstall/tasks/0080-system-configurations.yml at e01355834b461cf6e5578088ac377be2b6f70e6a · kubernetes-sigs/kubesprayDeploy a Production Ready Kubernetes Cluster. Contribute to kubernetes-sigs/kubespray development by creating an account on GitHub.![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes-sigs![](https://opengraph.githubassets.com/45277c7ab23fae6f3b03e3ec81f8fc930785f64e8b8f0475bb680e1865aaa0cf/kubernetes-sigs/kubespray)](https://github.com/kubernetes-sigs/kubespray/blob/e01355834b461cf6e5578088ac377be2b6f70e6a/roles/kubernetes/preinstall/tasks/0080-system-configurations.yml?ref=kimsehwan96.com#L107-L121) 위 코드를 통해 볼 수 있습니다. 다만, task 이름이 명확하지 않아서 관련한 이슈/PR 까지도 남겼습니다. [](https://github.com/kubernetes-sigs/kubespray/issues/11170?ref=kimsehwan96.com) [Change a task name in preinstall tasks (in 0080-system-configurations.yml ) · Issue #11170 · kubernetes-sigs/kubesprayWhat would you like to be added I think that change the task name Ensure kube-bench parameters are set in kubernetes/preinstall/tasks/0080-system-configurations.yml into Ensure kubelet expected par…![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes-sigs![](https://opengraph.githubassets.com/24291f2b8678d8e4efe6be76964dc88eb052023cb753d489eb834a04c7c6520f/kubernetes-sigs/kubespray/issues/11170)](https://github.com/kubernetes-sigs/kubespray/issues/11170?ref=kimsehwan96.com) [Change a task name in preinstall/0080-system-configurations.yml by kimsehwan96 · Pull Request #11171 · kubernetes-sigs/kubesprayWhat type of PR is this? /kind documentation What this PR does / why we need it: These kernel parameters are the expected values for kubelet to operate(not the kube-bench), so it would be good to c…![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes-sigs![](https://opengraph.githubassets.com/3c98f963b9e501ce0ca47a308ae41015a8a373fa28a1aa7454a3f925952a837b/kubernetes-sigs/kubespray/pull/11171)](https://github.com/kubernetes-sigs/kubespray/pull/11171?ref=kimsehwan96.com) ## Wrapping up 이렇게 해서 가상머신이 부팅 된 이후에 발생하는 kubelet 에러의 원인이 무엇인지 확인했고, 더 나아가서 kubelet 코드 내부에서 이것들을 어떻게 처리하고있고, 어떤 커널 파라미터들을 expect 하고 있는지 확인 할 수 있었습니다. `--protect-kernel-defaults=false` 옵션을 kubelet 실행인자에 주면 위 에러는 쉽게 해결됩니다. 해당 커널 파라미터들을 kubelet 이 덮어씌우기 때문이죠. 위와 같은 방법으로 쉽게 해결되는 문제이지만, 근본적으로 kubelet 이 예상하는(expect)커널 파라미터에 무엇이 있으며, 어떤 값으로 설정되어야하는지. 더 나아가서는 그 커널 파라미터, 값들이 어떤 의미를 갖는지까지 알아보는것이 좋다고 생각합니다. ### Raft 알고리즘 알아보기 URL: https://www.kimsehwan96.com/raft-consensus-algorithm/ Last updated: 2026-07-20T12:37:01.000Z 💡 온프레미스 쿠버네티스 클러스터를 구축하기 위해 준비하면서 etcd에 대해서 자세히 알아보고자 etcd 의 근간이 되는 Raft 합의 알고리즘을 자세하게 알아보게 되었습니다. 전반적으로 Raft 알고리즘 문서를 번역하고 그것을 조금 더 쉽게 풀어내기 위해서 Raftscope 라는 Raft 알고리즘 시각화 오픈소스를 통해 실제 동작을 보기쉽도록 내용을 추가하였습니다. Raft 알고리즘 논문 : [https://raft.github.io/raft.pdf](https://raft.github.io/raft.pdf?ref=kimsehwan96.com) 더 자세한 버전의 논문 : [https://web.stanford.edu/\~ouster/cgi-bin/papers/OngaroPhD.pdf](https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf?ref=kimsehwan96.com) Raft는 복제된 로그들을 관리하기 위한 합의 알고리즘 입니다. `Paxos` 와 비슷한 결과를 낼 수 있으며, `Paxos` 만큼이나 효율적인 알고리즘입니다. 하지만 `Paxos` 보다는 더 이해하기 쉬운 알고리즘이기도 합니다. `Raft` 의 이해도를 높이기 위해서, Raft 는 리더 선출, 로그 복제, 안전과 같은 합의 알고리즘의 핵심 요소를 분리하고 고려해야하는 상태의 수를 줄이기 위해 더 높은 수준의 일관성을 적용합니다. Raft 는 다른 합의 알고리즘 (Oki, Liskov’s Viewstamped Replication..)과 비슷하지만, 몇가지 주목할만한 기능들이 있습니다. - **Strong Leader :** Raft 는 다른 합의 알고리즘에 비해 더 강력한 형태의 리더십을 사용합니다. 예를들자면 로그 항목은 오직 leader 에서 다른 서버로만 전달됩니다. 이를 통해 복제된 로그 관리를 간단하게 하며, Raft 를 더 이해하기 쉬운 알고리즘으로 만들어줍니다. - **Leader election :** Raft는 리더를 선출하기 위해 랜덤화된 타이머를 사용합니다. 다른 합의 알고리즘에서 필요한 heaatbeat 에 아주 작은 부분만 추가한것이고, 복잡성을 줄이고 신속하게 리더를 선출 할 수 있게 합니다. - **Membership changes :** 클러스터의 서버 집합(set)을 변경하는 작동 방식은 새로운 방식의 `joint consensus` 방식을 사용합니다. 두 개의 서로 다른 구성이 전환중이더라도 클러스터가 계속 정상 동작 할 수 있습니다. ## Replicated state machine ![복제 상태 머신(Replicated state machine) 아키텍처를 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/04/ae5ad214-b866-47ad-923c-30b92cbe54c1.png) Replicated state machine 아키텍처. 합의 알고리즘은 클라이언트가 요청한 상태 머신 command 를 포함한 복제된 로그를 관리한다. 이 상태 머신은 로그로부터 command 시퀀스를 처리하고, 복제된 로그를 가진 복제된 상태 머신들은 동일한 결과를 응답 할 수 있다. 합의 알고리즘은 `Replicated state machine` 개념으로부터 출발했습니다. 이러한 접근법을 통해 여러 서버에 존재하는 상태머신은 명확하게 같은 상태를 복제하고, 일부 서버가 중단되더라도 정상적으로 동작하도록 합니다. `Replicated state machine` 는 분산 시스템에서의 다양한 내결함성 문제를 해결하기위해 사용됩니다. `Replicated state machine` 은 일반적으로 복제된 로그를 기반으로 구현됩니다.(위 사진 참조) 각 서버는 상태 머신이 순서태로 처리한 일련의 command 를 포함한 복제된 로그를 갖고 있고. 각 복제된 로그는 같은 command 를 같은 순서로 갖고 있어서 각 상태머신은 같은 command 들을 같은 순서로 처리합니다. 각 상태머신은 결정론적이므로 같은 상태를 계산하고, 같은 순서의 결과들을 반환합니다. 💡 결정론적(deterministic) / 결정론적 알고리즘이란 어떤 특정한 입력(input)이 들어오면 특정한 (output)을 반환하는것을 의미한다. 따라서 Replicated state machine 에서 여러 복제된 상태 머신들이, 동일한 복제된 로그를 토대로 응답을 반환하기 위해 계산하면, 복제된 로그의 내용이 모두 같기 때문에 모두 같은 결과를 반환 할 수 있는 것 ![각 서버가 동일한 복제 로그를 갖는 복제 상태 머신 구조 그림](https://www.kimsehwan96.com/content/images/2024/04/fea6de91-648c-4ea6-8bc2-bd8f850d2ca6.png) 각 서버의 상태머신들은 동일한 복제된 로그 (command 를 포함한)를 갖고있다. 클라이언트의 요청에 동일한 응답을 줄 수 있다. ![클라이언트가 요청한 command(z←x)를 리더가 자신의 로그에 기록하는 그림](https://www.kimsehwan96.com/content/images/2024/04/1b9671ea-aa50-45d3-b59f-4c1a135db478.png) Client 가 z ← x 라는 새로운 command 를 요청할 때 자신의 로그에 z ← x 를 기록한다 ![Consensus 모듈이 로그를 모든 상태 머신에 복제하는 그림](https://www.kimsehwan96.com/content/images/2024/04/e25b5eca-61f1-4da9-a0b7-4612fb83434b.png) 이후 Consensus 모듈은 이 기록(로그)를 모든 상태머신에 복제한다. ![로그 반영 후 상태 머신이 클라이언트에 응답하는 그림](https://www.kimsehwan96.com/content/images/2024/04/eb611ff5-c728-4d68-8a07-4311790b5ccc.png) 이후에 상태머신이 클라이언트에 응답한다. 복제된 로그를 일관성 있게 유지하는것이 합의 알고리즘이 하는 일입니다. 서버에 있는 합의 모듈(Consensus Module) 클라이언트로부터 command를 수신하고, 그것을 log 에 추가합니다. 그리고 합의 모듈은 이룹 서버가 문제가 생기더라도 모든 로그가 같은 요청을 같은 순서로 저장하도록 보장하기 위해서 다른 서버에 있는 합의모듈과 통신합니다. command들이 정상적으로 복제되었다면(로그로 잘 쌓였다면) 모든 서버의 상태머신은 로그를 순서대로 처리하게 되고, 모든 서버의 요청에대한 결과는 동일하게 됩니다. 결과적으로 서버들은 하나의 매우 안정적인 상태머신처럼 보이게 됩니다. (어떤 서버에 요청을 해도 동일한 응답을 받을 수 있으니까, 게다가 그 서버들중 몇개가 죽더라도 문제가 없으니까) 실제 시스템에 사용하기위한 합의 알고리즘은 아래과 같은 속성을 갖습니다. - 네트워크 딜레이, 네트워크 패킷 유실 과 같은 모든 비잔틴 장애 조건이 아닌 상황에서 결함이 없어야(safety 해야)합니다. - 서버의 대부분이 작동하고 클라이언트와 서로 통신 할 수 있는 한 기능적으로 완벽히 동작해야합니다. 따라서, 일반적인 5대의 서버로 구성된 클러스터는 어떤 서버가 장애가 발생하든 2개의 서버까지 장애는 견뎌 낼 수 있습니다. 서버는 중지됨으로써 장애가 발생하는것으로 가정되고, 장애가 발생한 서버들은 추후에 안정적인 스토리지에서 상태를 복구하고, 클러스터에 다시 join 할 수 있어야 합니다. - 로그의 일관성을 유지하기 위해 타이밍에 의존하지 않습니다. 고장난 클럭과 심각한 메시지의 지연은 심각한 상황에서 가용성 문제를 발생시킬 수 있습니다. - 일반적으로 클러스터의 과반수가 단일 RPC 호출에 응답하면 command를 완료 할 수 있습니다. 소수의 느린 서버가 클러스터 전체 성능에 영향을 미치지 않아야 합니다. ## 이해하기 쉽게 설계 Raft 를 설계할 때 몇가지 목표가 있었습니다. 하나는 개발자들이 설계를 하는데 시간을 확실히 줄일 수 있도록, 시스템을 만들기 위한 완벽하고 실질적으로 사용 가능한 기초(뼈대)를 제공하는 것이였고, 다른 하나는 일반적인 운영 상황에서는 효율적으로 동작하면서, 모든 조건에서 안전하게 동작하고, 특정한 조건의 운영 상황에서도 사용 가능하도록 하는것이 목표였습니다. 하지만 가장 큰 목표는 (그리고 제일 어려웠던) 바로 이해하기 쉽게 만들기였습니다. 많은 청중들이 알고리즘을 편안하게 이해할 수 있게 해야 했고, 시스템을 구축하려는 사람들이 실제 구현에서 불가피하지만 필요한 확장을 할 수 있도록, 이 알고리즘에 대한 직관을 얻을 수 있게 해야 했습니다. 결론적으로 Raft 알고리즘을 설계하면서 아래와 같은 두가지 기법을 사용하게 되었는데. 1. Problem decomposition (문제 분해) : Raft 의 설계에서 복잡한 문제를 더 작고 독립적으로 설명하고 해결 할 수 있는 부분들로 나누어 접근하는 방식입니다. 예를들어서 리더 선출, 로그 복제, 안정성 검증, 멤버십 변경등과 같은 부분을 별도의 구성요소(문제)로 분해해서 설명합니다. 2. Smplify the state space (상태 공간 단순화): 시스템이 가질 수 있는 가능한 상태의 수를 줄임으로써 시스템을 더 일관성 있게 만들고 비결정성(nondeterminism)을 가능한한 제거합니다. 예를들어서 로그는 비어있는 데이터를 저장할 수 없고 Raft는 로그들이 서로 일치하지 않도록 하는 작업/방법을 제한합니다. 하지만 가끔은 비결정성이 이해도를 더 높이기도 하는데. 예를들자면 무작위화(Randomization)가 그랬습니다. 무작위화는 비결정성을 오히려 도입하기는 하지만, 모든 가능한 선택을 비슷한 방식으로 처리하기 때문에 오히려 상태공간을 줄일 수 있었는데.(아무거나 선택해도 상관 없다 라는 관점) Raft 의 리더 선출 알고리즘에서의 무작위화가 상태공간을 줄일 수 있는 케이스였습니다. ![멤버십 변경과 로그 압축을 제외한 Raft 알고리즘 요약 표](https://www.kimsehwan96.com/content/images/2024/04/1c42ecdb-6f2b-4d9a-9eea-7b6a73ead6c4.png) 멤버십 변경과 로그 압축을 제외한 Raft 합의 알고리즘의 요약. #### 멤버십 변경과 로그 압축을 제외한 Raft 합의 알고리즘의 요약 번역 모든 서버에서 유지되는 영속적 상태 (RPC 응답 이전에 안정적인 저장공간에 업데이트되는 상태) - currentTerm: 서버가 본 최신 임기 (0으로 초기화하고 단조롭게 증가) - votedFor: 현재 임기에 투표한 후보 (null이면 투표하지 않음) - log\[\]: 로그 항목들; 각 항목은 상태 머신 명령과 그것을 리더로부터 받은 임기를 포함 (첫 인덱스는 1) ****모든 서버에서의 휘발성 상태** - commitIndex: 알려진 로그 항목 중 커밋된 것으로 알려진 최신 인덱스 (0으로 초기화하고 단조롭게 증가) - lastApplied: 상태 머신에 적용된 로그의 최신 인덱스 (0으로 초기화하고 단조롭게 증가) ****선거 후에 재초기화되는 휘발성 상태** - nextIndex\[\]: 다음 로그 항목의 인덱스를 각 서버에게 보낼 때 (리더로 초기화하고 각 서버에 대해 1로 설정) - matchIndex\[\]: 각 서버에서 복제된 가장 높은 로그 항목의 인덱스 (0으로 초기화하고 단조롭게 증가) RequestVote RPC 리더 선출에 대한 표를 모으기 위해서 후보자가 호출 인자 - term: 후보자의 임기 - candidateId: 투표를 요청하는 후보자의 아이디 - lastLogIndex: 후보자의 마지막 로그 인덱스 - lastLogTerm: 후보자의 마지막 로그 항목의 임기 응답 - term: 후보자가 자신의 현재 임기를 업데이트하기 위한 현재 임기 - voteGranted: 후보자에게 투표가 되었으면 true 수신하는쪽의 구현 1. 현재 임기가 인자의 임기보다 크거나 이미 투표한 경우 거짓으로 응답 2. 로그가 최소한 수신자의 로그만큼 최신이라면 true로 투표 AppendEntries RPC 리더가 로그 항목들을 복제하기 위해서 호출 + 하트비트로 사용됨 인자 - term: 리더의 임기 - prevLogIndex: 이전 로그 항목의 인덱스 - prevLogTerm: 이전 로그 항목의 임기 - entries\[\]: 복제할 로그 항목들 (하트비트에는 비어있을 수 있음) - leaderCommit: 리더의 commitIndex 응답 - term: 수신자가 자신의 현재 임기를 업데이트하기 위한 현재 임기 - success: 수신자가 prevLogIndex와 prevLogTerm을 포함하는 항목을 가지고 있다면 true 수신쪽 구현 1. 인자의 임기보다 현재의 임기가 크다면 false 로 응답. (이걸 호출한 리더는 임기가 최신이 아니라 리더의 자격이 없으니까) 2. 만약 로그가 prevLogIndex에서 prevLogTerm과 일치하는 항목을 포함하고 있지 않다면 거짓을 반환합니다 3. 만약 기존 항목이 새 항목과 충돌한다면 (같은 인덱스이지만 다른 임기), 기존 항목과 그 뒤의 모든 항목을 삭제합니다 4. 로그에 아직 없는 새 항목들을 추가합니다 5. 만약 leaderCommit이 commitIndex보다 크다면, commitIndex를 min(leaderCommit, 마지막 새 항목의 인덱스)로 설정합니다. Rules for Servers모든 서버 - 만약 commitIndex > lastApplied이면: lastApplied를 증가시키고, 상태 머신에 log\[lastApplied\]를 적용합니다 - 만약 RPC 요청 또는 응답이 currentTerm보다 큰 T라는 임기를 포함하고 있다면: currentTerm을 T로 설정하고, 팔로워 상태로 전환합니다 팔로워 - 후보자와 리더로부터 온 RPC에 응답합니다. - 만약 선거를 위한 타임아웃이 리더로부터 AppendEntries RPC(Heatbeat)를 받지 않은 상태로 지나가거나, 후보에게 투표하지 않은 상태라면 후보 상태로 전환합니다. 후보자(Candidate) - 후보자로 전환하면, 선거를 시작합니다: - - currentTerm을 증가시킵니다. - 자기 자신에게 투표합니다. - 선거 타이머를 재설정합니다. - 다른 모든 서버에게 RequestVote RPC를 보냅니다. - 만약 서버의 과반수로부터 표를 받으면: 리더가 됩니다. - 만약 새로운 리더로부터 AppendEntries RPC를 받으면: 팔로워 상태로 전환합니다. - 만약 선거 타임아웃이 지나면: 새로운 선거를 시작합니다. 리더 - 선거에서 승리한 후: 각 서버에게 초기 빈 AppendEntries RPC (하트비트)를 보냅니다; 유휴 시간 동안 이를 반복하여 선거 타임아웃을 방지합니다 - 만약 클라이언트로부터 명령을 받았다면: 로컬 로그에 항목을 추가하고, 상태 머신에 항목이 적용된 후에 응답합니다. - 만약 마지막 로그 인덱스 > 팔로워의 nextIndex라면: nextIndex에서 시작하는 로그 항목과 함께 AppendEntries RPC를 보냅니다. - - 성공적이면: 팔로워에 대한 nextIndex와 matchIndex를 업데이트합니다. - 만약 로그 불일치로 인해 AppendEntries가 실패한다면: nextIndex를 감소시키고 다시 시도합니다. - 만약 N이라는 수가 있어서 N > commitIndex이고, 과반수의 matchIndex\[i\] >= N이고, log\[N\].term == currentTerm이라면: commitIndex를 N으로 설정합니다. Raft는 복제된 로그를 관리하기 위한 알고리즘입니다. 우선 Raft는 하나의 단일 리더를 선출하고, 리더에게 복제된 로그들을 관리할 책임을 주는것으로부터 합의 알고리즘 구현을 시작합니다. 리더는 클라이언트로부터의 로그 생성을 허용하고, 다른 서버들에게 로그를 복제해줍니다, 그리고 상태 머신에 언제 로그 생성을 적용해야 안전한지도 전달합니다. 리더는 복제된 로그를 관리하는것을 간단하게 만듭니다. 예를들어 리더는 다른 서버들과 의견을 주고받을 필요 없이 새로운 로그 생성을 언제, 어디에 해야할지 정합니다. 따라서 데이터는 리더로부터 다른 서버로 간단하게 흐르게 됩니다. 리더가 실패하거나 다른 서버들과의 연결이 끊어 질 수 있는데 이 경우에는 새로운 리더가 선출됩니다. 💡 정리하자면, 새로운 로그 항목 (etcd 에서는 etcd put 동작)을 생성하는 동작은 오직 리더만이 관여하고, 리더가 다른 서버들에게 복제된 로그 항목을 전달하는 모든 책임을 갖는다고 생각하면 됩니다. 이에 따라 etcd 에서`put` 과 같은 데이터를 새로 생성하는 동작을 팔로워 etcd 멤버가 받더라도 리더에게 전달하는 이유가 바로 이것입니다. 이러한 접근법을 통해 Raft 는 합의 문제를 아래와같은 세개의 독립적인 하위 문제로 분리합니다. - 리더 선출 : 새로운 리더는 기존에 존재하는 리더가 실패하는 경우에만 선출합니다. - 로그 복제 : 리더만 클라이언트가 요청하는 로그 생성을 허용하고 전체 클러스터에 거쳐 로그를 복제합니다. 또한 다른 로그들이 자신의 로그와 일치하도록 강제합니다. - 안정성 : Raft에 있어서 핵심적인 안정성은 바로 상태 머신이라는 점인데, 만약 어떤 서버가 특정 로그 항목을 자신의 상태머신에 적용했다면, 다른 서버는 동일한 로그 인덱스에 대해서 다른 command(명령/항목)을 적용 할 수 없습니다. 뒷 부분에서 어떻게 이것을 보장하는지 설명합니다. ![Raft 가 항상 만족하는 속성들을 정리한 표](https://www.kimsehwan96.com/content/images/2024/04/0871aca5-784e-4c9e-a8e6-24f2f23e4387.png) Raft는 위에있는 속성들을 항상 만족합니다. - 선출 안정성 : `term` 을 임기라고 앞으로 이야기하겠습니다. 단일 임기동안 오직 하나의 리더만이 선출됩니다. - 리더는 Append only : 리더는 절대로 로그항목을 덮어쓰거나 삭제하지 않습니다. 오직 생성만 합니다. - 로그 일치 : 만약 두 로그가 동일한 인덱스와 항목을 갖고 있다면, 그 로그들은 주어진 인덱스를 통해 모든 항목에 있어서 동일합니다. - 리더 Completeness : 어떤 로그 항목이 특정 임기(어떤 리더가 존재할 때) 커밋되었다면, 그 항목은 더 높은 번호의 임기(term)을 가진 모든 리더들의 로그에 존재합니다. - 상태 머신 안정성 : 만약 서버가 로그 항목을 특정 인덱스에서 자신의 상태머신에 적용했다면, 다른 서버는 그 인덱스에 대해서 다른 로그 항목을 적용 할 수 없습니다. (그러니까 애초에 데이터에 대한 conflict이 나지 않는것) ## Raft 의 근간 Raft 클러스터에는 여러 서버가 포함되어있고, 일반적으로 5대를 사용하고, 이런 경우에는 2대의 서버 고장을 버틸 수 있습니다. 어떤 상황속에서도 각 서버는 세가지 상태 중 하나인데, 각 상태는 리더, 팔로워, 혹은 후보자(candidate)입니다. 정상 동작중에는 오직 하나의 리더가 있고, 다른 모든 서버는 팔로워입니다. **팔로워**는 수동적이라서 단순히 리더와 후보로 부터 온 요청에만 응답합니다. **리더**는 모든 클라이언트의 요청을 처리합니다. (논문에는 이렇게 적혀있지만, etcd 의 경우는 그렇지 않기 때문에 더 확인 해 볼 필요가 있음) 세번째 상태인 **후보자**는 새로운 리더를 뽑기 위해서 사용됩니다. 아래 그림은 이러한 리더, 팔로워, 후보 상태 변화를 간략하게 보여줍니다. 뒤에서 리더 선출에 대해서 자세히 다룹니다. ![Raft 서버의 팔로워·후보자·리더 상태 전이를 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/04/71aee1fd-c2c6-4d10-b33d-d12fba6cebdd.png) 서버의 여러 상태. 팔로워는 다른 서버로부터의 요청에 대해서만 응답합니다. 만약 팔로워가 아무런 통신을 주고받지 못한다면(heatbeat) 팔로워는 후보자 상태로 바뀌고, 리더 선출을 시작하게 됩니다. 과반수 이상의 투표를 받은 후보자는 전체 클러스터에 대한 리더가 됩니다. 리더로 뽑힌 서버가 장애가 발생하기 전까지는 일반적으로 계속해서 리더로 동작합니다. 아래 그림에서 보여지는 것 처럼 임의의 길이의 임기로 시간을 나눕니다. 이 임기는 연속정인 정수로 번호가 매겨집니다. 각 임기는 선거로 시작되며, 하나 이상의 후보가 리더가 위해서 서로 경쟁합니다. 한 후보가 리더 선출 선거에서 과반수 이상 득표하면, 그 임기동안 리더로서 역할을 합니다. 어떤 상황에서는 선거가 실패하는데, 이런 경우 리더 없이 해당 임기를 끝내고, 새로운 임기(새로운 리더 선출 선거 시작과 함께)가 시작됩니다. 이렇게 해서 Raft 는 한 임기에 오직 하나의 리더만 존재할 수 있도록 보장합니다. ![Raft 의 시간이 임기(term)로 나뉘는 개념을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/04/223780ed-25b8-4bfa-8efa-5d00fb38f716.png) 시간은 임기로 나눠질 수 있습니다. 각 임기는 선거의 시작과 함께 시작됩니다. 리더 선출이 성공적으로 된 경우, 하나의(단일)리더가 전체 클러스터를 임기가 끝날 때 까지 관리합니다. 만약 선거가 실패하면 리더를 선출하지 않고 임기를 끝냅니다. 각 서버는 단순하게 증가하는 정수로된 임기 번호를 저장합니다. 임기는 서버들이 통신 할 때 마다 서로 정보를 교환하며, 만약 한 서버의 임기가 다른 서버의 임기보다 작다면 작은 임기를 더 큰 값으로 업데이트 합니다. 만약 후보자나 리더가 자신의 임기가 구식이라는걸 발견하면 그 즉시 팔로워 상태로 되돌아갑니다. 또한 구식 임기 번호를 가진 요청을 받으면 그 요청을 거부합니다. Raft 서버들은 원격 프로시저 호출(RPC)를 사용해 통신하고. 기본적인 합의 알고리즘은 오직 두종류의 RPC만이 필요한데. RequestVote RPC 와 AppendEntries RPC가 그것입니다. RequestVoteRPC 는 리더 선출 선거 기간 중에 후보자들에 의해서 발생되고, AppendEntries RPC는 리더들이 로그 항목을 복제하고 하트비트 형태를 제공하기 위해 시작됩니다. InstallSnapshot RPC 라는 추가적인 확장 RPC도 존재하는데 이는 뒤에서 다루겠습니다. 서버들이 응답을 못받는 경우 RPC를 재시도하고, 성능을 위해 병렬로 요청합니다. ## 리더 선출 Raft는 리더 선출을 발생시키기 위해서 heatbeat 메커니즘을 사용합니다. 서버들이 최초에 시작되었을 때, 팔로워 상태로 시작합니다. 서버는 유효한 RPC를 리더나 후보자로부터 수신하기 전까지 팔로워 상태로 남아있습니다. 리더는 주기적으로 heatbeat(정확히는 로그 항목을 포함하지 않는 AppendEntries RPC를 보냅니다)를 팔로워들이 그들의 우선순위를 유지하도록 보냅니다. 💡 리더는 주기적으로 heatbeat 을 보내고. 팔로워는 election timeout 전까지 heatbeat를 받으면 timeout 카운터를 초기화합니다. 그래서 주기적으로 heatbeat 을 받으면 팔로워들은 계속해서 팔로워 상태로 유지되게 됩니다. 만약 팔로워가 `election timeout` 이 끝나기 전까지 아무 정보를 받지 못한다면, 살아있는 리더가 없다고 판단하고 새로운 리더를 선출하기 위한 선거를 시작합니다. 선거를 시작하기 위해서 팔로워는 자신의 현재 임기(term) 숫자를 증가시키고(1 증가시킵니다) 자신을 후보자 상태로 바꿉니다. 그리고 해당 후보자는 자기 자신에게 투표하고, RequestVote RPC 를 병렬적으로 다른 모든 서버들에 보냅니다. 그리고 후보자는 아래 세개의 일중 하나가 발생하기 전까지 자신의 상태를 유지합니다. 1. 선거에서 승리(정족수 이상 득표) 2. 다른 서버가 리더가 되는 경우 3. 리더 선출에서 아무도 승리하지 못하고 특정 시간이 지난 경우 만약 같은 임기(term)에서 전체 클러스터에 대해서 정족수 이상을 득표하면 후보자는 리더 선출 선거에서 승리하게 됩니다. 모든 서버들은 같은 임기 내에서 선착순으로 오직 하나의 후보자에게만 투표합니다. 그리고 정족수 이상 득표해야 리더 선출 선거에서 승리하는 규칙은 적어도 하나의 후보자만 특정 임기 내에서 승리 할 수 있음을 보장하기도 합니다. 후보자가 선거에서 승리하는 경우 후보자는 리더가 됩니다. 그리고 모든 서버들에게 자신들의 우선순위를 설정하도록(팔로워 상태로 유지하라고), 그리고 새로운 선거를 시작하지 않도록 heartbeat 메시지를 남은 모든 서버에게 보냅니다. 투표를 기다리는 동안, 후보자는 AppendEntries RPC 를 다른 서버로부터 해당 서버가 리더라고 이야기하는 정보를 받을수 있습니다. 만약 RPC에 들어있는 해당 리더의 임기(term)가 자신의 term 보다 적어도 같거나, 크다면 후보자는 해당 리더를 정상적인 리더라고 인지하고 팔로워 상태로 변환됩니다. 만약 RPC 안에 있는 임기(term)이 후보자의 현재 임기(term)보다 작다면, 후보자는 그 RPC 를 거부하고 계속해서 후보자 상태를 유지합니다. 💡 아래는 다른 서버가 리더가 되는 상황을 표현했습니다. ![리더 S1 의 AppendEntries RPC 를 term 이 더 높은 S4 가 거절하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/f413a7ae-890b-4795-a27b-511cf4f77246.png) S1 이 리더인 상태로 AppendEntries RPC 를 모든 서버에 보낸다. 이 때 S4 는 term이 리더의 13보다 높은 상태이므로, 해당 RPC를 거절한다. ![S4 가 더 높은 term 으로 리더 선출을 시작하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/cb29fcce-82e0-4887-9c01-eddb55f3fc32.png) S4 가 S1 쪽으로 보내는 `-` 표시는, AppendEntries 를 reject 한다는 의미이다. 반면 S5 와 S2는 S1 이 보낸 term 의 자신과 같거나 큰 조건에 부합하기 때문에 `accept` 한다. 이 와중에 S4 의 RequestVoteRPC가 S1, S2, S5에 전달되고 여기에는 `term` 정보가 들어있다. S1 리더를 포함한 S2, S5 팔로워들은 RequestVoteRPC 에 담겨있는 `term` 값이 자신이 갖고있는 값보다 크기 때문에 이제 `term` 값을 14로 설정하고, `S4` 에게 투표를 하게된다. ![다른 서버들이 term 을 14 로 올리고 S4 에게 투표하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/b7d2f700-a98f-4748-86c9-66e652109530.png) S2와 S5는 기존 리더 S1이 이미 보냈던 AppendEntriesRPC에 대해서 true 응답을 보내고 있었다, 그 바로 직후 S4의 RequestVoteRPC가 를 수신하자 마자 S4에 대해서 투표를 하고, S1은 S5, S2의 true 응답을 받지만 , 해당 RPC response 에는 `term` 값이 13이였을 때의 정보가 담겨있기 때문에 별다른 처리를 하지 않는다. ![S4 가 새 리더로 선출된 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/61eb4c11-c08f-4e7d-8eb3-df75fa7881d3.png) 이후 S4가 리더가 된다. 세번째 가능한 결과는, 후보자가 선거에서 이기지도, 지지도 않는 상황입니다. 만약 많은 팔로워들이 동시에 후보자가 된다면 어떤 후보자도 정족수 이상 득표를 할 수 없을겁니다. 이런 상황이 발생하면 각 후보자는 timeout 상태가 되고, 현재 임기 값을 1 늘리고 난 뒤 새로운 선거를 시작하고, RequestVote RPC 요청을 보냅니다. 어쨌든 추가적인 조치 없이는 이런 상황이 계속해서 발생 할 수도 있습니다. (만약 모든 서버의 타임아웃 설정이 동일하다면 ..) Raft는 랜덤화된 election timeout 을 통해 이런 표가 분산되는 상황을 거의 없애고, 그런 일이 발생하더라도 빠르게 해결하도록 하였습니다. 이런 득표 분산상황을 막기위해서 election timeout은 고정된 범위의 시간대 안에서 랜덤하게 결정됩니다. (예를들어 150 \~ 300ms). 이런 조치는 대부분의 상황에서 오직 하나의 서버만 타임아웃 상태가 되도록 할 수 있고. 타임아웃 상태가 된 서버가 빠르게 리더가 되고 다른 서버가 timeout이 되기전에 heartbeat을 보낼 수 있게 합니다. 이와 동일한 메커니즘이 득표 분산을 막기 위해서 사용됩니다. 모든 후보자들은 선거가 시작되는 시점에서 랜덤화된 election timeout 을 설정하고 재시작하고, 다음 선거를 시작하기 전에 해당하는 timeout 까지 경과를 지켜봅니다. ## 로그 복제 리더가 한번 선출되고 나면, 리더는 클라이언트의 요청을 처리하기 시작합니다. 각 클라이언트 요청은 모든 복제된 상태머신에서 처리되어야 하는 command 를 포함하고 있습니다. (논문에서는 command로 지칭하지만, 단적으로 etcd를 생각한다면 저장할 데이터라고 생각해도 무방합니다.) 리더는 새로운 항목으로 해당 command 를 append 하고, AppendEntries RPC를 각 모든 서버에 병렬적으로 요청해서 해당 항목을 복제하도록 합니다. 만약 해당 항목이 안전하게 복제되었다면, 리더는 해당 항목을 상태머신에 반영하고 클라이언트에게 처리 결과를 반환합니다. 만약 팔로워가 충돌이 나거나 느리게 동작하는경우, 혹은 네트워크 패킷이 유실되는 경우 리더는 모든 팔로워가 모든 로그 항목을 저장할 때 까지 AppendEntries RPC를 무한히 재시도 합니다. (처리 결과가 클라이언트에 응답되었다고 하더라도) ![리더가 팔로워에게 AppendEntries RPC 로 로그를 복제하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/96573eb3-432e-4ac0-8e8a-bb408bcbafe2.png) 로그는 항목들의 조합으로, 각 항목들은 순차적으로 숫자가 부여되어있습니다.(index) 각 항목들은 해당 항목이 생성될 시점의 임기(term)값과 command 를 상태머신을 위해 갖고 있습니다. `committed` 라고 간주되는 항목들은 모든 상태머신에 반영되기에 안전한것들을 의미합니다. (정족수 이상의 서버들에 잘 복제되어있는 상태기 때문에) 로그는 위 그림에서 보는 것 처럼 정리됩니다. 각 로그 항목은 리더로부터 전달받은 항목들을 상태머신의 command를 임기(term)숫자와 함께 저장합니다. 각 로그 항목은 로그안에서의 위치를 지정하기 위해서 정수형태의 index를 갖고 있습니다. 리더는 언제 로그 항목을 상태 머신에 반영해야 안전한지 결정합니다. 그런 로그 항목을 `committed` 되었다고 부르겠습니다. Raft는 커밋된 항목이 안정적이고, 결국에는 모든 가용한 상태 머신들에 의해 실행될 것임을 보장합니다. 로그 항목은 정족수 이상의 상태머신에 복제된 항목을 리더가 한번 커밋합니다. 이전 리더에 의해 생성된 항목을 포함하여 리더의 로그에 있는 모든 선행 항목도 모두 커밋합니다. 리더는 계속해서 커밋되어야할 가장 최신(높은)값의 index를 계속해서 추적합니다, 그리고 이 값은 AppendEntries RPC (hearthbeat 포함)내에 포함되어서 다른 서버들이 알 수 있게 합니다. 팔로워가 로그 항목이 커밋되었다는걸 알게 되면, 팔로워 자기 자신의 로컬 상태 머신에 해당 항목을 반영합니다. (순서대로) Raft는 아래의 속성을 유지합니다. - 만약 서로 다른 로그에서의 두개의 항목이 같은 index 와 같은 term 을 갖고있다면, 그것들은 같은 command를 저장합니다. - 만약 서로 다른 로그에서의 두개의 항목이 같은 index와 term 을 갖고있다면, 그것들은 모든 선행 항목에서 동일합니다. 첫번째 속성은 리더가 주어진 로그 index와 term에서 오직 하나의 항목만 생성한다는 사실을 통해 이뤄지고, 로그 항목은 절대로 로그 안에서의 위치(index)를 바꾸지 않습니다. 두번째 속성은 AppendEntires RPC를 통해 이뤄지는 간단한 일관성 체크를 통해서 보장됩니다. AppendEntries RPC가 보내질 때, 리더는 새로운 항목들 바로 앞에 있는 자신의 로그 내 항목의 index와 term 을 포함시킵니다. 만약 팔로워가 같은 index와 term을 포함하는 해당 로그의 항목을 찾지 못하는 경우, 새로운 항목을 거부합니다. 💡 아래는 일관성 검사에 대한 Raftscope 기반의 설명입니다. ![팔로워가 index·term 불일치로 새 로그 항목을 거부하는 상황 그림](https://www.kimsehwan96.com/content/images/2024/04/ad37b109-6a19-4064-a0e3-ead5e132324f.png) 현재 리더는 S5이다. S1은 term 값이 18인 상태이다(장애를 겪음). S1은 인덱스 6,7,8 에 대한 값이 없다. 현재 리더는 S1의 nextIndex 값을 8로 알고있다. 따라서 S5는 S1쪽으로 AppendEntries RPC 에 term 19, prevIndex7 로 요청한다. S1은 그것을 받았을 때 자신의 term 과 자신의 commitedIndex(5), nextIndex(6) 과는 맞지 않는 요청이라 reject 해야 한다. ![S1 이 term 을 19 로 바꾸고 matchIndex 0 을 반환하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/1ac1210e-3952-4d13-8c58-1251461668a6.png) AppendEntries RPC 가 성공하지 않았다고 반환하면서, S1은 자신의 term 을 19로 바꾸면서 matchIndex 0 (인덱스가 맞지 않음) 을 반환한다. ![리더가 prevIndex 를 6 으로 낮춰 재요청하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/77f8f6aa-4187-4cb6-b3cd-d084ac909f36.png) 인덱스가 일치 하지 않는다는 정보를 받은 리더는 다시 S1 쪽에 prevIndex를 6으로 (기존 7보다 낮은 값) 전달한다. ![여전히 인덱스 불일치로 matchIndex 0 을 응답하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/be711c31-e72a-498a-bab1-1d86c536e79a.png) 여전히 자신의 index와 일치하지 않기 때문에 실패했다고 보내면서 여전히 matchIndex 0 으로 응답한다. ![리더가 prevIndex 5 로 다시 요청하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/cc5ed012-2fc5-4bcc-8387-ab8f19dc7cf1.png) 리더가 그럼 prevIndex 5 인 정보를 담고 요청한다. ![prevIndex 5 가 일치해 matchIndex 5 로 성공 응답하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/074f8753-a11a-46f8-9951-7ea0bd15a0eb.png) 이제는 자신의 prevIndex가 5가 맞기 때문에, matchIndex 5를 넣고 성공했다고 응답한다. ![리더가 S1 에게 로그 항목을 전달하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/c51c1425-0884-4edf-aad5-2d6eaf9d8d55.png) 리더가 S1에게 이제 로그 항목을 담아서 전달한다. ![S1 이 matchIndex 6 으로 정상 응답하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/bf92413a-1b84-43ce-8f0e-881c0925e379.png) 정상적으로 응답하면서, matchIndex 6을 포함해 응답한다. ![리더가 7 번 인덱스 항목을 전달하며 로그를 맞춰가는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/aaa13163-ef56-4416-8c07-996d19b682e0.png) 이제 그 다음 항목. 7번 index에 넣을 항목을 리더가 전달해준다. 이와 같은 과정을 현재 리더의 commitIndex 값인 8이 될 때 까지 반복한다. 이런 일관성 검사는 귀납적으로 작용합니다. 로그의 초기 빈 상태는 로그 일치 속성을 만족시키고, 일관성 검사는 로그가 확장 될 때 마다 로그 일치 속성을 보존합니다. 그 결과로 AppendEntries 가 성공적으로 반환 될 때 마다 리더는 팔로워의 로그가 새 항목을 통해 자신의 로그와 동일하다는 것을 알게 됩니다. ![AppendEntries 성공으로 팔로워 로그가 리더와 일치하게 된 상태 그림](https://www.kimsehwan96.com/content/images/2024/04/3feb3597-2264-49cf-9260-8c375567636f.png) 가장 위쪽에 있는 박스들이 현재 리더의 로그들입니다. 이런 상황에서 a-f 팔로워들 에서 다양한 시나리오를 생각 해 볼 수 있는데. 각 박스는 하나의 로그 항목이며, 박스 안의 숫자는 그 항목의 term 입니다. 팔로워는 일부 항목을 누락 할 수 있습니다. (a-b), 또한 커밋 되지 않는 추가 항목을 가질 수 있습니다. (c-d), 또는 둘 다입니다.(누락도하고, 추가항목도 가지는) (e-f). 예를들어 f 상황에서는 해당 서버가 term 2 에서 리더였고, 로그에 여러 항목을 추가 한 후 그 어떤 항목을 커밋하기도 전에 크래시가 발생했다면 나타날 수 있습니다. 이 때 f 리더가 timeout 을 발생시키고 또 리더 선출을 통해 term3의 리더가 되었고, 이때도 여러개의 로그 항목을 추가했지만, 역시나 어떤것도 커밋 되기 전에 서버가 다시 크래시가 나고, 그 이후 여러 임기동안 다운이 되었다면 이렇게 될 수 있습니다. 💡 아래는 위 상황에 대한 설명을 Raftscope 기반의 설명입니다. ![기존 리더 장애 후 S2 가 새 리더가 된 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/f1934be3-eea0-425a-96ce-93761e7cb66f.png) 모종의 이유로 기존 리더가 장애가 발생하고, S2 서버가 리더가 되었다. ![S2 에 로그 3 개가 더 쌓인 뒤 타임아웃으로 재선출하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/dd8c5bd8-100a-490a-83f1-3e3008832ff8.png) 이 상황에서 S2 쪽에 3개의 로그가 더 쌓였는데, S2가 timeout 을 일으키고 다시 리더 선출을 한다. ![term 4 리더가 로그 2 개를 저장한 뒤 장애가 난 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/1b06e94e-4a74-4d58-8e15-04b39d289009.png) term 4에서 리더인 시점에서 2개의 로그를 더 저장했다가, 서버가 뻗어버렸다. ![다른 서버들이 S2 와 다른 로그를 커밋한 상황 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/e690a353-50a9-4600-b70b-5e586723fe38.png) 이후 다른 리더/팔로워들은 S2가 저장한 로그들과 다른 로그들을 commit 했다. 정상적인 운영 상황에서는 리더와 팔로워들의 로그가 일관성 있게 유지되므로, AppendEntries 의 일관성 체크는 절대로 실패하지 않습니다. 그러나 리더의 crash 는 로그 일관성을 깰 수 있습니다. (이전 리더가 자신의 로그에 있는 항목을 모두 복제 못했다면). 이런 비일관성은 여러차례의 리더와 팔로워의 crash 로 발생 될 수 있습니다. 팔로워는 리더에게는 있는데 자신에게 없는 항목을 누락 할 수도 있고, 리더에게 없는 추가 항목을 가질 수 도 있고, 둘다 그럴수도 있습니다. (위 그림들 참고) Raft 에서는 리더가 팔로워들의 로그를 자신의 로그를 복제하도록 강제함으로써 이런 일관성이 깨지는 문제를 처리합니다. 즉, 팔로워 로그의 충돌하는 항목들은 리더의 로그로 덮여쓰여진다는 것을 의미합니다. 팔로워의 로그를 리더의 로그와 일치시키기 위해서, 리더는 팔로워와 자신이 일치하는 가장 최근의 로그를 찾아야 합니다. 그리고 그 지점 이후의 팔로워의 로그의 모든 항목을 삭제하고, 팔로워에게 그 시점 이후의 모든 리더의 로그를 보내야 합니다. 이런 모든 일들은 AppendEntries RPC의 일관성 체크가 수행됨으로써 이루어 집니다. 리더는 각 팔로워의 `nextIndex` 를 관리합니다. 이 값은 팔로워에게 리더가 보내야하는 다음 로그 항목의 index 값을 의미합니다. 리더가 처음으로 본인의 임기가 시작되었을 때, 모든 팔로워의 `nextIndex` 값 (리더 내부적으로 관리하는 각 서버들에 대한 메타 데이터입니다.) 을 자신의 마직 로그 index의 다음 값으로 초기화 합니다. 만약 팔로워의 로그가 리더의 것과 일치하지 않는다면 AppendEntries RPC 에서의 일관성 체크가 실패할 것이고. 팔로워가 AppendEntries RPC 에 대한 응답을 거부하고, matchIndex 0 을 전달하면, 리더는 해당 팔로워의 `nextIndex` 값을 감소시키고 AppendEntries RPC 를 재시도 합니다. 이 과정이 반복되면 결국에 리더와 팔로워의 `nextIndex` 값이 일치하는 지점에 도달합니다. 결국 이렇게 `nextIndex` 값이 일치하는 지점에서 팔로워는 충돌되는 모든 해당 시점 이후의 로그를 제거하고, 리더의 로그에서 새로운 항목들을(있다면) 추가합니다. 이런 메커니즘을 통해 리더가 부임되었을 때 로그 일관성을 복원하기 위해 특별한 조치를 할 게 없습니다. AppendEntries의 일관성 검사의 실패가 누적되다 보면, 불일치 지점을 찾고, 복원하는 방향으로 로그들이 수렴됩니다. 리더는 절대로 자신의 로그에서 로그 항목을 덮어쓰거나 삭제하지 않습니다. 이러한 로그 복제 메커니즘은 서버의 정족수 이상이 작동중인 한 Raft 는 새로운 로그를 수락하고, 복제하며, 반영 할 수 있게 됩니다. 정상적인 클러스터는 정족수 이상의 서버에 단 한번의 RPC로 새 항목을 복제 할 수 있습니다. 또한 단 하나의 느린 팔로워가 성능에 영향을 미치지 않습니다. (리더는 정족수 이상의 서버에 로그가 복제되면 commit 하기 때문에 !!!) 💡 아래는 AppendEntries 의 로그 복제 수렴에 대한 Raftscope 기반의 설명입니다. ![정족수 이상 복제 시 커밋되는 로그 복제 수렴 상황 그림](https://www.kimsehwan96.com/content/images/2024/04/6e1445ec-158b-4d71-b62b-c7221f44e557.png) 이 상태에서 S2는 다른 서버들과 다른 로그항목을 갖고 있다. 그림에서 박스 아래쪽 화살표가 팔로워들의 `nextIndex` 값을 의미한다. S2는 현재 `nextIndex` 값이 6 인 상태이다 (리더가 보기에) 또한 임기 값도 4이기 때문에 업데이트 되어야 한다. ![리더 heartbeat 에 팔로워가 term 을 갱신하고 matchIndex 0 을 응답하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/43dd61a8-fdd1-4ce6-ac5f-b2a088a45b47.png) 리더의 heatbeat(AppendEntries RPC)에 반응해서, 자신의 임기를 업데이트하고, matchIndex가 0 (일치하는것이 없다)고 응답한다. ![리더가 nextIndex 를 낮춰 다시 일관성 검사를 진행하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/0937aaf6-75f5-4b40-b6fd-ff5f1aad44e6.png) 그러면 리더는 S2의 `nextIndex` 값은 5 라고 업데이트하고 (`prevIndex4`) 그리고 해당 인덱스에서의 로그 항목을 전달해서 일치하는지 체크해보려고 한다. (AppendEntries RPC) ![S2 가 여전히 인덱스 불일치로 실패를 응답하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/613c1eb5-2b8e-491d-ab80-eebced210a98.png) 여전히 일치하지 않기 때문에 실패했다는 응답을 S2가 반환한다. ![리더가 S2 의 nextIndex 를 4 로 낮춰 재요청하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/bbfb4887-5ba9-47a7-84cd-4e9f272285e1.png) 리더는 S2의 nextIndex 값을 4로 업데이트하고, 다시 AppendEntries RPC를 요청한다. ![S2 가 3 번 인덱스부터 로그가 일치함을 응답하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/db95fb21-9e33-4ef7-a083-09840e814f37.png) 지금 이 상황에서는S2 가 3번 인덱스의 로그 값을 커밋을 하지 못했던 상황이라서 커밋하고 3번 인덱스부터 로그가 일치한다고 리더에게 응답한다. ![리더가 4 번 인덱스 로그 값을 전달하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/b7184ab5-4780-4a49-9a60-87363417e6d9.png) 여기서 리더는 4번째 인덱스에 들어갈 값을 전달해준다. ![S2 가 4 번 인덱스 로그를 커밋한 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/fcacf58b-b09d-4e27-8a96-e1a70ab7604d.png) 4번째 인덱스에 이제 리더가 갖고있는 값을 S2는 커밋했다. ![S2 가 5 번 인덱스 로그를 커밋한 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/4bfbf155-c8a2-4dfd-a716-df3f4653844b.png) 5번째 인덱스 커밋 ![S2 가 6 번 인덱스 로그를 커밋한 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/e97668ac-16ba-4525-9051-dc985586b0ea.png) 6번째 인덱스 커밋 ![7 번 인덱스까지 커밋해 S1·S2 가 동일 상태가 되는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/1241fd6d-77cc-44b3-a9ee-b1cf033ac199.png) 7번째 인덱스 커밋, 이렇게 AppendEntries RPC 요청/응답을 S1, S2가 계속 주고받으면서 결국 동일한 상태 머신이 된다. ## 안정성 앞선 섹션들에서는 Raft가 어떻게 리더를 선출하고, 로그 항목을 복제하는지 설명했습니다. 그러나 **지금까지 설명한 메커니즘**만으로는 **각 상태머신**이 정확히 **같은 command 들을 같은 순서로 실행한다는 것을 충분히 보장하진 못합니다.** 예를들어서, 리더가 여러 로그 항목을 커밋하는 동안 팔로워가 사용 할 수 없게 되었다가, 그 후에 리더로 선출되어 이 항목을 새로운 것들로 덮어 쓸 수도 있습니다. 결과적으로 다른 상태머신들은 다른 시퀀스의 command를 수행하게 됩니다. 이제 부터는 리더로 선출 될 수 있는 서버에 대한 제한을 추가함으로써 Raft 알고리즘을 완성하는 내용을 설명합니다. 주어진 term 의 리더가 이전 term에서 커밋된 모든 항목을 포함하고 있음을 보장해야 한다는 제한을 추가합니다. 이러한 제한을 고려해서 커밋 규칙을 더 정확하게 만들어야 합니다. ## 선거 제한 리더 기반의 합의 알고리즘에서, 리더는 결국에는 모든 커밋된 로그의 항목을 반드시 저장하고 있어야 합니다. Viewstamped Replication 같은 일부 합의 알고리즘에서는, 모든 커밋된 항목을 갖고있지 않더라도 리더로 선출 될 수 있습니다. 이러한 알고리즘들은 선거 프로세스가 진행중이거나, 리더 선출 바로 직후에 누락된 항목을 파악하고, 새로운 리더에게 전달하는 추가적인 알고리즘을 포함하고 있습니다. 불행히도, 이런것들은 복잡한 알고리즘을 추가합니다. Raft는 리더에게 커밋되었지만 누락된 항목을 새로운 리더에게 보낼 필요 없이, 이전 임기에서 커밋된 모든 로그 항목이 새로운 선거 시점에서 존재하는 리더만 선출하도록 보장하는 더 쉬운 접근법을 사용합니다. 이건, 로그 항목은 오직 한 방향으로, 리더에서 팔로워로만 흐르는것을 의미하고, 리더는 절대로 자신이 기존에 갖고있던 항목을 덮어쓰지 않는다는것을 의미합니다. Raft는 모든 커밋된 로그 항목을 갖고있지 않은 후보자가 선거에서 이기는것을 막는 선거 프로세스를 사용합니다. 후보자는 반드시 클러스터 내의 과반수 이상과 통신하여서 뽑힙니다. 그 뜻은 모든 커밋된 항목은 반드시 연락했던 이 서버들중 최소한 하나에는 있다는 것입니다. (추가: 로그의 커밋은 과반수 이상의 클러스터 멤버에게 복제되었을 때 수행됨) 만약 후보자의 로그가 클러스터의 과반수 멤버들 중 어떤 로그보다 최신이라면, 그건 모든 커밋된 항목을 포함했다는 의미입니다. RequestVote RPC는 이러한 제한을 구현합니다. RequestVote RPC 는 후보의 로그에 대한 정보를 포함하고, 투표자는 자신의 로그가 후보자의 것보다 더 최신이라면 해당 투표 요청을 거절합니다. Raft는 두 로그 중 어느것이 더 최신인지를 로그의 마지막 항목의 인덱스와 임기를 비교해서 결정합니다. 만약 로그가 서로 다른 임기의 마지막 항목을 갖고있다면, 더 높은 숫자의 임기가 더 최신 로그입니다. 로그가 같은 임기로 끝나면, 더 긴 로그가 더 최신입니다. (같은 임기에 대한 로그가 더 많은쪽이 더 최신 로그까지 갖고 있는것) ![로그 최신성을 임기와 길이로 판단하는 선거 제한 규칙 그림](https://www.kimsehwan96.com/content/images/2024/04/48487a10-9b6e-498c-bb1b-d83fee8c69de.png) 리더가 이전 임기의 로그 항목을 사용하여 커밋을 결정할 수 없는 이유를 보여주는 시간 순서입니다. (a)에서S1은 리더고, 인덱스 2 에 있는 로그를 S2까지만 복제합니다. 이 때 S1에 크래시가 발생하여 (b)에서와같이 S5가 S3, S4, S5(자기자신) 으로부터 표를 받아서 (S1, S2는 S5 의 투표를 거절한다. 더 최신 로그까지 갖고 있으니까) 리더가 되고 로그 인덱스 2에서 다른 항목을 수락합니다. 이 때 S5에 크래시가 발생하고 S1 이 재시작되어서 리더로 선출되고 로그인덱스2의 항목을 S3 까지 복제합니다. 과반수 이상의 서버에 복제 되었지만 아직 커밋되지는 않은 상태입니다. 이렇게 커밋되지 않은 상태에서 S1이 크래시가 발생하고, S5가 다시 리더로 선출되었을 때 (d) 그림처럼 자신의 임기 3 항목으로 모든 항목을 덮어쓸 수 있습니다. 만약 S1 이 크래시하기전에 (c)상황에서 자신의 임기 4 에서의 로그 항목들(로그인덱스 3) 값을 서버 과반수 이상에 복제했다면, 이 항목은 커밋됩니다. (S5는 이 시점에서는 선거에서 절대 이길 수 없습니다). 이 시점에서 선행 항목(인덱스2에 있던 로그)도 커밋됩니다. 💡 아래는 위 상황에 대한 Raftscope 기반의 설명입니다. ![term 2 리더 S1 이 S2 까지만 로그를 복제한 뒤 크래시하는 상황 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/8f7fd307-92a2-482c-a2ba-638cc623da11.png) S1 이 term 2 인 이 시점에서 리더이다. 이 때 S1 은 S2 까지만 자신의 로그를 복제 성공했다. 하지만 커밋되진 않았으며(과반수 이상에 복제되지 않았기 때문에) 이 때 S1에 크래시가 발생한다고 해보자. ![S1 크래시 후 S5 가 RequestVote RPC 를 보내 일부 표를 받는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/85b89c19-0532-474d-9df7-31d4de25aabe.png) S1 에 크래시가 발생하고 S5가 타임아웃에 이어 RequestVote RPC 를 모든 멤버에 보냈다고 해보자. S1, S2 는 S5가 갖고있는 마지막 로그의 임기 값이 자신이 갖고 있는 로그의 임기값보다 낮기 때문에 투표를 거절하지만, S5 자기 자신을 포함한 S3, S4는 임기값이 같기 때문에 투표해준다. ![term 3 리더 S5 가 로그 인덱스 3 을 반영한 뒤 크래시하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/30fc9eab-24b2-4852-a31f-d97450be5a0c.png) S5 가 term3 인 시점에서 리더이다. 이 때 S5 는 로그인덱스 3에 로그를 반영했지만, 곧바로 크래시 했다고 하자. ![S1 의 RequestVote 를 S5 가 더 높은 임기로 거절하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/2622d40d-154a-4a00-b41a-3b665929f9ae.png) S1 이 타임아웃 이후 RequestVote RPC 를 모든 멤버에 보냈을 때. S5는 자신의 로그의 임기값이 S1의 마지막 로그의 임기값보다 높기 때문에 거절하지만, 나머지 멤버들은 투표해준다. 이 때 역시 S1 이 크래시났다고 해보자. ![S1 크래시 후 S5 가 과반수 표를 받아내는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/83e99c82-2d9f-474f-b3f2-35ee09fff2a6.png) S1의 크래시 이후 S5가 타임아웃 및 RequestVote RPC 를 모든 멤버들에게 보냈을 때. S1 을 제외한 멤버들은 투표에 응한다. S5 의 마지막 로그의 임기값이 S1을 제외한 멤버들의 마지막 로그의 임기값보다 높기 때문이다. ![커밋되지 않은 로그가 리더 S5 의 값으로 덮어써지는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/d597e0b4-b0b1-47a6-bd64-05842e86ff22.png) 이후 리더의 AppendEntries RPC 가 모든 멤버들에게 전달되고, S1, S2, S3 는 커밋되지 않은 로그인덱스 2번의 값을 S5의 로그 정보로 덮어씌운다. (커밋된 로그는 덮어쓸 수 없지만, 커밋 되지 않은 로그 항목은 덮어 쓸 수 있다.) ![모든 서버가 리더 S5 의 로그로 동기화된 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/e91dcb07-dab1-4017-b867-b38429270809.png) 결국 S5 리더의 정보로 모두 동기화 된다. ![S1 이 인덱스 3 까지 커밋해 S5 가 선거에서 이길 수 없는 상황 그림](https://www.kimsehwan96.com/content/images/2024/04/15fb180c-eb43-4376-b750-ff15d01e0c5b.png) 만약 임기 4에서의 S1 이 로그 인덱스 3 의 항목까지 모두 커밋 했다면. S5 는 이 시점에서 절대로 투표에서 이길 수 없습니다. S5 가 타임아웃을 발생시키고 RequestVote RPC 를 호출하더라도, 과반수 이상의 멤버가 자신의 마지막 로그의 임기 값보다 높은 임기값의 로그를 커밋해두었기 때문입니다. ![S5 가 자신과 S4 외에는 표를 받지 못하는 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/9be7f9e8-61c2-4bf4-9e4c-d084f2b67520.png) 자기 자신(S5)과 S4 를 제외하고는, 선거에서 뽑아주지 않는다. ## 이전 임기의 항목들을 커밋 리더는 현재 임기의 항목이 서버 과반수에 저장되면 그 항목이 커밋되었다고 알고 있습니다. 리더가 항목을 커밋하기 전에 크래시가 발생하면, 미래의 리더들은 그 항목의 복제를 완료하려고 시도할 것입니다. 그러나, 리더는 항목이 **서버 과반수에 저장되었다고 해서 곧바로 이전 임기의 항목이 커밋되었다고 결론지을 수 없습니다.** 앞서 표현한 그림에서는 오래된 로그 항목이 서버 과반수에 저장되었지만, 여전히 미래의 리더에 의해 덮어쓰여질 수 있는 상황을 보여줍니다. Raft는 이전 임기의 로그 항목을 복제 수를 계산하여 절대 커밋하지 않습니다. 오직 리더의 현재 임기에서 온 로그 항목만이 복제 수를 계산하여 커밋됩니다. 현재 임기에서 온 항목이 이런 식으로 커밋되면, 그 이전의 모든 항목들은 로그 일치 속성 때문에 간접적으로 커밋됩니다. 어떤 상황에서는 리더가 오래된 로그 항목이 커밋되었다고 안전하게 결론지을 수 있습니다(예를 들어, 그 항목이 모든 서버에 저장되어 있는 경우), 그러나 Raft는 단순함을 위해 보다 보수적인 접근 방식을 취합니다. Raft는 커밋 규칙에서 이 추가적인 복잡성을 감수하는데, 이는 리더가 이전 임기의 항목들을 복제할 때, 로그 항목이 원래의 임기 번호를 유지하기 때문입니다. Raft의 접근 방식은 로그 항목들이 시간이 지나도 같은 임기 번호를 유지하고 로그 전체에서 유지되기 때문에 로그 항목에 대해 추론하기가 더 쉽습니다. 또한, Raft의 새로운 리더들은 다른 알고리즘보다 이전 임기의 로그 항목들을 더 적게 보냅니다(다른 알고리즘은 커밋하기 전에 재번호를 매기기 위해 중복 로그 항목들을 보내야 합니다). ![이전 임기 항목 커밋과 이후 임기 리더 선출의 관계를 설명하는 그림](https://www.kimsehwan96.com/content/images/2024/04/f3f6d270-6ad4-40b3-905a-1c885d75616b.png) 만약 S1(임기 T의 리더)이 자신의 임기에서 새로운 로그 항목을 커밋하고, 나중의 임기 U에 S5가 리더로 선출된다면, 로그 항목을 수락했던 적어도 하나의 서버(S3)가 S5에게 투표했어야 합니다. ## 팔로워와 후보자의 충돌 지금까지는 리더의 실패에 대해서만 설명했는데, 팔로워와 후보자의 충돌은 더 처리하기 간단하고, 둘 다 같은 방식으로 처리 됩니다. 팔로워와 후보가 충돌한다면 그것들에게 보내진 미래의 RequestVote RPC , AppendEntires RPC 모두 실패 할 것입니다. Raft 는 이러한 실패를 무한히 재시도 함으로써 처리합니다. 크래시한 서버가 재시작 되면 RPC 는 성공적으로 처리 될 것입니다. 예를들어 팔로워가 이미 자신의 로그에 존재하는 로그 항목을 포함하는 AppendEntries RPC 요청을 받는다면, 새 요청에서 그 항목을 무시합니다. ## 타이밍 그리고 가용성 Raft 의 요구사항중 하나는 타이밍에 의존해서는 안된다는 점입니다. 몇몇 이벤트가 예상보다 빠르거나, 느리게 처리된다고 해서 올바르지 않은 결과를 발생시키면 안됩니다. 어쨌든 가용성은 타이밍에 의존하게 될 수 밖에 없습니다. 예를 들어서, 메시지 교환이 일반적인 서버 크래시 시간보다 길게 걸린다면, 후보자는 선거에 이기기에 충분한 시간동안 유지되지 못 할 것입니다. (안정적인 리더 없이 Raft는 계속해서 진행 될 수 없음) 리더 선출은 Raft 에서 타이밍이 가장 중요한 부분인데. 시스템이 다음 요구사항을 만족하면 안정적인 리더를 선출하고 유지 할 수 있습니다. `broadcastTime << electionTimeout << MTBF` 이 부등식에서 broadcastTime 은 서버가 클러스터 내의 모든 서버에게 병렬로 RPC 요청을 보내고, 그들의 응답을 받는데 걸리는 평균 시간입니다. 그리고 MTBF 는 단일 서버에 대한 평균 고장 사이 시간입니다. ![Raftscope 코드와 실행 화면으로 broadcastTime·electionTimeout 을 보여주는 캡처](https://www.kimsehwan96.com/content/images/2024/04/e2040fc7-0601-4f53-a1dd-0fcb57a7c472.png) 위 캡쳐는 Raftscope 의 코드 및 그것을 띄운 사진입니다. RPC 요청을 주고받은 레이턴시를 broadcastTime 으로 볼 수 있고, election time out 은 ELECTION\_TIMEOUT 으로 변수로 설정되어있습니다. 서버의 장애는 우리가 임의로 만들 수 있기 때문에 논외로 하겠습니다. 위 케이스에서는 Raft 알고리즘이 안정적으로 동작합니다. 0:00 /0:26 1× 정상적으로 동작하고 있다. 만약 RPC 레이턴시 (broadcastTime)을 election timeout 보다 크게 잡는다면 어떻게 될까요? ![broadcastTime 을 electionTimeout 보다 크게 설정한 Raftscope 화면](https://www.kimsehwan96.com/content/images/2024/04/6adfe74e-241c-4cb0-aaa6-7890af2491e5.png) broadcastTime 이 electionTimeout 보다 크게 해보겠습니다. 0:00 /0:12 1× RPC 요청이 주고 받아지는 시간보다 각 서버의 electionTimeout이 더 크기 때문에 리더가 선출되기 전에 계속해서 timeout이 발생하고 리더가 선출되지 않는 상황이 발생합니다. 여기서 broadcastTime 과 MTBF 는 우리가 설정 할 수 있는 속성이 아닌, 시스템이 가진 기본 속성입니다. 우리는 electionTimeout 을 설정 할 수 있습니다. 일반적인 서버에 대한 MTBF(평균 고장 간 시간)은 몇달 이상이고, broadcastTime 은 환경에 따라 다르지만 몇ms 에서 길게는 수십 ms 일 것입니다. (만약 etcd 멤버가 대륙간으로 멀리 떨어져있다면 수백ms 가 넘을 수 있겠지만). 일반적인 상황에서는 electionTimeout 은 아마도 10ms \~ 500ms 사이가 될 가능성이 높습니다. 따라서 이 타이밍 요구사항을 만족하는것은 크게 어려운 일은 아닐 것입니다. 💡 만약 etcd 멤버가 대륙간으로 멀리 떨어져있고, 그것에 대한 latency 가 500ms 라고 하면, electionTimeout은 500ms 이상, 뭐 1초 이상으로 설정하는게 낫겠죠? etcd 에서는 [Tuning](https://etcd.io/docs/v3.5/tuning/?ref=kimsehwan96.com) 이 문서에서 관련 내용을 다룹니다. ## 클러스터 멤버쉽 변경 (멤버 추가 / 제거) 지금까지 우리는 클러스터의 configuration (합의 알고리즘에 참여하고있는 서버의 집합)이 고정되어있다고 가정했습니다. 실무에서는, 설정을 변경 할는것이 종종 필요합니다. 예를 들어서 장애가 발생한 서버를 대체하기 위해서나, 합의 알고리즘에 참여하는 서버의 개수를 변경할 때 필요합니다. 이런 작업은 전체 클러스터를 오프라인으로 만들고, configuration(설정)을 업데이트하고, 전체 클러스터를 재시작하는 형태로도 동작 할 수 있겠지만, 이 과정에서 클러스터 전체를 사용 불가능한 시점이 발생하게 됩니다. 게다가 그런 작업에 수동 작업 절차가 있다면, 오퍼레이터(운영자)가 에러를 발생시킬 리스크를 높이게 됩니다. 이러한 문제를을 피하기 위해서, Raft 합의 알고리즘에 자동화된 설정 방식을 포함하기로 했습니다. 클러스터의 설정을 안전하게 변경하기 위해서는, 설정을 변경하는 시점에서 반드시 같은 임기에 두 리더가 선출되는 가능성이 없어야 합니다. 불행하게도, 과거 설정에서 새로운 설정으로 서버가 직접 설정을 바꾸는 접근법에서는 안전한 방법이 없습니다. 모든 서버를 원자적으로 전환 할 수 없기 때문에, 전환중에 클러스터는 두개의 독립적인 과반수로 분리될 수 있습니다. 안정성을 확보하기 위해서는 설정의 변경은 두단계 절차를 통한 접근법을 사용해야 합니다. 예를 들어서, 일부 시스템은 과거 설정을 사용하지 않기 위해 첫번째 절차 들어가고, 그 시스템은 클라이언트의 요청을 처리하지 못합니다. 이후에 두번째 절차에서는 새로운 설정을 활성화시킵니다. Raft에서 클러스터는 공동 합의라고 부르는 전환 설정으로 전환합니다. 공동 합의가 커밋되면, 시스템은 새로운 설정으로 전환합니다. 공동 합의는 오래된 설정과 새 설정을 모두 결합합니다. - 로그 항목은 두 설정의 모든 서버에 복제됩니다. - 어느 설정의 서버든 리더가 될 수 있습니다. - 합의(리더 선출 및 항목 커밋)는 과거 설정과 새로운 설정 모든 쪽에서 각각 과반수 이상의 합의가 필요합니다. ![멤버십 변경 시 두 설정 모두에서 과반수 합의를 요구하는 joint consensus 그림](https://www.kimsehwan96.com/content/images/2024/04/742c24f4-59ca-4a37-adc8-fad335d319ec-1.png) 점선은 생성되었지만 아직 커밋되지 않은 설정을 의미하고, 실선은 가장 최근에 커밋된 구성 항목을 의미합니다. 리더는 먼저 C\_old,new 구성 항목을 자신의 로그에 생성하고, 이를 C\_old,new(C\_old 의 과반수와, C\_new의 과반수에)에 커밋합니다. C\_old,new 가 커밋된 시점에서는 C\_old 도, C\_new 도 독립적으로 결정을 내릴 수 없습니다. 이후 C\_new 항목을 생성하는 시점 부터는 C\_new는 독립적으로 결정을 내릴 수 있습니다. 생성된 C\_new 가 C\_new 과반수 이상에 커밋되면 이 시점에서 C\_new 에 없는 리더는 자신의 임기를 마칩니다. ****C\_new , C\_old 가 동시에 각각 독립적으로 결정을 내릴 수 있는 시점이 없기 때문에 설정 전환 간 안정성이 보장됩니다.** 이러한 공동 합의는 각각의 서버들이 서로 다른 시간에 설정 전환을 할 수 있게 함으로써 안전성을 해치지 않습니다. 게다가, 공동 합의는 이런 설정이 전환되는 과정에서도 클라이언트 요청을 지속적으로 처리 할 수 있게 합니다. 클러스터 구성은 복제된 로그에 특별한 항목을 사용해서 저장되고 통신됩니다. 위 그림은 설정 변경 과정을 나타내는 그림입니다. 리더가 C\_old 에서 C\_new로 설정을 바꾸라는 요청을 받았을 때, 리더는 공동 합의를 위해 설정을 저장하고(C\_old,new) 이 항목들을 복제합니다. 특정 서버가 자신의 로그에 새로운 설정 항목을 추가하면, 향후의 모든 결정에 대해 해당 설정을 사용합니다. (서버는 항상 로그에있는 최신 설정을 사용하고, 설정 정보가 커밋되었는지 여부에 상관없이 사용합니다.) 이는 리더가 C\_old,new 규칙을 사용해서 언제 C\_old,new 로그 항목이 커밋 될 지를 정한다는것을 의미합니다. 쉽게 정리하자면, 클러스터의 설정(구성) 정보가 변경 될 때, 구성 정보 변경하는 시점에서 일시적으로 분리(C\_old,new)되는 클러스터가 독립적으로 각각 결정권을 갖는 시점이 없기 때문에 이 시점에서의 분리된(C\_old,new) 클러스터간 데이터가 불일치하는 문제같은건 발생하지 않는다는 의미입니다. ## 로그 압축(Compaction) Raft의 로그는 클라이언트의 요청이 포함된 정상적인 운영 상황에서 계속해서 늘어나게 됩니다. 하지만 실제 시스템에서는 경계(한계)없이 늘어날 수 없습니다. 로그가 더 길어지면 길어질수록, 더 많은 공간을 차지하게 되고, 응답을 하기위해 더 많은 시간을 사용하게 됩니다. 쓸모없는 정보가 로그에 계속 누적되었다는것을 인지하기 위한 메커니즘이 따로 없다면 결국 가용성 문제가 발생하게 됩니다. Snapshotting(스냅샷)은 압축을 위한 가장 쉬운 방법입니다. 스냅샷을 찍게되면 현재의 모든 시스템의 상태가 안정적인 저장공간에 스냅샷 형태로 저장되고, 스냅샷을 찍는 시점 이전의 로그를 버립니다. ![커밋된 로그를 스냅샷으로 대체하는 로그 압축 개념 그림](https://www.kimsehwan96.com/content/images/2024/04/05b18ad0-5c2e-42c9-b487-9515ad43d87e.png) 서버는 로그에 커밋된 항목들 (인덱스 1부터 5까지)을 새로운 스냅샷으로 대체합니다. 이 스냅샷은 현재 상태를 저장하고있습니다. 스냅샷의 마지막 인덱스와 임기는 인덱스 6번 항목 이전의 스냅샷의 위치를 결정하는데 사용됩니다. 위 그림은 Raft 에서 스냅샷 생성의 기본 개념을 보여줍니다. 각 서버는 로그에 커밋된 항목들만 포함하여 독립적으로 스냅샷을 생성합니다. Raft 는 스냅샷에 소량의 메타데이터도 포함하는데, 스냅샷이 대체하는 로그의 마지막 항목의 인덱스, 그 항목의 임기(마지막 임기)를 포함합니다. 이 정보는 스냅샷 다음에 오는 첫번째 로그 항목에 대한 AppendEntries RPC의 일관성 검사를 지원하기 위해 보존되는 것입니다. 클러스터 멤버십 변경또한 지원하기 위해 마지막 서버 구성도 포함합니다. 스냅샷을 만들고 나면 그 이전 항목들을 삭제 할 수 있습니다. ![InstallSnapshot RPC 를 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/04/4abda513-63f7-48b8-91c0-ac2f1edae210.png) InstallSnapshot RPC 리더는 InstallSnapshot RPC를 너무 멀리 뒤떨어진 팔로워들에게 스냅샷을 보내기 위해 요청합니다. 팔로워가 이 RPC 를 수신했을 때, 기존 로그 항목들을 어떻게 처리할지 결정해야 합니다. 보통은 팔로워가 갖고있는 로그 항목에 대한 정보보다 스냅샷에 있는 정보가 더 최신 정보입니다. 이런 케이스에서는 팔로워는 자신의 기존 로드 항목을 모두 버립니다. 만약 팔로워가 자신의 로그 항목의 앞부분을 설명하는 스냅샷을 받게 된다면 (재전송, 혹은 실수로 인해), 스냅샷으로 커버되는 앞선 로그항목은 삭제되더라도, 그 이후에 따르는 항목은 여전히 보존되어야 합니다. 이 스냅샷 접근 방식은 팔로워가 리더에 대한 정보 없이 스냅샷을 생성 할 수 있기 때문에 Raft 의 강한 리더 원칙을 벗어납니다. 하지만, 이러한 부분은 정당화가 된다고 생각합니다. 리더를 두는것은 합의에 도달할 때에 충돌하는 결정을 피하는데에 도움은 되지만, 스냅샷을 찍는 시점에서는 이미 합의가 된 상태기 때문에 어떤 결정도 충돌되지 않습니다. 데이터는 여전히 리더에서 팔로워로 한 방향으로만 흐릅니다. 💡 여기서 이야기하는 Snapshot 은 etcd 에서의 backup & store 의 snapshot 이 아닌, Log Compaction 단계에서 필요한 snapshot 을 의미합니다. [Maintenance](https://etcd.io/docs/v3.5/op-guide/maintenance/?ref=kimsehwan96.com) 이 문서를 보면 알 수 있듯이. `etcd` 의 `--snapshot-count` 는 compaction 수행 전에 in-memory 에 들고있을 Raft 로그 항목의 개수를 지정하는 옵션입니다. `etcdctl backup` 을 통해 만드는 snapshot 과는 별개의 개념입니다. ## Wrapping up 이렇게해서 Raft 알고리즘이 어떻게 동작하는지 논문을 기반으로 알아보았습니다. 이해하기 쉬우면서도(?) 잘 동작하는 합의 알고리즘을 구현하기 위해 어떤 요소들이 필요하고 일부 머신에 장애가 발생했을 때 어떻게 장애를 극복하는지 알아 볼 수 있었습니다. [Raft Consensus AlgorithmRaft is a consensus algorithm that is designed to be easy to understand.![](https://raft.github.io/logo/favicon.ico)![](https://raft.github.io/logo/solo.svg)](https://raft.github.io/?ref=kimsehwan96.com) 위 사이트의 맨 아래쪽 부분에는 어떤 소프트웨어들이 Raft 알고리즘을 "구현" 하여 사용중인지 확인 가능합니다. ### etcd 의 snapshot 과 WAL이 무엇일까요? (etcd backup snapshot이 아닌) URL: https://www.kimsehwan96.com/etcd-snapshot-and-wal/ Last updated: 2026-07-20T12:37:01.000Z 💡 etcd 는 Raft 알고리즘 기반으로 구현되어습니다. Raft 알고리즘에서는 Log Compaction 단락에서 Snapshot 개념을 이야기합니다. 여기서의 Snapshot 은 전체 데이터베이스의 데이터가 아닌, 스냅샷의 로그 인덱스와, Raft term (임기)를 의미합니다. 반면 etcd backup / restore 시에 사용하는 snapshot 은 진짜 db 파일을 복제한것이라고 보면 됩니다. [MaintenancePeriodic etcd cluster maintenance guide![](https://etcd.io/favicons/apple-touch-icon-180x180.png).cls-1{fill:#419eda}etcdTerms](https://etcd.io/docs/v3.5/op-guide/maintenance/?ref=kimsehwan96.com) etcd 에서는 compaction 을 진행하기 이전에, 인메모리에 보관할 Raft 항목의 수를 `--snapshot-count` 라는 인자로 지정 할 수 있습니다. 로그 항목의 수가 `snapshot-count` 에 도달하면, 서버는 스냅샷 데이터를 디스크에 저장한 다음, 오래된 항목을 잘라내는 작업을 합니다. 또한 느린 팔로워(랙)가 압축된 이전의 로그를 요청하면 리더는 팔로워가 상태를 덮어쓰도록 스냅샷을 보내게 됩니다. 이 스냅샷은 인메모리에 저장할 로그 항목의 수를 지정하는것으로, 이 값이 크면 클수록 더 많은 로그 항목을 인메모리에 저장하기때문에, 메모리 사용량이 반복적으로 증가하게됩니다. 따라서 `snapshot-count` 는 메모리 사용량의 증가와, 느린 팔로워의 가용성 사이에서 절충안을 찾아 설정해야 합니다.`v3.2` 이후부터는 기본 값이 100,000 으로 변경되었습니다. (인메모리에 100,000 개의 로그 항목을 저장하도록) 여기서 `DefaultSnapshotCatchupEntries` 라는 값이 5000 으로 하드코딩 되어있습니다. ```go const ( DefaultSnapshotCount = 10000 // DefaultSnapshotCatchUpEntries is the number of entries for a slow follower // to catch-up after compacting the raft storage entries. // We expect the follower has a millisecond level latency with the leader. // The max throughput is around 10K. Keep a 5K entries is enough for helping // follower to catch up. DefaultSnapshotCatchUpEntries uint64 = 5000 ... ) ``` ([https://github.com/etcd-io/etcd/blob/e513b1a6b759a75d077f2d52f1429035697ceb52/server/etcdserver/server.go#L75-L83](https://github.com/etcd-io/etcd/blob/e513b1a6b759a75d077f2d52f1429035697ceb52/server/etcdserver/server.go?ref=kimsehwan96.com#L75-L83)) 이 값은, Raft 스토리지의 항목을 압축 한 이후에, 느린 팔로워가 따라잡을 수 있는 항목의 개수를 의미하는데. 스냅샷을 찍은 이후에 인메모리에 우선 5000개의 항목만을 메모리에 들고있고, 만약 느린 팔로워가 5천개 이상 Lag을 갖고있다면 리더가 스냅샷을 보내서 따라오도록 하는 설정이라고 볼 수 있습니다. ```go // etcd/server/storage/storage.go // SaveSnap saves the snapshot file to disk and writes the WAL snapshot entry. func (st *storage) SaveSnap(snap raftpb.Snapshot) error { st.mux.RLock() defer st.mux.RUnlock() walsnap := walpb.Snapshot{ Index: snap.Metadata.Index, Term: snap.Metadata.Term, ConfState: &snap.Metadata.ConfState, } // save the snapshot file before writing the snapshot to the wal. // This makes it possible for the snapshot file to become orphaned, but prevents // a WAL snapshot entry from having no corresponding snapshot file. err := st.s.SaveSnap(snap) if err != nil { return err } // gofail: var raftBeforeWALSaveSnaphot struct{} return st.w.SaveSnapshot(walsnap) } ``` ([https://github.com/etcd-io/etcd/blob/e513b1a6b759a75d077f2d52f1429035697ceb52/server/storage/storage.go#L58-L77](https://github.com/etcd-io/etcd/blob/e513b1a6b759a75d077f2d52f1429035697ceb52/server/storage/storage.go?ref=kimsehwan96.com#L58-L77)) ```go // etcd/server/storage/wal/wal.go func (w *WAL) SaveSnapshot(e walpb.Snapshot) error { if err := walpb.ValidateSnapshotForWrite(&e); err != nil { return err } b := pbutil.MustMarshal(&e) w.mu.Lock() defer w.mu.Unlock() rec := &walpb.Record{Type: SnapshotType, Data: b} if err := w.encoder.encode(rec); err != nil { return err } // update enti only when snapshot is ahead of last index if w.enti < e.Index { w.enti = e.Index } return w.sync() } ``` ([https://github.com/etcd-io/etcd/blob/e513b1a6b759a75d077f2d52f1429035697ceb52/server/storage/wal/wal.go#L964-L983](https://github.com/etcd-io/etcd/blob/e513b1a6b759a75d077f2d52f1429035697ceb52/server/storage/wal/wal.go?ref=kimsehwan96.com#L964-L983)) 이 시점에서 etcd 는 두가지의 동작을 하게 됩니다. (위 코드를 보면 알겠지만) 1. 현재의 메타 데이터를 `.snap` 파일로 저장 2. 1번 데이터를 포함한 현재 상태(state)를 `wal` 파일에 저장 따라서 `.snap` 파일은 데이터베이스가 얼마나 크건, 얼마나 많이 만들었건에 관련 없이 일정한 크기를 갖게 됩니다. 그리고 메모리에서 제거한 데이터(스냅샷)는 `wal` 파일에 저장합니다. ⚠️ etcd v3 store 의 동작의 특성으로 인해, 실제 데이터를 잘라서 어딘가에 Disk 에 저장하는 것이 아니라, Snapshot 정보를 토대로 bbolt 파일(db 파일)을 기반으로 한 암시적인 스냅샷을 생성합니다. 💡 Raft 에서의 Compaction / etcd 에서의 Compaction 은 동일한 용어를 사용하지만 세부적인 용도가 또 다릅니다. Raft 에서의 Compaction은 Snapshot 을 생성하면서, 모든 데이터에 대한 기록을 저장하는 것이 아닌 스냅샷을 찍는 시점에서의 임기의 상태머신의 상태 값을 저장하는 개념입니다. (앞에 어떤 기록으로 값들이 바뀌여 왔건 무시) 따라서 느린 팔로워가 `compact-index` \- `snapshot-index` 사이에 `last-applied-index` 를 갖고있는 경우에는 스냅샷을 받아서 리더를 따라가는게 아니라, 리더와 AppendEntries RPC 를 주고 받으면서 따라가고, 그렇지 않은 경우에는 스냅샷을 받아서 처리하게 됩니다. 그리고 `snapshot-index` \- `compact-index` 의 값의 차이는 5000으로 고정되어있다고 생각하면 됩니다. ## WAL 에 저장되는 데이터들 (Write Ahead Log) 앞서서, Raft Snapshot 도 궁극적으로 WAL 에 저장된다고 했습니다. 여기서는 WAL 에 어떤 데이터들이 어떤 형식으로 저장되는지 설명합니다. ### 물리적 요소 WAL 로그 파일은 `프레임` 의 연속적인 내용을 저장하는데, 각 프레임은 아래를 포함하고있습니다. - LittleEndian : 인코딩된 uint64 형태의 마샬링된(직렬화와 비슷한 개념이라고 생각하면 됩니다) `walpb.Record` 의 길이 - Padding : 전체 프레임이 정렬된 크기가 되도록 하는 0 바이트의 패딩 - 마샬링된 `walpb.Record` 의 데이터 이 파일은 64\*10^6 바이트(64MB) 마다 잘라집니다. ### 논리적인 요소 WAL 로그 파일은 아래와 같은 논리적 계층의 내용을 포함합니다. - `Raftpb.Entry` : Raft 의 리더에 의해 복제된 제안(로그 항목 추가하라는 제안). 일부 제안은 커밋된것으로 간주됩니다. - `Raftpb.HardState(term,commit,vote)` : 커밋되어(과반수 이상에 복제되어서 커밋된) 변경/재정의 되지 않도록 보장되고, 백엔드에 적용 될 수 있는 로그 항목의 index 에 대한 정보, term 정보, 투표 정보 - `walpb.Snapshot(term, index)` : Raft 상태에 대한 주기적인 스냅샷 (DB 내용은 하나도 없고, 스냅샷 로그 인덱스와 Raft 임기 정보만 있습니다.) etcd v3 store 의 경우 데이터는 bbolt 파일에 유지되고, Snapshot 에 임기/인덱스 정보가 반영되면 암시적인 스냅샷이 됩니다. (데이터에 대한 스냅샷 파일을 따로 만드는게 아님) - `crc32 체크섬` - `etcdserverpb.Metadata(node_id, cluster_id)` : 로그가 나타내는 클러스터 및 복제를 표현 각각의 WAL-log 파일은 아래 순서대로 빌드됩니다. 1. CRC-32 프레임 2. 메타데이터 프레임(클러스터, 복제본 IDs) 3. 최초의 WAL 파일인 경우 1. 빈 Snapshot 프레임 (인덱스, 임기 모두 0 인) 4. 최초가 아닌 WAL 파일 1. Hardstate 프레임 (임기, 커밋, 투표 정보) 5. 항목, hard-state, snapshot record 의 조합 WAL 로그의 인덱스는 아래와 같이 생성, 유지됩니다. 1. snapshot 의 인덱스로부터 시작 2. 같은 임기(term)에 있는 경우 해당 스냅샷 이후로 순차적으로 증가 3. 임기가 변경되면 인덱스가 감소 할 수 있지만, 최신 Hardstate.commit 보다 높은 새로운 값으로 변경 가능 4. 새 스냅샷은 반드시 index >= Hardstate.commit 에서 발생해야 하며, 인덱스에 대한 새로운 시퀀스를 생성 ![WAL 로그와 *.snap.db 스냅샷 동기화를 추상화한 그림](https://www.kimsehwan96.com/content/images/2024/04/0e0ed9bf-b20b-4a2e-ac1e-d63cd3d7b61b.png) WAL 로그의 추상화된 그림. \*.snap.db 파일은 리더 <-> 팔로워간 스냅샷 기반의 동기화가 진행 될 때, 리더가 뿌려주는 파일이다. (실제 데이터베이스 컨텐츠가 들어있음) ## 정리 WAL 파일은 Write Ahead Log 의 약자로, B-Tree 에서의 보통 쓰기 전 임시로 저장하여 쓰기 실패시 복구하기 위한 용도로 사용되는데, etcd 는 한 단계 더 확장해서, Raft 로 부터 제안된 로그 항목 임시 저장 + Snapshot 정보 저장, 기타 메타데이터 저장(임기, 커밋, 인덱스) 용도로 사용되는 파일입니다. etcd 의 `--snapshot-count` 도달시 생기는 `000000-000000.snap` 형태의 스냅 파일은 일종의 메타데이터를 저장하고 있는 파일이고, 실제로는 WAL에도 저장되며, 이 시점에서의 DB 에 대한 스냅샷 파일을 명시적으로 만드는것이 아닌(만들어서 Disk 에 별도로 저장하는게 아니라) 암시적으로 스냅샷 시점의 임기, 인덱스 값을 기반으로 스냅샷을 논리적으로 갖고있는 것이라고 볼 수 있습니다. 실제로 스냅샷이 필요한 시점 (느린 팔로워의 동기화를 위해서)에서는 암시적인 스냅샷을 실제 물리적인 파일 형태로 만들어서 (`*.snap.db`) 팔로워에게 파일을 전달하고 동기화 된 이후에는 삭제하게 됩니다. ### RR DNS 를 통한 kube-apiserver 앞단의 LB 대체 테스트 URL: https://www.kimsehwan96.com/can-rr-dns-replace-load-balancer-in-front-of-kube-apiserver/ Last updated: 2026-07-20T12:37:01.000Z 💡 HA Kubernetes 클러스터에서, kube-apiserver 앞단에 LB 가 있어야 단일 엔드포인트로 여러 kube-apiserver를 접근 할 수 있고. 보통은 그 LB 또한 HA 구성을 하기 위해서 보통 HAProxy + Keepalived 조합을 통해 LB 자체는 Active-Standby 로. kube-apiserver 들은 모두 Active-Active 상태로 구축하는게 보편적인 구축 방식입니다. 이 문서에서는 LB 없이 RR DNS 를 통해서 HA Kubernetes 클러스터 구축, 특히 kube-apiserver 와 kubelet 사이에 문제가 생기지 않을지 등을 염두에 두고 내용을 작성했습니다. ## 쿠버네티스 고가용성 토폴리지 (etcd) [](https://kubernetes.io/ko/docs/setup/production-environment/tools/kubeadm/ha-topology/?ref=kimsehwan96.com) [고가용성 토폴로지 선택이 페이지는 고가용성(HA) 쿠버네티스 클러스터의 토플로지를 구성하는 두 가지 선택 사항을 설명한다. 다음과 같이 HA 클러스터를 구성할 수 있다. etcd 노드와 컨트롤 플레인 노드를 함께 위치시키는 중첩된(stacked) 컨트롤 플레인 노드 방식 etcd와 컨트롤 플레인이 분리된 노드에서 운영되는 외부 etcd 노드 방식 HA 클러스터를 구성하기 전에 각 토플로지의 장단점을 주의 깊게 고려해야 한다. 참고: kubeadm은 etcd 클러스터를 정적으로 부트스트랩한다. 자세한 내용은 etcd 클러스터 구성 가이드 를 읽는다. 중첩된 etcd 토플로지 중첩된 HA 클러스터는 etcd에서 제공하는 분산 데이터 저장소 클러스터를, 컨트롤 플레인 구성 요소를 실행하는 kubeadm으로 관리되는 노드에 의해서 형성된 클러스터 상단에 중첩하는 토플로지이다.![](https://kubernetes.io/favicons/apple-touch-icon-180x180.png)Kubernetes![](https://kubernetes.io/images/kubernetes-horizontal-color.png)](https://kubernetes.io/ko/docs/setup/production-environment/tools/kubeadm/ha-topology/?ref=kimsehwan96.com) 위 문서는 쿠버네티스 공식 문서에서 고가용성이 확보된 쿠버네티스 클러스터를 구축하는 두가지 토폴로지에 대해서 서술하고 있습니다. 위 문서에서의 초점은 `etcd` 이며. `etcd` 를 컨트롤 플레인 노드(머신)과 중첩시켜서 구축할지, 아니면 외부 다른 노드(머신)에 구축할지에 대한 내용입니다. 이 문서는 `etcd` 를 초점으로 한 문서는 아니지만 간략하게 요약하자면. ### Stacked etcd 토폴로지 ![Stacked etcd 토폴로지를 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/71d93c83-edf1-41a5-975a-8a272b940aba.png) Stacked etcd 토폴로지 `Stacked` etcd 토폴로지의 경우 컨트롤 플레인 노드(머신)에 etcd 를 같이 설치하는 형태로. `kube-apiserver` 와 `etcd` 간에 `Network I/O` 가 없다는 장점이 있지만. RAFT 알고리즘을 사용하는 `etcd` 의 특성, 그리고 그 `etcd` 를 컨트롤 플레인 노드에 같이 설치한다는 구성상의 특징으로 컨트롤 플레인 노드의 개수는 `etcd` 클러스터 멤버의 개수와 동일하며. 그에 따라서 고가용성 유지를 위한 `Quorum` 개수를 맞추려고 할 때 컨트롤 플레인 노드의 개수도 `etcd` 와 동일하게 맞춰줘야 한다는 단점이 있을 수 있습니다. `etcd` 클러스터는 `etcd` 멤버를 3대 이상 유지했을 때 1개의 `etcd` 멤버가 다운되더라도 정상 동작이 가능하고, 5대로 구성하는경우 최대 2개의 멤버가 다운되더라도 정상 동작이 가능합니다. 참조 : [https://etcd.io/docs/v3.5/faq/#what-is-failure-tolerance](https://etcd.io/docs/v3.5/faq/?ref=kimsehwan96.com#what-is-failure-tolerance) 따라서 고가용성을 유지하기 위해 `etcd` 를 5개를 설치하기로 선택 했을 때, 컨트롤 플레인의 개수 자체도 5개가 되어야 한다는것이 단점이 될 수 있습니다. ### External etcd 토폴로지 ![External etcd 토폴로지를 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/aae19bd1-e380-4d66-9bf0-00116512e302.png) External etcd 토폴로지 `External` etcd 토폴로지의 경우 컨트롤 플레인 노드와 별도로 `etcd` 를 설치해서 사용하는 형태로. `kube-apiserver` 와 `etcd` 가 물리적으로 다른 머신에 설치되어있는 경우 `Network I/O` 를 염두해서 사용해야 합니다. 다만 이 케이스에서는 컨트롤 플레인의 개수는 `etcd` 의 개수와 전혀 상관없이 구성이 가능하다는 점이 장점입니다. 컨트롤 플레인 노드는 2대, `etcd` 멤버는 5개로 설치하거나, 컨트롤 플레인은 3대 , etcd 멤버는 3개 등으로도 설치 가능합니다. 이렇게 컨트롤 플레인의 개수와 `etcd` 개수간의 디펜던시가 없는것이 장점이 될 수 있습니다. 다만 `etcd` 멤버 수 만큼 추가적인 노드(머신)이 필요 한 것이 단점이 될 수 있습니다. ## kube-apiserver 앞단의 로드밸런서 (software LB) 앞서서 HA 쿠버네티스를 구성하는 두가지 토폴로지를 보면서 확인 가능했겠지만. 워커노드들이 `kube-apiserver` 에 접근 할 때 로드밸런서를 통해 접근하는것은 동일하다는 것을 볼 수 있었습니다. `kubeadm` 을 통해 HA 쿠버네티스 클러스터를 구성하는 아래와 같은 문서에서도 `kube-apsierver` 용 로드밸런서를 생성하라는 이야기가 나와있습니다. [Creating Highly Available Clusters with kubeadm](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/high-availability/?ref=kimsehwan96.com#first-steps-for-both-methods) 또한 위 문서에서 소프트웨어를 통한 부하 분산에 대한 내용이 아래와 같이 링크 되어있는데요. [https://github.com/kubernetes/kubeadm/blob/main/docs/ha-considerations.md#options-for-software-load-balancing](https://github.com/kubernetes/kubeadm/blob/main/docs/ha-considerations.md?ref=kimsehwan96.com#options-for-software-load-balancing) 위 문서에서는 VIP(Virtual IP)를 통해 로드밸런싱을 제공하는 keepalived + haproxy 조합이 오래동안 사용되어왔고, 충분한 테스트를 거친 조합이라고 설명하고있습니다. ![Keepalived + HAProxy 로 구성한 HA 쿠버네티스 클러스터 구성도](https://www.kimsehwan96.com/content/images/2024/03/b769d6ce-36a6-4e14-b677-c5871d579acf.png) Keepalived + HAProxy 조합의 HA 쿠버네티스 클러스터 구성 위 그림에서의 IP 주소는 이해를 돕기위해 임의로 설정한 주소입니다. 아래쪽의 각 컨트롤 플레인 노드는 각각 `192.168.100.2` , `192.168.100.3` , `192.168.100.4` 주소를 갖고 있다고 할 때. `kube-apiserver` 는 각 컨트롤 플레인 노드에서 `6443(기본적으로 설정되는 포트이며 변경될 수 있음)` 포트로 listen 하고 있는 상태일 것입니다. 이러한 `kube-apiserver` 들에 부하를 분산해서, 그리고 문제가 생겼을때 문제가 없는 서버로 트래픽을 틀어주기 위해서 `HAProxy` 를 통해 각 `kube-apiserver` 에 round-robin 형태로 트래픽을 틀어주도록 설정하고. 이 때 각 `HAProxy` 는 `192.168.100.10` , `192.168.100.20` 주소를 갖고 있다고 가정합니다. 이때 각 `LB(HAProxy)` 간의 HA 구성을 위해 `Keepalived` 를 두고. `VIP` 를 `192.168.100.100` 으로 두고. 설정을 통해 한쪽 LB를 마스터, 한쪽 LB 를 백업으로 두는 `Active-Standby` 조합으로 구성하면 클라이언트나 워커노드는 `192.168.100.100:6443` 혹은 도메인을 `192.168.100.100` 에 연결했다고 하고, 그 도메인이 `foo.bar.com` 이라고 하면 `https://foo.bar.com:6443` 으로 요청을 보낼 수 있습니다. 그렇게 요청을 보내면 `Keepalived` 를 통해 한쪽 `LB` 로 요청이 넘어가고. 해당 LB 는 round-robin 형태로 세 컨트롤 플레인 노드의 `kube-apiserver` 중 하나로 트래픽을 틀어주게 됩니다. 이것이 일반적인 `Keepalived` \+ `HAProxy` 조합의 HA 쿠버네티스 클러스터 구성입니다. 이렇게 하게 되면 `kube-apiserver` , `LB(HAProxy)` 중 하나가 장애가 생기더라도 정상적인 `kube-apiserver`, `LB` 로 트래픽을 틀어 줄 수 있기 때문에 고가용성이 확보되는 것입니다. 물론 뒷단의 `Control Plane Node` 를 `Stacked etcd 토폴로지` 로 했냐, `External etcd 토폴로지` 로 했냐에 따라서 고가용성 확보 여부가 달라질 수 있고. `Keepalived` 에 장애가 생겼냐에 따라서도 고가용성 확보 여부가 달라 질 수는 있습니다. 여기서는 단편적인 케이스에 대해서만 설명하였습니다. 위 구성에서 `VIP(Virtual IP)` 그리고 `Keepalived` 를 사용해서 `LB(HAProxy)` 에 대한 고가용성을 확보했기 때문에 두 `LB(HAProxy)` 중 하나는 유휴상태(`Standby`) 상태로 남아있게 되는 것 단점으로 남습니다. 위 부분을 해결하기 위해서는 별도의 `Keepalived` 를 하나 더 추가하고. 2개의 `VIP(Virtual IP)` 를 구성하고. `Round Robin DNS` , 그리고 `Health Check` 까지 겸해서 사용하거나, 두개의 `LB` 에 대해서 `Round Robin DNS` 그리고 `Health Check` 를 겸해서 사용하는 등의 추가적인 방법을 사용해야 할 것입니다. ## Round-Robin DNS 로 해보자! 💡 라운드 로빈 DNS는 구현하기는 쉽지만 DNS 계층 구조 자체의 레코드 캐싱과 클라이언트 측 주소 캐싱 및 재사용으로 인해 발생하는 여러 가지 단점이 있으며, 이러한 조합은 관리하기 어려울 수 있습니다. 서비스 가용성을 위해 라운드 로빈 DNS에만 의존해서는 안 됩니다. 목록에 있는 주소 중 하나의 서비스에 장애가 발생하면 DNS는 해당 주소를 계속 전달하고 클라이언트는 여전히 작동하지 않는 서비스에 연결하려고 시도합니다. 💡 라운드 로빈 DNS는 네임 서버를 쿼리할 때마다 주소 레코드의 순서를 번갈아 가며 쿼리하기 때문에 그 자체로는 [부하 분산에](https://en.wikipedia.org/wiki/Load%5Fbalancing%5F%28computing%29?ref=kimsehwan96.com "https://en.wikipedia.org/wiki/Load_balancing_(computing)") 가장 적합한 선택이 아닐 수 있습니다. 트랜잭션 시간, 서버 부하, 네트워크 혼잡을 고려하지 않기 때문에 동일한 용량의 서버에 균일하게 분산된 많은 수의 연결이 있는 서비스에 가장 적합합니다. 그렇지 않으면 [부하 분산만](https://en.wikipedia.org/wiki/Load%5Fdistribution?ref=kimsehwan96.com "https://en.wikipedia.org/wiki/Load_distribution") 수행합니다.[\[9\]](https://en.wikipedia.org/wiki/Round-robin%5FDNS?ref=kimsehwan96.com#cite%5Fnote-RFC%5F1794-9 "https://en.wikipedia.org/wiki/Round-robin_DNS#cite_note-RFC_1794-9") Round-robin DNS 는 DNS 요청에 대한 IP 주소들의 응답을 여러 주소를 반환하고 그와 더불어서 첫번째 레코드를 순회(round-robin)하면서 DNS 응답을 하는 방법입니다. 일반적인 상황에서 S/W , H/W LB 를 두지 않고 가장 간단하게 부하 분산을 할 수 있는 방법에 그 이점이 있습니다. 다만 고려해야할 아래와 같은 단점이 있습니다. 1. DNS 쿼리 정보를 캐싱하는 주체가 다양하다. (OS, 애플리케이션, 기타 클라이언트 등등) 2. DNS 쿼리 캐싱 문제로 인해 문제가 발생했을 때 바로 반영이 되지 않을 수 있다. 기존 쿼리해서 받은 IP 주소로 계속해서 애플리케이션/클라이언트 등이 호출을 하려 할 것이다. 3. DNS 서버에 Health Check 기능이 존재하지 않으면 비정상적인 IP 주소를 계속 담아서 보내줄 수도 있다. 우선 1, 2번 문제를 제외하고 3번 부분을 처리하기 위해서 `AWS Route53` 의 다중 응답 기능 + 상태 검사 기능을 사용해서 테스트를 해보기로 하였습니다. ### 테스트 환경 테스트를 하고자 하는 환경 구성은 아래와 같습니다. ![RR DNS 테스트를 위한 환경 구성도](https://www.kimsehwan96.com/content/images/2024/03/70c524f4-27c5-43cc-9f37-17af65062167.png) 2개의 Control Plane Node (`172.xx.xx.31` , `172.xx.xx.32`) 와 1개의 Worker Node (`172.xx.xx.33`) 이 존재하고. 이 노드들은 모두 사설 대역대에 존재합니다. 이 사설망과 연결된 게이트웨이는 `xx.xx.xx.xx` 라는 공인 ip 주소를 갖고 있고 `Route53` 의 헬스체크 요청이 도달할 수 있도록 포트포워딩을 아래와 같이 하였습니다. `xx.xx.xx.xx:16443` → `172.xx.xx.31:6443` (kube-apiserver) `xx.xx.xx.xx:26443` → `172.xx.xx.32:6443` (kube-apiserver) `xx.xx.xx.xx` 를 `public_ip` 라고 지칭하겠습니다. ### Route53 설정 ![AWS Route53 에 kube-apiserver 레코드를 설정하는 화면](https://www.kimsehwan96.com/content/images/2024/03/a4dd7c57-4437-4297-9da5-4a7842b4366d.png) 위와 같이 `public_ip:16443` , `public_ip:26443` 에 대해서 상태검사를 생성합니다. ![Route53 에서 kube-apiserver 포트에 대한 상태 검사를 생성하는 화면](https://www.kimsehwan96.com/content/images/2024/03/d71dec53-a60c-448c-a68b-f0fc98a4066f.png) 이후 `A Record` 에 위와 같이 다중값 응답 및 상태확인을 연결하여 생성합니다. `172.xx.xx.31:6443` , `172.xx.xx.32:6443` 으로 떠있는 각 `kube-apiserver` 가 정상 상태라면 두 상태 검사도 정상이고, 이에 따라서 DNS 쿼리시 두 IP 주소를 번갈아가면서 응답해주게 됩니다. ### DNS 테스트 ![두 kube-apiserver 노드와 로컬 머신에서 DNS 응답을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/f7b3320a-0121-428e-b560-7602ce7ce468.png) 맨 위쪽이 172.xx.xx.31 , 중간이 172.xx.xx.32 , 아래는 로컬 머신(맥북) `kube-apiserver` 는 static pod 로 띄워져있기때문에 ,한번 `172.xx.xx.31` 쪽에 있는 `kube-apiserver` 를 제거해보고 `DNS` 응답이 어떻게 나오는지 확인해봅니다. `$ mv /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml` 위와 같이 `kube-apiserver.yaml` 을 잠시 `/etc/kubernetes/manifests` 경로에서 빼내줍니다. 💡 kubelet은 static pod yaml 파일 경로를 지정해놓고, 해당 경로에 있는 yaml 파일을 static pod로 띄웁니다. ``` cat /etc/kubernetes/kubelet-config.yaml | grep staticPodPath staticPodPath: /etc/kubernetes/manifests ``` ![kubelet 의 staticPodPath 설정을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/afa02f67-7aad-41c2-a192-b804654c0a21.png) 잠시 기다리면 `kubelet` 이 `kube-apiserver.yaml` 파일이 제거됨을 인지하고, `kube-apsierver` static pod 를 제거합니다. 위 사진처럼 `node1 (172.xx.xx.31)` 에는 `kube-apiserver` 가 제거되었습니다. ![Route53 상태 검사에서 kube1 이 비정상으로 표시된 화면](https://www.kimsehwan96.com/content/images/2024/03/eb9a5444-3370-4bd6-afbe-b7ccf258a332.png) kube1 상태 검사가 비정상 상태로 보임 ![dig 결과 비정상 노드는 빠지고 172.xx.xx.32 만 반환되는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/42ba262e-a181-432c-b4a1-5964ebb38644.png) dig 결과 비정상적인 172.xx.xx.31 은 반환되지 않고 172.xx.xx.32 만 반환 됨을 확인 할 수 있다. 이렇게 상태 검사 + Rotue53 다중 응답(Round Robin DNS)을 동작하도록 했다. `172.xx.xx.31` 에서 잠깐 제거한 `kube-apiserver.yaml` 을 다시 아래 명령어를 통해 정상화 한다. `$ mv /root/kube-apiserver.yaml /etc/kubernetes/manifests/` ### kube-apiserver 인증서 재발급 이제 `kube.xxx.com` 과 같은 도메인으로(host header) `kube-apiserver`를 요청하더라도 처리가 되도록 기존에 프로비저닝 된 `kube-apiserver` 인증서를 재발급 해줘야한다. 💡 이 테스트에서의 쿠버네티스 클러스터는 kubespray 를 통해 프로비저닝 되었고, kubespray는 내부적으로 kubeadm 을 통해 인증서 생성 , 클러스터 조인 등의 작업을 진행합니다. 따라서 인증서 재발급등의 작업도 `kubeadm` 을 통해 수행합니다. `172.xx.xx.31` 서버에서 아래와 같이 `kube-apiserver` 가 사용하는 기존 인증서를 확인해보자. `$ openssl x509 -text -noout -in /etc/kubernetes/ssl/apiserver.crt` ![openssl 로 기존 kube-apiserver 인증서를 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/38e450c0-051e-4fd3-b8f4-d216bbeffc07.png) 노란색 박스 부분을 잘 보자(Subject Alternative Names). 여기에 우리가 사용하려는 도메인 주소가 없다면 `kube-apsierver` 에서 ssl termination 을 할 때 오류가 발생한다. `kubeadm` 을 통해 다시 인증서를 발급하기 위해서 우선 `/etc/kubernetes/kubeadm-config.yaml` 파일의 `certSANs` 항목에 도메인을 추가해야 한다. `$ vi /etc/kubernetes/kubeadm-config.yaml` 위는 `kubeadm-config.yaml` 파일의 `certSANS` 항목의 일부이다. 해당 부분의 아무 라인에서 `kube.xxx.com` 과 같은 도메인을 추가해줘야 한다. 이후 기존 `kube-apiserver` 인증서, 키파일을 제거한다. `$ rm -rf /etc/kubernetes/ssl/{apiserver.crt,apiserver.key}` 이후 `kubeadm` 을 통해 `kube-apiserver` 인증서를 업데이트된 `kubeadm-config.yaml` 파일 기반으로 재생성한다. ⚠️ `kubeadm` 바이너리를 호출해서 실행하면 된다. `kubespray`로 프로비저닝 한 경우 `kubeadm` 이 `/usr/local/bin` 에 설치되어있다. `$ /usr/local/bin/kubeadm init phase certs apiserver --config=/etc/kubernetes/kubeadm-config.yaml` 위 명령어를 수행하면 `apiserver.crt` , `apiserver.key` 가 새롭게 `/etc/kubernetes/ssl` 디렉터리에 생성된다. 이후 제대로 생성이 되었는지 `$ openssl x509 -text -noout -in /etc/kubernetes/ssl/apiserver.crt` 명령어를 통해 확인한다. ![재발급한 kube-apiserver 인증서 SAN 에 도메인이 추가된 것을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/6a22169a-316d-4d76-a8c3-054088fb595d-1.png) [kube.xxx.com](http://kube.xxx.com/?ref=kimsehwan96.com "http://kube.xxx.com") 과 같은 도메인이 SAN 에 추가된것을 볼 수 있다. 이후 `172.xx.xx.31` 에서 생성된 인증서를 다른 모든 컨트롤플레인(현재 테스트 환경에서는 `172.xx.xx.32`)에도 반영해준다. (기존 인증서 제거 및 `172.xx.xx.31` 에서 생성한 인증서 복사) ### 워커노드에서의 kubelet 설정 이제 `172.xx.xx.33` (워커노드) 에서 본격적인 테스트를 진행해보자. 워커노드에서 `$ vi /etc/kubernetes/kubelet.conf` 을 통해 `kubelet` 이 사용할 `kubeconfig` 파일을 열어봅니다. 💡 `kubelet` , `kube-scheduler` , `kube-controller-manager` 와 같은 쿠버네티스 컴포넌트들은 `kubeconfig` 형태의 파일을 통해 어떤 서버 주소로 접근하고, 어떤 인증서로 API 서버에 인증할지 등을 설정할 수 있습니다. 우리가 로컬 머신에서 사용하는 `kubectl` 의 경우도 동일한 파일 포맷을 사용합니다. ![워커 노드 kubelet 이 사용하는 kubeconfig 설정 화면](https://www.kimsehwan96.com/content/images/2024/03/36da176c-20c1-4d87-9c81-b2782a283917.png) 위와 같이 `server` 항목에 도메인([kube.xxx.com:6443](http://kube.xxx.com:6443/?ref=kimsehwan96.com)) 을 입력해줍니다. 이로써 워커노드의`kubelet` 은 `kube.xxx.com` 도메인 네임을 resolve 하기 위해 DNS Query를 하게 되고. 그 응답으로 돌아오는 IP 주소를 통해 `kube-apiserver` 에 호출하게됩니다. `kubelet` 의 동작을 자세하게 확인하기 위해서 `kubelet` 의 로그레벨을 잠시 올려봅니다. `kubelet` 은 `systemd` 서비스로 설치되었고. 로그는 `journald` 에 기록되기때문에 로그를 확인하기 위해서는 `$ journalctl -xeu kubelet -f` 형태로 확인 가능합니다. 로그 레벨 변경은 `$ vi /etc/kubernetes/kubelet.env` 을 통해 `kubelet.env` 파일을 수정해야합니다. 기본적으로 위와 같이 `KUBE_LOG_LEVEL` 은 `--v=2` 로 되어있습니다. 이것을 잠시 `9`로 올려봅니다. 위 환경변수를 반영하기 위해서 `$ systemctl restart kubelet` 으로 `kubelet` 을 재실행합니다. ### 워커노드에서의 테스트 현재 환경에서는 2개의 `kube-apiserver` 가 있는데. 두 `kube-apiserver` 를 죽여봅니다. 각각의 컨트롤 플레인에서 `$ mv /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml` 를 통해 `kube-apiserver` static pod 를 제거해봅니다. ![kube-apiserver static pod 를 제거해 6443 포트가 닫힌 것을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/8b6cecfb-8ebd-44d3-bd80-3a4136713d66.png) static pod manifest 경로에서 `kube-apiserver.yaml` 을 제거하고. 프로세스가 떠있지 않고 6443 포트가 열려있지 않음을 확인 ![두 노드 모두 Route53 상태 검사에서 비정상으로 확인된 화면](https://www.kimsehwan96.com/content/images/2024/03/ede04682-d51f-4bf9-8c2f-f838bd972f97.png) Route53 상태 검사에서도 모두 비정상으로 확인 위와 같은 상황일때. `Route53` 은 `kube.xxx.com` 에 대한 DNS 쿼리 응답으로. `kube.xxx.com` 에 등록한 모든 `A Record` 를 반환합니다. ![모든 상태 검사가 비정상일 때 Route53 이 모든 A 레코드를 반환하는 화면](https://www.kimsehwan96.com/content/images/2024/03/c91b6bc8-2136-47c6-9ef3-6f5ea2e1f5b5.png) 모든 레코드를 반환 현재 `172.xx.xx.31` , `172.xx.xx.32` 두 컨트롤 플레인 노드에 떠있는 `kube-apiserver` 가 모두 없는 상황입니다. 이 때 워커노드의 `kubelet` 은 어떤지 로그를 살펴보면 (`$ journalctl -xeu kubelet -f`) ![kube-apiserver 가 모두 내려간 상황의 워커 노드 kubelet 로그](https://www.kimsehwan96.com/content/images/2024/03/9317cc90-61dc-48ab-a34b-dc924a2caa69.png) kubelet 의 로그. 노란색 박스 친 부분을 보면 `round_trippers` 코드 부분에서 DNS Loookup 을 진행해서 두개의 IP 주소를 받아오고. 이후 앞쪽에 있는 IP 주소에 먼저 TCP 커넥션 맺기를 시도해보고, 이후 다른 IP 주소로도 TCP 커넥션을 맺어보려고 한다. 이 `round_trippers` 는 DNS 쿼리 결과에 따른 IP 주소들에 대해서 성공하는 IP 주소로 DNS Query 결과를 `kubelet` 이 캐싱해서 해당 IP 주소로 계속해서 요청하기 위한 로직입니다. [https://github.com/kubernetes/client-go/blob/7ebe0ea60e0a6bea3e8280871c1241d0405cdb32/transport/round\_trippers.go#L459-L586](https://github.com/kubernetes/client-go/blob/7ebe0ea60e0a6bea3e8280871c1241d0405cdb32/transport/round%5Ftrippers.go?ref=kimsehwan96.com#L459-L586) 실제 kubernetes 컴포넌트들의 클라이언트 모듈인 `client-go` 레포 내의 `round_trippers` 코드를 보면 도메인을 DNS Query 하고, IP 주소로 커넥션을 맺어보려고 시도하는 로직들이 들어있습니다. 여기서 `172.xx.xx.31` 서버의 `kube-apiserver` 를 복구하고 `kubelet` 로그를 다시 확인해보겠습니다. ![kube-apiserver 복구 후 정상화된 워커 노드 kubelet 로그](https://www.kimsehwan96.com/content/images/2024/03/4539f7e9-00b9-4279-9224-84289127e12b.png) 172.xx.xx.31 의 kube-apiserver를 복구한 이후의 kubelet 로그 `172.xx.xx.31` 에 떠있는 `kube-apiserver` 로의 TCP 커넥션 맺기가 성공했다는 로그를 확인 할 수 있습니다. 이러한 로그가 생긴 시점 이후로는 `kube.xxx.com` 도메인으로 `kube-apiserver` 접근 요청을 하더라도 클라이언트 내부적으로 `172.xx.xx.31` 로 계속해서 요청을 보내게 됩니다. 추가적으로`172.xx.xx.32` 의 `kube-apiserver` 를 복구하더라도 더이상 DNS Query 를 하지 않고 계속해서 기존에 정상적으로 호출했던 `172.xx.xx.31` 쪽으로 요청을 하는것도 확인 할 수 있었습니다. ### 그럼 잘 동작하는거 아닌가요? kube-apiserver 앞단에 로드밸런서 이제 걷어내도 되나요? 위 테스트를 간단하게 했을때는 `kube-apiserver`의 일부가 장애가 발생했더라도, 정상적인 `kube-apiserver` 로 쿠버네티스 컴포넌트들(kubelet 포함한 kube-scheduler, kube-controller-manager 등)이 Round Robin DNS 를 통해서 정상적으로 동작하는 것 처럼 보입니다. 여기서 중요한 점은, `kubelet` 을 포함한 쿠버네티스 컴포넌트들은 아까 `kubelet` 이 사용했던 `kubernetes/client-go` 를 사용하는데. 이 클라이언트 코드는 `DNS` 쿼리를 하고, 반환된 IP 주소에 TCP 커넥션을 맺어보고 성공한 `IP:Port` 조합으로 계속해서 요청을 보내는 코드입니다. 따라서 `172.xx.xx.31` , `172.xx.xx.32` 에 존재하는 두개의 `kube-apiserver` 가 모두 장애가 발생했다가, 한쪽이 복구되었을 때, 복구된 한 쪽으로 `kubelet` 같은 쿠버네티스 컴포넌트들, 그리고 클라이언트들이 잘 호출 할 수 있겠지만, 클라이언트 코드쪽의 DNS 캐싱정책으로 인해서, 장애가 있었던 나머지 한쪽이 복구가 되더라도 트래픽이 Load Balancing 되는 것이 아니라, 여전히 한쪽으로만 흘러가게 되는 현상을 겪을 수 밖에 없습니다. 이는 `Round Robin DNS` 자체의 문제점으로, 불특정 다수의 다양한, 수많은 클라이언트가 동일 도메인으로 접속하는 경우에 대해서는 어느정도의 Load Balancing 이 가능하지만. 소수의 일부 클라이언트가 지속해서 접속해야하는 케이스에서는 적절한 Load Balancing 을 기대하긴 어렵습니다. ## Wrapping up Round Robin DNS 를 통해 `kube-apiserver` 앞단의 로드밸런서를 대체 할 수 있는지 테스트해보았는데 결론적으로는 반은 되고 반은 안되는 구성이라고 볼 수 있습니다. `kube-apiserver` 의 앞단 LB 를 대체 할 수 있는 부분은 일부 `kube-apiserver` 장애시 장애에 대한 복구(fail over)는 어느정도 가능하지만 대체 할 수 없는 부분이 일부 존재해 정말 진정한 `Load Balancing` 을 수행하기는 어렵다는 부분이 있습니다. 일반적인 LB의 경우 들어온 요청을 자신이 관리하는 뒷단의 백엔드 서버들로 로드밸런싱 정책에 맞춰 부하를 분산해줍니다. 하지만 지금과 같은 설정 케이스(RR-DNS)는 별도의 서버가 트래픽을 중계(proxy)해서 처리하는 형태가 아닌. 각각의 클라이언트들이 Domain 에 대한 IP 주소를 Resolve하고. 그 IP 주소가 여러대의 백엔드 서버(머신)주소 일 때 진정한 부하 분산이 되는 케이스라고 볼 수 있습니다. 여기서 클라이언트의 DNS Query 캐싱 동작으로 인해, 여러 클라이언트들이 여러 IP주소의 백엔드로 호출하고 있다가, 장애가 발생해서 일부 백엔드로 호출하고, 다시 모든 백엔드 서버가 정상이 되어서 여러 IP 주소의 백엔드로 호출하는 (부하분산) 형태가 아닌점이 문제가 됩니다. 대부분의 클라이언트들은 한번 DNS Query 를 하고 정상 동작중이라면 계속해서 동일한 IP주소의 백엔드에 요청을 보낸다는 점이 문제가 됩니다. 따라서 장애 발생 후 복구가 되었더라도, 장애 발생시 정상적으로 처리했던 백엔드에만 클라이언트가 요청을 하게 되는것입니다. 이렇게 되면 일부 서버에만 부하가 몰리게 됩니다. 물론 `kubelet` 같은 것들을 임의로 재시작해서(kubelet 프로세스의 재시작에 의미가 있는 건 아니고 DNS 캐싱을 초기화 하는 부분에 의미가 있음) Round Robin DNS 를 통해 어느정도의 강제적인 로드밸런싱을 수행 할 수도 있겠지만 그렇게 하는것이 과연 H/W, S/W LB 를 `kube-apiserver` 앞단에 두는것보다 얼마나 더 장점이 있을지를 고려해보고, 사용 자체는 해볼 수 있을 것으로 판단은 됩니다. ### K8s 오퍼레이터 URL: https://www.kimsehwan96.com/k8s-operator/ Last updated: 2026-07-20T12:37:01.000Z 💡 K8s 의 오퍼레이터(오퍼레이터, 혹은 오퍼레이터 패턴)에 대해서 알아보는 문서입니다. K8s 의 오퍼레이터 컨셉을 이해해보고, 오퍼레이터간의 미묘한 차이도 알아봅니다. (Custom Resource 를 이용한 오퍼레이터, 정말 Pure 한 오퍼레이터) ## 오퍼레이터 디자인 패턴 오퍼레이터는 [Introducing Operators: Putting Operational Knowledge into Software](https://cloud.redhat.com/blog/introducing-operators-putting-operational-knowledge-into-software?ref=kimsehwan96.com) CoreOS 블로그에서 공개된 디자인 패턴으로 운영자의 역할을 소프트웨어에 새긴 개념입니다. SRE / DevOps 엔지니어는 애플리케이션을 운영하는 엔지니어로서 운영 노하우, 도메인 지식등이 수반되어야 소프트웨어 개발/운영이 가능한데. 이러한 애플리케이션 운용에 있어서 가용성이 유지되도록 할 때 엔지니어가 수동으로 작업하는 부분이 꽤나 있습니다. [https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper\_v1-0.md](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper%5Fv1-0.md?ref=kimsehwan96.com) CNCF에서 소개한 위 자료에서는 상태를 수동적으로 관리해주어야 하는 한계를 극복하기 위해 고안된 오퍼레이터 패턴의 역할과 기능에 대해 자세하게 설명되어있습니다. ![오퍼레이터 디자인 패턴을 설명하는 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/4ae4cea8-286d-47a2-b1df-91db11f7f8f0.png) 이 오퍼레이터 패턴은 세 가지의 구성요소로 구성되는데 - 관리하려는 애플리케이션 또는 인프라 - 사용자가 선언적 방식으로 애플리케이션의 원하는 상태로 지정할 수 있게 해주는 도메인 특화 언어 - 지속적으로 실행되는 컨트롤러 - 상태를 읽고 인식 - 자동화된 방식으로 애플리케이션에 대한 작업을 실행 - 선언적 방식으로 애플리케이션의 상태를 보고 ## 쿠버네티스의 오퍼레이터 구성요소 ![쿠버네티스 오퍼레이터의 구성요소를 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/262ecd0b-d63c-403b-95a6-60a28d1a0dc4.png) - 쿠버네티스 컨트롤러 - Custom Resource / Custom Resource Definition - 컨트롤 루프 **컨트롤러**는 하나 이상의 여러 오브젝트를 감시 할 수 있으며, 이 객체는 Deployment, Service 같은 쿠버네티스의 기본 요소일 수도 있고, 가상머신, 데이터베이스와 같은 클러스터 외부에 있는 객체일수도 있습니다. 컨트롤러는 감시중인 객체가 정의된 방식으로 원하는 상태(Desired State)로 전환되도록 보장하는 **컨트롤 루프**를 사용해서 원하는 상태를 현재 상태와 지속적으로 비교합니다. Desired state는 하나 이상의 **Kubernetes Custom Resource Definition**에 캡슐화 되고, 컨트롤러에는 객체를 Desired state로 만드는 운영 지식이 포함되어있습니다. ### 쿠버네티스 컨트롤러 쿠버네티스 컨트롤러는 특정 리소스 유형으로 표현되는 Desired state로 실제(현재)상태가 일치되도록 관리합니다. 예를들자면 Deployment 컨트롤러는 하나의 파드가 삭제되거나 실패 할 때 원하는 양의 파드 복제본이 실행되고 새 파드가 가동되도록 관리합니다. ### Custom Resource / Custom Resource Definition Custom Resource는 Kubernetes API 를 확장하는 방식입니다. Kubernetes API 에서 리소스는 특정 종류의 API 오브젝트 모음을 저장하는 엔드포인트라고 할 수 있습니다. 예를들어서 Kuberentes 에 내장된 Pod 리소스에는 Pod 오브젝트의 모음이 포함되어있습니다. Custom Resource Definition은 고유한 객체 종류를 정의하고, API 서버가 전체 라이프 사이클을 처리 할 수 있도록 합니다. (파드의 라이프사이클을 API 서버가 관리하듯) 이것을 통해 사용자가 정의한 리소스를 만들어서 Desired State(원하는 상태)를 선언 할 수 있고, Operator 패턴과 함께 사용해서 Desired State 로 유지하도록 하는것이 보통의 유즈케이스입니다. ### 컨트롤 루프 컨트롤 루프는 쿠버네티스 컨트롤러 내에서 운영 지식을 기반으로 사용자 정의 리소스, 혹은 기존 K8s 리소스, 혹은 이벤트등을 감지해서 Desired State 로 만드는 반복되는 루프라고 할 수 있습니다. ## 정리 오퍼레이터는 운영 지식을 담아 운영을 자동화하는 소프트웨어의 디자인 패턴이라고 볼 수 있고. 파드를 재배치하거나, 원하는 상태로 되도록 조정하고, Custom Resource Definition & Custom Resource 를 통해 도메인지식/운영지식을 담아서 쿠버네티스 API를 확장하고, Custom Controller (CRD / CR 을 감시하고, 동작하도록 하기 위한 구성요소)는 그것들을 보고 실제 쿠버네티스 오브젝트를 배치하거나, 각종 조작등을하게 됩니다. 다만 `Keel` 같은 쿠버네티스 오퍼레이터는 CRD / CR 및 Custom Controller를 따로 두지 않고 primitive 쿠버네티스 오브젝트(Deployment / Stateful Set / Daemon Set)등을 관리해주는 역할을 하게 됩니다. 그러니까 CRD/CR , Custom Controller 는 필수 사항은 아니지만, 대부분의 오퍼레이터(Datadog Operator, ECK operator, Istiod그외 다양한 오퍼레이터들)들은 CRD/CR 을 통해 Kubenernets API 를 확장하고, CR 을 사용해 선언한 Deisred State 로 유지하도록 동작하게 됩니다. 간단하게 예를 들자면 Datadog 오퍼레이터를 사용하지 않을 경우 Helm을 통해 혹은 직접 Deployment, ConfigMap, Service Object, Service Account 등을(primitive k8s object)를 생성해서 구성해야겠지만, Datadog 오퍼레이터를 사용하면 Datadog 에서 제공하는 CR 을 통해 간단하게 구성을 할 수 있게 됩니다. ```yaml apiVersion: datadoghq.com/v2alpha1 kind: DatadogAgent metadata: name: datadog spec: global: credentials: apiSecret: secretName: datadog-secret keyName: api-key appSecret: secretName: datadog-secret keyName: app-key features: apm: enabled: true logCollection: enabled: true ``` 위와 같이 CR 을 통해 추상화된 리소스를 사용 할 수 있다. 개인적인 의견으로는, 설정이 복잡한 애플리케이션의 경우 대부분 Operator 도 제공해주고 있고 해당 Operator 가 업데이트가 잘 되고 있다면 사용하는데 크게 문제가 없다고 생각이 들긴 합니다. 그렇지만 그럼에도 Operator 를 사용하지 않고 구성 할 수 있는 경우에는 Operator 를 사용하지 않고 구성하는것이 장애포인트를 한단계 낮추는 작업이 된다고도 생각합니다. 그럼에도 Operator 패턴을 거의 무조건 써야한다고 생각이 들 때가 있는데, `Istiod` 처럼 `Istio` 의 확장 API (Istio 에서는 Gateway, VirtualSerice, DestionationRule 등등)를 수도 없이 많이 써야 할 경우와, primitive k8s 오브젝트로 커버 할 수 없는 정보를 저장하고, 그 정보를 토대로 오퍼레이터가 동작해야하는 경우가 그 경우라고 생각합니다.(물론 ConfigMap, Secrets 같은것들로 정보 자체를 넘겨 줄 수야 있겠지만, 가독성 측면에서 CR이 더 낫지 않을까요?) 예시로 Istio 를 들긴 했지만 해당 시스템에서, 해당 시스템 컨텍스트(추상적으로 이야기했지만..)안에서 돌아가야하는 리소스가 매우매우 많은 경우에는 오히려 primitive k8s 오브젝트를 쓰는것보다 나을거라고 생각합니다. (ArgoCD 도 이런 케이스라고 생각됩니다.) 잘못 된 정보를 수정해주시거나 피드백을 주실 것이 있으면 댓글은 언제든 환영입니다. ### How to increase pod number limit in eks (with terraform-aws-modules) URL: https://www.kimsehwan96.com/how-to-increase-eks-pod-number-limit-with-eks-terraform-module/ Last updated: 2024-03-28T16:03:00.000Z You can increase pod number limit in eks worker node by adding environment variable in `terraform-aws-modules`. In `terraform-aws-modules` you can use `cluster-addons` for installing cluster addons for your eks cluster, and you can inject some environment variable in `cluster-addons` member object. So we can inject environment variable in `vpc-cni` addon like following code snippet. You should read the attached `amazon-vpc-cni-k8s` link and you need to decide `WARM_PREFIX_TARGET` `WARM_IP_TARGET`, `MINIMUM_IP_TARGET` values. amazon-vpc-cni-k8s document : [https://github.com/aws/amazon-vpc-cni-k8s/blob/master/docs/prefix-and-ip-target.md](https://github.com/aws/amazon-vpc-cni-k8s/blob/master/docs/prefix-and-ip-target.md?ref=kimsehwan96.com) --- cluster-addons 에서의 `vpc-cni` 영역에 환경변수 설정을 주입해서 `ENABLE_PREFIX_DELEGATION=true` 로, `WARM_IP_TARGET` , `MINIMUM_IP_TARGET` 등은 각자 상황에 맞게 ([https://github.com/aws/amazon-vpc-cni-k8s/blob/master/docs/prefix-and-ip-target.md](https://github.com/aws/amazon-vpc-cni-k8s/blob/master/docs/prefix-and-ip-target.md?ref=kimsehwan96.com) 참고) ### Secrets of Running Etcd (번역) URL: https://www.kimsehwan96.com/secrets-of-running-etcd-korean-translation/ Last updated: 2026-07-20T12:37:01.000Z 💡 이 문서는 etcd 메인테니어이자, 구글 소프트웨어 엔지니어인 Marek Siarkowicz 이 2023 북미 KubeCon | CloudNativeCon 에서 발표한 내용을 정리한 내용입니다. ## Failures in distributed systems (분산 시스템에서의 Failures) ![발표 'Secrets of Running Etcd' 의 제목 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/28490609-ada9-49c7-aa73-2e550f178c74.png) etcd 는 Raft 합의 알고리즘을 사용하고 있기 때문에, 클러스터링 된 etcd 중 하나가 장애가 발생하더라도 최소한의 Quorum(정족수)이상의 클러스터 멤버가 정상적이라면 문제가 발생하지 않습니다. ![etcd 의 Raft 합의 알고리즘을 나타낸 발표 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/76c360ab-0ee8-4be6-8e38-2bf56ef5c49b.png) Raft 알고리즘 Raft 알고리즘은 여러 concurrent request 를 하나의 조직화된 상태로 만들어서 그것을 모든 Cluster 멤버에게 동기화 시킬 수 있는 알고리즘이라고 이야기 할 수도 있습니다. 이 알고리즘을 통해 모든 멤버는 동일한 데이터를 갖고, 동일한 상태로 유지하도록 할 수 있습니다. ![Raft 에서 장애가 발생한 상황을 나타낸 발표 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/8aa2dfdb-3a7c-42fc-a686-c8331360fbd3.png) Raft 에서의 failure Raft 알고리즘의 특성으로, 하나의 애플리케이션이나 클라이언트에서 들어오는 잘못된 요청이나, 예기치않고 문제가 되는 데이터나, 행동들(많은 데이터를 미친듯이 박아넣는다던지) 역시나 모든 멤버에게 그런 잘못된 데이터나, 많은 데이터들이 뿌려지게 되어서 모든 멤버에게 영향을 끼칠수도 있기도 합니다. ## Kubernetes events explosion (K8s 이벤트 오브젝트의 폭발적인 발생으로 인한 문제라고 생각하시면 되는..) ![etcd 가 쿠버네티스 objects 와 events 를 저장하는 구조를 나타낸 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/ff68aca6-4e5b-486b-964d-e655143e6151.png) etcd 는 쿠버네티스의 objects 와 events 를 저장(또는 lease)한다. 쿠버네티스는 2가지 타입의 리소스를 etcd에 저장하는데, 하나는 `Objects` 이고 하나는 `Events` 입니다. `Objects` 는 우리가 잘 알고있는 Pod / Deplyoment / StatefulSet / Secret / ConfigMap 과 같은 리소스들로서, 쿠버네티스 클러스터 내의 워크로드들의 상태를 표현하는 것으로 생각 할 수 있습니다. 우리가 `Pod` 를 선언적으로든, 명령적으로든 쿠버네티스 클러스터에 `apply` 요청을하면 우리는 `Desired State` 를 쿠버네티스에 알려준셈입니다. 쿠버네티스는 `Desired State` 를 유지하기 위해서 `kube-schedueler` , `kube-controller-manager` , `kubelet` 등의 컴포넌트들이 열심히 일을 합니다. 따라서 `Objects` 들은 매우 매우 중요하며 (그것들 자체가 쿠버네티스 워크로드와 밀접한 관련이 있으니까), 삭제 하지 않는 한 `etcd` 에 영구히 남아있는 데이터들입니다. 또한 이러한 `Objects` 들은 쿠버네티스 관리자 / 백엔드 개발자 등 쿠버네티스를 접근하는 관리자들이 선언하는 리소스니까 당연하게도 예측 가능한 사이즈로 존재하게 됩니다. (봇/오퍼레이터 같은 자동화 된 쿠버네티스 요소가 갑자기 Pod 나 ConfigMap 을 1000개, 10000개 씩 만드는 이상한 일을 하지 않는 이상) `Events` 는 쿠버네티스 오브젝트의 상태 변화를 기록한것으로, API 를 통한 로그또한 확인이 가능한 리소스입니다. Best effort 로 생성되며, 해당 이벤트가 쿠버네티스 관리자에게 전달되었든지, 안되었든지등을 판단하지 않고 상태가 변할 때 마다 계속해서 생성되는 리소스입니다. 또한 이 데이터들은 `etcd Leases` 기능을 사용해서 1시간 뒤에는 삭제되는 데이터들입니다. 보통 설정의 오류나 클러스터의 일부 소프트웨어 설정 오류로 인해서 failures 가 발생하는 경우 발생하게 됩니다. ![etcd leases 로 데이터를 임시 저장하는 개념을 설명하는 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/f86153ad-a16b-404e-9493-f1bf7098b9a6.png) etcd leases. 데이터를 임시로 저장하는 개념이라고 볼 수 있다. etcd 의 leases 기능은 짧은 시간 저장할 데이터 (Time To Live 기반)를 위해 설계되었습니다. - 오직 etcd 리더만이 TTL 만료 카운트를 처리하고 - TTL 카운트의 진행상태에 대해서는 멤버간에 공유되지 않습니다. - 이 상황에서 리더가 변경되면 처음부터 다시 TTL 처리를 위한 카운트가 재시작됩니다.(초기화) 쿠버네티스는 `Events` 리소스에 대한 TTL 을 1시간으로 설정해두었습니다. (PPT 에는 2시간으로 되어있는데. `kube-apiserver` 의 `--event-ttl` 값을 명시적으로 주지 않을 경우 Default 가 1시간입니다. / [kube-apiserver](https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/?ref=kimsehwan96.com) 참조) ![etcd 이벤트 처리에 대한 메인테이너 권장 사항 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/fe53106c-55ac-40d0-a32a-dc8f709fce8b.png) etcd 에 대해서 메인테이너가 추천하는 것들 - Event 를 위한 etcd 인스턴스(클러스터)를 분리하는 것. (kubespray 에서 이것을 쉽게 활성화해서 event 용 etcd 를 분리해서 프로비저닝 할 수 있음. 직접하는것도 크게 문제 될 요소가 아님. kube-apiserver 에 event 용 etcd 서버를 `--etcd-servers-overrides` 인자를 통해서 별도 지정하면 됨.) - `etcd` 이벤트를 persistent 하게 만들지 말고 별도의 로깅 백엔드로 전달하기. (e.g Loki / ElasticSearch, etcs..) - 만약 Event 리소스때문에 etcd 로드가 계속해서 늘어난다면 TTL 을 줄이기. (`kube-apiserver`) - 리더의 TTL 만료를 확인하기 위한 count 체크를 아래 두가지 인자를 활성화해서 더 안전하게 만들기. (카운트 리셋을 방지하기) - `-experimental-enable-lease-checkpoint` → 리더 변경시 TTL 리셋을 방지 - `-experimental-enable-lease-checkpoint-persist` → 재시작시 TTL 리셋 방지 ([Leases may be auto-renewed indefinitely due to leader elections · Issue #9888 · etcd-io/etcd](https://github.com/etcd-io/etcd/issues/9888?ref=kimsehwan96.com) ) ## etcd quota deadlock ![etcd quota 개념을 설명하는 발표 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/7691724b-67e3-47e7-b973-3301bc46eb21.png) etcd quota etcd는 아래와 같은것을 저장 - 특정 키에 대한 최신 상태 - compaction(압축) 이후의 키의 모든 변경사항 (revision) etcd 는 아래와 같은 quota 존재 - Disk 에 저장되는 db 파일 사이즈의 limit (디폴트 2GB) - db 파일사이즈 리밋에 도달하면 etcd member 는 알람을 발생시킴 - 이 알람은 etcd 관리자의 개입이 필요함 (`compaction` and `Defragmentation`) ![Compaction 으로 이전 Revision 을 제거해 용량을 확보하는 개념 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/60781d03-981e-4c86-897a-2e0828233b6f.png) Compaction(압축)이란 특정 시점의 Revision 이전의 모든 Revision 을 제거하는것. 이렇게해서 용량 확보가 가능 ![Compaction 후 Defragmentation 으로 물리 용량을 확보하는 과정 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/f61bbcaa-d908-4def-a4ce-de0aacda5821.png) Compaction 이후에는 Defragmentation 을 통해 실제 물리적인 용량 확보가 필요함 ![kube-apiserver 의 --etcd-compaction-interval 옵션을 설명하는 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/909e393a-2004-43a9-bab4-66afa8891a7d.png) kube-apiserver 의 `--etcd-compaction-interval` 옵션에 대한 설명. Default 는 5m0s 로 되어있음. `kube-apiserver` 의 `--etcd-compaction-interval` 알고리즘에 대한 간단한 설명인데. 가장 큰 문제는 `etcd` 가 `out of quota` 상황일 경우 동작하지 않는 다는 점이 있습니다. ![etcd Compaction·Defragmentation 에 대한 메인테이너 권장 사항 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/9c340cb3-9687-4d37-bfa2-6713e1344bda.png) etcd Compaction and Defragmentation 에 관한 메인테이너의 권장 사항 - Compaction(압축) - etcd 의 빌트인 compaction 매커니즘을 활용하기 (`--auto-compaction-retention=5m`) - etcd 가 할당량(db 파일 사이즈 리밋)을 넘어섰을 때도 동작 함 - Reduces overhead of window - `kube-apiserver` 의 `--etcd-compaction-interval` 옵션보다 `etcd` 의 `--auto-compaction-retention` 이 오버헤드가 상대적으로 적다는 메인테이너의 발언이 있음. 즉 `kube-apiserver` 의 `--etcd-compaction-interval` 을 0으로 설정하여 비활성화하고, `etcd` 측에서 `--auto-compaction-retention` 을 설정하는 편이 오버헤드가 훨씬 적다고 함. `kube-apiserver` 의 로직은 필요한 etcd 데이터 용량의 2배정도를 상용하는 반면, `etcd` 의 로직은 10% 의 오버헤드만 가진다고 함 - etcd 할당 대비 사용량(db size) 모니터링 - Exclude `out of quota` alarm from health probes - Deframentation(조각모음) - X 분마다 Cronjob 형태로 수행하기 + 일부 jitter 시간을 둬서 동시에 여러 etcd 멤버가 Defrag 수행하는것을 방지하기 - Defrag 를 아래 컨디션에서만 수행하기 - At least xxx MB of data to recover AND - Quota utilization over 75% - Disalarm out of quota alarm ## etcd watch starvation ![kube-scheduler 등 클라이언트가 kube-apiserver 와 통신하는 구조 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/295d1ce1-6658-42c8-a656-3667c477b4b3.png) kube-scheduler / kube-controller-manager / kubelet 등은 kube-apiserver 와 통신한다. 다양한 쿠버네티스 컴포넌트 (kube-scheduler / kube-controller-manager 등)은 kube-apiserver 를 통해 통신하는데. 결국 각 리소스 유형 별로 별도의 스토리지와 Go 스트럭쳐 구조가 있고, 각 클라이언트는 etcd에 독립적인 gRPC 커넥션을 맺습니다. 그래서 특정 리소스(Deployment / Pods / Nodes)에 대한 트래픽이 몰리면 그 많은 트래픽이 단일 커넥션으로 몰리게 됩니다. ![watch starvation 으로 트래픽이 단일 커넥션에 몰린 사례 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/e94e7c3f-b8e1-443e-a245-42279c6aaf29.png) 메인테이너가 겪은 상황 TLS 활성화를 위한 작업을 진행하던 도중 리소스 `watch` 에 대해서 starvation 이 발생했던 경험 공유 ([Etcd watch stream starvation under high read response load when sharing same connection and TLS is enabled · Issue #15402 · etcd-io/etcd](https://github.com/etcd-io/etcd/issues/15402?ref=kimsehwan96.com) ) 어느 한 클러스터에서만 문제가 생겼는데. 그 클러스터는 다수의 데몬셋 컨트롤러(데몬셋)가 실행중이였다는 것. 해당 클러스터에는 Fluentd / Fluentbit 과 같이 로깅과 관련된 데몬셋이 많이 떠있었습니다. 이런 데몬셋 컨트롤러들은 로깅과 관련된 메타데이터를 수정한다든지(Fluentbit 설정?) 할 때 kube-apiserver 와 커넥션을 맺어야 하는데. 이 때 다수의 노드에 짧은 다운 타임이 있다면 정말 많은 동시 다발적인 LIST 요청이 들어가고, 단일 리소스 타입에 대해서 하나의 gRPC 커넥션에 엄청난 트래픽이 몰렸습니다. 거기에 더해 알고리즘이 Watch 보다 List 에 우선순위가 더 높아서 List 응답만 계속 하다가 Watch 는 응답을 몇분동안 못받는 포화(starvation)상태가 되어버렸습니다. ![etcd serving stack 의 TLS·비TLS 처리 경로를 나타낸 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/245d2392-19bf-460d-b4e4-516eda9f2265.png) etcd serving stack. 비 TLS 경로와 TLS 경로가 좀 다르다. 이 문제가 발생한 이유는 HTTP2 표준에서 TLS커넥션에 대해서 Multiplexing를 허용하지 않았다는것. ❗ (추가) HTTP2 표준이라고 영상에서는 말하는데, 아마 etcd serving stack 이라고 지칭하는 etcd 앞단에서 트래픽을 처리하는 부분에서 구현이 잘 되어있지 않았던게 아닌가 판단됩니다. non-TLS 트래픽인경우 HTTP 요청은 HTTP 서버로, gRPC 요청인경우 gRPC 서버로 커넥션 레벨에서 체크해서 트래픽을 틀어줄 수 있는데, TLS 트래픽인경우에는 커넥션 레벨에서 HTTP 요청인지, gRPC 요청인지 구분을 할 수 없기 때문에 일단 HTTP 서버에 넘겨주고 gRPC 헤더가 있는경우에 gRPC 핸들러(서버)로 넘겨주는 구조로 되어있었습니다. 즉, non-TLS 트래픽은 `gRPC` 요청(stream) 을 `gRPC handler` 로 곧바로 넘겨 줄 수 있었기 때문에 `stream` 요청에 대한 우선순위가 밀리는등의 처리가 없이 잘 동작했지만. TLS 트래픽의 경우는 일단 `HTTP` 서버로 넘어간 이후에 처리되었고, 이 때 `stream` 요청에 대한 우선순위 처리가 제대로 되지 않아 문제가 생겼었던것으로 정리 할 수 있습니다. ![TLS 트래픽의 HTTP 서버와 gRPC 서버 처리 차이를 나타낸 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/2b889f85-96ac-45a7-9468-687f14709cd3.png) HTTP vs gRPC Server gRPC 는 HTTP 서버를 통해 실행하는것을 지원하지만 - 성능과 기능이 좀 다를 수 있고 - grpc-go의 HTTP/2 서버를 통해 제공되는 일부 gRPC 기능을 지원하지 않습니다. 원인은 - HTTP/2 는 writer 별 다중 스트림을 지원하고 - 어떤 스트림을 통해 응답할지 결정하는 알고리즘이 다르고 - HTTP 서버는 `PriorityWriteScheduler` 를 사용하는데 (golang) - 짧은 요청은 우선순위가 높고 처리가 잘 되었지만 - etcd Watch 같은 gRPC 스트림은 starvation 상태 - 정리하자면, 짧은 요청이 지속적으로 많이 발생하는동안, gRPC streams 는 완전히 차단되어버림 ([net/http: Stream starvation in http2 priority write scheduler · Issue #58804 · golang/go](https://github.com/golang/go/issues/58804?ref=kimsehwan96.com) ) 결론적으로는 `etcd` 와 `golang` 팀과의 협력을 통해서 `HTTP/2 server (golang)` 부분이 개선되고, `etcd watch` 에 대한 처리 알고리즘 또한 개선되었습니다. ![etcd watch starvation 에 대한 권장 사항 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/19c38bf6-fcbb-48ba-9847-34d2e16f801d.png) 권장 사항 etcd v3.4.28, v3.5.10 에서는 이 부분이 해결되었다고 합니다. ([golang.org/x/net](http://golang.org/x/net?ref=kimsehwan96.com) 이 RR 알고리즘을 사용하도록 업데이트 되면서 같이 반영) ## Failure Mitigation ![전체 클러스터를 하나의 장애 도메인으로 간주하는 완화 전략 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/054b5a0b-ce3b-4f2b-a295-8e5d4ad57f3d.png) 전체 클러스터를 하나의 장애 도메인으로 간주하기 ![한 클러스터 장애 시 다른 클러스터가 트래픽을 인계받는 구조 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/22114f03-28dd-4e62-8b82-efcba550520c.png) 클러스터에 장애 발생한다면? 다른 클러스터가 트래픽을 받도록 해야한다. Kubernetes가 100% 신뢰성을 제공하거나 ,개발자의 실수로부터 완전히 보호 해 줄 것이라고 가정해서는 안됩니다. .클러스터에 변경이 발생 할 경우 발견되지 않은 문제로 이어 질 수 있고, 모든 것을 한 곳에 의존하기보다는 클러스터를 분리하고, 장애 발생시 다른 클러스터가 트래픽을 인계 받을 수 있도록 준비하는것이 좋습니다. 전체가 장애가 발생하는것보다는, 부분 적인 다운타임이 발생하거나 약간의 성능저하나 지연이 발생하는것이 낫습니다. ![카나리 방식의 점진적 rollout 을 나타낸 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/300dc389-4fc0-4642-b560-742d3200ffba.png) 카나리 rollout 블루-그린 배포처럼 모든 변경 사항을 클러스터의 잠재적인 중단으로 간주하고 독립적으로 롤아웃하는것을 추천합니다. `etcd` 업그레이드, `Kubernetes` 업그레이드, 혹은 새로운 트래픽 패턴을 유발하는 애플리케이션 업그레이드 같은것 모든것을 잠재적인 중단, 장애포인트로 생각해야합니다. 이런 변경사항을 모두 블랙박스로 간주하고 독립적으로 롤아웃한다면 문제를 조기에 발견하고 장애 여파를 줄일 수 있습니다. ![변경 사항을 독립적으로 점진 롤아웃하는 전략 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/cabdf16d-7653-464e-8212-a5c46e3361e5.png) 점진적인 rollout 모든 변경을 중단, 잠재적인 장애 포인트로 간주하고 모든 변경을 검증해야 합니다. 클러스터를 다양한 중요도로 분리하고 각 각에 대해서 단계적 롤아웃을 할당함으로써 위험을 최소화 할 수 있습니다. GKE 가 그렇게 하고 있습니다. 이런 모든 해결책은 장애를 완화하기위한 표준적인 접근 방식이며, 새로 제안된 내용이 아닙니다. ## What is the secret ![장애 완화의 표준적 접근을 요약한 'What is the secret' 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/eb94558a-5a5c-4470-a09f-ede41073300a.png) 이런 etcd 운영에 관련한 비밀, 노하우는 어딘가에 숨겨져있는것이 아니라 모두 공개적으로 논의되고있었습니다. 여러분들은 그것을 읽고 확인 할 수 있었습니다. 예를들어 Kubernetes 이벤트 이슈는 오랫동안 존재해왔고 etcd 의 이벤트 저장용 클러스터를 분리하는 것이 해결책으로 제시되었습니다. 그럼에도 불구하고 여전히 많은 사람들이 이벤트를 어떻게 다뤄야 하는지 질문하고있습니다. !['free speech, not free beer' 문구가 담긴 발표 마무리 슬라이드](https://www.kimsehwan96.com/content/images/2024/03/1888dbd5-17fb-4b8d-8ecd-df5197e19676.png) free speech, not free beer 사람들은 오픈소스를 완성된 해결책으로 여기고 경험을 쌓는 과정을 건너뛰고, 무료 맥주를 하나 꺼내 마시듯이 그것을 사용하려 하지만 오픈 소스는 아이디어를 교환하고 함께 배우는 자유에 관한 것입니다. etcd 를 운영 할 때 오픈 소스 커뮤니티가 어떻게 운영되는지를 알고 오랜 시간 동안 테스트되어온 etcd 의 일반적인 사용 사례를 벗어나는 함정을 피하기 위해 시간을 투자해야 합니다. etcd 는 생산적인 key-value 스토어이지만 그것을 프로덕션 레디 상태로 만드는 것은 여러분의 몫입니다. 이 발표자가 논의한 대부분의 etcd 이슈에 대한 해결책은 오픈 소스로 구현 할 수 없었습니다. Kubernetes 클러스터를 재설계 해야하거나 재구성 해야 하는 것을 요구하기 때문입니다. (실제로 이 메인테이너가 TLS , HTTP2 관련해서 이슈를 올린 것을 살펴보면 여러 대안을 제시하고 일부는 현실적으로 어려움이 있어서 폐기하기도 하였음. / [Etcd watch stream starvation under high read response load when sharing same connection and TLS is enabled · Issue #15402 · etcd-io/etcd](https://github.com/etcd-io/etcd/issues/15402?ref=kimsehwan96.com) ) 여러분은 그것을 고려하고 어떻게 알고있는 흔한 함정을 피할 수 있을지 생각해야 합니다. 그러기 위해 가장 좋은 방법은 커뮤니티의 집단적인 경험을 활용하는 것입니다. 대부분의 사람들이 여전히 알려진 문제가 있는 etcd 버전을 사용하고 있으며, 그 문제에 대해 여러차례 메일이 발송되었음에도 불구하고 여전히 그것을 따르지 않다는 것을 알 고 있습니다. 저의 제안(메인테이너)은 커뮤니티가 무엇을 하고 있는지, 논의가 어떻게 진행되고 있는지를 알아보는데 시간을 투자하는 것입니다. ## Wrapping up 이 세션을 발표한 etcd 메인테이너이자, 구글 소프트웨어 엔지니어인 Marek Siarkowicz는 etcd 의 잘 알려진 (그러나 많이들 따르지 않거나 모르고 있는) 문제에 대해서 발표 전체에 걸쳐서 다뤘습니다. 뒤쪽에서는 `etcd`를 단순히 쉽게 `free beer` 처럼 꺼내 쓰는 것이 아니라 정말 생산적이고, Production Ready 상태로 만드는것은 사용자(개발자)의 몫이며, 잘 알려진, 오랜시간동안 테스트되어온 사용사례를 따르기 위해서 커뮤니티를 살펴보고 이 프로젝트가 어떻게 흘러가고있는지 시간을 투자하라는 이야기를 하고 있습니다. 또한 etcd 를 사용하다 문제가 생긴 경우 커뮤니티, etcd 프로젝트를 찾아보거나 메일링 리스트를 살펴보고, 커뮤니티에 질문하고 더 나아가서는 겪은 문제에 대해서 공유하고, 혼자서 해결하려고 시도하지 말고 커뮤니티의 집단적인 경험을 잘 활용하라고 이야기 합니다. > 모든 것을 혼자서 해결 하려 하지 말고, 커뮤니티의 집단적 경험을 활용하고, 대화를 나누면서 배우세요. 발표자의 발표 말미의 한 마디를 인용하면서 글을 마칩니다. ### gRPC transcoder in Istio 테스트 URL: https://www.kimsehwan96.com/grpc-transcoder-in-istio/ Last updated: 2026-07-20T12:37:01.000Z 💡 gRPC transcoder 를 envoy가 아닌 istio 레벨에서 사용해보는 예제입니다. gRPC transcoder 는 gRPC 서버에서 기존 `RESTful API` 혹은 `HTTP API`라고 불리는 `json` 기반의 API 호출을 gRPC로 변환해주는 일종의 프록시라고 할 수 있습니다. 이것을 통해 우리는 gRPC 엔드포인트와 HTTP 엔드포인트를 동시에 제공 할 수 있습니다. 테스트 코드들 [GitHub - kimsehwan96/gRPC-python-example](https://github.com/kimsehwan96/gRPC-python-example?ref=kimsehwan96.com) [GitHub - kimsehwan96/istio-example](https://github.com/kimsehwan96/istio-example?ref=kimsehwan96.com) ## gRPC 테스트 서버 gRPC 의 경우 직렬화/역직렬화 과정에서 protobuf 를 사용합니다. 따라서 `.proto` 파일을 통해 주고받을 데이터의 형식을 지정해야 합니다. ```proto // Copyright 2015 gRPC authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; option java_multiple_files = true; option java_package = "io.grpc.examples.helloworld"; option java_outer_classname = "HelloWorldProto"; option objc_class_prefix = "HLW"; package helloworld; import "google/api/annotations.proto"; // The greeting service definition. service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) { option (google.api.http) = { get: "/v1/hello" }; } // Another Method rpc SayHelloAgain (HelloRequest) returns (HelloReply) {} rpc SayHelloStreamReply (HelloRequest) returns (stream HelloReply) {} rpc SayHelloBidiStream (stream HelloRequest) returns (stream HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; } ``` proto 파일 예제 위와 같이 간단한 `proto` 파일을 생성합니다. 여기서 `json` 트랜스코더에서 사용할 `option (google.api.http)` 부분을 추가하면 기존 Restful API(HTTP API) 형태로 사용이 가능합니다. 다만 이때 `proto descriptor` 생성을 할 때 `googleapis` 를 proto descriptor 빌드하는 환경에 추가해야 합니다. 굳이 위 예제에서의 gRPC 서버가 아니더라도, HTTP 엔드포인트를 노출한 protobuf + gRPC 서버라면 어찌되었든 이 예제에서 사용하려는 테스트는 진행 가능합니다. ### Istio Envoy filter ```Yaml apiVersion: networking.istio.io/v1alpha3 kind: EnvoyFilter metadata: name: grpc-transcoder spec: workloadSelector: labels: app: grpc-python configPatches: - applyTo: HTTP_FILTER match: context: SIDECAR_INBOUND listener: portNumber: 7777 # 5xxxx 번대 포트를 지정하면 반영이 안된다. 이건 왜그런지; filterChain: filter: name: "envoy.filters.network.http_connection_manager" subFilter: name: "envoy.filters.http.router" patch: operation: INSERT_BEFORE value: name: envoy.filters.http.grpc_json_transcoder typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder services: - helloworld.Greeter print_options: add_whitespace: true always_print_enums_as_ints: false always_print_primitive_fields: true preserve_proto_field_names: false convert_grpc_status: true proto_descriptor_bin: CuF4ChVnb29nbGUvYXBpL2h0dHAucHJvdG8SCmdvb2dsZS5hcGkieQoESHR0cBIqCgVydWxlcxgBIAMoCzIULmdvb2dsZS5hcGkuSHR0cFJ1bGVSBXJ1bGVzEkUKH2Z1bGx5X2RlY29kZV9yZXNlcnZlZF9leHBhbnNpb24YAiABKAhSHGZ1bGx5RGVjb2RlUmVzZXJ2ZWRFeHBhbnNpb24i2gIKCEh0dHBSdWxlEhoKCHNlbGVjdG9yGAEgASgJUghzZWxlY3RvchISCgNnZXQYAiABKAlIAFIDZ2V0EhIKA3B1dBgDIAEoCUgAUgNwdXQSFAoEcG9zdBgEIAEoCUgAUgRwb3N0EhgKBmRlbGV0ZRgFIAEoCUgAUgZkZWxldGUSFgoFcGF0Y2gYBiABKAlIAFIFcGF0Y2gSNwoGY3VzdG9tGAggASgLMh0uZ29vZ2xlLmFwaS5DdXN0b21IdHRwUGF0dGVybkgAUgZjdXN0b20SEgoEYm9keRgHIAEoCVIEYm9keRIjCg1yZXNwb25zZV9ib2R5GAwgASgJUgxyZXNwb25zZUJvZHkSRQoTYWRkaXRpb25hbF9iaW5kaW5ncxgLIAMoCzIULmdvb2dsZS5hcGkuSHR0cFJ1bGVSEmFkZGl0aW9uYWxCaW5kaW5nc0IJCgdwYXR0ZXJuIjsKEUN1c3RvbUh0dHBQYXR0ZXJuEhIKBGtpbmQYASABKAlSBGtpbmQSEgoEcGF0aBgCIAEoCVIEcGF0aEJqCg5jb20uZ29vZ2xlLmFwaUIJSHR0cFByb3RvUAFaQWdvb2dsZS5nb2xhbmcub3JnL2dlbnByb3RvL2dvb2dsZWFwaXMvYXBpL2Fubm90YXRpb25zO2Fubm90YXRpb25z+AEBogIER0FQSUqycwoHEgUOAPoCAQq8BAoBDBIDDgASMrEEIENvcHlyaWdodCAyMDIzIEdvb2dsZSBMTEMKCiBMaWNlbnNlZCB1bmRlciB0aGUgQXBhY2hlIExpY2Vuc2UsIFZlcnNpb24gMi4wICh0aGUgIkxpY2Vuc2UiKTsKIHlvdSBtYXkgbm90IHVzZSB0aGlzIGZpbGUgZXhjZXB0IGluIGNvbXBsaWFuY2Ugd2l0aCB0aGUgTGljZW5zZS4KIFlvdSBtYXkgb2J0YWluIGEgY29weSBvZiB0aGUgTGljZW5zZSBhdAoKICAgICBodHRwOi8vd3d3LmFwYWNoZS5vcmcvbGljZW5zZXMvTElDRU5TRS0yLjAKCiBVbmxlc3MgcmVxdWlyZWQgYnkgYXBwbGljYWJsZSBsYXcgb3IgYWdyZWVkIHRvIGluIHdyaXRpbmcsIHNvZnR3YXJlCiBkaXN0cmlidXRlZCB1bmRlciB0aGUgTGljZW5zZSBpcyBkaXN0cmlidXRlZCBvbiBhbiAiQVMgSVMiIEJBU0lTLAogV0lUSE9VVCBXQVJSQU5USUVTIE9SIENPTkRJVElPTlMgT0YgQU5ZIEtJTkQsIGVpdGhlciBleHByZXNzIG9yIGltcGxpZWQuCiBTZWUgdGhlIExpY2Vuc2UgZm9yIHRoZSBzcGVjaWZpYyBsYW5ndWFnZSBnb3Zlcm5pbmcgcGVybWlzc2lvbnMgYW5kCiBsaW1pdGF0aW9ucyB1bmRlciB0aGUgTGljZW5zZS4KCggKAQISAxAAEwoICgEIEgMSAB8KCQoCCB8SAxIAHwoICgEIEgMTAFgKCQoCCAsSAxMAWAoICgEIEgMUACIKCQoCCAoSAxQAIgoICgEIEgMVACoKCQoCCAgSAxUAKgoICgEIEgMWACcKCQoCCAESAxYAJwoICgEIEgMXACIKCQoCCCQSAxcAIgrNAQoCBAASBBwAKQEawAEgRGVmaW5lcyB0aGUgSFRUUCBjb25maWd1cmF0aW9uIGZvciBhbiBBUEkgc2VydmljZS4gSXQgY29udGFpbnMgYSBsaXN0IG9mCiBbSHR0cFJ1bGVdW2dvb2dsZS5hcGkuSHR0cFJ1bGVdLCBlYWNoIHNwZWNpZnlpbmcgdGhlIG1hcHBpbmcgb2YgYW4gUlBDIG1ldGhvZAogdG8gb25lIG9yIG1vcmUgSFRUUCBSRVNUIEFQSSBtZXRob2RzLgoKCgoDBAABEgMcCAwKogEKBAQAAgASAyACHhqUASBBIGxpc3Qgb2YgSFRUUCBjb25maWd1cmF0aW9uIHJ1bGVzIHRoYXQgYXBwbHkgdG8gaW5kaXZpZHVhbCBBUEkgbWV0aG9kcy4KCiAqKk5PVEU6KiogQWxsIHNlcnZpY2UgY29uZmlndXJhdGlvbiBydWxlcyBmb2xsb3cgImxhc3Qgb25lIHdpbnMiIG9yZGVyLgoKDAoFBAACAAQSAyACCgoMCgUEAAIABhIDIAsTCgwKBQQAAgABEgMgFBkKDAoFBAACAAMSAyAcHQqUAgoEBAACARIDKAIrGoYCIFdoZW4gc2V0IHRvIHRydWUsIFVSTCBwYXRoIHBhcmFtZXRlcnMgd2lsbCBiZSBmdWxseSBVUkktZGVjb2RlZCBleGNlcHQgaW4KIGNhc2VzIG9mIHNpbmdsZSBzZWdtZW50IG1hdGNoZXMgaW4gcmVzZXJ2ZWQgZXhwYW5zaW9uLCB3aGVyZSAiJTJGIiB3aWxsIGJlCiBsZWZ0IGVuY29kZWQuCgogVGhlIGRlZmF1bHQgYmVoYXZpb3IgaXMgdG8gbm90IGRlY29kZSBSRkMgNjU3MCByZXNlcnZlZCBjaGFyYWN0ZXJzIGluIG11bHRpCiBzZWdtZW50IG1hdGNoZXMuCgoMCgUEAAIBBRIDKAIGCgwKBQQAAgEBEgMoByYKDAoFBAACAQMSAygpKgq7UwoCBAESBrsCAPECARqsUyAjIGdSUEMgVHJhbnNjb2RpbmcKCiBnUlBDIFRyYW5zY29kaW5nIGlzIGEgZmVhdHVyZSBmb3IgbWFwcGluZyBiZXR3ZWVuIGEgZ1JQQyBtZXRob2QgYW5kIG9uZSBvcgogbW9yZSBIVFRQIFJFU1QgZW5kcG9pbnRzLiBJdCBhbGxvd3MgZGV2ZWxvcGVycyB0byBidWlsZCBhIHNpbmdsZSBBUEkgc2VydmljZQogdGhhdCBzdXBwb3J0cyBib3RoIGdSUEMgQVBJcyBhbmQgUkVTVCBBUElzLiBNYW55IHN5c3RlbXMsIGluY2x1ZGluZyBbR29vZ2xlCiBBUElzXShodHRwczovL2dpdGh1Yi5jb20vZ29vZ2xlYXBpcy9nb29nbGVhcGlzKSwKIFtDbG91ZCBFbmRwb2ludHNdKGh0dHBzOi8vY2xvdWQuZ29vZ2xlLmNvbS9lbmRwb2ludHMpLCBbZ1JQQwogR2F0ZXdheV0oaHR0cHM6Ly9naXRodWIuY29tL2dycGMtZWNvc3lzdGVtL2dycGMtZ2F0ZXdheSksCiBhbmQgW0Vudm95XShodHRwczovL2dpdGh1Yi5jb20vZW52b3lwcm94eS9lbnZveSkgcHJveHkgc3VwcG9ydCB0aGlzIGZlYXR1cmUKIGFuZCB1c2UgaXQgZm9yIGxhcmdlIHNjYWxlIHByb2R1Y3Rpb24gc2VydmljZXMuCgogYEh0dHBSdWxlYCBkZWZpbmVzIHRoZSBzY2hlbWEgb2YgdGhlIGdSUEMvUkVTVCBtYXBwaW5nLiBUaGUgbWFwcGluZyBzcGVjaWZpZXMKIGhvdyBkaWZmZXJlbnQgcG9ydGlvbnMgb2YgdGhlIGdSUEMgcmVxdWVzdCBtZXNzYWdlIGFyZSBtYXBwZWQgdG8gdGhlIFVSTAogcGF0aCwgVVJMIHF1ZXJ5IHBhcmFtZXRlcnMsIGFuZCBIVFRQIHJlcXVlc3QgYm9keS4gSXQgYWxzbyBjb250cm9scyBob3cgdGhlCiBnUlBDIHJlc3BvbnNlIG1lc3NhZ2UgaXMgbWFwcGVkIHRvIHRoZSBIVFRQIHJlc3BvbnNlIGJvZHkuIGBIdHRwUnVsZWAgaXMKIHR5cGljYWxseSBzcGVjaWZpZWQgYXMgYW4gYGdvb2dsZS5hcGkuaHR0cGAgYW5ub3RhdGlvbiBvbiB0aGUgZ1JQQyBtZXRob2QuCgogRWFjaCBtYXBwaW5nIHNwZWNpZmllcyBhIFVSTCBwYXRoIHRlbXBsYXRlIGFuZCBhbiBIVFRQIG1ldGhvZC4gVGhlIHBhdGgKIHRlbXBsYXRlIG1heSByZWZlciB0byBvbmUgb3IgbW9yZSBmaWVsZHMgaW4gdGhlIGdSUEMgcmVxdWVzdCBtZXNzYWdlLCBhcyBsb25nCiBhcyBlYWNoIGZpZWxkIGlzIGEgbm9uLXJlcGVhdGVkIGZpZWxkIHdpdGggYSBwcmltaXRpdmUgKG5vbi1tZXNzYWdlKSB0eXBlLgogVGhlIHBhdGggdGVtcGxhdGUgY29udHJvbHMgaG93IGZpZWxkcyBvZiB0aGUgcmVxdWVzdCBtZXNzYWdlIGFyZSBtYXBwZWQgdG8KIHRoZSBVUkwgcGF0aC4KCiBFeGFtcGxlOgoKICAgICBzZXJ2aWNlIE1lc3NhZ2luZyB7CiAgICAgICBycGMgR2V0TWVzc2FnZShHZXRNZXNzYWdlUmVxdWVzdCkgcmV0dXJucyAoTWVzc2FnZSkgewogICAgICAgICBvcHRpb24gKGdvb2dsZS5hcGkuaHR0cCkgPSB7CiAgICAgICAgICAgICBnZXQ6ICIvdjEve25hbWU9bWVzc2FnZXMvKn0iCiAgICAgICAgIH07CiAgICAgICB9CiAgICAgfQogICAgIG1lc3NhZ2UgR2V0TWVzc2FnZVJlcXVlc3QgewogICAgICAgc3RyaW5nIG5hbWUgPSAxOyAvLyBNYXBwZWQgdG8gVVJMIHBhdGguCiAgICAgfQogICAgIG1lc3NhZ2UgTWVzc2FnZSB7CiAgICAgICBzdHJpbmcgdGV4dCA9IDE7IC8vIFRoZSByZXNvdXJjZSBjb250ZW50LgogICAgIH0KCiBUaGlzIGVuYWJsZXMgYW4gSFRUUCBSRVNUIHRvIGdSUEMgbWFwcGluZyBhcyBiZWxvdzoKCiBIVFRQIHwgZ1JQQwogLS0tLS18LS0tLS0KIGBHRVQgL3YxL21lc3NhZ2VzLzEyMzQ1NmAgIHwgYEdldE1lc3NhZ2UobmFtZTogIm1lc3NhZ2VzLzEyMzQ1NiIpYAoKIEFueSBmaWVsZHMgaW4gdGhlIHJlcXVlc3QgbWVzc2FnZSB3aGljaCBhcmUgbm90IGJvdW5kIGJ5IHRoZSBwYXRoIHRlbXBsYXRlCiBhdXRvbWF0aWNhbGx5IGJlY29tZSBIVFRQIHF1ZXJ5IHBhcmFtZXRlcnMgaWYgdGhlcmUgaXMgbm8gSFRUUCByZXF1ZXN0IGJvZHkuCiBGb3IgZXhhbXBsZToKCiAgICAgc2VydmljZSBNZXNzYWdpbmcgewogICAgICAgcnBjIEdldE1lc3NhZ2UoR2V0TWVzc2FnZVJlcXVlc3QpIHJldHVybnMgKE1lc3NhZ2UpIHsKICAgICAgICAgb3B0aW9uIChnb29nbGUuYXBpLmh0dHApID0gewogICAgICAgICAgICAgZ2V0OiIvdjEvbWVzc2FnZXMve21lc3NhZ2VfaWR9IgogICAgICAgICB9OwogICAgICAgfQogICAgIH0KICAgICBtZXNzYWdlIEdldE1lc3NhZ2VSZXF1ZXN0IHsKICAgICAgIG1lc3NhZ2UgU3ViTWVzc2FnZSB7CiAgICAgICAgIHN0cmluZyBzdWJmaWVsZCA9IDE7CiAgICAgICB9CiAgICAgICBzdHJpbmcgbWVzc2FnZV9pZCA9IDE7IC8vIE1hcHBlZCB0byBVUkwgcGF0aC4KICAgICAgIGludDY0IHJldmlzaW9uID0gMjsgICAgLy8gTWFwcGVkIHRvIFVSTCBxdWVyeSBwYXJhbWV0ZXIgYHJldmlzaW9uYC4KICAgICAgIFN1Yk1lc3NhZ2Ugc3ViID0gMzsgICAgLy8gTWFwcGVkIHRvIFVSTCBxdWVyeSBwYXJhbWV0ZXIgYHN1Yi5zdWJmaWVsZGAuCiAgICAgfQoKIFRoaXMgZW5hYmxlcyBhIEhUVFAgSlNPTiB0byBSUEMgbWFwcGluZyBhcyBiZWxvdzoKCiBIVFRQIHwgZ1JQQwogLS0tLS18LS0tLS0KIGBHRVQgL3YxL21lc3NhZ2VzLzEyMzQ1Nj9yZXZpc2lvbj0yJnN1Yi5zdWJmaWVsZD1mb29gIHwKIGBHZXRNZXNzYWdlKG1lc3NhZ2VfaWQ6ICIxMjM0NTYiIHJldmlzaW9uOiAyIHN1YjogU3ViTWVzc2FnZShzdWJmaWVsZDoKICJmb28iKSlgCgogTm90ZSB0aGF0IGZpZWxkcyB3aGljaCBhcmUgbWFwcGVkIHRvIFVSTCBxdWVyeSBwYXJhbWV0ZXJzIG11c3QgaGF2ZSBhCiBwcmltaXRpdmUgdHlwZSBvciBhIHJlcGVhdGVkIHByaW1pdGl2ZSB0eXBlIG9yIGEgbm9uLXJlcGVhdGVkIG1lc3NhZ2UgdHlwZS4KIEluIHRoZSBjYXNlIG9mIGEgcmVwZWF0ZWQgdHlwZSwgdGhlIHBhcmFtZXRlciBjYW4gYmUgcmVwZWF0ZWQgaW4gdGhlIFVSTAogYXMgYC4uLj9wYXJhbT1BJnBhcmFtPUJgLiBJbiB0aGUgY2FzZSBvZiBhIG1lc3NhZ2UgdHlwZSwgZWFjaCBmaWVsZCBvZiB0aGUKIG1lc3NhZ2UgaXMgbWFwcGVkIHRvIGEgc2VwYXJhdGUgcGFyYW1ldGVyLCBzdWNoIGFzCiBgLi4uP2Zvby5hPUEmZm9vLmI9QiZmb28uYz1DYC4KCiBGb3IgSFRUUCBtZXRob2RzIHRoYXQgYWxsb3cgYSByZXF1ZXN0IGJvZHksIHRoZSBgYm9keWAgZmllbGQKIHNwZWNpZmllcyB0aGUgbWFwcGluZy4gQ29uc2lkZXIgYSBSRVNUIHVwZGF0ZSBtZXRob2Qgb24gdGhlCiBtZXNzYWdlIHJlc291cmNlIGNvbGxlY3Rpb246CgogICAgIHNlcnZpY2UgTWVzc2FnaW5nIHsKICAgICAgIHJwYyBVcGRhdGVNZXNzYWdlKFVwZGF0ZU1lc3NhZ2VSZXF1ZXN0KSByZXR1cm5zIChNZXNzYWdlKSB7CiAgICAgICAgIG9wdGlvbiAoZ29vZ2xlLmFwaS5odHRwKSA9IHsKICAgICAgICAgICBwYXRjaDogIi92MS9tZXNzYWdlcy97bWVzc2FnZV9pZH0iCiAgICAgICAgICAgYm9keTogIm1lc3NhZ2UiCiAgICAgICAgIH07CiAgICAgICB9CiAgICAgfQogICAgIG1lc3NhZ2UgVXBkYXRlTWVzc2FnZVJlcXVlc3QgewogICAgICAgc3RyaW5nIG1lc3NhZ2VfaWQgPSAxOyAvLyBtYXBwZWQgdG8gdGhlIFVSTAogICAgICAgTWVzc2FnZSBtZXNzYWdlID0gMjsgICAvLyBtYXBwZWQgdG8gdGhlIGJvZHkKICAgICB9CgogVGhlIGZvbGxvd2luZyBIVFRQIEpTT04gdG8gUlBDIG1hcHBpbmcgaXMgZW5hYmxlZCwgd2hlcmUgdGhlCiByZXByZXNlbnRhdGlvbiBvZiB0aGUgSlNPTiBpbiB0aGUgcmVxdWVzdCBib2R5IGlzIGRldGVybWluZWQgYnkKIHByb3RvcyBKU09OIGVuY29kaW5nOgoKIEhUVFAgfCBnUlBDCiAtLS0tLXwtLS0tLQogYFBBVENIIC92MS9tZXNzYWdlcy8xMjM0NTYgeyAidGV4dCI6ICJIaSEiIH1gIHwgYFVwZGF0ZU1lc3NhZ2UobWVzc2FnZV9pZDoKICIxMjM0NTYiIG1lc3NhZ2UgeyB0ZXh0OiAiSGkhIiB9KWAKCiBUaGUgc3BlY2lhbCBuYW1lIGAqYCBjYW4gYmUgdXNlZCBpbiB0aGUgYm9keSBtYXBwaW5nIHRvIGRlZmluZSB0aGF0CiBldmVyeSBmaWVsZCBub3QgYm91bmQgYnkgdGhlIHBhdGggdGVtcGxhdGUgc2hvdWxkIGJlIG1hcHBlZCB0byB0aGUKIHJlcXVlc3QgYm9keS4gIFRoaXMgZW5hYmxlcyB0aGUgZm9sbG93aW5nIGFsdGVybmF0aXZlIGRlZmluaXRpb24gb2YKIHRoZSB1cGRhdGUgbWV0aG9kOgoKICAgICBzZXJ2aWNlIE1lc3NhZ2luZyB7CiAgICAgICBycGMgVXBkYXRlTWVzc2FnZShNZXNzYWdlKSByZXR1cm5zIChNZXNzYWdlKSB7CiAgICAgICAgIG9wdGlvbiAoZ29vZ2xlLmFwaS5odHRwKSA9IHsKICAgICAgICAgICBwYXRjaDogIi92MS9tZXNzYWdlcy97bWVzc2FnZV9pZH0iCiAgICAgICAgICAgYm9keTogIioiCiAgICAgICAgIH07CiAgICAgICB9CiAgICAgfQogICAgIG1lc3NhZ2UgTWVzc2FnZSB7CiAgICAgICBzdHJpbmcgbWVzc2FnZV9pZCA9IDE7CiAgICAgICBzdHJpbmcgdGV4dCA9IDI7CiAgICAgfQoKCiBUaGUgZm9sbG93aW5nIEhUVFAgSlNPTiB0byBSUEMgbWFwcGluZyBpcyBlbmFibGVkOgoKIEhUVFAgfCBnUlBDCiAtLS0tLXwtLS0tLQogYFBBVENIIC92MS9tZXNzYWdlcy8xMjM0NTYgeyAidGV4dCI6ICJIaSEiIH1gIHwgYFVwZGF0ZU1lc3NhZ2UobWVzc2FnZV9pZDoKICIxMjM0NTYiIHRleHQ6ICJIaSEiKWAKCiBOb3RlIHRoYXQgd2hlbiB1c2luZyBgKmAgaW4gdGhlIGJvZHkgbWFwcGluZywgaXQgaXMgbm90IHBvc3NpYmxlIHRvCiBoYXZlIEhUVFAgcGFyYW1ldGVycywgYXMgYWxsIGZpZWxkcyBub3QgYm91bmQgYnkgdGhlIHBhdGggZW5kIGluCiB0aGUgYm9keS4gVGhpcyBtYWtlcyB0aGlzIG9wdGlvbiBtb3JlIHJhcmVseSB1c2VkIGluIHByYWN0aWNlIHdoZW4KIGRlZmluaW5nIFJFU1QgQVBJcy4gVGhlIGNvbW1vbiB1c2FnZSBvZiBgKmAgaXMgaW4gY3VzdG9tIG1ldGhvZHMKIHdoaWNoIGRvbid0IHVzZSB0aGUgVVJMIGF0IGFsbCBmb3IgdHJhbnNmZXJyaW5nIGRhdGEuCgogSXQgaXMgcG9zc2libGUgdG8gZGVmaW5lIG11bHRpcGxlIEhUVFAgbWV0aG9kcyBmb3Igb25lIFJQQyBieSB1c2luZwogdGhlIGBhZGRpdGlvbmFsX2JpbmRpbmdzYCBvcHRpb24uIEV4YW1wbGU6CgogICAgIHNlcnZpY2UgTWVzc2FnaW5nIHsKICAgICAgIHJwYyBHZXRNZXNzYWdlKEdldE1lc3NhZ2VSZXF1ZXN0KSByZXR1cm5zIChNZXNzYWdlKSB7CiAgICAgICAgIG9wdGlvbiAoZ29vZ2xlLmFwaS5odHRwKSA9IHsKICAgICAgICAgICBnZXQ6ICIvdjEvbWVzc2FnZXMve21lc3NhZ2VfaWR9IgogICAgICAgICAgIGFkZGl0aW9uYWxfYmluZGluZ3MgewogICAgICAgICAgICAgZ2V0OiAiL3YxL3VzZXJzL3t1c2VyX2lkfS9tZXNzYWdlcy97bWVzc2FnZV9pZH0iCiAgICAgICAgICAgfQogICAgICAgICB9OwogICAgICAgfQogICAgIH0KICAgICBtZXNzYWdlIEdldE1lc3NhZ2VSZXF1ZXN0IHsKICAgICAgIHN0cmluZyBtZXNzYWdlX2lkID0gMTsKICAgICAgIHN0cmluZyB1c2VyX2lkID0gMjsKICAgICB9CgogVGhpcyBlbmFibGVzIHRoZSBmb2xsb3dpbmcgdHdvIGFsdGVybmF0aXZlIEhUVFAgSlNPTiB0byBSUEMgbWFwcGluZ3M6CgogSFRUUCB8IGdSUEMKIC0tLS0tfC0tLS0tCiBgR0VUIC92MS9tZXNzYWdlcy8xMjM0NTZgIHwgYEdldE1lc3NhZ2UobWVzc2FnZV9pZDogIjEyMzQ1NiIpYAogYEdFVCAvdjEvdXNlcnMvbWUvbWVzc2FnZXMvMTIzNDU2YCB8IGBHZXRNZXNzYWdlKHVzZXJfaWQ6ICJtZSIgbWVzc2FnZV9pZDoKICIxMjM0NTYiKWAKCiAjIyBSdWxlcyBmb3IgSFRUUCBtYXBwaW5nCgogMS4gTGVhZiByZXF1ZXN0IGZpZWxkcyAocmVjdXJzaXZlIGV4cGFuc2lvbiBuZXN0ZWQgbWVzc2FnZXMgaW4gdGhlIHJlcXVlc3QKICAgIG1lc3NhZ2UpIGFyZSBjbGFzc2lmaWVkIGludG8gdGhyZWUgY2F0ZWdvcmllczoKICAgIC0gRmllbGRzIHJlZmVycmVkIGJ5IHRoZSBwYXRoIHRlbXBsYXRlLiBUaGV5IGFyZSBwYXNzZWQgdmlhIHRoZSBVUkwgcGF0aC4KICAgIC0gRmllbGRzIHJlZmVycmVkIGJ5IHRoZSBbSHR0cFJ1bGUuYm9keV1bZ29vZ2xlLmFwaS5IdHRwUnVsZS5ib2R5XS4gVGhleQogICAgYXJlIHBhc3NlZCB2aWEgdGhlIEhUVFAKICAgICAgcmVxdWVzdCBib2R5LgogICAgLSBBbGwgb3RoZXIgZmllbGRzIGFyZSBwYXNzZWQgdmlhIHRoZSBVUkwgcXVlcnkgcGFyYW1ldGVycywgYW5kIHRoZQogICAgICBwYXJhbWV0ZXIgbmFtZSBpcyB0aGUgZmllbGQgcGF0aCBpbiB0aGUgcmVxdWVzdCBtZXNzYWdlLiBBIHJlcGVhdGVkCiAgICAgIGZpZWxkIGNhbiBiZSByZXByZXNlbnRlZCBhcyBtdWx0aXBsZSBxdWVyeSBwYXJhbWV0ZXJzIHVuZGVyIHRoZSBzYW1lCiAgICAgIG5hbWUuCiAgMi4gSWYgW0h0dHBSdWxlLmJvZHldW2dvb2dsZS5hcGkuSHR0cFJ1bGUuYm9keV0gaXMgIioiLCB0aGVyZSBpcyBubyBVUkwKICBxdWVyeSBwYXJhbWV0ZXIsIGFsbCBmaWVsZHMKICAgICBhcmUgcGFzc2VkIHZpYSBVUkwgcGF0aCBhbmQgSFRUUCByZXF1ZXN0IGJvZHkuCiAgMy4gSWYgW0h0dHBSdWxlLmJvZHldW2dvb2dsZS5hcGkuSHR0cFJ1bGUuYm9keV0gaXMgb21pdHRlZCwgdGhlcmUgaXMgbm8gSFRUUAogIHJlcXVlc3QgYm9keSwgYWxsCiAgICAgZmllbGRzIGFyZSBwYXNzZWQgdmlhIFVSTCBwYXRoIGFuZCBVUkwgcXVlcnkgcGFyYW1ldGVycy4KCiAjIyMgUGF0aCB0ZW1wbGF0ZSBzeW50YXgKCiAgICAgVGVtcGxhdGUgPSAiLyIgU2VnbWVudHMgWyBWZXJiIF0gOwogICAgIFNlZ21lbnRzID0gU2VnbWVudCB7ICIvIiBTZWdtZW50IH0gOwogICAgIFNlZ21lbnQgID0gIioiIHwgIioqIiB8IExJVEVSQUwgfCBWYXJpYWJsZSA7CiAgICAgVmFyaWFibGUgPSAieyIgRmllbGRQYXRoIFsgIj0iIFNlZ21lbnRzIF0gIn0iIDsKICAgICBGaWVsZFBhdGggPSBJREVOVCB7ICIuIiBJREVOVCB9IDsKICAgICBWZXJiICAgICA9ICI6IiBMSVRFUkFMIDsKCiBUaGUgc3ludGF4IGAqYCBtYXRjaGVzIGEgc2luZ2xlIFVSTCBwYXRoIHNlZ21lbnQuIFRoZSBzeW50YXggYCoqYCBtYXRjaGVzCiB6ZXJvIG9yIG1vcmUgVVJMIHBhdGggc2VnbWVudHMsIHdoaWNoIG11c3QgYmUgdGhlIGxhc3QgcGFydCBvZiB0aGUgVVJMIHBhdGgKIGV4Y2VwdCB0aGUgYFZlcmJgLgoKIFRoZSBzeW50YXggYFZhcmlhYmxlYCBtYXRjaGVzIHBhcnQgb2YgdGhlIFVSTCBwYXRoIGFzIHNwZWNpZmllZCBieSBpdHMKIHRlbXBsYXRlLiBBIHZhcmlhYmxlIHRlbXBsYXRlIG11c3Qgbm90IGNvbnRhaW4gb3RoZXIgdmFyaWFibGVzLiBJZiBhIHZhcmlhYmxlCiBtYXRjaGVzIGEgc2luZ2xlIHBhdGggc2VnbWVudCwgaXRzIHRlbXBsYXRlIG1heSBiZSBvbWl0dGVkLCBlLmcuIGB7dmFyfWAKIGlzIGVxdWl2YWxlbnQgdG8gYHt2YXI9Kn1gLgoKIFRoZSBzeW50YXggYExJVEVSQUxgIG1hdGNoZXMgbGl0ZXJhbCB0ZXh0IGluIHRoZSBVUkwgcGF0aC4gSWYgdGhlIGBMSVRFUkFMYAogY29udGFpbnMgYW55IHJlc2VydmVkIGNoYXJhY3Rlciwgc3VjaCBjaGFyYWN0ZXJzIHNob3VsZCBiZSBwZXJjZW50LWVuY29kZWQKIGJlZm9yZSB0aGUgbWF0Y2hpbmcuCgogSWYgYSB2YXJpYWJsZSBjb250YWlucyBleGFjdGx5IG9uZSBwYXRoIHNlZ21lbnQsIHN1Y2ggYXMgYCJ7dmFyfSJgIG9yCiBgInt2YXI9Kn0iYCwgd2hlbiBzdWNoIGEgdmFyaWFibGUgaXMgZXhwYW5kZWQgaW50byBhIFVSTCBwYXRoIG9uIHRoZSBjbGllbnQKIHNpZGUsIGFsbCBjaGFyYWN0ZXJzIGV4Y2VwdCBgWy1fLn4wLTlhLXpBLVpdYCBhcmUgcGVyY2VudC1lbmNvZGVkLiBUaGUKIHNlcnZlciBzaWRlIGRvZXMgdGhlIHJldmVyc2UgZGVjb2RpbmcuIFN1Y2ggdmFyaWFibGVzIHNob3cgdXAgaW4gdGhlCiBbRGlzY292ZXJ5CiBEb2N1bWVudF0oaHR0cHM6Ly9kZXZlbG9wZXJzLmdvb2dsZS5jb20vZGlzY292ZXJ5L3YxL3JlZmVyZW5jZS9hcGlzKSBhcwogYHt2YXJ9YC4KCiBJZiBhIHZhcmlhYmxlIGNvbnRhaW5zIG11bHRpcGxlIHBhdGggc2VnbWVudHMsIHN1Y2ggYXMgYCJ7dmFyPWZvby8qfSJgCiBvciBgInt2YXI9Kip9ImAsIHdoZW4gc3VjaCBhIHZhcmlhYmxlIGlzIGV4cGFuZGVkIGludG8gYSBVUkwgcGF0aCBvbiB0aGUKIGNsaWVudCBzaWRlLCBhbGwgY2hhcmFjdGVycyBleGNlcHQgYFstXy5+LzAtOWEtekEtWl1gIGFyZSBwZXJjZW50LWVuY29kZWQuCiBUaGUgc2VydmVyIHNpZGUgZG9lcyB0aGUgcmV2ZXJzZSBkZWNvZGluZywgZXhjZXB0ICIlMkYiIGFuZCAiJTJmIiBhcmUgbGVmdAogdW5jaGFuZ2VkLiBTdWNoIHZhcmlhYmxlcyBzaG93IHVwIGluIHRoZQogW0Rpc2NvdmVyeQogRG9jdW1lbnRdKGh0dHBzOi8vZGV2ZWxvcGVycy5nb29nbGUuY29tL2Rpc2NvdmVyeS92MS9yZWZlcmVuY2UvYXBpcykgYXMKIGB7K3Zhcn1gLgoKICMjIFVzaW5nIGdSUEMgQVBJIFNlcnZpY2UgQ29uZmlndXJhdGlvbgoKIGdSUEMgQVBJIFNlcnZpY2UgQ29uZmlndXJhdGlvbiAoc2VydmljZSBjb25maWcpIGlzIGEgY29uZmlndXJhdGlvbiBsYW5ndWFnZQogZm9yIGNvbmZpZ3VyaW5nIGEgZ1JQQyBzZXJ2aWNlIHRvIGJlY29tZSBhIHVzZXItZmFjaW5nIHByb2R1Y3QuIFRoZQogc2VydmljZSBjb25maWcgaXMgc2ltcGx5IHRoZSBZQU1MIHJlcHJlc2VudGF0aW9uIG9mIHRoZSBgZ29vZ2xlLmFwaS5TZXJ2aWNlYAogcHJvdG8gbWVzc2FnZS4KCiBBcyBhbiBhbHRlcm5hdGl2ZSB0byBhbm5vdGF0aW5nIHlvdXIgcHJvdG8gZmlsZSwgeW91IGNhbiBjb25maWd1cmUgZ1JQQwogdHJhbnNjb2RpbmcgaW4geW91ciBzZXJ2aWNlIGNvbmZpZyBZQU1MIGZpbGVzLiBZb3UgZG8gdGhpcyBieSBzcGVjaWZ5aW5nIGEKIGBIdHRwUnVsZWAgdGhhdCBtYXBzIHRoZSBnUlBDIG1ldGhvZCB0byBhIFJFU1QgZW5kcG9pbnQsIGFjaGlldmluZyB0aGUgc2FtZQogZWZmZWN0IGFzIHRoZSBwcm90byBhbm5vdGF0aW9uLiBUaGlzIGNhbiBiZSBwYXJ0aWN1bGFybHkgdXNlZnVsIGlmIHlvdQogaGF2ZSBhIHByb3RvIHRoYXQgaXMgcmV1c2VkIGluIG11bHRpcGxlIHNlcnZpY2VzLiBOb3RlIHRoYXQgYW55IHRyYW5zY29kaW5nCiBzcGVjaWZpZWQgaW4gdGhlIHNlcnZpY2UgY29uZmlnIHdpbGwgb3ZlcnJpZGUgYW55IG1hdGNoaW5nIHRyYW5zY29kaW5nCiBjb25maWd1cmF0aW9uIGluIHRoZSBwcm90by4KCiBFeGFtcGxlOgoKICAgICBodHRwOgogICAgICAgcnVsZXM6CiAgICAgICAgICMgU2VsZWN0cyBhIGdSUEMgbWV0aG9kIGFuZCBhcHBsaWVzIEh0dHBSdWxlIHRvIGl0LgogICAgICAgICAtIHNlbGVjdG9yOiBleGFtcGxlLnYxLk1lc3NhZ2luZy5HZXRNZXNzYWdlCiAgICAgICAgICAgZ2V0OiAvdjEvbWVzc2FnZXMve21lc3NhZ2VfaWR9L3tzdWIuc3ViZmllbGR9CgogIyMgU3BlY2lhbCBub3RlcwoKIFdoZW4gZ1JQQyBUcmFuc2NvZGluZyBpcyB1c2VkIHRvIG1hcCBhIGdSUEMgdG8gSlNPTiBSRVNUIGVuZHBvaW50cywgdGhlCiBwcm90byB0byBKU09OIGNvbnZlcnNpb24gbXVzdCBmb2xsb3cgdGhlIFtwcm90bzMKIHNwZWNpZmljYXRpb25dKGh0dHBzOi8vZGV2ZWxvcGVycy5nb29nbGUuY29tL3Byb3RvY29sLWJ1ZmZlcnMvZG9jcy9wcm90bzMjanNvbikuCgogV2hpbGUgdGhlIHNpbmdsZSBzZWdtZW50IHZhcmlhYmxlIGZvbGxvd3MgdGhlIHNlbWFudGljcyBvZgogW1JGQyA2NTcwXShodHRwczovL3Rvb2xzLmlldGYub3JnL2h0bWwvcmZjNjU3MCkgU2VjdGlvbiAzLjIuMiBTaW1wbGUgU3RyaW5nCiBFeHBhbnNpb24sIHRoZSBtdWx0aSBzZWdtZW50IHZhcmlhYmxlICoqZG9lcyBub3QqKiBmb2xsb3cgUkZDIDY1NzAgU2VjdGlvbgogMy4yLjMgUmVzZXJ2ZWQgRXhwYW5zaW9uLiBUaGUgcmVhc29uIGlzIHRoYXQgdGhlIFJlc2VydmVkIEV4cGFuc2lvbgogZG9lcyBub3QgZXhwYW5kIHNwZWNpYWwgY2hhcmFjdGVycyBsaWtlIGA/YCBhbmQgYCNgLCB3aGljaCB3b3VsZCBsZWFkCiB0byBpbnZhbGlkIFVSTHMuIEFzIHRoZSByZXN1bHQsIGdSUEMgVHJhbnNjb2RpbmcgdXNlcyBhIGN1c3RvbSBlbmNvZGluZwogZm9yIG11bHRpIHNlZ21lbnQgdmFyaWFibGVzLgoKIFRoZSBwYXRoIHZhcmlhYmxlcyAqKm11c3Qgbm90KiogcmVmZXIgdG8gYW55IHJlcGVhdGVkIG9yIG1hcHBlZCBmaWVsZCwKIGJlY2F1c2UgY2xpZW50IGxpYnJhcmllcyBhcmUgbm90IGNhcGFibGUgb2YgaGFuZGxpbmcgc3VjaCB2YXJpYWJsZSBleHBhbnNpb24uCgogVGhlIHBhdGggdmFyaWFibGVzICoqbXVzdCBub3QqKiBjYXB0dXJlIHRoZSBsZWFkaW5nICIvIiBjaGFyYWN0ZXIuIFRoZSByZWFzb24KIGlzIHRoYXQgdGhlIG1vc3QgY29tbW9uIHVzZSBjYXNlICJ7dmFyfSIgZG9lcyBub3QgY2FwdHVyZSB0aGUgbGVhZGluZyAiLyIKIGNoYXJhY3Rlci4gRm9yIGNvbnNpc3RlbmN5LCBhbGwgcGF0aCB2YXJpYWJsZXMgbXVzdCBzaGFyZSB0aGUgc2FtZSBiZWhhdmlvci4KCiBSZXBlYXRlZCBtZXNzYWdlIGZpZWxkcyBtdXN0IG5vdCBiZSBtYXBwZWQgdG8gVVJMIHF1ZXJ5IHBhcmFtZXRlcnMsIGJlY2F1c2UKIG5vIGNsaWVudCBsaWJyYXJ5IGNhbiBzdXBwb3J0IHN1Y2ggY29tcGxpY2F0ZWQgbWFwcGluZy4KCiBJZiBhbiBBUEkgbmVlZHMgdG8gdXNlIGEgSlNPTiBhcnJheSBmb3IgcmVxdWVzdCBvciByZXNwb25zZSBib2R5LCBpdCBjYW4gbWFwCiB0aGUgcmVxdWVzdCBvciByZXNwb25zZSBib2R5IHRvIGEgcmVwZWF0ZWQgZmllbGQuIEhvd2V2ZXIsIHNvbWUgZ1JQQwogVHJhbnNjb2RpbmcgaW1wbGVtZW50YXRpb25zIG1heSBub3Qgc3VwcG9ydCB0aGlzIGZlYXR1cmUuCgoLCgMEAQESBLsCCBAKjwEKBAQBAgASBMACAhYagAEgU2VsZWN0cyBhIG1ldGhvZCB0byB3aGljaCB0aGlzIHJ1bGUgYXBwbGllcy4KCiBSZWZlciB0byBbc2VsZWN0b3JdW2dvb2dsZS5hcGkuRG9jdW1lbnRhdGlvblJ1bGUuc2VsZWN0b3JdIGZvciBzeW50YXgKIGRldGFpbHMuCgoNCgUEAQIABRIEwAICCAoNCgUEAQIAARIEwAIJEQoNCgUEAQIAAxIEwAIUFQrQAQoEBAEIABIGxQIC2wIDGr8BIERldGVybWluZXMgdGhlIFVSTCBwYXR0ZXJuIGlzIG1hdGNoZWQgYnkgdGhpcyBydWxlcy4gVGhpcyBwYXR0ZXJuIGNhbiBiZQogdXNlZCB3aXRoIGFueSBvZiB0aGUge2dldHxwdXR8cG9zdHxkZWxldGV8cGF0Y2h9IG1ldGhvZHMuIEEgY3VzdG9tIG1ldGhvZAogY2FuIGJlIGRlZmluZWQgdXNpbmcgdGhlICdjdXN0b20nIGZpZWxkLgoKDQoFBAEIAAESBMUCCA8KXAoEBAECARIEyAIEExpOIE1hcHMgdG8gSFRUUCBHRVQuIFVzZWQgZm9yIGxpc3RpbmcgYW5kIGdldHRpbmcgaW5mb3JtYXRpb24gYWJvdXQKIHJlc291cmNlcy4KCg0KBQQBAgEFEgTIAgQKCg0KBQQBAgEBEgTIAgsOCg0KBQQBAgEDEgTIAhESCkAKBAQBAgISBMsCBBMaMiBNYXBzIHRvIEhUVFAgUFVULiBVc2VkIGZvciByZXBsYWNpbmcgYSByZXNvdXJjZS4KCg0KBQQBAgIFEgTLAgQK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IHZlcmIuCgoNCgUEAgIABRIE9gICCAoNCgUEAgIAARIE9gIJDQoNCgUEAgIAAxIE9gIQEQo1CgQEAgIBEgT5AgISGicgVGhlIHBhdGggbWF0Y2hlZCBieSB0aGlzIGN1c3RvbSB2ZXJiLgoKDQoFBAICAQUSBPkCAggKDQoFBAICAQESBPkCCQ0KDQoFBAICAQMSBPkCEBFiBnByb3RvMwriwgMKIGdvb2dsZS9wcm90b2J1Zi9kZXNjcmlwdG9yLnByb3RvEg9nb29nbGUucHJvdG9idWYiTQoRRmlsZURlc2NyaXB0b3JTZXQSOAoEZmlsZRgBIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5GaWxlRGVzY3JpcHRvclByb3RvUgRmaWxlIv4EChNGaWxlRGVzY3JpcHRvclByb3RvEhIKBG5hbWUYASABKAlSBG5hbWUSGAoHcGFja2FnZRgCIAEoCVIHcGFja2FnZRIeCgpkZXBlbmRlbmN5GAMgAygJUgpkZXBlbmRlbmN5EisKEXB1YmxpY19kZXBlbmRlbmN5GAogAygFUhBwdWJsaWNEZXBlbmRlbmN5EicKD3dlYWtfZGVwZW5kZW5jeRgLIAMoBVIOd2Vha0RlcGVuZGVuY3kSQwoMbWVzc2FnZV90eXBlGAQgAygLMiAuZ29vZ2xlLnByb3RvYnVmLkRlc2NyaXB0b3JQcm90b1ILbWVzc2FnZVR5cGUSQQoJZW51bV90eXBlGAUgAygLMiQuZ29vZ2xlLnByb3RvYnVmLkVudW1EZXNjcmlwdG9yUHJvdG9SCGVudW1UeXBlEkEKB3NlcnZpY2UYBiADKAsyJy5nb29nbGUucHJvdG9idWYuU2VydmljZURlc2NyaXB0b3JQcm90b1IHc2VydmljZRJDCglleHRlbnNpb24YByADKAsyJS5nb29nbGUucHJvdG9idWYuRmllbGREZXNjcmlwdG9yUHJvdG9SCWV4dGVuc2lvbhI2CgdvcHRpb25zGAggASgLMhwuZ29vZ2xlLnByb3RvYnVmLkZpbGVPcHRpb25zUgdvcHRpb25zEkkKEHNvdXJjZV9jb2RlX2luZm8YCSABKAsyHy5nb29nbGUucHJvdG9idWYuU291cmNlQ29kZUluZm9SDnNvdXJjZUNvZGVJbmZvEhYKBnN5bnRheBgMIAEoCVIGc3ludGF4EhgKB2VkaXRpb24YDSABKAlSB2VkaXRpb24iuQYKD0Rlc2NyaXB0b3JQcm90bxISCgRuYW1lGAEgASgJUgRuYW1lEjsKBWZpZWxkGAIgAygLMiUuZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvUgVmaWVsZBJDCglleHRlbnNpb24YBiADKAsyJS5nb29nbGUucHJvdG9idWYuRmllbGREZXNjcmlwdG9yUHJvdG9SCWV4dGVuc2lvbhJBCgtuZXN0ZWRfdHlwZRgDIAMoCzIgLmdvb2dsZS5wcm90b2J1Zi5EZXNjcmlwdG9yUHJvdG9SCm5lc3RlZFR5cGUSQQoJZW51bV90eXBlGAQgAygLMiQuZ29vZ2xlLnByb3RvYnVmLkVudW1EZXNjcmlwdG9yUHJvdG9SCGVudW1UeXBlElgKD2V4dGVuc2lvbl9yYW5nZRgFIAMoCzIvLmdvb2dsZS5wcm90b2J1Zi5EZXNjcmlwdG9yUHJvdG8uRXh0ZW5zaW9uUmFuZ2VSDmV4dGVuc2lvblJhbmdlEkQKCm9uZW9mX2RlY2wYCCADKAsyJS5nb29nbGUucHJvdG9idWYuT25lb2ZEZXNjcmlwdG9yUHJvdG9SCW9uZW9mRGVjbBI5CgdvcHRpb25zGAcgASgLMh8uZ29vZ2xlLnByb3RvYnVmLk1lc3NhZ2VPcHRpb25zUgdvcHRpb25zElUKDnJlc2VydmVkX3JhbmdlGAkgAygLMi4uZ29vZ2xlLnByb3RvYnVmLkRlc2NyaXB0b3JQcm90by5SZXNlcnZlZFJhbmdlUg1yZXNlcnZlZFJhbmdlEiMKDXJlc2VydmVkX25hbWUYCiADKAlSDHJlc2VydmVkTmFtZRp6Cg5FeHRlbnNpb25SYW5nZRIUCgVzdGFydBgBIAEoBVIFc3RhcnQSEAoDZW5kGAIgASgFUgNlbmQSQAoHb3B0aW9ucxgDIAEoCzImLmdvb2dsZS5wcm90b2J1Zi5FeHRlbnNpb25SYW5nZU9wdGlvbnNSB29wdGlvbnMaNwoNUmVzZXJ2ZWRSYW5nZRIUCgVzdGFydBgBIAEoBVIFc3RhcnQSEAoDZW5kGAIgASgFUgNlbmQirQQKFUV4dGVuc2lvblJhbmdlT3B0aW9ucxJYChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvblITdW5pbnRlcnByZXRlZE9wdGlvbhJZCgtkZWNsYXJhdGlvbhgCIAMoCzIyLmdvb2dsZS5wcm90b2J1Zi5FeHRlbnNpb25SYW5nZU9wdGlvbnMuRGVjbGFyYXRpb25CA4gBAlILZGVjbGFyYXRpb24SaAoMdmVyaWZpY2F0aW9uGAMgASgOMjguZ29vZ2xlLnByb3RvYnVmLkV4dGVuc2lvblJhbmdlT3B0aW9ucy5WZXJpZmljYXRpb25TdGF0ZToKVU5WRVJJRklFRFIMdmVyaWZpY2F0aW9uGrMBCgtEZWNsYXJhdGlvbhIWCgZudW1iZXIYASABKAVSBm51bWJlchIbCglmdWxsX25hbWUYAiABKAlSCGZ1bGxOYW1lEhIKBHR5cGUYAyABKAlSBHR5cGUSIwoLaXNfcmVwZWF0ZWQYBCABKAhCAhgBUgppc1JlcGVhdGVkEhoKCHJlc2VydmVkGAUgASgIUghyZXNlcnZlZBIaCghyZXBlYXRlZBgGIAEoCFIIcmVwZWF0ZWQiNAoRVmVyaWZpY2F0aW9uU3RhdGUSDwoLREVDTEFSQVRJT04QABIOCgpVTlZFUklGSUVEEAEqCQjoBxCAgICAAiLBBgoURmllbGREZXNjcmlwdG9yUHJvdG8SEgoEbmFtZRgBIAEoCVIEbmFtZRIWCgZudW1iZXIYAyABKAVSBm51bWJlchJBCgVsYWJlbBgEIAEoDjIrLmdvb2dsZS5wcm90b2J1Zi5GaWVsZERlc2NyaXB0b3JQcm90by5MYWJlbFIFbGFiZWwSPgoEdHlwZRgFIAEoDjIqLmdvb2dsZS5wcm90b2J1Zi5GaWVsZERlc2NyaXB0b3JQcm90by5UeXBlUgR0eXBlEhsKCXR5cGVfbmFtZRgGIAEoCVIIdHlwZU5hbWUSGgoIZXh0ZW5kZWUYAiABKAlSCGV4dGVuZGVlEiMKDWRlZmF1bHRfdmFsdWUYByABKAlSDGRlZmF1bHRWYWx1ZRIfCgtvbmVvZl9pbmRleBgJIAEoBVIKb25lb2ZJbmRleBIbCglqc29uX25hbWUYCiABKAlSCGpzb25OYW1lEjcKB29wdGlvbnMYCCABKAsyHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zUgdvcHRpb25zEicKD3Byb3RvM19vcHRpb25hbBgRIAEoCFIOcHJvdG8zT3B0aW9uYWwitgIKBFR5cGUSDwoLVFlQRV9ET1VCTEUQARIOCgpUWVBFX0ZMT0FUEAISDgoKVFlQRV9JTlQ2NBADEg8KC1RZUEVfVUlOVDY0EAQSDgoKVFlQRV9JTlQzMhAFEhAKDFRZUEVfRklYRUQ2NBAGEhAKDFRZUEVfRklYRUQzMhAHEg0KCVRZUEVfQk9PTBAIEg8KC1RZUEVfU1RSSU5HEAkSDgoKVFlQRV9HUk9VUBAKEhAKDFRZUEVfTUVTU0FHRRALEg4KClRZUEVfQllURVMQDBIPCgtUWVBFX1VJTlQzMhANEg0KCVRZUEVfRU5VTRAOEhEKDVRZUEVfU0ZJWEVEMzIQDxIRCg1UWVBFX1NGSVhFRDY0EBASDwoLVFlQRV9TSU5UMzIQERIPCgtUWVBFX1NJTlQ2NBASIkMKBUxhYmVsEhIKDkxBQkVMX09QVElPTkFMEAESEgoOTEFCRUxfUkVRVUlSRUQQAhISCg5MQUJFTF9SRVBFQVRFRBADImMKFE9uZW9mRGVzY3JpcHRvclByb3RvEhIKBG5hbWUYASABKAlSBG5hbWUSNwoHb3B0aW9ucxgCIAEoCzIdLmdvb2dsZS5wcm90b2J1Zi5PbmVvZk9wdGlvbnNSB29wdGlvbnMi4wIKE0VudW1EZXNjcmlwdG9yUHJvdG8SEgoEbmFtZRgBIAEoCVIEbmFtZRI/CgV2YWx1ZRgCIAMoCzIpLmdvb2dsZS5wcm90b2J1Zi5FbnVtVmFsdWVEZXNjcmlwdG9yUHJvdG9SBXZhbHVlEjYKB29wdGlvbnMYAyABKAsyHC5nb29nbGUucHJvdG9idWYuRW51bU9wdGlvbnNSB29wdGlvbnMSXQoOcmVzZXJ2ZWRfcmFuZ2UYBCADKAsyNi5nb29nbGUucHJvdG9idWYuRW51bURlc2NyaXB0b3JQcm90by5FbnVtUmVzZXJ2ZWRSYW5nZVINcmVzZXJ2ZWRSYW5nZRIjCg1yZXNlcnZlZF9uYW1lGAUgAygJUgxyZXNlcnZlZE5hbWUaOwoRRW51bVJlc2VydmVkUmFuZ2USFAoFc3RhcnQYASABKAVSBXN0YXJ0EhAKA2VuZBgCIAEoBVIDZW5kIoMBChhFbnVtVmFsdWVEZXNjcmlwdG9yUHJvdG8SEgoEbmFtZRgBIAEoCVIEbmFtZRIWCgZudW1iZXIYAiABKAVSBm51bWJlchI7CgdvcHRpb25zGAMgASgLMiEuZ29vZ2xlLnByb3RvYnVmLkVudW1WYWx1ZU9wdGlvbnNSB29wdGlvbnMipwEKFlNlcnZpY2VEZXNjcmlwdG9yUHJvdG8SEgoEbmFtZRgBIAEoCVIEbmFtZRI+CgZtZXRob2QYAiADKAsyJi5nb29nbGUucHJvdG9idWYuTWV0aG9kRGVzY3JpcHRvclByb3RvUgZtZXRob2QSOQoHb3B0aW9ucxgDIAEoCzIfLmdvb2dsZS5wcm90b2J1Zi5TZXJ2aWNlT3B0aW9uc1IHb3B0aW9ucyKJAgoVTWV0aG9kRGVzY3JpcHRvclByb3RvEhIKBG5hbWUYASABKAlSBG5hbWUSHQoKaW5wdXRfdHlwZRgCIAEoCVIJaW5wdXRUeXBlEh8KC291dHB1dF90eXBlGAMgASgJUgpvdXRwdXRUeXBlEjgKB29wdGlvbnMYBCABKAsyHi5nb29nbGUucHJvdG9idWYuTWV0aG9kT3B0aW9uc1IHb3B0aW9ucxIwChBjbGllbnRfc3RyZWFtaW5nGAUgASgIOgVmYWxzZVIPY2xpZW50U3RyZWFtaW5nEjAKEHNlcnZlcl9zdHJlYW1pbmcYBiABKAg6BWZhbHNlUg9zZXJ2ZXJTdHJlYW1pbmcikQkKC0ZpbGVPcHRpb25zEiEKDGphdmFfcGFja2FnZRgBIAEoCVILamF2YVBhY2thZ2USMAoUamF2YV9vdXRlcl9jbGFzc25hbWUYCCABKAlSEmphdmFPdXRlckNsYXNzbmFtZRI1ChNqYXZhX211bHRpcGxlX2ZpbGVzGAogASgIOgVmYWxzZVIRamF2YU11bHRpcGxlRmlsZXMSRAodamF2YV9nZW5lcmF0ZV9lcXVhbHNfYW5kX2hhc2gYFCABKAhCAhgBUhlqYXZhR2VuZXJhdGVFcXVhbHNBbmRIYXNoEjoKFmphdmFfc3RyaW5nX2NoZWNrX3V0ZjgYGyABKAg6BWZhbHNlUhNqYXZhU3RyaW5nQ2hlY2tVdGY4ElMKDG9wdGltaXplX2ZvchgJIAEoDjIpLmdvb2dsZS5wcm90b2J1Zi5GaWxlT3B0aW9ucy5PcHRpbWl6ZU1vZGU6BVNQRUVEUgtvcHRpbWl6ZUZvchIdCgpnb19wYWNrYWdlGAsgASgJUglnb1BhY2thZ2USNQoTY2NfZ2VuZXJpY19zZXJ2aWNlcxgQIAEoCDoFZmFsc2VSEWNjR2VuZXJpY1NlcnZpY2VzEjkKFWphdmFfZ2VuZXJpY19zZXJ2aWNlcxgRIAEoCDoFZmFsc2VSE2phdmFHZW5lcmljU2VydmljZXMSNQoTcHlfZ2VuZXJpY19zZXJ2aWNlcxgSIAEoCDoFZmFsc2VSEXB5R2VuZXJpY1NlcnZpY2VzEjcKFHBocF9nZW5lcmljX3NlcnZpY2VzGCogASgIOgVmYWxzZVIScGhwR2VuZXJpY1NlcnZpY2VzEiUKCmRlcHJlY2F0ZWQYFyABKAg6BWZhbHNlUgpkZXByZWNhdGVkEi4KEGNjX2VuYWJsZV9hcmVuYXMYHyABKAg6BHRydWVSDmNjRW5hYmxlQXJlbmFzEioKEW9iamNfY2xhc3NfcHJlZml4GCQgASgJUg9vYmpjQ2xhc3NQcmVmaXgSKQoQY3NoYXJwX25hbWVzcGFjZRglIAEoCVIPY3NoYXJwTmFtZXNwYWNlEiEKDHN3aWZ0X3ByZWZpeBgnIAEoCVILc3dpZnRQcmVmaXgSKAoQcGhwX2NsYXNzX3ByZWZpeBgoIAEoCVIOcGhwQ2xhc3NQcmVmaXgSIwoNcGhwX25hbWVzcGFjZRgpIAEoCVIMcGhwTmFtZXNwYWNlEjQKFnBocF9tZXRhZGF0YV9uYW1lc3BhY2UYLCABKAlSFHBocE1ldGFkYXRhTmFtZXNwYWNlEiEKDHJ1YnlfcGFja2FnZRgtIAEoCVILcnVieVBhY2thZ2USWAoUdW5pbnRlcnByZXRlZF9vcHRpb24Y5wcgAygLMiQuZ29vZ2xlLnByb3RvYnVmLlVuaW50ZXJwcmV0ZWRPcHRpb25SE3VuaW50ZXJwcmV0ZWRPcHRpb24iOgoMT3B0aW1pemVNb2RlEgkKBVNQRUVEEAESDQoJQ09ERV9TSVpFEAISEAoMTElURV9SVU5USU1FEAMqCQjoBxCAgICAAkoECCYQJyK7AwoOTWVzc2FnZU9wdGlvbnMSPAoXbWVzc2FnZV9zZXRfd2lyZV9mb3JtYXQYASABKAg6BWZhbHNlUhRtZXNzYWdlU2V0V2lyZUZvcm1hdBJMCh9ub19zdGFuZGFyZF9kZXNjcmlwdG9yX2FjY2Vzc29yGAIgASgIOgVmYWxzZVIcbm9TdGFuZGFyZERlc2NyaXB0b3JBY2Nlc3NvchIlCgpkZXByZWNhdGVkGAMgASgIOgVmYWxzZVIKZGVwcmVjYXRlZBIbCgltYXBfZW50cnkYByABKAhSCG1hcEVudHJ5ElYKJmRlcHJlY2F0ZWRfbGVnYWN5X2pzb25fZmllbGRfY29uZmxpY3RzGAsgASgIQgIYAVIiZGVwcmVjYXRlZExlZ2FjeUpzb25GaWVsZENvbmZsaWN0cxJYChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvblITdW5pbnRlcnByZXRlZE9wdGlvbioJCOgHEICAgIACSgQIBBAFSgQIBRAGSgQIBhAHSgQICBAJSgQICRAKIoUJCgxGaWVsZE9wdGlvbnMSQQoFY3R5cGUYASABKA4yIy5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zLkNUeXBlOgZTVFJJTkdSBWN0eXBlEhYKBnBhY2tlZBgCIAEoCFIGcGFja2VkEkcKBmpzdHlwZRgGIAEoDjIkLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMuSlNUeXBlOglKU19OT1JNQUxSBmpzdHlwZRIZCgRsYXp5GAUgASgIOgVmYWxzZVIEbGF6eRIuCg91bnZlcmlmaWVkX2xhenkYDyABKAg6BWZhbHNlUg51bnZlcmlmaWVkTGF6eRIlCgpkZXByZWNhdGVkGAMgASgIOgVmYWxzZVIKZGVwcmVjYXRlZBIZCgR3ZWFrGAogASgIOgVmYWxzZVIEd2VhaxIoCgxkZWJ1Z19yZWRhY3QYECABKAg6BWZhbHNlUgtkZWJ1Z1JlZGFjdBJLCglyZXRlbnRpb24YESABKA4yLS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zLk9wdGlvblJldGVudGlvblIJcmV0ZW50aW9uEkoKBnRhcmdldBgSIAEoDjIuLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMuT3B0aW9uVGFyZ2V0VHlwZUICGAFSBnRhcmdldBJICgd0YXJnZXRzGBMgAygOMi4uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucy5PcHRpb25UYXJnZXRUeXBlUgd0YXJnZXRzElgKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uUhN1bmludGVycHJldGVkT3B0aW9uIi8KBUNUeXBlEgoKBlNUUklORxAAEggKBENPUkQQARIQCgxTVFJJTkdfUElFQ0UQAiI1CgZKU1R5cGUSDQoJSlNfTk9STUFMEAASDQoJSlNfU1RSSU5HEAESDQoJSlNfTlVNQkVSEAIiVQoPT3B0aW9uUmV0ZW50aW9uEhUKEVJFVEVOVElPTl9VTktOT1dOEAASFQoRUkVURU5USU9OX1JVTlRJTUUQARIUChBSRVRFTlRJT05fU09VUkNFEAIijAIKEE9wdGlvblRhcmdldFR5cGUSFwoTVEFSR0VUX1RZUEVfVU5LTk9XThAAEhQKEFRBUkdFVF9UWVBFX0ZJTEUQARIfChtUQVJHRVRfVFlQRV9FWFRFTlNJT05fUkFOR0UQAhIXChNUQVJHRVRfVFlQRV9NRVNTQUdFEAMSFQoRVEFSR0VUX1RZUEVfRklFTEQQBBIVChFUQVJHRVRfVFlQRV9PTkVPRhAFEhQKEFRBUkdFVF9UWVBFX0VOVU0QBhIaChZUQVJHRVRfVFlQRV9FTlVNX0VOVFJZEAcSFwoTVEFSR0VUX1RZUEVfU0VSVklDRRAIEhYKElRBUkdFVF9UWVBFX01FVEhPRBAJKgkI6AcQgICAgAJKBAgEEAUicwoMT25lb2ZPcHRpb25zElgKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uUhN1bmludGVycHJldGVkT3B0aW9uKgkI6AcQgICAgAIimAIKC0VudW1PcHRpb25zEh8KC2FsbG93X2FsaWFzGAIgASgIUgphbGxvd0FsaWFzEiUKCmRlcHJlY2F0ZWQYAyABKAg6BWZhbHNlUgpkZXByZWNhdGVkElYKJmRlcHJlY2F0ZWRfbGVnYWN5X2pzb25fZmllbGRfY29uZmxpY3RzGAYgASgIQgIYAVIiZGVwcmVjYXRlZExlZ2FjeUpzb25GaWVsZENvbmZsaWN0cxJYChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvblITdW5pbnRlcnByZXRlZE9wdGlvbioJCOgHEICAgIACSgQIBRAGIp4BChBFbnVtVmFsdWVPcHRpb25zEiUKCmRlcHJlY2F0ZWQYASABKAg6BWZhbHNlUgpkZXByZWNhdGVkElgKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uUhN1bmludGVycHJldGVkT3B0aW9uKgkI6AcQgICAgAIinAEKDlNlcnZpY2VPcHRpb25zEiUKCmRlcHJlY2F0ZWQYISABKAg6BWZhbHNlUgpkZXByZWNhdGVkElgKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uUhN1bmludGVycHJldGVkT3B0aW9uKgkI6AcQgICAgAIi4AIKDU1ldGhvZE9wdGlvbnMSJQoKZGVwcmVjYXRlZBghIAEoCDoFZmFsc2VSCmRlcHJlY2F0ZWQScQoRaWRlbXBvdGVuY3lfbGV2ZWwYIiABKA4yLy5nb29nbGUucHJvdG9idWYuTWV0aG9kT3B0aW9ucy5JZGVtcG90ZW5jeUxldmVsOhNJREVNUE9URU5DWV9VTktOT1dOUhBpZGVtcG90ZW5jeUxldmVsElgKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uUhN1bmludGVycHJldGVkT3B0aW9uIlAKEElkZW1wb3RlbmN5TGV2ZWwSFwoTSURFTVBPVEVOQ1lfVU5LTk9XThAAEhMKD05PX1NJREVfRUZGRUNUUxABEg4KCklERU1QT1RFTlQQAioJCOgHEICAgIACIpoDChNVbmludGVycHJldGVkT3B0aW9uEkEKBG5hbWUYAiADKAsyLS5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvbi5OYW1lUGFydFIEbmFtZRIpChBpZGVudGlmaWVyX3ZhbHVlGAMgASgJUg9pZGVudGlmaWVyVmFsdWUSLAoScG9zaXRpdmVfaW50X3ZhbHVlGAQgASgEUhBwb3NpdGl2ZUludFZhbHVlEiwKEm5lZ2F0aXZlX2ludF92YWx1ZRgFIAEoA1IQbmVnYXRpdmVJbnRWYWx1ZRIhCgxkb3VibGVfdmFsdWUYBiABKAFSC2RvdWJsZVZhbHVlEiEKDHN0cmluZ192YWx1ZRgHIAEoDFILc3RyaW5nVmFsdWUSJwoPYWdncmVnYXRlX3ZhbHVlGAggASgJUg5hZ2dyZWdhdGVWYWx1ZRpKCghOYW1lUGFydBIbCgluYW1lX3BhcnQYASACKAlSCG5hbWVQYXJ0EiEKDGlzX2V4dGVuc2lvbhgCIAIoCFILaXNFeHRlbnNpb24ipwIKDlNvdXJjZUNvZGVJbmZvEkQKCGxvY2F0aW9uGAEgAygLMiguZ29vZ2xlLnByb3RvYnVmLlNvdXJjZUNvZGVJbmZvLkxvY2F0aW9uUghsb2NhdGlvbhrOAQoITG9jYXRpb24SFgoEcGF0aBgBIAMoBUICEAFSBHBhdGgSFgoEc3BhbhgCIAMoBUICEAFSBHNwYW4SKQoQbGVhZGluZ19jb21tZW50cxgDIAEoCVIPbGVhZGluZ0NvbW1lbnRzEisKEXRyYWlsaW5nX2NvbW1lbnRzGAQgASgJUhB0cmFpbGluZ0NvbW1lbnRzEjoKGWxlYWRpbmdfZGV0YWNoZWRfY29tbWVudHMYBiADKAlSF2xlYWRpbmdEZXRhY2hlZENvbW1lbnRzItACChFHZW5lcmF0ZWRDb2RlSW5mbxJNCgphbm5vdGF0aW9uGAEgAygLMi0uZ29vZ2xlLnByb3RvYnVmLkdlbmVyYXRlZENvZGVJbmZvLkFubm90YXRpb25SCmFubm90YXRpb24a6wEKCkFubm90YXRpb24SFgoEcGF0aBgBIAMoBUICEAFSBHBhdGgSHwoLc291cmNlX2ZpbGUYAiABKAlSCnNvdXJjZUZpbGUSFAoFYmVnaW4YAyABKAVSBWJlZ2luEhAKA2VuZBgEIAEoBVIDZW5kElIKCHNlbWFudGljGAUgASgOMjYuZ29vZ2xlLnByb3RvYnVmLkdlbmVyYXRlZENvZGVJbmZvLkFubm90YXRpb24uU2VtYW50aWNSCHNlbWFudGljIigKCFNlbWFudGljEggKBE5PTkUQABIHCgNTRVQQARIJCgVBTElBUxACQn4KE2NvbS5nb29nbGUucHJvdG9idWZCEERlc2NyaXB0b3JQcm90b3NIAVotZ29vZ2xlLmdvbGFuZy5vcmcvcHJvdG9idWYvdHlwZXMvZGVzY3JpcHRvcnBi+AEBogIDR1BCqgIaR29vZ2xlLlByb3RvYnVmLlJlZmxlY3Rpb25K/fsCCgcSBSYAgwgBCqoPCgEMEgMmABIywQwgUHJvdG9jb2wgQnVmZmVycyAtIEdvb2dsZSdzIGRhdGEgaW50ZXJjaGFuZ2UgZm9ybWF0CiBDb3B5cmlnaHQgMjAwOCBHb29nbGUgSW5jLiAgQWxsIHJpZ2h0cyByZXNlcnZlZC4KIGh0dHBzOi8vZGV2ZWxvcGVycy5nb29nbGUuY29tL3Byb3RvY29sLWJ1ZmZlcnMvCgogUmVkaXN0cmlidXRpb24gYW5kIHVzZSBpbiBzb3VyY2UgYW5kIGJpbmFyeSBmb3Jtcywgd2l0aCBvciB3aXRob3V0CiBtb2RpZmljYXRpb24sIGFyZSBwZXJtaXR0ZWQgcHJvdmlkZWQgdGhhdCB0aGUgZm9sbG93aW5nIGNvbmRpdGlvbnMgYXJlCiBtZXQ6CgogICAgICogUmVkaXN0cmlidXRpb25zIG9mIHNvdXJjZSBjb2RlIG11c3QgcmV0YWluIHRoZSBhYm92ZSBjb3B5cmlnaHQKIG5vdGljZSwgdGhpcyBsaXN0IG9mIGNvbmRpdGlvbnMgYW5kIHRoZSBmb2xsb3dpbmcgZGlzY2xhaW1lci4KICAgICAqIFJlZGlzdHJpYnV0aW9ucyBpbiBiaW5hcnkgZm9ybSBtdXN0IHJlcHJvZHVjZSB0aGUgYWJvdmUKIGNvcHlyaWdodCBub3RpY2UsIHRoaXMgbGlzdCBvZiBjb25kaXRpb25zIGFuZCB0aGUgZm9sbG93aW5nIGRpc2NsYWltZXIKIGluIHRoZSBkb2N1bWVudGF0aW9uIGFuZC9vciBvdGhlciBtYXRlcmlhbHMgcHJvdmlkZWQgd2l0aCB0aGUKIGRpc3RyaWJ1dGlvbi4KICAgICAqIE5laXRoZXIgdGhlIG5hbWUgb2YgR29vZ2xlIEluYy4gbm9yIHRoZSBuYW1lcyBvZiBpdHMKIGNvbnRyaWJ1dG9ycyBtYXkgYmUgdXNlZCB0byBlbmRvcnNlIG9yIHByb21vdGUgcHJvZHVjdHMgZGVyaXZlZCBmcm9tCiB0aGlzIHNvZnR3YXJlIHdpdGhvdXQgc3BlY2lmaWMgcHJpb3Igd3JpdHRlbiBwZXJtaXNzaW9uLgoKIFRISVMgU09GVFdBUkUgSVMgUFJPVklERUQgQlkgVEhFIENPUFlSSUdIVCBIT0xERVJTIEFORCBDT05UUklCVVRPUlMKICJBUyBJUyIgQU5EIEFOWSBFWFBSRVNTIE9SIElNUExJRUQgV0FSUkFOVElFUywgSU5DTFVESU5HLCBCVVQgTk9UCiBMSU1JVEVEIFRPLCBUSEUgSU1QTElFRCBXQVJSQU5USUVTIE9GIE1FUkNIQU5UQUJJTElUWSBBTkQgRklUTkVTUyBGT1IKIEEgUEFSVElDVUxBUiBQVVJQT1NFIEFSRSBESVNDTEFJTUVELiBJTiBOTyBFVkVOVCBTSEFMTCBUSEUgQ09QWVJJR0hUCiBPV05FUiBPUiBDT05UUklCVVRPUlMgQkUgTElBQkxFIEZPUiBBTlkgRElSRUNULCBJTkRJUkVDVCwgSU5DSURFTlRBTCwKIFNQRUNJQUwsIEVYRU1QTEFSWSwgT1IgQ09OU0VRVUVOVElBTCBEQU1BR0VTIChJTkNMVURJTkcsIEJVVCBOT1QKIExJTUlURUQgVE8sIFBST0NVUkVNRU5UIE9GIFNVQlNUSVRVVEUgR09PRFMgT1IgU0VSVklDRVM7IExPU1MgT0YgVVNFLAogREFUQSwgT1IgUFJPRklUUzsgT1IgQlVTSU5FU1MgSU5URVJSVVBUSU9OKSBIT1dFVkVSIENBVVNFRCBBTkQgT04gQU5ZCiBUSEVPUlkgT0YgTElBQklMSVRZLCBXSEVUSEVSIElOIENPTlRSQUNULCBTVFJJQ1QgTElBQklMSVRZLCBPUiBUT1JUCiAoSU5DTFVESU5HIE5FR0xJR0VOQ0UgT1IgT1RIRVJXSVNFKSBBUklTSU5HIElOIEFOWSBXQVkgT1VUIE9GIFRIRSBVU0UKIE9GIFRISVMgU09GVFdBUkUsIEVW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cyBmaWVsZC4gVGhlIHZhbHVlIGlzIHNldCBieSBwcm90b2NvbCBjb21waWxlci4gSWYgdGhlCiB1c2VyIGhhcyBzZXQgYSAianNvbl9uYW1lIiBvcHRpb24gb24gdGhpcyBmaWVsZCwgdGhhdCBvcHRpb24ncyB2YWx1ZQogd2lsbCBiZSB1c2VkLiBPdGhlcndpc2UsIGl0J3MgZGVkdWNlZCBmcm9tIHRoZSBmaWVsZCdzIG5hbWUgYnkgY29udmVydGluZwogaXQgdG8gY2FtZWxDYXNlLgoKDQoFBAQCCAQSBIICAgoKDQoFBAQCCAUSBIICCxEKDQoFBAQCCAESBIICEhsKDQoFBAQCCAMSBIICHiAKDAoEBAQCCRIEhAICJAoNCgUEBAIJBBIEhAICCgoNCgUEBAIJBhIEhAILFwoNCgUEBAIJARIEhAIYHwoNCgUEBAIJAxIEhAIiIwqzCQoEBAQCChIEmwICJRqkCSBJZiB0cnVlLCB0aGlzIGlzIGEgcHJvdG8zICJvcHRpb25hbCIuIFdoZW4gYSBwcm90bzMgZmllbGQgaXMgb3B0aW9uYWwsIGl0CiB0cmFja3MgcHJlc2VuY2UgcmVnYXJkbGVzcyBvZiBmaWVsZCB0eXBlLgoKIFdoZW4gcHJvdG8zX29wdGlvbmFsIGlzIHRydWUsIHRoaXMgZmllbGQgbXVzdCBiZSBiZWxvbmcgdG8gYSBvbmVvZiB0bwogc2lnbmFsIHRvIG9sZCBwcm90bzMgY2xpZW50cyB0aGF0IHByZXNlbmNlIGlzIHRyYWNrZWQgZm9yIHRoaXMgZmllbGQuIFRoaXMKIG9uZW9mIGlzIGtub3duIGFzIGEgInN5bnRoZXRpYyIgb25lb2YsIGFuZCB0aGlzIGZpZWxkIG11c3QgYmUgaXRzIHNvbGUKIG1lbWJlciAoZWFjaCBwcm90bzMgb3B0aW9uYWwgZmllbGQgZ2V0cyBpdHMgb3duIHN5bnRoZXRpYyBvbmVvZikuIFN5bnRoZXRpYwogb25lb2ZzIGV4aXN0IGluIHRoZSBkZXNjcmlwdG9yIG9ubHksIGFuZCBkbyBub3QgZ2VuZXJhdGUgYW55IEFQSS4gU3ludGhldGljCiBvbmVvZnMgbXVzdCBiZSBvcmRlcmVkIGFmdGVyIGFsbCAicmVhbCIgb25lb2ZzLgoKIEZvciBtZXNzYWdlIGZpZWxkcywgcHJvdG8zX29wdGlvbmFsIGRvZXNuJ3QgY3JlYXRlIGFueSBzZW1hbnRpYyBjaGFuZ2UsCiBzaW5jZSBub24tcmVwZWF0ZWQgbWVzc2FnZSBmaWVsZHMgYWx3YXlzIHRyYWNrIHByZXNlbmNlLiBIb3dldmVyIGl0IHN0aWxsCiBpbmRpY2F0ZXMgdGhlIHNlbWFudGljIGRldGFpbCBvZiB3aGV0aGVyIHRoZSB1c2VyIHdyb3RlICJvcHRpb25hbCIgb3Igbm90LgogVGhpcyBjYW4gYmUgdXNlZnVsIGZvciByb3VuZC10cmlwcGluZyB0aGUgLnByb3RvIGZpbGUuIEZvciBjb25zaXN0ZW5jeSB3ZQogZ2l2ZSBtZXNzYWdlIGZpZWxkcyBhIHN5bnRoZXRpYyBvbmVvZiBhbHNvLCBldmVuIHRob3VnaCBpdCBpcyBub3QgcmVxdWlyZWQKIHRvIHRyYWNrIHByZXNlbmNlLiBUaGlzIGlzIGVzcGVjaWFsbHkgaW1wb3J0YW50IGJlY2F1c2UgdGhlIHBhcnNlciBjYW4ndAogdGVsbCBpZiBhIGZpZWxkIGlzIGEgbWVzc2FnZSBvciBhbiBlbnVtLCBzbyBpdCBtdXN0IGFsd2F5cyBjcmVhdGUgYQogc3ludGhldGljIG9uZW9mLgoKIFByb3RvMiBvcHRpb25hbCBmaWVsZHMgZG8gbm90IHNldCB0aGlzIGZsYWcsIGJlY2F1c2UgdGhleSBhbHJlYWR5IGluZGljYXRlCiBvcHRpb25hbCB3aXRoIGBMQUJFTF9PUFRJT05BTGAuCgoNCgUEBAIKBBIEmwICCgoNCgUEBAIKBRIEmwILDwoNCgUEBAIKARIEmwIQHwoNCgUEBAIKAxIEmwIiJAoiCgIEBRIGnwIAogIBGhQgRGVzY3JpYmVzIGEgb25lb2YuCgoLCgMEBQESBJ8CCBwKDAoEBAUCABIEoAICGwoNCgUEBQIABBIEoAICCgoNCgUEBQIABRIEoAILEQoNCgUEBQIAARIEoAISFgoNCgUEBQIAAxIEoAIZGgoMCgQEBQIBEgShAgIkCg0KBQQFAgEEEgShAgIKCg0KBQQFAgEGEgShAgsXCg0KBQQFAgEBEgShAhgf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ZSBkZWZpbml0aW9ucyBhYm92ZSBtYXkgaGF2ZSAib3B0aW9ucyIgYXR0YWNoZWQuICBUaGVzZSBhcmUKIGp1c3QgYW5ub3RhdGlvbnMgd2hpY2ggbWF5IGNhdXNlIGNvZGUgdG8gYmUgZ2VuZXJhdGVkIHNsaWdodGx5IGRpZmZlcmVudGx5CiBvciBtYXkgY29udGFpbiBoaW50cyBmb3IgY29kZSB0aGF0IG1hbmlwdWxhdGVzIHByb3RvY29sIG1lc3NhZ2VzLgoKIENsaWVudHMgbWF5IGRlZmluZSBjdXN0b20gb3B0aW9ucyBhcyBleHRlbnNpb25zIG9mIHRoZSAqT3B0aW9ucyBtZXNzYWdlcy4KIFRoZXNlIGV4dGVuc2lvbnMgbWF5IG5vdCB5ZXQgYmUga25vd24gYXQgcGFyc2luZyB0aW1lLCBzbyB0aGUgcGFyc2VyIGNhbm5vdAogc3RvcmUgdGhlIHZhbHVlcyBpbiB0aGVtLiAgSW5zdGVhZCBpdCBzdG9yZXMgdGhlbSBpbiBhIGZpZWxkIGluIHRoZSAqT3B0aW9ucwogbWVzc2FnZSBjYWxsZWQgdW5pbnRlcnByZXRlZF9vcHRpb24uIFRoaXMgZmllbGQgbXVzdCBoYXZlIHRoZSBzYW1lIG5hbWUKIGFjcm9zcyBhbGwgKk9wdGlvbnMgbWVzc2FnZXMuIFdlIHRoZW4gdXNlIHRoaXMgZmllbGQgdG8gcG9wdWxhdGUgdGhlCiBleHRlbnNpb25zIHdoZW4gd2UgYnVpbGQgYSBkZXNjcmlwdG9yLCBhdCB3aGljaCBwb2ludCBhbGwgcHJvdG9zIGhhdmUgYmVlbgogcGFyc2VkIGFuZCBzbyBhbGwgZXh0ZW5zaW9ucyBhcmUga25vd24uCgogRXh0ZW5zaW9uIG51bWJlcnMgZm9yIGN1c3RvbSBvcHRpb25zIG1heSBiZSBjaG9zZW4gYXMgZm9sbG93czoKICogRm9yIG9wdGlvbnMgd2hpY2ggd2lsbCBvbmx5IGJlIHVzZWQgd2l0aGluIGEgc2luZ2xlIGFwcGxpY2F0aW9uIG9yCiAgIG9yZ2FuaXphdGlvbiwgb3IgZm9yIGV4cGVyaW1lbnRhbCBvcHRpb25zLCB1c2UgZmllbGQgbnVtYmVycyA1MDAwMAogICB0aHJvdWdoIDk5OTk5LiAgSXQgaXMgdXAgdG8geW91IHRvIGVuc3VyZSB0aGF0IHlvdSBkbyBub3QgdXNlIHRoZQogICBzYW1lIG51bWJlciBmb3IgbXVsdGlwbGUgb3B0aW9ucy4KICogRm9yIG9wdGlvbnMgd2hpY2ggd2lsbCBiZSBwdWJsaXNoZWQgYW5kIHVzZWQgcHVibGljbHkgYnkgbXVsdGlwbGUKICAgaW5kZXBlbmRlbnQgZW50aXRpZXMsIGUtbWFpbCBwcm90b2J1Zi1nbG9iYWwtZXh0ZW5zaW9uLXJlZ2lzdHJ5QGdvb2dsZS5jb20KICAgdG8gcmVzZXJ2ZSBleHRlbnNpb24gbnVtYmVycy4gU2ltcGx5IHByb3ZpZGUgeW91ciBwcm9qZWN0IG5hbWUgKGUuZy4KICAgT2JqZWN0aXZlLUMgcGx1Z2luKSBhbmQgeW91ciBwcm9qZWN0IHdlYnNpdGUgKGlmIGF2YWlsYWJsZSkgLS0gdGhlcmUncyBubwogICBuZWVkIHRvIGV4cGxhaW4gaG93IHlvdSBpbnRlbmQgdG8gdXNlIHRoZW0uIFVzdWFsbHkgeW91IG9ubHkgbmVlZCBvbmUKICAgZXh0ZW5zaW9uIG51bWJlci4gWW91IGNhbiBkZWNsYXJlIG11bHRpcGxlIG9wdGlvbnMgd2l0aCBvbmx5IG9uZSBleHRlbnNpb24KICAgbnVtYmVyIGJ5IHB1dHRpbmcgdGhlbSBpbiBhIHN1Yi1tZXNzYWdlLiBTZWUgdGhlIEN1c3RvbSBPcHRpb25zIHNlY3Rpb24gb2YKICAgdGhlIGRvY3MgZm9yIGV4YW1wbGVzOgogICBodHRwczovL2RldmVsb3BlcnMuZ29vZ2xlLmNvbS9wcm90b2NvbC1idWZmZXJzL2RvY3MvcHJvdG8jb3B0aW9ucwogICBJZiB0aGlzIHR1cm5zIG91dCB0byBiZSBwb3B1bGFyLCBhIHdlYiBzZXJ2aWNlIHdpbGwgYmUgc2V0IHVwCiAgIHRvIGF1dG9tYXRpY2FsbHkgYXNzaWduIG9wdGlvbiBudW1iZXJzLgoKCwoDBAoBEgSCAwgTCvQBCgQECgIAEgSIAwIjGuUBIFNldHMgdGhlIEphdmEgcGFja2FnZSB3aGVyZSBjbGFzc2VzIGdlbmVyYXRlZCBmcm9tIHRoaXMgLnByb3RvIHdpbGwgYmUKIHBsYWNlZC4gIEJ5IGRlZmF1bHQsIHRoZSBwcm90byBwYWNrYWdlIGlzIHVzZWQsIGJ1dCB0aGlzIGlzIG9mdGVuCiBpbmFwcHJvcHJpYXRlIGJlY2F1c2UgcHJvdG8gcGFja2FnZXMgZG8gbm90IG5vcm1hbGx5IHN0YXJ0IHdpdGggYmFja3dhcmRzCiBkb21haW4gbmFtZXMuCgoNCgUECgIABBIEiAMCCgoNCgUECgIABRIEiAMLEQoNCgUECgIAARIEiAMSHgoNCgUECgIAAxIEiAMhIgrxAgoEBAoCARIEjwMCKxriAiBDb250cm9scyB0aGUgbmFtZSBvZiB0aGUgd3JhcHBlciBKYXZhIGNsYXNzIGdlbmVyYXRlZCBmb3IgdGhlIC5wcm90byBmaWxlLgogVGhhdCBjbGFzcyB3aWxsIGFsd2F5cyBjb250YWluIHRoZSAucHJvdG8gZmlsZSdzIGdldERlc2NyaXB0b3IoKSBtZXRob2QgYXMKIHdlbGwgYXMgYW55IHRvcC1sZXZlbCBleHRlbnNpb25zIGRlZmluZWQgaW4gdGhlIC5wcm90byBmaWxlLgogSWYgamF2YV9tdWx0aXBsZV9maWxlcyBpcyBkaXNhYmxlZCwgdGhlbiBhbGwgdGhlIG90aGVyIGNsYXNzZXMgZnJvbSB0aGUKIC5wcm90byBmaWxlIHdpbGwgYmUgbmVzdGVkIGluc2lkZSB0aGUgc2luZ2xlIHdyYXBwZXIgb3V0ZXIgY2xhc3MuCgoNCgUECgIBBBIEjwMCCgoNCgUECgIBBRIEjwMLEQoNCgUECgIBARIEjwMSJgoNCgUECgIBAxIEjwMpKgqmAwoEBAoCAhIElwMCOxqXAyBJZiBlbmFibGVkLCB0aGVuIHRoZSBKYXZhIGNvZGUgZ2VuZXJhdG9yIHdpbGwgZ2VuZXJhdGUgYSBzZXBhcmF0ZSAuamF2YQogZmlsZSBmb3IgZWFjaCB0b3AtbGV2ZWwgbWVzc2FnZSwgZW51bSwgYW5kIHNlcnZpY2UgZGVmaW5lZCBpbiB0aGUgLnByb3RvCiBmaWxlLiAgVGh1cywgdGhlc2UgdHlwZXMgd2lsbCAqbm90KiBiZSBuZXN0ZWQgaW5zaWRlIHRoZSB3cmFwcGVyIGNsYXNzCiBuYW1lZCBieSBqYXZhX291dGVyX2NsYXNzbmFtZS4gIEhvd2V2ZXIsIHRoZSB3cmFwcGVyIGNsYXNzIHdpbGwgc3RpbGwgYmUKIGdlbmVyYXRlZCB0byBjb250YWluIHRoZSBmaWxlJ3MgZ2V0RGVzY3JpcHRvcigpIG1ldGhvZCBhcyB3ZWxsIGFzIGFueQogdG9wLWxldmVsIGV4dGVuc2lvbnMgZGVmaW5lZCBpbiB0aGUgZmlsZS4KCg0KBQQKAgIEEgSXAwIKCg0KBQQKAgIFEgSXAwsPCg0KBQQKAgIBEgSXAxAjCg0KBQQKAgIDEgSXAyYoCg0KBQQKAgIIEgSXAyk6Cg0KBQQKAgIHEgSXAzQ5CikKBAQKAgMSBJoDAkUaGyBUaGlzIG9wdGlvbiBkb2VzIG5vdGhpbmcuCgoNCgUECgIDBBIEmgMCCgoNCgUECgIDBRIEmgMLDwoNCgUECgIDARIEmgMQLQoNCgUECgIDAxIEmgMwMgoNCgUECgIDCBIEmgMzRAoOCgYECgIDCAMSBJoDNEMK5gIKBAQKAgQSBKIDAj4a1wIgSWYgc2V0IHRydWUsIHRoZW4gdGhlIEphdmEyIGNvZGUgZ2VuZXJhdG9yIHdpbGwgZ2VuZXJhdGUgY29kZSB0aGF0CiB0aHJvd3MgYW4gZXhjZXB0aW9uIHdoZW5ldmVyIGFuIGF0dGVtcHQgaXMgbWFkZSB0byBhc3NpZ24gYSBub24tVVRGLTgKIGJ5dGUgc2VxdWVuY2UgdG8gYSBzdHJpbmcgZmllbGQuCiBNZXNzYWdlIHJlZmxlY3Rpb24gd2lsbCBkbyB0aGUgc2FtZS4KIEhvd2V2ZXIsIGFuIGV4dGVuc2lvbiBmaWVsZCBzdGlsbCBhY2NlcHRzIG5vbi1VVEYtOCBieXRlIHNlcXVlbmNlcy4KIFRoaXMgb3B0aW9uIGhhcyBubyBlZmZlY3Qgb24gd2hlbiB1c2VkIHdpdGggdGhlIGxpdGUgcnVudGltZS4KCg0KBQQKAgQEEgSiAwIKCg0KBQQKAgQFEgSiAwsPCg0KBQQKAgQBEgSiAxAmCg0KBQQKAgQDEgSiAykrCg0KBQQKAgQIEgSiAyw9Cg0KBQQKAgQHEgSiAzc8CkwKBAQKBAASBqUDAqoDAxo8IEdlbmVyYXRlZCBjbGFzc2VzIGNhbiBiZSBvcHRpbWl6ZWQgZm9yIHNwZWVkIG9yIGNvZGUgc2l6ZS4KCg0KBQQKBAABEgSlAwcTCkQKBgQKBAACABIEpgMEDiI0IEdlbmVyYXRlIGNvbXBsZXRlIGNvZGUgZm9yIHBhcnNpbmcsIHNlcmlhbGl6YXRpb24sCgoPCgcECgQAAgABEgSmAwQJCg8KBwQKBAACAAISBKYDDA0KRwoGBAoEAAIBEgSoAwQSGgYgZXRjLgoiLyBVc2UgUmVmbGVjdGlvbk9wcyB0byBpbXBsZW1lbnQgdGhlc2UgbWV0aG9kcy4KCg8KBwQKBAACAQESBKgDBA0KDwoHBAoEAAIBAhIEqAMQEQpHCgYECgQAAgISBKkDBBUiNyBHZW5lcmF0ZSBjb2RlIHVzaW5nIE1lc3NhZ2VMaXRlIGFuZCB0aGUgbGl0ZSBydW50aW1lLgoKDwoHBAoEAAICARIEqQMEEAoPCgcECgQAAgICEgSpAxMUCgwKBAQKAgUSBKsDAjsKDQoFBAoCBQQSBKsDAgoKDQoFBAoCBQYSBKsDCxcKDQoFBAoCBQESBKsDGCQKDQoFBAoCBQMSBKsDJygKDQoFBAoCBQgSBKsDKToKDQoFBAoCBQcSBKsDNDkK4gIKBAQKAgYSBLIDAiIa0wIgU2V0cyB0aGUgR28gcGFja2FnZSB3aGVyZSBzdHJ1Y3RzIGdlbmVyYXRlZCBmcm9tIHRoaXMgLnByb3RvIHdpbGwgYmUKIHBsYWNlZC4gSWYgb21pdHRlZCwgdGhlIEdvIHBhY2thZ2Ugd2lsbCBiZSBkZXJpdmVkIGZyb20gdGhlIGZvbGxvd2luZzoKICAgLSBUaGUgYmFzZW5hbWUgb2YgdGhlIHBhY2thZ2UgaW1wb3J0IHBhdGgsIGlmIHByb3ZpZGVkLgogICAtIE90aGVyd2lzZSwgdGhlIHBhY2thZ2Ugc3RhdGVtZW50IGluIHRoZSAucHJvdG8gZmlsZSwgaWYgcHJlc2VudC4KICAgLSBPdGhlcndpc2UsIHRoZSBiYXNlbmFtZSBvZiB0aGUgLnByb3RvIGZpbGUsIHdpdGhvdXQgZXh0ZW5zaW9uLgoKDQoFBAoCBgQSBLIDAgoKDQoFBAoCBgUSBLIDCxEKDQoFBAoCBgESBLIDEhwKDQoFBAoCBgMSBLIDHyEK1AQKBAQKAgcSBL4DAjsaxQQgU2hvdWxkIGdlbmVyaWMgc2VydmljZXMgYmUgZ2VuZXJhdGVkIGluIGVhY2ggbGFuZ3VhZ2U/ICAiR2VuZXJpYyIgc2VydmljZXMKIGFyZSBub3Qgc3BlY2lmaWMgdG8gYW55IHBhcnRpY3VsYXIgUlBDIHN5c3RlbS4gIFRoZXkgYXJlIGdlbmVyYXRlZCBieSB0aGUKIG1haW4gY29kZSBnZW5lcmF0b3JzIGluIGVhY2ggbGFuZ3VhZ2UgKHdpdGhvdXQgYWRkaXRpb25hbCBwbHVnaW5zKS4KIEdlbmVyaWMgc2VydmljZXMgd2VyZSB0aGUgb25seSBraW5kIG9mIHNlcnZpY2UgZ2VuZXJhdGlvbiBzdXBwb3J0ZWQgYnkKIGVhcmx5IHZlcnNpb25zIG9mIGdvb2dsZS5wcm90b2J1Zi4KCiBHZW5lcmljIHNlcnZpY2VzIGFyZSBub3cgY29uc2lkZXJlZCBkZXByZWNhdGVkIGluIGZhdm9yIG9mIHVzaW5nIHBsdWdpbnMKIHRoYXQgZ2VuZXJhdGUgY29kZSBzcGVjaWZpYyB0byB5b3VyIHBhcnRpY3VsYXIgUlBDIHN5c3RlbS4gIFRoZXJlZm9yZSwKIHRoZXNlIGRlZmF1bHQgdG8gZmFsc2UuICBPbGQgY29kZSB3aGljaCBkZXBlbmRzIG9uIGdlbmVyaWMgc2VydmljZXMgc2hvdWxkCiBleHBsaWNpdGx5IHNldCB0aGVtIHRvIHRydWUuCgoNCgUECgIHBBIEvgMCCgoNCgUECgIHBRIEvgMLDwoNCgUECgIHARIEvgMQIwoNCgUECgIHAxIEvgMmKAoNCgUECgIHCBIEvgMpOgoNCgUECgIHBxIEvgM0OQoMCgQECgIIEgS/AwI9Cg0KBQQKAggEEgS/AwIKCg0KBQQKAggFEgS/AwsPCg0KBQQKAggBEgS/AxAlCg0KBQQKAggDEgS/AygqCg0KBQQKAggIEgS/Ays8Cg0KBQQKAggHEgS/AzY7CgwKBAQKAgkSBMADAjsKDQoFBAoCCQQSBMADAgoKDQoFBAoCCQUSBMADCw8KDQoFBAoCCQESBMADECMKDQoFBAoCCQMSBMADJigKDQoFBAoCCQgSBMADKToKDQoFBAoCCQcSBMADNDkKDAoEBAoCChIEwQMCPAoNCgUECgIKBBIEwQMCCgoNCgUECgIKBRIEwQMLDwoNCgUECgIKARIEwQMQJAoNCgUECgIKAxIEwQMnKQoNCgUECgIKCBIEwQMqOwoNCgUECgIKBxIEwQM1OgrzAQoEBAoCCxIExwMCMhrkASBJcyB0aGlzIGZpbGUgZGVwcmVjYXRlZD8KIERlcGVuZGluZyBvbiB0aGUgdGFyZ2V0IHBsYXRmb3JtLCB0aGlzIGNhbiBlbWl0IERlcHJlY2F0ZWQgYW5ub3RhdGlvbnMKIGZvciBldmVyeXRoaW5nIGluIHRoZSBmaWxlLCBvciBpdCB3aWxsIGJlIGNvbXBsZXRlbHkgaWdub3JlZDsgaW4gdGhlIHZlcnkKIGxlYXN0LCB0aGlzIGlzIGEgZm9ybWFsaXphdGlvbiBmb3IgZGVwcmVjYXRpbmcgZmlsZXMuCgoNCgUECgILBBIExwMCCgoNCgUECgILBRIExwMLDwoNCgUECgILARIExwMQGgoNCgUECgILAxIExwMdHwoNCgUECgILCBIExwMgMQoNCgUECgILBxIExwMrMAp/CgQECgIMEgTLAwI3GnEgRW5hYmxlcyB0aGUgdXNlIG9mIGFyZW5hcyBmb3IgdGhlIHByb3RvIG1lc3NhZ2VzIGluIHRoaXMgZmlsZS4gVGhpcyBhcHBsaWVzCiBvbmx5IHRvIGdlbmVyYXRlZCBjbGFzc2VzIGZvciBDKysuCgoNCgUECgIMBBIEywMCCgoNCgUECgIMBRIEywMLDwoNCgUECgIMARIEywMQIAoNCgUECgIMAxIEywMjJQoNCgUECgIMCBIEywMmNgoNCgUECgIMBxIEywMxNQqSAQoEBAoCDRIEzwMCKRqDASBTZXRzIHRoZSBvYmplY3RpdmUgYyBjbGFzcyBwcmVmaXggd2hpY2ggaXMgcHJlcGVuZGVkIHRvIGFsbCBvYmplY3RpdmUgYwogZ2VuZXJhdGVkIGNsYXNzZXMgZnJvbSB0aGlzIC5wcm90by4gVGhlcmUgaXMgbm8gZGVmYXVsdC4KCg0KBQQKAg0EEgTPAwIKCg0KBQQKAg0FEgTPAwsRCg0KBQQKAg0BEgTPAxIjCg0KBQQKAg0DEgTPAyYoCkkKBAQKAg4SBNIDAigaOyBOYW1lc3BhY2UgZm9yIGdlbmVyYXRlZCBjbGFzc2VzOyBkZWZhdWx0cyB0byB0aGUgcGFja2FnZS4KCg0KBQQKAg4EEgTSAwIKCg0KBQQKAg4FEgTSAwsRCg0KBQQKAg4BEgTSAxIiCg0KBQQKAg4DEgTSAyUnCpECCgQECgIPEgTYAwIkGoICIEJ5IGRlZmF1bHQgU3dpZnQgZ2VuZXJhdG9ycyB3aWxsIHRha2UgdGhlIHByb3RvIHBhY2thZ2UgYW5kIENhbWVsQ2FzZSBpdAogcmVwbGFjaW5nICcuJyB3aXRoIHVuZGVyc2NvcmUgYW5kIHVzZSB0aGF0IHRvIHByZWZpeCB0aGUgdHlwZXMvc3ltYm9scwogZGVmaW5lZC4gV2hlbiB0aGlzIG9wdGlvbnMgaXMgcHJvdmlkZWQsIHRoZXkgd2lsbCB1c2UgdGhpcyB2YWx1ZSBpbnN0ZWFkCiB0byBwcmVmaXggdGhlIHR5cGVzL3N5bWJvbHMgZGVmaW5lZC4KCg0KBQQKAg8EEgTYAwIKCg0KBQQKAg8FEgTYAwsRCg0KBQQKAg8BEgTYAxIeCg0KBQQKAg8DEgTYAyEjCn4KBAQKAhASBNwDAigacCBTZXRzIHRoZSBwaHAgY2xhc3MgcHJlZml4IHdoaWNoIGlzIHByZXBlbmRlZCB0byBhbGwgcGhwIGdlbmVyYXRlZCBjbGFzc2VzCiBmcm9tIHRoaXMgLnByb3RvLiBEZWZhdWx0IGlzIGVtcHR5LgoKDQoFBAoCEAQSBNwDAgoKDQoFBAoCEAUSBNwDCxEKDQoFBAoCEAESBNwDEiIKDQoFBAoCEAMSBNwDJScKvgEKBAQKAhESBOEDAiUarwEgVXNlIHRoaXMgb3B0aW9uIHRvIGNoYW5nZSB0aGUgbmFtZXNwYWNlIG9mIHBocCBnZW5lcmF0ZWQgY2xhc3Nlcy4gRGVmYXVsdAogaXMgZW1wdHkuIFdoZW4gdGhpcyBvcHRpb24gaXMgZW1wdHksIHRoZSBwYWNrYWdlIG5hbWUgd2lsbCBiZSB1c2VkIGZvcgogZGV0ZXJtaW5pbmcgdGhlIG5hbWVzcGFjZS4KCg0KBQQKAhEEEgThAwIKCg0KBQQKAhEFEgThAwsRCg0KBQQKAhEBEgThAxIfCg0KBQQKAhEDEgThAyIkCsoBCgQECgISEgTmAwIuGrsBIFVzZSB0aGlzIG9wdGlvbiB0byBjaGFuZ2UgdGhlIG5hbWVzcGFjZSBvZiBwaHAgZ2VuZXJhdGVkIG1ldGFkYXRhIGNsYXNzZXMuCiBEZWZhdWx0IGlzIGVtcHR5LiBXaGVuIHRoaXMgb3B0aW9uIGlzIGVtcHR5LCB0aGUgcHJvdG8gZmlsZSBuYW1lIHdpbGwgYmUKIHVzZWQgZm9yIGRldGVybWluaW5nIHRoZSBuYW1lc3BhY2UuCgoNCgUECgISBBIE5gMCCgoNCgUECgISBRIE5gMLEQoNCgUECgISARIE5gMSKAoNCgUECgISAxIE5gMrLQrCAQoEBAoCExIE6wMCJBqzASBVc2UgdGhpcyBvcHRpb24gdG8gY2hhbmdlIHRoZSBwYWNrYWdlIG9mIHJ1YnkgZ2VuZXJhdGVkIGNsYXNzZXMuIERlZmF1bHQKIGlzIGVtcHR5LiBXaGVuIHRoaXMgb3B0aW9uIGlzIG5vdCBzZXQsIHRoZSBwYWNrYWdlIG5hbWUgd2lsbCBiZSB1c2VkIGZvcgogZGV0ZXJtaW5pbmcgdGhlIHJ1YnkgcGFja2FnZS4KCg0KBQQKAhMEEgTrAwIKCg0KBQQKAhMFEgTrAwsRCg0KBQQKAhMBEgTrAxIeCg0KBQQKAhMDEgTrAyEjCnwKBAQKAhQSBO8DAjoabiBUaGUgcGFyc2VyIHN0b3JlcyBvcHRpb25zIGl0IGRvZXNuJ3QgcmVjb2duaXplIGhlcmUuCiBTZWUgdGhlIGRvY3VtZW50YXRpb24gZm9yIHRoZSAiT3B0aW9ucyIgc2VjdGlvbiBhYm92ZS4KCg0KBQQKAhQEEgTvAwIKCg0KBQQKAhQGEgTvAwseCg0KBQQKAhQBEgTvAx8zCg0KBQQKAhQDEgTvAzY5CocBCgMECgUSBPMDAhkaeiBDbGllbnRzIGNhbiBkZWZpbmUgY3VzdG9tIG9wdGlvbnMgaW4gZXh0ZW5zaW9ucyBvZiB0aGlzIG1lc3NhZ2UuCiBTZWUgdGhlIGRvY3VtZW50YXRpb24gZm9yIHRoZSAiT3B0aW9ucyIgc2VjdGlvbiBhYm92ZS4KCgwKBAQKBQASBPMDDRgKDQoFBAoFAAESBPMDDREKDQoFBAoFAAISBPMDFRgKCwoDBAoJEgT1AwIOCgwKBAQKCQASBPUDCw0KDQoFBAoJAAESBPUDCw0KDQoFBAoJAAISBPUDCw0KDAoCBAsSBvgDAMUEAQoLCgMECwESBPgDCBYK2AUKBAQLAgASBIsEAj4ayQUgU2V0IHRydWUgdG8gdXNlIHRoZSBvbGQgcHJvdG8xIE1lc3NhZ2VTZXQgd2lyZSBmb3JtYXQgZm9yIGV4dGVuc2lvbnMuCiBUaGlzIGlzIHByb3ZpZGVkIGZvciBiYWNrd2FyZHMtY29tcGF0aWJpbGl0eSB3aXRoIHRoZSBNZXNzYWdlU2V0IHdpcmUKIGZvcm1hdC4gIFlvdSBzaG91bGQgbm90IHVzZSB0aGlzIGZvciBhbnkgb3RoZXIgcmVhc29uOiAgSXQncyBsZXNzCiBlZmZpY2llbnQsIGhhcyBmZXdlciBmZWF0dXJlcywgYW5kIGlzIG1vcmUgY29tcGxpY2F0ZWQuCgogVGhlIG1lc3NhZ2UgbXVzdCBiZSBkZWZpbmVkIGV4YWN0bHkgYXMgZm9sbG93czoKICAgbWVzc2FnZSBGb28gewogICAgIG9wdGlvbiBtZXNzYWdlX3NldF93aXJlX2Zvcm1hdCA9IHRydWU7CiAgICAgZXh0ZW5zaW9ucyA0IHRvIG1heDsKICAgfQogTm90ZSB0aGF0IHRoZSBtZXNzYWdlIGNhbm5vdCBoYXZlIGFueSBkZWZpbmVkIGZpZWxkczsgTWVzc2FnZVNldHMgb25seQogaGF2ZSBleHRlbnNpb25zLgoKIEFsbCBleHRlbnNpb25zIG9mIHlvdXIgdHlwZSBtdXN0IGJlIHNpbmd1bGFyIG1lc3NhZ2VzOyBlLmcuIHRoZXkgY2Fubm90CiBiZSBpbnQzMnMsIGVudW1zLCBvciByZXBlYXRlZCBtZXNzYWdlcy4KCiBCZWNhdXNlIHRoaXMgaXMgYW4gb3B0aW9uLCB0aGUgYWJvdmUgdHdvIHJlc3RyaWN0aW9ucyBhcmUgbm90IGVuZm9yY2VkIGJ5CiB0aGUgcHJvdG9jb2wgY29tcGlsZXIuCgoNCgUECwIABBIEiwQCCgoNCgUECwIABRIEiwQLDwoNCgUECwIAARIEiwQQJwoNCgUECwIAAxIEiwQqKwoNCgUECwIACBIEiwQsPQoNCgUECwIABxIEiwQ3PArrAQoEBAsCARIEkAQCRhrcASBEaXNhYmxlcyB0aGUgZ2VuZXJhdGlvbiBvZiB0aGUgc3RhbmRhcmQgImRlc2NyaXB0b3IoKSIgYWNjZXNzb3IsIHdoaWNoIGNhbgogY29uZmxpY3Qgd2l0aCBhIGZpZWxkIG9mIHRoZSBzYW1lIG5hbWUuICBUaGlzIGlzIG1lYW50IHRvIG1ha2UgbWlncmF0aW9uCiBmcm9tIHByb3RvMSBlYXNpZXI7IG5ldyBjb2RlIHNob3VsZCBhdm9pZCBmaWVsZHMgbmFtZWQgImRlc2NyaXB0b3IiLgoKDQoFBAsCAQQSBJAEAgoKDQoFBAsCAQUSBJAECw8KDQoFBAsCAQESBJAEEC8KDQoFBAsCAQMSBJAEMjMKDQoFBAsCAQgSBJAENEUKDQoFBAsCAQcSBJAEP0QK7gEKBAQLAgISBJYEAjEa3wEgSXMgdGhpcyBtZXNzYWdlIGRlcHJlY2F0ZWQ/CiBEZXBlbmRpbmcgb24gdGhlIHRhcmdldCBwbGF0Zm9ybSwgdGhpcyBjYW4gZW1pdCBEZXByZWNhdGVkIGFubm90YXRpb25zCiBmb3IgdGhlIG1lc3NhZ2UsIG9yIGl0IHdpbGwgYmUgY29tcGxldGVseSBpZ25vcmVkOyBpbiB0aGUgdmVyeSBsZWFzdCwKIHRoaXMgaXMgYSBmb3JtYWxpemF0aW9uIGZvciBkZXByZWNhdGluZyBtZXNzYWdlcy4KCg0KBQQLAgIEEgSWBAIKCg0KBQQLAgIFEgSWBAsPCg0KBQQLAgIBEgSWBBAaCg0KBQQLAgIDEgSWBB0eCg0KBQQLAgIIEgSWBB8wCg0KBQQLAgIHEgSWBCovCgsKAwQLCRIEmAQCEwoMCgQECwkAEgSYBAsMCg0KBQQLCQABEgSYBAsMCg0KBQQLCQACEgSYBAsMCgwKBAQLCQESBJgEDg8KDQoFBAsJAQESBJgEDg8KDQoFBAsJAQISBJgEDg8KDAoEBAsJAhIEmAQREgoNCgUECwkCARIEmAQREgoNCgUECwkCAhIEmAQREgqgBgoEBAsCAxIErwQCHhqRBiBOT1RFOiBEbyBub3Qgc2V0IHRoZSBvcHRpb24gaW4gLnByb3RvIGZpbGVzLiBBbHdheXMgdXNlIHRoZSBtYXBzIHN5bnRheAogaW5zdGVhZC4gVGhlIG9wdGlvbiBzaG91bGQgb25seSBiZSBpbXBsaWNpdGx5IHNldCBieSB0aGUgcHJvdG8gY29tcGlsZXIKIHBhcnNlci4KCiBXaGV0aGVyIHRoZSBtZXNzYWdlIGlzIGFuIGF1dG9tYXRpY2FsbHkgZ2VuZXJhdGVkIG1hcCBlbnRyeSB0eXBlIGZvciB0aGUKIG1hcHMgZmllbGQuCgogRm9yIG1hcHMgZmllbGRzOgogICAgIG1hcDxLZXlUeXBlLCBWYWx1ZVR5cGU+IG1hcF9maWVsZCA9IDE7CiBUaGUgcGFyc2VkIGRlc2NyaXB0b3IgbG9va3MgbGlrZToKICAgICBtZXNzYWdlIE1hcEZpZWxkRW50cnkgewogICAgICAgICBvcHRpb24gbWFwX2VudHJ5ID0gdHJ1ZTsKICAgICAgICAgb3B0aW9uYWwgS2V5VHlwZSBrZXkgPSAxOwogICAgICAgICBvcHRpb25hbCBWYWx1ZVR5cGUgdmFsdWUgPSAyOwogICAgIH0KICAgICByZXBlYXRlZCBNYXBGaWVsZEVudHJ5IG1hcF9maWVsZCA9IDE7CgogSW1wbGVtZW50YXRpb25zIG1heSBjaG9vc2Ugbm90IHRvIGdlbmVyYXRlIHRoZSBtYXBfZW50cnk9dHJ1ZSBtZXNzYWdlLCBidXQKIHVzZSBhIG5hdGl2ZSBtYXAgaW4gdGhlIHRhcmdldCBsYW5ndWFnZSB0byBob2xkIHRoZSBrZXlzIGFuZCB2YWx1ZXMuCiBUaGUgcmVmbGVjdGlvbiBBUElzIGluIHN1Y2ggaW1wbGVtZW50YXRpb25zIHN0aWxsIG5lZWQgdG8gd29yayBhcwogaWYgdGhlIGZpZWxkIGlzIGEgcmVwZWF0ZWQgbWVzc2FnZSBmaWVsZC4KCg0KBQQLAgMEEgSvBAIKCg0KBQQLAgMFEgSvBAsPCg0KBQQLAgMBEgSvBBAZCg0KBQQLAgMDEgSvBBwdCiQKAwQLCRIEsQQCDSIXIGphdmFsaXRlX3NlcmlhbGl6YWJsZQoKDAoEBAsJAxIEsQQLDAoNCgUECwkDARIEsQQLDAoNCgUECwkDAhIEsQQLDAofCgMECwkSBLIEAg0iEiBqYXZhbmFub19hc19saXRlCgoMCgQECwkEEgSyBAsMCg0KBQQLCQQBEgSyBAsM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IGEKIENvcmQsIG9yIHdoZW4gcGFyc2luZyB3aXRoIGFsaWFzaW5nIGVuYWJsZWQsIGFzIHRoZSBwYXJzZWQgQ29yZCBtYXkgdGhlbgogYWxpYXMgdGhlIG9yaWdpbmFsIGJ1ZmZlci4KCg8KBwQMBAACAQESBNkEBAgKDwoHBAwEAAIBAhIE2QQLDAoOCgYEDAQAAgISBNsEBBUKDwoHBAwEAAICARIE2wQEEAoPCgcEDAQAAgICEgTbBBMUCtoCCgQEDAIBEgTiBAIbGssCIFRoZSBwYWNrZWQgb3B0aW9uIGNhbiBiZSBlbmFibGVkIGZvciByZXBlYXRlZCBwcmltaXRpdmUgZmllbGRzIHRvIGVuYWJsZQogYSBtb3JlIGVmZmljaWVudCByZXByZXNlbnRhdGlvbiBvbiB0aGUgd2lyZS4gUmF0aGVyIHRoYW4gcmVwZWF0ZWRseQogd3JpdGluZyB0aGUgdGFnIGFuZCB0eXBlIGZvciBlYWNoIGVsZW1lbnQsIHRoZSBlbnRpcmUgYXJyYXkgaXMgZW5jb2RlZCBhcwogYSBzaW5nbGUgbGVuZ3RoLWRlbGltaXRlZCBibG9iLiBJbiBwcm90bzMsIG9ubHkgZXhwbGljaXQgc2V0dGluZyBpdCB0bwogZmFsc2Ugd2lsbCBhdm9pZCB1c2luZyBwYWNrZWQgZW5jb2RpbmcuCgoNCgUEDAIBBBIE4gQCCgoNCgUEDAIBBRIE4gQLDwoNCgUEDAIBARIE4gQQFgoNCgUEDAIBAxIE4gQZGgqaBQoEBAwCAhIE7wQCMxqLBSBUaGUganN0eXBlIG9wdGlvbiBkZXRlcm1pbmVzIHRoZSBKYXZhU2NyaXB0IHR5cGUgdXNlZCBmb3IgdmFsdWVzIG9mIHRoZQogZmllbGQuICBUaGUgb3B0aW9uIGlzIHBlcm1pdHRlZCBvbmx5IGZvciA2NCBiaXQgaW50ZWdyYWwgYW5kIGZpeGVkIHR5cGVzCiAoaW50NjQsIHVpbnQ2NCwgc2ludDY0LCBmaXhlZDY0LCBzZml4ZWQ2NCkuICBBIGZpZWxkIHdpdGgganN0eXBlIEpTX1NUUklORwogaXMgcmVwcmVzZW50ZWQgYXMgSmF2YVNjcmlwdCBzdHJpbmcsIHdoaWNoIGF2b2lkcyBsb3NzIG9mIHByZWNpc2lvbiB0aGF0CiBjYW4gaGFwcGVuIHdoZW4gYSBsYXJnZSB2YWx1ZSBpcyBjb252ZXJ0ZWQgdG8gYSBmbG9hdGluZyBwb2ludCBKYXZhU2NyaXB0LgogU3BlY2lmeWluZyBKU19OVU1CRVIgZm9yIHRoZSBqc3R5cGUgY2F1c2VzIHRoZSBnZW5lcmF0ZWQgSmF2YVNjcmlwdCBjb2RlIHRvCiB1c2UgdGhlIEphdmFTY3JpcHQgIm51bWJlciIgdHlwZS4gIFRoZSBiZWhhdmlvciBvZiB0aGUgZGVmYXVsdCBvcHRpb24KIEpTX05PUk1BTCBpcyBpbXBsZW1lbnRhdGlvbiBkZXBlbmRlbnQuCgogVGhpcyBvcHRpb24gaXMgYW4gZW51bSB0byBwZXJtaXQgYWRkaXRpb25hbCB0eXBlcyB0byBiZSBhZGRlZCwgZS5nLgogZ29vZy5tYXRoLkludGVnZXIuCgoNCgUEDAICBBIE7wQCCgoNCgUEDAICBhIE7wQLEQoNCgUEDAICARIE7wQSGAoNCgUEDAICAxIE7wQbHAoNCgUEDAICCBIE7wQdMgoNCgUEDAICBxIE7wQoMQoOCgQEDAQBEgbwBAL5BAMKDQoFBAwEAQESBPAEBw0KJwoGBAwEAQIAEgTyBAQSGhcgVXNlIHRoZSBkZWZhdWx0IHR5cGUuCgoPCgcEDAQBAgABEgTyBAQNCg8KBwQMBAECAAISBPIEEBEKKQoGBAwEAQIBEgT1BAQSGhkgVXNlIEphdmFTY3JpcHQgc3RyaW5ncy4KCg8KBwQMBAECAQESBPUEBA0KDwoHBAwEAQIBAhIE9QQQEQopCgYEDAQBAgISBPgEBBIaGSBVc2UgSmF2YVNjcmlwdCBudW1iZXJzLgoKDwoHBAwEAQICARIE+AQEDQoPCgcEDAQBAgICEgT4BBARCv8NCgQEDAIDEgSZBQIrGvANIFNob3VsZCB0aGlzIGZpZWxkIGJlIHBhcnNlZCBsYXppbHk/ICBMYXp5IGFwcGxpZXMgb25seSB0byBtZXNzYWdlLXR5cGUKIGZpZWxkcy4gIEl0IG1lYW5zIHRoYXQgd2hlbiB0aGUgb3V0ZXIgbWVzc2FnZSBpcyBpbml0aWFsbHkgcGFyc2VkLCB0aGUKIGlubmVyIG1lc3NhZ2UncyBjb250ZW50cyB3aWxsIG5vdCBiZSBwYXJzZWQgYnV0IGluc3RlYWQgc3RvcmVkIGluIGVuY29kZWQKIGZvcm0uICBUaGUgaW5uZXIgbWVzc2FnZSB3aWxsIGFjdHVhbGx5IGJlIHBhcnNlZCB3aGVuIGl0IGlzIGZpcnN0IGFjY2Vzc2VkLgoKIFRoaXMgaXMgb25seSBhIGhpbnQuICBJbXBsZW1lbnRhdGlvbnMgYXJlIGZyZWUgdG8gY2hvb3NlIHdoZXRoZXIgdG8gdXNlCiBlYWdlciBvciBsYXp5IHBhcnNpbmcgcmVnYXJkbGVzcyBvZiB0aGUgdmFsdWUgb2YgdGhpcyBvcHRpb24uICBIb3dldmVyLAogc2V0dGluZyB0aGlzIG9wdGlvbiB0cnVlIHN1Z2dlc3RzIHRoYXQgdGhlIHByb3RvY29sIGF1dGhvciBiZWxpZXZlcyB0aGF0CiB1c2luZyBsYXp5IHBhcnNpbmcgb24gdGhpcyBmaWVsZCBpcyB3b3J0aCB0aGUgYWRkaXRpb25hbCBib29ra2VlcGluZwogb3ZlcmhlYWQgdHlwaWNhbGx5IG5lZWRlZCB0byBpbXBsZW1lbnQgaXQuCgogVGhpcyBvcHRpb24gZG9lcyBub3QgYWZmZWN0IHRoZSBwdWJsaWMgaW50ZXJmYWNlIG9mIGFueSBnZW5lcmF0ZWQgY29kZTsKIGFsbCBtZXRob2Qgc2lnbmF0dXJlcyByZW1haW4gdGhlIHNhbWUuICBGdXJ0aGVybW9yZSwgdGhyZWFkLXNhZmV0eSBvZiB0aGUKIGludGVyZmFjZSBpcyBub3QgYWZmZWN0ZWQgYnkgdGhpcyBvcHRpb247IGNvbnN0IG1ldGhvZHMgcmVtYWluIHNhZmUgdG8KIGNhbGwgZnJvbSBtdWx0aXBsZSB0aHJlYWRzIGNvbmN1cnJlbnRseSwgd2hpbGUgbm9uLWNvbnN0IG1ldGhvZHMgY29udGludWUKIHRvIHJlcXVpcmUgZXhjbHVzaXZlIGFjY2Vzcy4KCiBOb3RlIHRoYXQgaW1wbGVtZW50YXRpb25zIG1heSBjaG9vc2Ugbm90IHRvIGNoZWNrIHJlcXVpcmVkIGZpZWxkcyB3aXRoaW4KIGEgbGF6eSBzdWItbWVzc2FnZS4gIFRoYXQgaXMsIGNhbGxpbmcgSXNJbml0aWFsaXplZCgpIG9uIHRoZSBvdXRlciBtZXNzYWdlCiBtYXkgcmV0dXJuIHRydWUgZXZlbiBpZiB0aGUgaW5uZXIgbWVzc2FnZSBoYXMgbWlzc2luZyByZXF1aXJlZCBmaWVsZHMuCiBUaGlzIGlzIG5lY2Vzc2FyeSBiZWNhdXNlIG90aGVyd2lzZSB0aGUgaW5uZXIgbWVzc2FnZSB3b3VsZCBoYXZlIHRvIGJlCiBwYXJzZWQgaW4gb3JkZXIgdG8gcGVyZm9ybSB0aGUgY2hlY2ssIGRlZmVhdGluZyB0aGUgcHVycG9zZSBvZiBsYXp5CiBwYXJzaW5nLiAgQW4gaW1wbGVtZW50YXRpb24gd2hpY2ggY2hvb3NlcyBub3QgdG8gY2hlY2sgcmVxdWlyZWQgZmllbGRzCiBtdXN0IGJlIGNvbnNpc3RlbnQgYWJvdXQgaXQuICBUaGF0IGlzLCBmb3IgYW55IHBhcnRpY3VsYXIgc3ViLW1lc3NhZ2UsIHRoZQogaW1wbGVtZW50YXRpb24gbXVzdCBlaXRoZXIgKmFsd2F5cyogY2hlY2sgaXRzIHJlcXVpcmVkIGZpZWxkcywgb3IgKm5ldmVyKgogY2hlY2sgaXRzIHJlcXVpcmVkIGZpZWxkcywgcmVnYXJkbGVzcyBvZiB3aGV0aGVyIG9yIG5vdCB0aGUgbWVzc2FnZSBoYXMKIGJlZW4gcGFyc2VkLgoKIEFzIG9mIE1heSAyMDIyLCBsYXp5IHZlcmlmaWVzIHRoZSBjb250ZW50cyBvZiB0aGUgYnl0ZSBzdHJlYW0gZHVyaW5nCiBwYXJzaW5nLiAgQW4gaW52YWxpZCBieXRlIHN0cmVhbSB3aWxsIGNhdXNlIHRoZSBvdmVyYWxsIHBhcnNpbmcgdG8gZmFpbC4KCg0KBQQMAgMEEgSZBQIKCg0KBQQMAgMFEgSZBQsPCg0KBQQMAgMBEgSZBRAUCg0KBQQMAgMDEgSZBRcYCg0KBQQMAgMIEgSZBRkqCg0KBQQMAgMHEgSZBSQpCq8BCgQEDAIEEgSeBQI3GqABIHVudmVyaWZpZWRfbGF6eSBkb2VzIG5vIGNvcnJlY3RuZXNzIGNoZWNrcyBvbiB0aGUgYnl0ZSBzdHJlYW0uIFRoaXMgc2hvdWxkCiBvbmx5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EQUAEgS4Bg0YCg0KBQQRBQABEgS4Bg0RCg0KBQQRBQACEgS4BhUYCosDCgIEEhIGwQYA1QYBGvwCIEEgbWVzc2FnZSByZXByZXNlbnRpbmcgYSBvcHRpb24gdGhlIHBhcnNlciBkb2VzIG5vdCByZWNvZ25pemUuIFRoaXMgb25seQogYXBwZWFycyBpbiBvcHRpb25zIHByb3RvcyBjcmVhdGVkIGJ5IHRoZSBjb21waWxlcjo6UGFyc2VyIGNsYXNzLgogRGVzY3JpcHRvclBvb2wgcmVzb2x2ZXMgdGhlc2Ugd2hlbiBidWlsZGluZyBEZXNjcmlwdG9yIG9iamVjdHMuIFRoZXJlZm9yZSwKIG9wdGlvbnMgcHJvdG9zIGluIGRlc2NyaXB0b3Igb2JqZWN0cyAoZS5nLiByZXR1cm5lZCBieSBEZXNjcmlwdG9yOjpvcHRpb25zKCksCiBvciBwcm9kdWNlZCBieSBEZXNjcmlwdG9yOjpDb3B5VG8oKSkgd2lsbCBuZXZlciBoYXZlIFVuaW50ZXJwcmV0ZWRPcHRpb25zCiBpbiB0aGVtLgoKCwoDBBIBEgTBBggbCssCCgQEEgMAEgbHBgLKBgMaugIgVGhlIG5hbWUgb2YgdGhlIHVuaW50ZXJwcmV0ZWQgb3B0aW9uLiAgRWFjaCBzdHJpbmcgcmVwcmVzZW50cyBhIHNlZ21lbnQgaW4KIGEgZG90LXNlcGFyYXRlZCBuYW1lLiAgaXNfZXh0ZW5zaW9uIGlzIHRydWUgaWZmIGEgc2VnbWVudCByZXByZXNlbnRzIGFuCiBleHRlbnNpb24gKGRlbm90ZWQgd2l0aCBwYXJlbnRoZXNlcyBpbiBvcHRpb25zIHNwZWNzIGluIC5wcm90byBmaWxlcykuCiBFLmcuLHsgWyJmb28iLCBmYWxzZV0sIFsiYmFyLmJheiIsIHRydWVdLCBbIm1vbyIsIGZhbHNlXSB9IHJlcHJlc2VudHMKICJmb28uKGJhci5iYXopLm1vbyIuCgoNCgUEEgMAARIExwYKEgoOCgYEEgMAAgASBMgGBCIKDwoHBBIDAAIABBIEyAYEDAoPCgcEEgMAAgAFEgTIBg0TCg8KBwQSAwACAAESBMgGFB0KDwoHBBIDAAIAAxIEyAYgIQoOCgYEEgMAAgESBMkGBCMK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dHNlbGYgKGkuZS4gbm90IHRvIGFueQogICBwYXJ0aWN1bGFyIGluZGV4IHdpdGhpbiBpdCkuICBUaGlzIGlzIHVzZWQgd2hlbmV2ZXIgYSBzZXQgb2YgZWxlbWVudHMgYXJlCiAgIGxvZ2ljYWxseSBlbmNsb3NlZCBpbiBhIHNpbmdsZSBjb2RlIHNlZ21lbnQuICBGb3IgZXhhbXBsZSwgYW4gZW50aXJlCiAgIGV4dGVuZCBibG9jayAocG9zc2libHkgY29udGFpbmluZyBtdWx0aXBsZSBleHRlbnNpb24gZGVmaW5pdGlvbnMpIHdpbGwKICAgaGF2ZSBhbiBvdXRlciBsb2NhdGlvbiB3aG9zZSBwYXRoIHJlZmVycyB0byB0aGUgImV4dGVuc2lvbnMiIHJlcGVhdGVkCiAgIGZpZWxkIHdpdGhvdXQgYW4gaW5kZXguCiAtIE11bHRpcGxlIGxvY2F0aW9ucyBtYXkgaGF2ZSB0aGUgc2FtZSBwYXRoLiAgVGhpcyBoYXBwZW5zIHdoZW4gYSBzaW5nbGUKICAgbG9naWNhbCBkZWNsYXJhdGlvbiBpcyBzcHJlYWQgb3V0IGFjcm9zcyBtdWx0aXBsZSBwbGFjZXMuICBUaGUgbW9zdAogICBvYnZpb3VzIGV4YW1wbGUgaXMgdGhlICJleHRlbmQiIGJsb2NrIGFnYWluIC0tIHRoZXJlIG1heSBiZSBtdWx0aXBsZQogICBleHRlbmQgYmxvY2tzIGluIHRoZSBzYW1lIHNjb3BlLCBlYWNoIG9mIHdoaWNoIHdpbGwgaGF2ZSB0aGUgc2FtZSBwYXRoLgogLSBBIGxvY2F0aW9uJ3Mgc3BhbiBpcyBub3QgYWx3YXlzIGEgc3Vic2V0IG9mIGl0cyBwYXJlbnQncyBzcGFuLiAgRm9yCiAgIGV4YW1wbGUsIHRoZSAiZXh0ZW5kZWUiIG9mIGFuIGV4dGVuc2lvbiBkZWNsYXJhdGlvbiBhcHBlYXJzIGF0IHRoZQogICBiZWdpbm5pbmcgb2YgdGhlICJleHRlbmQiIGJsb2NrIGFuZCBpcyBzaGFyZWQgYnkgYWxsIGV4dGVuc2lvbnMgd2l0aGluCiAgIHRoZSBibG9jay4KIC0gSnVzdCBiZWNhdXNlIGEgbG9jYXRpb24ncyBzcGFuIGlzIGEgc3Vic2V0IG9mIHNvbWUgb3RoZXIgbG9jYXRpb24ncyBzcGFuCiAgIGRvZXMgbm90IG1lYW4gdGhhdCBpdCBpcyBhIGRlc2NlbmRhbnQuICBGb3IgZXhhbXBsZSwgYSAiZ3JvdXAiIGRlZmluZXMKICAgYm90aCBhIHR5cGUgYW5kIGEgZmllbGQgaW4gYSBzaW5nbGUgZGVjbGFyYXRpb24uICBUaHVzLCB0aGUgbG9jYXRpb25zCiAgIGNvcnJlc3BvbmRpbmcgdG8gdGhlIHR5cGUgYW5kIGZpZWxkIGFuZCB0aGVpciBjb21wb25lbnRzIHdpbGwgb3ZlcmxhcC4KIC0gQ29kZSB3aGljaCB0cmllcyB0byBpbnRlcnByZXQgbG9jYXRpb25zIHNob3VsZCBwcm9iYWJseSBiZSBkZXNpZ25lZCB0bwogICBpZ25vcmUgdGhvc2UgdGhhdCBpdCBkb2Vzbid0IHVuZGVyc3RhbmQsIGFzIG1vcmUgdHlwZXMgb2YgbG9jYXRpb25zIGNvdWxkCiAgIGJlIHJlY29yZGVkIGluIHRoZSBmdXR1cmUuCgoNCgUEEwIABBIEiAcCCgoNCgUEEwIABhIEiAcLEwoNCgUEEwIAARIEiAcUHAoNCgUEEwIAAxIEiAcfIAoOCgQEEwMAEgaJBwLcBwMKDQoFBBMDAAESBIkHChIKiQcKBgQTAwACABIEoQcELBr4BiBJZGVudGlmaWVzIHdoaWNoIHBhcnQgb2YgdGhlIEZpbGVEZXNjcmlwdG9yUHJvdG8gd2FzIGRlZmluZWQgYXQgdGhpcwogbG9jYXRpb24uCgogRWFjaCBlbGVtZW50IGlzIGEgZmllbGQgbnVtYmVyIG9yIGFuIGluZGV4LiAgVGhleSBmb3JtIGEgcGF0aCBmcm9tCiB0aGUgcm9vdCBGaWxlRGVzY3JpcHRvclByb3RvIHRvIHRoZSBwbGFjZSB3aGVyZSB0aGUgZGVmaW5pdGlvbiBvY2N1cnMuCiBGb3IgZXhhbXBsZSwgdGhpcyBwYXRoOgogICBbIDQsIDMsIDIsIDcsIDEgXQogcmVmZXJzIHRvOgogICBmaWxlLm1lc3NhZ2VfdHlwZSgzKSAgLy8gNCwgMwogICAgICAgLmZpZWxkKDcpICAgICAgICAgLy8gMiwgNwogICAgICAgLm5hbWUoKSAgICAgICAgICAgLy8gMQogVGhpcyBpcyBiZWNhdXNlIEZpbGVEZXNjcmlwdG9yUHJvdG8ubWVzc2FnZV90eXBlIGhhcyBmaWVsZCBudW1iZXIgNDoKICAgcmVwZWF0ZWQgRGVzY3JpcHRvclByb3RvIG1lc3NhZ2VfdHlwZSA9IDQ7CiBhbmQgRGVzY3JpcHRvclByb3RvLmZpZWxkIGhhcyBmaWVsZCBudW1iZXIgMjoKICAgcmVwZWF0ZWQgRmllbGREZXNjcmlwdG9yUHJvdG8gZmllbGQgPSAyOwogYW5kIEZpZWxkRGVzY3JpcHRvclByb3RvLm5hbWUgaGFzIGZpZWxkIG51bWJlciAxOgogICBvcHRpb25hbCBzdHJpbmcgbmFtZSA9IDE7CgogVGh1cywgdGhlIGFib3ZlIHBhdGggZ2l2ZXMgdGhlIGxvY2F0aW9uIG9mIGEgZmllbGQgbmFtZS4gIElmIHdlIHJlbW92ZWQKIHRoZSBsYXN0IGVsZW1lbnQ6CiAgIFsgNCwgMywgMiwgNyBdCiB0aGlzIHBhdGggcmVmZXJzIHRvIHRoZSB3aG9sZSBmaWVsZCBkZWNsYXJhdGlvbiAoZnJvbSB0aGUgYmVnaW5uaW5nCiBvZiB0aGUgbGFiZWwgdG8gdGhlIHRlcm1pbmF0aW5nIHNlbWljb2xvbikuCgoPCgcEEwMAAgAEEgShBwQMCg8KBwQTAwACAAUSBKEHDRIKDwoHBBMDAAIAARIEoQcTFwoPCgcEEwMAAgADEgShBxobCg8KBwQTAwACAAgSBKEHHCsKEAoIBBMDAAIACAISBKEHHSoK0gIKBgQTAwACARIEqAcELBrBAiBBbHdheXMgaGFzIGV4YWN0bHkgdGhyZWUgb3IgZm91ciBlbGVtZW50czogc3RhcnQgbGluZSwgc3RhcnQgY29sdW1uLAogZW5kIGxpbmUgKG9wdGlvbmFsLCBvdGhlcndpc2UgYXNzdW1lZCBzYW1lIGFzIHN0YXJ0IGxpbmUpLCBlbmQgY29sdW1uLgogVGhlc2UgYXJlIHBhY2tlZCBpbnRvIGEgc2luZ2xlIGZpZWxkIGZvciBlZmZpY2llbmN5LiAgTm90ZSB0aGF0IGxpbmUKIGFuZCBjb2x1bW4gbnVtYmVycyBhcmUgemVyby1iYXNlZCAtLSB0eXBpY2FsbHkgeW91IHdpbGwgd2FudCB0byBhZGQKIDEgdG8gZWFjaCBiZWZvcmUgZGlzcGxheWluZyB0byBhIHVzZXIuCgoPCgcEEwMAAgEEEgSoBwQMCg8KBwQTAwACAQUSBKgHDRIKDwoHBBMDAAIBARIEqAcTFwoPCgcEEwMAAgEDEgSoBxobCg8KBwQTAwACAQgSBKgHHCsKEAoIBBMDAAIBCAISBKgHHSoKpQwKBgQTAwACAhIE2QcEKRqUDCBJZiB0aGlzIFNvdXJjZUNvZGVJbmZvIHJlcHJlc2VudHMgYSBjb21wbGV0ZSBkZWNsYXJhdGlvbiwgdGhlc2UgYXJlIGFueQogY29tbWVudHMgYXBwZWFyaW5nIGJlZm9yZSBhbmQgYWZ0ZXIgdGhlIGRlY2xhcmF0aW9uIHdoaWNoIGFwcGVhciB0byBiZQogYXR0YWNoZWQgdG8gdGhlIGRlY2xhcmF0aW9uLgoKIEEgc2VyaWVzIG9mIGxpbmUgY29tbWVudHMgYXBwZWFyaW5nIG9uIGNvbnNlY3V0aXZlIGxpbmVzLCB3aXRoIG5vIG90aGVyCiB0b2tlbnMgYXBwZWFyaW5nIG9uIHRob3NlIGxpbmVzLCB3aWxsIGJlIHRyZWF0ZWQgYXMgYSBzaW5nbGUgY29tbWVudC4KCiBsZWFkaW5nX2RldGFjaGVkX2NvbW1lbnRzIHdpbGwga2VlcCBwYXJhZ3JhcGhzIG9mIGNvbW1lbnRzIHRoYXQgYXBwZWFyCiBiZWZvcmUgKGJ1dCBub3QgY29ubmVjdGVkIHRvKSB0aGUgY3VycmVudCBlbGVtZW50LiBFYWNoIHBhcmFncmFwaCwKIHNlcGFyYXRlZCBieSBlbXB0eSBsaW5lcywgd2lsbCBiZSBvbmUgY29tbWVudCBlbGVtZW50IGluIHRoZSByZXBlYXRlZAogZmllbGQuCgogT25seSB0aGUgY29tbWVudCBjb250ZW50IGlzIHByb3ZpZGVkOyBjb21tZW50IG1hcmtlcnMgKGUuZy4gLy8pIGFyZQogc3RyaXBwZWQgb3V0LiAgRm9yIGJsb2NrIGNvbW1lbnRzLCBsZWFkaW5nIHdoaXRlc3BhY2UgYW5kIGFuIGFzdGVyaXNrCiB3aWxsIGJlIHN0cmlwcGVkIGZyb20gdGhlIGJlZ2lubmluZyBvZiBlYWNoIGxpbmUgb3RoZXIgdGhhbiB0aGUgZmlyc3QuCiBOZXdsaW5lcyBhcmUgaW5jbHVkZWQgaW4gdGhlIG91dHB1dC4KCiBFeGFtcGxlczoKCiAgIG9wdGlvbmFsIGludDMyIGZvbyA9IDE7ICAvLyBDb21tZW50IGF0dGFjaGVkIHRvIGZvby4KICAgLy8gQ29tbWVudCBhdHRhY2hlZCB0byBiYXIuCiAgIG9wdGlvbmFsIGludDMyIGJhciA9IDI7CgogICBvcHRpb25hbCBzdHJpbmcgYmF6ID0gMzsKICAgLy8gQ29tbWVudCBhdHRhY2hlZCB0byBiYXouCiAgIC8vIEFub3RoZXIgbGluZSBhdHRhY2hlZCB0byBiYXouCgogICAvLyBDb21tZW50IGF0dGFjaGVkIHRvIG1vby4KICAgLy8KICAgLy8gQW5vdGhlciBsaW5lIGF0dGFjaGVkIHRvIG1vby4KICAgb3B0aW9uYWwgZG91YmxlIG1vbyA9IDQ7CgogICAvLyBEZXRhY2hlZCBjb21tZW50IGZvciBjb3JnZS4gVGhpcyBpcyBub3QgbGVhZGluZyBvciB0cmFpbGluZyBjb21tZW50cwogICAvLyB0byBtb28gb3IgY29yZ2UgYmVjYXVzZSB0aGVyZSBhcmUgYmxhbmsgbGluZXMgc2VwYXJhdGluZyBpdCBmcm9tCiAgIC8vIGJvdGguCgogICAvLyBEZXRhY2hlZCBjb21tZW50IGZvciBjb3JnZSBwYXJhZ3JhcGggMi4KCiAgIG9wdGlvbmFsIHN0cmluZyBjb3JnZSA9IDU7CiAgIC8qIEJsb2NrIGNvbW1lbnQgYXR0YWNoZWQKICAgICogdG8gY29yZ2UuICBMZWFkaW5nIGFzdGVyaXNrcwogICAgKiB3aWxsIGJlIHJlbW92ZWQuICovCiAgIC8qIEJsb2NrIGNvbW1lbnQgYXR0YWNoZWQgdG8KICAgICogZ3JhdWx0LiAqLwogICBvcHRpb25hbCBpbnQzMiBncmF1bHQgPSA2OwoKICAgLy8gaWdub3JlZCBkZXRhY2hlZCBjb21tZW50cy4KCg8KBwQTAwACAgQSBNkHBAwKDwoHBBMDAAICBRIE2QcN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GiogVGhlIGVsZW1lbnQgaXMgc2V0IG9yIG90aGVyd2lzZSBtdXRhdGVkLgoKEQoJBBQDAAQAAgEBEgT9BwYJChEKCQQUAwAEAAIBAhIE/QcMDQo4CggEFAMABAACAhIE/wcGEBomIEFuIGFsaWFzIHRvIHRoZSBlbGVtZW50IGlzIHJldHVybmVkLgoKEQoJBBQDAAQAAgIBEgT/BwYLChEKCQQUAwAEAAICAhIE/wcODwoOCgYEFAMAAgQSBIEIBCMKDwoHBBQDAAIEBBIEgQgEDAoPCgcEFAMAAgQGEgSBCA0VCg8KBwQUAwACBAESBIEIFh4KDwoHBBQDAAIEAxIEgQghIgrUCAocZ29vZ2xlL2FwaS9hbm5vdGF0aW9ucy5wcm90bxIKZ29vZ2xlLmFwaRoVZ29vZ2xlL2FwaS9odHRwLnByb3RvGiBnb29nbGUvcHJvdG9idWYvZGVzY3JpcHRvci5wcm90bzpLCgRodHRwEh4uZ29vZ2xlLnByb3RvYnVmLk1ldGhvZE9wdGlvbnMYsMq8IiABKAsyFC5nb29nbGUuYXBpLkh0dHBSdWxlUgRodHRwQm4KDmNvbS5nb29nbGUuYXBpQhBBbm5vdGF0aW9uc1Byb3RvUAFaQWdvb2dsZS5nb2xhbmcub3JnL2dlbnByb3RvL2dvb2dsZWFwaXMvYXBpL2Fubm90YXRpb25zO2Fubm90YXRpb25zogIER0FQSUqpBgoGEgQOAB4BCrwECgEMEgMOABIysQQgQ29weXJpZ2h0IDIwMTUgR29vZ2xlIExMQwoKIExpY2Vuc2VkIHVuZGVyIHRoZSBBcGFjaGUgTGljZW5zZSwgVmVyc2lvbiAyLjAgKHRoZSAiTGljZW5zZSIpOwogeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHQgaW4gY29tcGxpYW5jZSB3aXRoIHRoZSBMaWNlbnNlLgogWW91IG1heSBvYnRhaW4gYSBjb3B5IG9mIHRoZSBMaWNlbnNlIGF0CgogICAgIGh0dHA6Ly93d3cuYXBhY2hlLm9yZy9saWNlbnNlcy9MSUNFTlNFLTIuMAoKIFVubGVzcyByZXF1aXJlZCBieSBhcHBsaWNhYmxlIGxhdyBvciBhZ3JlZWQgdG8gaW4gd3JpdGluZywgc29mdHdhcmUKIGRpc3RyaWJ1dGVkIHVuZGVyIHRoZSBMaWNlbnNlIGlzIGRpc3RyaWJ1dGVkIG9uIGFuICJBUyBJUyIgQkFTSVMsCiBXSVRIT1VUIFdBUlJBTlRJRVMgT1IgQ09ORElUSU9OUyBPRiBBTlkgS0lORCwgZWl0aGVyIGV4cHJlc3Mgb3IgaW1wbGllZC4KIFNlZSB0aGUgTGljZW5zZSBmb3IgdGhlIHNwZWNpZmljIGxhbmd1YWdlIGdvdmVybmluZyBwZXJtaXNzaW9ucyBhbmQKIGxpbWl0YXRpb25zIHVuZGVyIHRoZSBMaWNlbnNlLgoKCAoBAhIDEAATCgkKAgMAEgMSAB8KCQoCAwESAxMAKgoICgEIEgMVAFgKCQoCCAsSAxUAWAoICgEIEgMWACIKCQoCCAoSAxYAIgoICgEIEgMXADEKCQoCCAgSAxcAMQoICgEIEgMYACcKCQoCCAESAxgAJwoICgEIEgMZACIKCQoCCCQSAxkAIgoJCgEHEgQbAB4BChwKAgcAEgMdAhsaESBTZWUgYEh0dHBSdWxlYC4KCgoKAwcAAhIDGwckCgoKAwcABhIDHQIKCgoKAwcAARIDHQsPCgoKAwcAAxIDHRIaYgZwcm90bzMKyA4KEGhlbGxvd29ybGQucHJvdG8SCmhlbGxvd29ybGQaHGdvb2dsZS9hcGkvYW5ub3RhdGlvbnMucHJvdG8iIgoMSGVsbG9SZXF1ZXN0EhIKBG5hbWUYASABKAlSBG5hbWUiJgoKSGVsbG9SZXBseRIYCgdtZXNzYWdlGAEgASgJUgdtZXNzYWdlMroCCgdHcmVldGVyEk8KCFNheUhlbGxvEhguaGVsbG93b3JsZC5IZWxsb1JlcXVlc3QaFi5oZWxsb3dvcmxkLkhlbGxvUmVwbHkiEYLT5JMCCxIJL3YxL2hlbGxvEkMKDVNheUhlbGxvQWdhaW4SGC5oZWxsb3dvcmxkLkhlbGxvUmVxdWVzdBoWLmhlbGxvd29ybGQuSGVsbG9SZXBseSIAEksKE1NheUhlbGxvU3RyZWFtUmVwbHkSGC5oZWxsb3dvcmxkLkhlbGxvUmVxdWVzdBoWLmhlbGxvd29ybGQuSGVsbG9SZXBseSIAMAESTAoSU2F5SGVsbG9CaWRpU3RyZWFtEhguaGVsbG93b3JsZC5IZWxsb1JlcXVlc3QaFi5oZWxsb3dvcmxkLkhlbGxvUmVwbHkiACgBMAFCNgobaW8uZ3JwYy5leGFtcGxlcy5oZWxsb3dvcmxkQg9IZWxsb1dvcmxkUHJvdG9QAaICA0hMV0rACgoGEgQOADIBCr8ECgEMEgMOABIytAQgQ29weXJpZ2h0IDIwMTUgZ1JQQyBhdXRob3JzLgoKIExpY2Vuc2VkIHVuZGVyIHRoZSBBcGFjaGUgTGljZW5zZSwgVmVyc2lvbiAyLjAgKHRoZSAiTGljZW5zZSIpOwogeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHQgaW4gY29tcGxpYW5jZSB3aXRoIHRoZSBMaWNlbnNlLgogWW91IG1heSBvYnRhaW4gYSBjb3B5IG9mIHRoZSBMaWNlbnNlIGF0CgogICAgIGh0dHA6Ly93d3cuYXBhY2hlLm9yZy9saWNlbnNlcy9MSUNFTlNFLTIuMAoKIFVubGVzcyByZXF1aXJlZCBieSBhcHBsaWNhYmxlIGxhdyBvciBhZ3JlZWQgdG8gaW4gd3JpdGluZywgc29mdHdhcmUKIGRpc3RyaWJ1dGVkIHVuZGVyIHRoZSBMaWNlbnNlIGlzIGRpc3RyaWJ1dGVkIG9uIGFuICJBUyBJUyIgQkFTSVMsCiBXSVRIT1VUIFdBUlJBTlRJRVMgT1IgQ09ORElUSU9OUyBPRiBBTlkgS0lORCwgZWl0aGVyIGV4cHJlc3Mgb3IgaW1wbGllZC4KIFNlZSB0aGUgTGljZW5zZSBmb3IgdGhlIHNwZWNpZmljIGxhbmd1YWdlIGdvdmVybmluZyBwZXJtaXNzaW9ucyBhbmQKIGxpbWl0YXRpb25zIHVuZGVyIHRoZSBMaWNlbnNlLgoKCAoBCBIDEAAiCgkKAggKEgMQACIKCAoBCBIDEQA0CgkKAggBEgMRADQKCAoBCBIDEgAwCgkKAggIEgMSADAKCAoBCBIDEwAhCgkKAggkEgMTACEKCAoBAhIDFQATCgkKAgMAEgMXACYKLgoCBgASBBoAKAEaIiBUaGUgZ3JlZXRpbmcgc2VydmljZSBkZWZpbml0aW9uLgoKCgoDBgABEgMaCA8KIAoEBgACABIEHAIgAxoSIFNlbmRzIGEgZ3JlZXRpbmcKCgwKBQYAAgABEgMcBg4KDAoFBgACAAISAxwQHAoMCgUGAAIAAxIDHCcxCg0KBQYAAgAEEgQdBB8GChEKCQYAAgAEsMq8IhIEHQQfBgodCgQGAAIBEgMjAjoaECBBbm90aGVyIE1ldGhvZAoKDAoFBgACAQESAyMGEwoMCgUGAAIBAhIDIxUhCgwKBQYAAgEDEgMjLDYKCwoEBgACAhIDJQJHCgwKBQYAAgIBEgMlBhkKDAoFBgACAgISAyUbJwoMCgUGAAICBhIDJTI4CgwKBQYAAgIDEgMlOUMKCwoEBgACAxIDJwJNCgwKBQYAAgMBEgMnBhgKDAoFBgACAwUSAycaIAoMCgUGAAIDAhIDJyEtCgwKBQYAAgMGEgMnOD4KDAoFBgACAwMSAyc/SQo9CgIEABIEKwAtARoxIFRoZSByZXF1ZXN0IG1lc3NhZ2UgY29udGFpbmluZyB0aGUgdXNlcidzIG5hbWUuCgoKCgMEAAESAysIFAoLCgQEAAIAEgMsAhIKDAoFBAACAAUSAywCCAoMCgUEAAIAARIDLAkNCgwKBQQAAgADEgMsEBEKOwoCBAESBDAAMgEaLyBUaGUgcmVzcG9uc2UgbWVzc2FnZSBjb250YWluaW5nIHRoZSBncmVldGluZ3MKCgoKAwQBARIDMAgSCgsKBAQBAgASAzECFQoMCgUEAQIABRIDMQIICgwKBQQBAgABEgMxCRAKDAoFBAECAAMSAzETFGIGcHJvdG8z ``` 위와 같은 `EnvoyFilter` 를 사용해서 HTTP + gRPC 를 동시에 사용가능한 gRPC 서버를 구축 가능합니다. 일단 위 예제에서는 애플리케이션 `파드` 의 사이드카에 이 필터를 삽입해서 처리를 하는 예제입니다. (context 가 `SIDECAR_INBOUND` 이기 때문에 .. 필요에 따라 ingress gateway 쪽에 이 필터를 삽입 할 수도 있습니다.) 여기서 주의깊게 봐야하는것은 `listener.portNumber` 와 `patch.value.services` , `patch.value.proto_descriptor_bin` 부분입니다. `listener.portNumber` 의 경우 gRPC 서버 이미지를 띄운 애플리케이션의 gRPC 포트번호를 지정해야 합니다. `patch.value.services` 에는 어떤 서비스들에 대해서 이 규칙을 사용해 gRPC 서버에 http json transcoder 를 적용해서 요청할지에 대한 부분이고. `patch.value.proto_descriptor_bin` 의 경우 `protobuf descriptor` 를 base64 인코딩한 값을 넣어주었습니다. **당연하게도, 위** `EnvoyFilter` **를 적용하려면 gRPC 서버 애플리케이션 파드에 Istio Proxy(Enovy) 사이드카가 떠있어야 합니다!** 아래는 gRPC 서버에 대한 `deployment.yaml` 예시입니다. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: grpc-python spec: replicas: 1 selector: matchLabels: app: grpc-python template: metadata: labels: app: grpc-python sidecar.istio.io/inject: 'true' annotations: sidecar.istio.io/rewriteAppHTTPProbers: "false" # 위 어노테이션을 달지 않으면 istio-sidecar-injector 가 mutatingWebhook 에서 강제로 http-get 으로 수정한다. # 글로벌하게 설정하고 싶으면 istio-sidecar-injector 의 configmap 을 수정하면 된다. # https://preliminary.istio.io/latest/docs/ops/configuration/mesh/app-health-check/#disable-the-http-probe-rewrite-for-a-pod spec: containers: - name: grpc-python image: kimsehwan96/python-grpc imagePullPolicy: Always ports: - containerPort: 7777 name: grpc resources: requests: cpu: 500m limits: cpu: 500m readinessProbe: grpc: port: 7777 ``` 위와 같이 `istio sidecar` 를 label 을 통해 inject 하도록 명시하였고, `annotations` 의 `sidecar.istio.io/rewriteAppHTTPProbers: "false"` 은 주석에도 달려있지만, gRPC HealthCheck 프로브를 istio가 mutating Webhook 에서 강제로 덮어쓰지 않도록 방지하는 내용입니다. (yaml 파일 예제는 [https://github.com/kimsehwan96/istio-example/tree/master/grpc-python](https://github.com/kimsehwan96/istio-example/tree/master/grpc-python?ref=kimsehwan96.com) 여기에 있습니다.) 위 내용을 `$ kubectl kustomize --enable-helm . | kubectl apply -f - --server-side --force-conflicts` 로 배포해봅니다. kustomize 를 통해 위 yaml 들을 하나의 yaml로 묶어서 배포하기 위해 위와 같은 명령어를 사용하였습니다. (그래서 Deployment 든, EnvoyFilter 든 네임스페이스 레이블이 빠져있는데, 실제로는 kustomization.yaml 항목에 지정되어있습니다.) ![gRPC 예제 Deployment 의 배포 결과 화면](https://www.kimsehwan96.com/content/images/2024/03/890a7a2a-5214-4de7-8905-ee06ddbbdabb.png) Deployment 의 배포 결과 ![EnvoyFilter 가 grpc-python 네임스페이스에 적용된 것을 확인하는 화면](https://www.kimsehwan96.com/content/images/2024/03/f8f42fb6-1a2e-480b-9287-3ab8a058dfa7.png) EnvoyFilter 가 grpc-python 네임스페이스에 적용됨 ### 테스트 [GitHub - kimsehwan96/gRPC-python-example: 테스트용테스트용. Contribute to kimsehwan96/gRPC-python-example development by creating an account on GitHub.![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkimsehwan96![](https://opengraph.githubassets.com/8adec5defd950d1028c8575fa2fa73dcad825ce8d06df1701f93df64ec42eeed/kimsehwan96/gRPC-python-example)](https://github.com/kimsehwan96/gRPC-python-example?ref=kimsehwan96.com) 테스트를 위한 grpc 서버는 위 링크에 구현되어있고, 편의를 위해 gRPC reflection 등을 통해 어떤 메서드들이 노출되는지 볼 수 있게 세팅하고, + 기본적으로 제공되는 health check 기능을 넣어두었습니다. (health check 기능 추가 라인 : [https://github.com/kimsehwan96/gRPC-python-example/blob/266ec547524d7afb73f10ad596e8b0eb96759eea/greeter\_server.py#L54-L58](https://github.com/kimsehwan96/gRPC-python-example/blob/266ec547524d7afb73f10ad596e8b0eb96759eea/greeter%5Fserver.py?ref=kimsehwan96.com#L54-L58)) ![gRPC 예제 서버 greeter_server.py 의 서비스 정의 코드](https://www.kimsehwan96.com/content/images/2024/03/image.webp) ![gRPC 클라이언트로 호출한 요청이 정상 동작하는 결과](https://www.kimsehwan96.com/content/images/2024/03/image--2-.webp) 당연하게도 gRPC 요청은 잘 됩니다. ![transcoder 를 통해 gRPC 가 아닌 일반 HTTP(JSON)로 호출한 결과](https://www.kimsehwan96.com/content/images/2024/03/image--3-.webp) gRPC 가 아닌 일반 HTTP 호출 [https://github.com/kimsehwan96/gRPC-python-example/blob/266ec547524d7afb73f10ad596e8b0eb96759eea/proto/helloworld.proto#L27-L33](https://github.com/kimsehwan96/gRPC-python-example/blob/266ec547524d7afb73f10ad596e8b0eb96759eea/proto/helloworld.proto?ref=kimsehwan96.com#L27-L33) 위 코드에서 정의했듯이 `/v1/hello` 에 대해서 `rpc SayHello (HelloRequest) returns (HelloReply)` 를 호출하도록 하였는데, 정상적으로 HTTP 요청으로 호출되는것을 볼 수 있습니다. 이 때 `Content-Type` 헤더를 `application/json` 으로 명시적으로 헤더에 담아서 요청해야 처리됩니다. EnvoyFilter 를 제거하고 호출해보면 ![Content-Type 을 application/json 으로 지정해 HTTP 호출한 결과](https://www.kimsehwan96.com/content/images/2024/03/image--4-.webp) 이렇게 처리할 수 없는 헤더로 요청이 들어왔기때문에 요청이 처리되지 않습니다. ## gRPC HealthCheck in K8s k8s v1.27 부터 stable 하게 들어온 기능 [Configure Liveness, Readiness and Startup Probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/?ref=kimsehwan96.com#define-a-grpc-liveness-probe) 이것을 사용하기 위해서는 gRPC 서버 측에 Health Check 기능이 GRPC Health Checking Protocol 에 맞게 구현되어있으면 되고 (프로토콜 : [https://github.com/grpc/grpc/blob/master/doc/health-checking.md](https://github.com/grpc/grpc/blob/master/doc/health-checking.md?ref=kimsehwan96.com) ) 각 언어별로 위 프로토콜 문서를 따른 gRPC HealthCheck 구현체가 있기때문에, 단순하게 서버에 삽입하기만 하면 사용 가능합니다. ([https://github.com/kimsehwan96/gRPC-python-example/blob/266ec547524d7afb73f10ad596e8b0eb96759eea/greeter\_server.py#L54-L58](https://github.com/kimsehwan96/gRPC-python-example/blob/266ec547524d7afb73f10ad596e8b0eb96759eea/greeter%5Fserver.py?ref=kimsehwan96.com#L54-L58)) ```yaml apiVersion: apps/v1 kind: Deployment metadata: ... spec: ... template: ... spec: containers: - name: grpc-python ... readinessProbe: grpc: port: 7777 ``` 위와 같이 `readinessProbe` / `livenessProbe` 등에 `grpc.port` 및 `grpc.service` 를 지정하면 사용 가능하고. `service` 를 지정하지 않으면 GRPC Health Checking Protocol 에서 사용하는 기본적인 서비스.메서드로 프로브를 호출하게 됩니다. (매우 간편) ## Proto Descriptor 를 base64 로 직접 envoy filter 에 넣지않고 ConfigMap 기반으로 경로를 지정해 세팅하는 방법 `kustomize` 의 `configMapGenerator` 를 사용합니다. (템플리팅 툴으로 helm 을 쓴다면 helm 에도 비슷한 기능이 있습니다. kustomize 든 helm 이든 yaml 관리를 위한 툴을 사용하지 않고서는 사용하기 어려움) [https://github.com/kimsehwan96/istio-example/blob/73f993ffead22376edbd74f5905091b575c30679/grpc-python/helloworld.pb](https://github.com/kimsehwan96/istio-example/blob/73f993ffead22376edbd74f5905091b575c30679/grpc-python/helloworld.pb?ref=kimsehwan96.com) 위 파일처럼 `kustomization.yaml` 이 있는 디렉터리에 바이너리로 된 `proto descriptor` (`pb`) 파일을 넣어둡니다. ```yaml namespace: grpc-python resources: - ./namespace.yaml - ./deployment.yaml - ./service.yaml - ./mtls.yaml - ./grpc-virtualservice.yaml - ./grpc-gateway.yaml - ./envoy-filter.yaml configMapGenerator: - name: grpc-python-proto-descriptor-configmap files: - ./helloworld.pb generatorOptions: disableNameSuffixHash: true ``` `kustomization.yaml` 에 `configMapGenerator` 를 이용해 바이너리 파일을 `configMap` 으로 생성합니다. ![proto descriptor 를 ConfigMap 으로 생성하는 kustomization 설정](https://www.kimsehwan96.com/content/images/2024/03/image--5-.webp) 위 사진처럼 로컬에 있는 파일이 base64 인코딩 되어서 configMap 으로 알아서 잘 들어갑니다. 이때의 configMap binary Data 의 키는 파일명과 동일(`helloworld.pb`) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: grpc-python spec: replicas: 1 selector: matchLabels: app: grpc-python template: metadata: labels: app: grpc-python sidecar.istio.io/inject: 'true' annotations: sidecar.istio.io/rewriteAppHTTPProbers: "false" # 위 어노테이션을 달지 않으면 istio-sidecar-injector 가 mutatingWebhook 에서 강제로 http-get 으로 수정한다. # 글로벌하게 설정하고 싶으면 istio-sidecar-injector 의 configmap 을 수정하면 된다. # https://preliminary.istio.io/latest/docs/ops/configuration/mesh/app-health-check/#disable-the-http-probe-rewrite-for-a-pod sidecar.istio.io/userVolumeMount: '[{"name":"proto-descriptor","mountPath":"/etc/envoy","readOnly":true}]' sidecar.istio.io/userVolume: '[{"name":"proto-descriptor","configMap":{"name":"grpc-python-proto-descriptor-configmap","items":[{"key":"helloworld.pb","path":"helloworld.pb"}]}}]' ... ... ``` `Deployment` 오브젝트에 `istio` 어노테이션을 사용해서 `configMap` 을 `sidecar` 컨테이너에 마운트 하도록 합니다. `sidecar.istio.io/userVolumeMount` , `sidecar.istio.io/userVolume` 어노테이션을 이용합니다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: EnvoyFilter metadata: name: grpc-transcoder spec: workloadSelector: labels: app: grpc-python configPatches: - applyTo: HTTP_FILTER match: context: SIDECAR_INBOUND listener: portNumber: 7777 # 5xxxx 번대 포트를 지정하면 반영이 안된다. 이건 왜그런지; filterChain: filter: name: "envoy.filters.network.http_connection_manager" subFilter: name: "envoy.filters.http.router" patch: operation: INSERT_BEFORE value: name: envoy.filters.http.grpc_json_transcoder typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder services: - helloworld.Greeter print_options: add_whitespace: true always_print_enums_as_ints: false always_print_primitive_fields: true preserve_proto_field_names: false convert_grpc_status: true proto_descriptor: /etc/envoy/helloworld.pb ``` `EnvoyFilter` 오브젝트에서는 `proto_descriptor` 를 사용해서 사이드카 내에 `proto descriptor` 가 있는 경로를 지정해서 사용합니다. 만약 `kustomize` 가 아닌 `helm` 으로만 네이티브하게 구현한다면 ```yaml # configMap.yml kind: ConfigMap apiVersion: v1 data: binaryData: {{- $binaryFiles := $.Files }} {{- range .binaryFiles }} {{ base .}}: |- {{- $binaryFiles.Get (printf "%s/%s" "configs" . ) | b64enc | nindent 6 -}} {{- end}} # values.yaml configs: - name: proto-descriptor binaryFiles: - api_descriptor.pb ``` 위와 같이 `helm` 에서 사용 가능한 템플리팅 함수들이나 기능을 이용해서 구현하면 됩니다. 다만 `kustomize` 든 `helm` 이든 `proto descriptor` 파일이 `helm` / `kustomize` 명령어들을 수행하는 디렉터리와 같은 위치에 있어야 한다는게 단점? 일수도 있습니다. ### 위 상황에서, `proto descriptor` 파일이 바뀌었을 때 언제 반영이 되는가? 테스트 결과, `configMap` 의 바이너리( `proto descriptor` )를 바꾸더라도 기존에 떠있는 `파드` 에는 영향이 가지 않습니다. 파드를 내렸다가 올리는 경우에만 반영이 됩니다. **따라서 실제 프로덕션 환경에서는** `proto descriptor` **업데이트 이후 모든** `Deployment / StatefulSet` **의 파드를 rolloing update 하거나, canary 배포 하는등 아무튼 모든 파드가 내려갔다가 올라갈 필요가 있습니다.** 참고 : [https://github.com/grpc-ecosystem/grpc-gateway](https://github.com/grpc-ecosystem/grpc-gateway?ref=kimsehwan96.com) ### etcd 의 WAL 이 무엇인가요? URL: https://www.kimsehwan96.com/etcd-wal/ Last updated: 2026-07-20T12:37:01.000Z 💡 WAL (Write Ahead Log)는 etcd 가 데이터베이스에 적용되기 전에 모든 변경 사항을 기록하는 곳으로, `미리 쓰기` 라는 측면에서 반영될 작업이 미리 기록되어 데이터 무결성과 시스템 일관성을 보장한다는것을 의미합니다. ![etcd 데이터 디렉터리에서 WAL 파일을 조회한 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/ce603d6f-bb3d-4478-97df-36ad008785e0.png) etcd 데이터 디렉터리에서 `wal` 파일을 조회한 모습 각 파일은 `WAL` 의 세그먼트를 의미하고, 이 파일은 `etcd` 클러스터의 트랜잭션 스냅샷을 캡처해서, 필요한 경우 `etcd` 상태를 재 구축하는데 사용 할 수 있는 변경 내역을 보존합니다. 각 파일 내부에는 Raft 의 상태와 데이터가 저장되어있습니다. 각 `WAL` 파일은 `WAL` 레코드의 스트림이고, `$seq-$index.wal` 형태로 저장됩니다. `WAL` 파일은 `64MB` 크기를 초과하면 `wal` 파일을 잘라내고 새로운 내부 시퀀스 번호와 함께 새로운 파일이 생성됩니다. 뒤쪽의 `index` 는 마지막 저장된 `Raft` 인덱스의 증분만큼 늘어난 값으로 저장됩니다. `0000000000000001-0000000000000021.wal` 처음 파일이 이런 형태였고 이후 `0x10` 인덱스만큼 Raft 파일 인덱스가 늘어난 상태에서 파일을 잘라내면 새로운 파일은 `0000000000000002-0000000000000031.wal` 이 됩니다. `0.tmp` , `1.tmp` 는 번갈아가면서 나타나고 `wal` 파일을 저장하기 위한 임시 파일로써 쓰입니다. etcd 의 과거 문서(v2.3) 에서는 ([Administration](https://etcd.io/docs/v2.3/admin%5Fguide/?ref=kimsehwan96.com) ) `wal` 파일을 저장할 전용 디스크를 사용하면 처리량을 개선하고 클러스터를 안정화 할 수 있다고 설명합니다. etcd 의 나름 최근 문서 (v3.4) 에서는 ([Configuration options](https://etcd.io/docs/v3.4/op-guide/configuration/?ref=kimsehwan96.com) ) `--wal-dir` 을 지정해서 etcd data directory 가 아닌곳에 저장하고, 전용 디스크를 사용하면서 로깅과 다른 `I/O` 작업과의 경쟁을 피할 수 있다고 설명합니다. `wal` 파일 전용 디스크를 생성하고, 해당 디스크가 마운트된 경로에 `wal` 파일을 저장하도록 설정하는 것입니다. 이 설정은 현재에도 있으며 인자로 전달할 때 `--wal-dir` , 환경 변수로는 `ETCD_WAL_DIR` 형태로 전달 해줄 수 있는 값입니다. etcd 의 최근 문서 (v3.5 , v3.6) 에서는 관련된 내용은 없고 이러한 설정값이 있다고만 설명하고 있습니다. 또한 이와 별도로 `--max-wals` , `ETCD_MAX_WALS` 형태로 `wal` 파일의 개수를 지정 할 수 있는데, 디폴트 값은 `5` 이며 윈도우 사용자는 `0` 으로 설정되는데, `0` 은 해당 파일 개수를 제한을 두지 않겠다는 설정입니다. ([Configuration options](https://etcd.io/docs/v3.4/op-guide/configuration/?ref=kimsehwan96.com#--max-wals)) ## 그래서 실제로 생기고 동작하나요? `wal` 파일은 `etcd` 에 쓰기 작업을 요청할때, 그리고 그 크기가 `64MB` 가 넘어 갈 때 마다 잘라서 생성됩니다. 그래서 `etcd` 쓰기작업을 시뮬레이션 하는 겸, `etcd` 성능 테스트를 할 수 있는 `$ etcdctl check perf --load="s"` 같은 명령어로 테스트 해봅니다. `--load` 는 `s`, `m` , `l` 이렇게 있고, 각각의 수준의 `load` 별로 `etcd` 의 `I/O` 성능이 적절한지 테스트하는것이라고 보면 됩니다. 현재 `192.168.100.2` , `192.168.100.2` , `192.168.100.3` 총 3개의 머신에 `etcd` 가 설치되어있다고 가정하고 아래와 같은 명령어로 테스트를 시작해봅니다. ```bash export ETCDCTL_API=3 HOST_1=192.168.100.2 HOST_2=192.168.100.3 HOST_3=192.168.100.4 ENDPOINTS=$HOST_1:2379,$HOST_2:2379,$HOST_3:2379 /usr/local/bin/etcdctl --endpoints=$ENDPOINTS \ --cacert=/etc/ssl/etcd/ssl/ca.pem \ --cert=/etc/ssl/etcd/ssl/admin-node1.pem \ --key=/etc/ssl/etcd/ssl/admin-node1-key.pem \ check perf --load="l" ``` 이후 `/var/lib/etcd/member/wal` 디렉터리를 관찰해보면 ![etcd 부하 테스트 시작 시 WAL 파일과 1.tmp 임시 파일을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/91612e03-ef8e-49cc-9733-c07c0e79a1ac.png) 최초 테스트 시작시 wal 파일들. 1.tmp 파일도 확인 가능 ![WAL 파일이 하나 더 생성되고 임시 파일이 0.tmp 로 바뀐 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/7bffa409-4059-460f-88e4-8a6905dd07d7.png) 최초 시작 이후 1개의 wal 파일이 더 생긴 장면. 0.tmp 로 임시파일이 변경 ![WAL 파일이 추가로 생성되고 임시 파일이 다시 1.tmp 로 바뀐 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/867157cb-20eb-4f4c-bb8a-6144bf80ba74.png) 이후 추가적인 wal 이 하나 더 생긴 장면. 1.tmp 로 다시 임시파일 변경 이런식으로 `wal` 파일이 새로운 시퀀스 번호를 부여 받아서 생성되면서 + `tmp` 파일은 0,1,0 번갈아 나타납니다. ## 왜 WAL 이 필요하죠? `etcd` 에 데이터를 쓰기 직전에 `wal` 파일을 생성함으로써 쓰기작업이 진행 중일 때 장애/충돌이 발생 했을 때 쓰고있었던 데이터가 손실 되지 않도록 하는데 목적이 있습니다. 다만 실제로 우리가 사용할 때 이 파일을 통해 복구하거나 처리할 일은 따로 없습니다. 다만 etcd 의 `fault tolerance` 기능 강화 목적으로 필요한 파일들입니다. ### K8s logging URL: https://www.kimsehwan96.com/k8s-logging/ Last updated: 2026-07-20T12:37:00.000Z 💡 K8s 과 관련된 로깅에 대한 정보를 정리합니다. (어떤걸 봐야하고 어떤 의미가 있는지 등) ## 로깅 아키텍처 [Logging Architecture](https://kubernetes.io/docs/concepts/cluster-administration/logging/?ref=kimsehwan96.com) 컨테이너화 된 애플리케이션의 가장 쉽고, 널리 적용된 로깅 방법은 표준 출력 및 표준 에러 스트림으로 로그를 내보내는 것입니다. ℹ️ 여기서의 표준 출력 및 표준 에러 스트림이라는것은 file descriptor 1, 2번을 의미합니다. 많은 프로그래밍 언어에서 이것들을 쉽게 사용하는데요. C/C++ 이였다면 `stdio` 라이브러리에 존재하는 stdout, stderr 가 그것에 해당하고. node.js 였다면 `console.log` , `console.error` 에 해당하는 것들 입니다. 하지만 컨테이너 엔진이나, 런타임(node, python, etc..)에서 제공하는 로깅은 완전한 솔루션이라고 부르기에 충분하지 않습니다. 예를들어, 컨테이너가 충돌나거나, 파드가 축출되거나, 노드가 죽어버렸을 때 애플리케이션의 로그에 접근해서 트러블 슈팅을 하고 싶다고 생각해보세요. 표준 출력 / 표준 에러 스트림으로 내보낸 로깅으로는 부족하거나, 휘발되버릴 수 있습니다. 쿠버네티스 클러스터에서 로그는 노드, 파드(컨테이너)와 독립적인 별도의 로그를 저장할 스토리지와 라이프 사이클을 가져야 하고 이 개념을 **클러스터 레벨 로깅**이라고 부릅니다. **클러스터 수준 로깅 아키텍처**는 로그를 저장, 분석 및 쿼리하기 위한 별도의 백엔드가 필요합니다. (ELK 스택 + EKS EBS storage 같이). 쿠버네티스는 로그 데이터를 위한 기본 스토리지 솔루션을 제공하지 않지만, 쿠버네티스와 통합되는 다양한 로깅 솔루션이 있습니다. --- ## 파드 및 컨테이너 로그 표준 출력 및 표준 에러로 텍스트를 내보내는 애플리케이션을 파드로 띄운다고 생각해봅시다. 표준 출력 및 표준 에러(로그)는 `kubectl logs {podNmae}` 을 통해서 확인이 가능합니다. ![컨테이너의 표준 출력·표준 에러 로그 흐름을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/67380095-3927-4323-a2a3-c1649cd96136.png) 컨테이너 런타임은 생성된 모든 로그(출력)을 처리하고, stdout, stderr 스트림으로 리다이렉션 합니다. 컨테이너 런타임 별로 다른 방식으로 구현은 되지만 kubelete 과의 통합은 CRI 로깅 포맷으로 표준화 되어있습니다. (CRI = Container Runtime Interface) 여기서 Kubelet 이 파드 및 컨테이너 메타 데이터를 사용해서 모든 컨테이너 로그에 대한 심볼릭 링크를 아래와 같이 생성하게 됩니다. `/var/log/containers/__-.log` ![/var/log/containers 의 컨테이너 로그 심볼릭 링크를 보여주는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/03/-----------2024-03-03------9.32.39.png) 실제 심볼릭 링크가 걸리는 예시 위와 같이 `/var/log/pods/monitoring_xxxxx/container_name/*.log` 와 `/var/log/containers/foobarbaz.log` 와 연결이 됩니다. 위와 같이 로그의 위치/네이밍이 표준화 되어서 `kubectl logs` 를 하는경우 내부적으로 위 파일들을 찾아서 클라이언트에 보여주게 되는 것! 그리고 파드(컨테이너)내에서 만드는 로그는 모두 Node 에 쓰게 되는 것과 마찬가지. CRI(Container Runtime Interface) 에서 정의한 컨테이너의 로그 위치는 `/var/log/containers` 다. `/var/log/containers/파드이름_파드네임스페이스이름_컨테이너이름컨테이너ID.log` 를 `/var/log/pods/파드UID/컨테이너이름/0.log` 와 심볼릭 링크를 Kubelet 이 걸어준다. [GitHub - kubernetes/cri-api: Container Runtime Interface (CRI) – a plugin interface which enables kubelet to use a wide variety of container runtimes.Container Runtime Interface (CRI) – a plugin interface which enables kubelet to use a wide variety of container runtimes. - kubernetes/cri-api![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkubernetes![](https://opengraph.githubassets.com/20cffc1d3b78e16da1e8f3b5f2cd0714728815ea413cf5d3cb2e7c95ae05f503/kubernetes/cri-api)](https://github.com/kubernetes/cri-api?ref=kimsehwan96.com) ## 시스템 컴포넌트 로그 시스템 컴포넌트에는 일반적으로 컨테이너에서 실행되는 구성 요소와, 컨테이너 실행에 직접 관여하는 구성요소 두가지 유형이 있습니다. Kubelet과 컨테이너 런타임은 컨테이너에서 실행되는게 아닙니다. Kubelet 은 컨테이너를 실행시키는 역할을 합니다. (그리고 컨테이너들은 파드로 그룹화 되고) 쿠버네티스 스케쥴러, 컨트롤러 매니저 API 서버는 파드(보통 static Pod) 안에서 실행됩니다. etcd 컴포넌트는 컨트롤 플레인에서 실행되고, 정적파드로 실행됩니다. 클러스터에서 kube-proxy를 쓰는 경우에는 일반적으로 DaemonSet 으로 실행됩니다. ### 시스템 컴포넌트 로그 위치 Kubelet 과 컨테이너 런타임은 노드의 운영체제에 따라서 다른 위치에 로그를 저장합니다. Systemd 를 쓰는 리눅스 노드의 경우 kubelet 과 컨테이너 런타임(우리 케이스의 경우 containerd)은 systemd 서비스로 등록되어있기 때문에 `journalctl` 을 사용해서 systemd journal 을 읽을 수 있습니다. (`journalctl -u kubelet`) 또한 Kubelet 은 컨테이너 런타임이 로그를 `/var/log/pods` 내에서 쓰도록 강제합니다. 또한 파드에서 실행되는 쿠버네티스 클러스터 컴포넌트의 경우 (쿠버네티스 스케쥴러, 컨트롤러 매니저, API 서버 등) 기본 로깅 정책을 우회해서 `/var/log` 내 파일에 기록합니다.(컨테이너/파드 기 때문에 systemd 저널에 당연히 기록되지도 않고) 이런 쿠버네티스 클러스터 컴포넌트의 경우 컨트롤 플레인에서 파드 형태로, (특히나 static pod) 떠있다. (static pod 는 kubelet 에서 bootstrapping 하는 파드고, 우리가 직접 생성하지 않아도 kubelet 이 직접 만듦. 만약에 이 파드가 모든 노드에 필요하다고 하면 DaemonSet 으로 사용하게 됨) EKS 환경에서 컨트롤 플레인 내의 이런 static pod 의 로그를 보려면 CloudWatch 를 통해 봐야하는데. 그 방법은 아래 링크 [https://docs.aws.amazon.com/ko\_kr/eks/latest/userguide/control-plane-logs.html](https://docs.aws.amazon.com/ko%5Fkr/eks/latest/userguide/control-plane-logs.html?ref=kimsehwan96.com) --- ## 클러스터 레벨 로깅 아키텍처 ## ℹ️ Node / Pod / Container 의 라이프사이클과 별도로 로그를 저장, 분석, 쿼리를 하기 위한 별도의 백엔드를 두는것을 의미 별도의 백엔드는 보통 Elasticsearch , Prometheus(이건 메트릭 수집에 가깝지만..) 등을 의미하고. PV 를 붙여서 사용한다. EKS 같은걸 쓴다면 클라우드 공급자의 스토리지 서비스인 EBS 같은걸 붙여서 데이터가 날아가지 않도록 잘 보존해야 함 쿠버네티스는 클러스터 레벨 로깅을 위한 네이티브 솔루션을 제공하지는 않지만, 우리가 고려해볼만한 몇가지 접근법은 존재합니다. - 노드 레벨 로깅 에이전트를 모든 노드에서 돌리는 것 (e.g `Filebeat` , `Fluentd`) - 애플리케이션 파드 내에 로깅을 위한 전용 사이드카를 포함해서 돌리는 것 - 애플리케이션 내에서 백엔드로 직접 로그를 푸시 하는 것 (e.g application -Direct-> DataDog) ### 노드 로깅 에이전트 사용 ![노드마다 로깅 에이전트를 배치하는 방식을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/f170ad41-77a2-4b24-afef-e539436549c8-1.png) 각 노드에 노드 레벨 로깅 에이전트를 포함시켜서 클러스터 레벨 로깅을 구현 할 수 있습니다. 로깅 에이전트는 로그를 export 하거나, 로그를 백엔드(Elasticsearch, S3, etc…)로 푸시하는 전용 도구입니다. 일반적으로 로깅 에이전트는 해당 노드의 모든 애플리케이션 컨테이너에서 로그 파일이 있는 디렉터리에 (`/var/log/containers` 혹은 `/var/log/pods`) 접근 할 수 있는 컨테이너입니다. 로깅 에이전트는 모든 노드에서 실행되어야 하기 때문에 에이전트를 `DaemonSet` 으로 동작시키는것을 추천합니다. 노드 레벨 로깅의 경우 노드 별 하나의 에이전트만 생성하고, 노드에 실행되는 애플리케이션에 대한 변경은 필요하지 않습니다. 그도 그럴것이. 애플리케이션의 stdout, stderr 스트림은 노드의 `/var/log/containers` 위치로 로그가 기록되고 (로그 로테이팅은 뭐 노드레벨에서 하거나 사용자가 설정하거나, 이거는 사용자의 책임.) 이 에이전트는 해당 위치를 바라보고 로그를 로깅 백엔드로 푸시하면 되기 때문입니다. #### 노드 로깅 에이전트 예시 - Fluentd - Filebeat - Promtail ### 로깅 에이전트 + 사이드카 컨테이너 (비추?) ![애플리케이션 파드에 로깅 사이드카를 두는 방식을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/4fa2505b-e921-48c5-8ea5-584eb7342722.png) 사이드카 컨테이너가 자체 `stdout` , `stderr` 스트림을 기록하도록 하면, 각 노드에서 이미 실행중인 kubelet 과 노드 로깅 에이전트를 활용 할 수 있다. 사이드카 컨테이너는 파일, 소켓, 또는 journald 에서 로그를 읽는다. 이후에 사이드카 컨테이너는 자체 stdout, stderr 스트림으로 로그를 출력한다. 이 방법을 쓰면 애플리케이션의 다른 부분에서 여러 로그 스트림을 분리 할 수 있다. (뭐 생각해보자면 .. 백엔드 애플리케이션의 어떤 엔드포인트? 경로? API 별 로깅을 분리 할 수 있지 않을까 하는 ..). 보통 로그를 리다이렉션 하는 로직은 최소화 되어있기 때문에 사이드카 컨테이너가 붙는것에 대해서 대부분 케이스에서 심각한 오버헤드가 아니다. 또, stdout ,stderr 가 kubelet 에 의해 처리 되기 때문에 `kubectl logs` 와 같은 빌트인 도구를 사용 가능한 장점이 있다 ! 동일 파드 내에 여러 애플리케이션 컨테이너가(!) 있는 경우, 혹은 그게 아니더라도 여러 컨테이너가 있는 경우에 소스 컨테이너에 따라 로그 라인을 파싱하도록 에이전트를 구성 할 수도 있다. 만약 단일 파일에 로그를 기록해도 되는 케이스라면, 위 이미지와 같은 스트리밍 컨테이너 방식을 사용하기보다는, 그냥 stdout, stderr 로 보내도록 하는게 훨씬 낫다. (스트리밍 컨테이너 + 로그파일 기록을 두번씩 하게 된다면 스토리지 사용량도 두배가 되니까;;) 사이드카 컨테이너를 사용해서 애플리케이션 자체에서 로테이션 할 수 없는 로그파일을 로테이션 할 수도 있다. 그럼에도 stdout, stderr 를 직접 사용하고 로그 로테이션과 유지/ retention 정책을 kubelet 이 하도록 하는게 매우 직관적이고 좋다. ### 로깅 에이전트가 있는 사이드카 컨테이너 ![로깅 에이전트를 사이드카로 포함한 파드 구조 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/46674bf8-fc42-4834-bf43-67b526a0426d.png) 노드 레벨 로깅 에이전트가 상황에 맞게 충분히 유연하지 않은 경우 애플리케이션과 함께 실행하도록 특별히 구성된 별도의 로깅 에이전트를 사용하는것도 방법. 다만 사이드카 컨테이너에서 로깅 컨테이너를 사용하면 상당한 리소스 소비로 이어질 수 있고. 무엇보다 `stdout`, `stderr` 를 통해 로그를 관리하는게 아니기 때문에 `kubelet logs` 를 사용해서 로그 접근을 못한다는 매우 큰 단점이 있다. (물론 로깅 백엔드가 datadog 같이 훌륭한거라면 문제가 없을수도야 있겠지만. 그래도 우리가 사랑하는 kubectl 을 못쓰는건 아쉽다.) 이런 케이스의 예시로는 `fluentd` 가 있다. ## 애플리케이션에서 직접 로그 노출 ![애플리케이션이 백엔드로 로그를 직접 푸시하는 방식을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/0663a797-3cd5-4fba-99ba-2a482bf8e234.png) 애플리케이션이 직접 로그를 노출하거나, 원격지/백엔드에 푸시하는 케이스. 쿠버네티스의 범위를 벗어남 --- ## 관심있게 봐야하는 로그(?) ## 컨트롤 플레인 EKS 환경에서는 별도로 enable 해서 CloudWatch 를 통해 봐야 한다. 컨트롤 플레인이 돌고있는 어떤 서버(?) 정보에 대해서는 우리가 알 지 못하기 때문에 (EKS 같은 클라우드 공급자 케이스 한정) 컨트롤 플레인 내의 시스템 컴포넌트(컨테이너/파드 형태로 돌고있는)에 대한 아래와 같은 로그는 수집하고, 알 필요가 있다. - kube-apiserver 로그 - audit 로그 - scheduler 로그 - controller manager 로그 - etcd 로그 ## 워커 노드 워커 노드는 우리가 접속도하고, 조작도 할 수 있다. 여기서도 두가지로 분류해서 생각해봐야한다. 그리고 EC2/서버 자체의 로그/메트릭같은? 쿠버네티스가 아니더라도 잘 확인하고 봐야하는 부분은 생략하고 작성 1. 컨테이너/파드 형태로 돌지 않는 컴포넌트 2. 컨테이너/파드 형태로 도는 컴포넌트(애플리케이션 파드 같은 것들이 아닌) 3. 애플리케이션 로그 ### 컨테이너/파드 형태로 돌지 않는 컴포넌트 #### Kubelet Kubelet은 각 노드에서 실행되는 에이전트로. 파드에서 컨테이너가 확실하게 동작하도록 관리하는 역할을 합니다. `kube-apiserver`와 통신하며 `PodSpec`에 기술된 컨테이너들이 정상적으로 작동되도록 돕습니다. Kubelet 이 파드를 어디에 배치할지 같은건 처리하지 않습니다. (그런건 컨트롤 플레인 내 컴포넌트들이 처리) Kubelet 은 파드를 배치해야하는 상황에서 컨테이너 런타임에 정확하게 컨테이너/파드를 배치 할 수 있도록 동작을 합니다. Kubelet 은 Node 에 systemd 서비스로 등록되고, 그 로그는 journald 에서 저장/관리 합니다. systemd 로 등록된 서비스가 만들어낸 로그는 journald 를 통해 저장되고 이 로그는 바이너리라 직접 확인 불가능. journalctl 사용해야만 가능. **rsyslog 같은걸 같이 써서 systemd 서비스 로그를 따로** `/var/log` **내에 파일로 읽을 수 있게 떨어트리는 방법이 있다면. 그것을 쓰는것도 방법.** #### Containerd (컨테이너 런타임) Kubelet 과 동일. systmed 서비스로 등록 됨 ### 컨테이너/파드 형태로 도는 컴포넌트 (static pods) 애플리케이션과 동일하게 로그가 노드에 `/var/log/containers` , `/var/log/pods` 에 저장되기 때문에 크게 신경쓸 부분은 없음 kube-proxy 같은것이 있음. ### ArgoCD 란 무엇이고 무엇을 하는가? (with 공식 도큐먼트) URL: https://www.kimsehwan96.com/what-is-argocd/ Last updated: 2026-07-20T12:37:00.000Z ## ArgoCD? ArgoCD는 쿠버네티스를 위한 선언적 GitOps, 지속적 배포 도구 ArgoCD는 원하는 애플리케이션 상태를 정의하기 위해 Git 레포지토리를 소스로 사용하는 GitOps 패턴을 따르고, 쿠버네티스 manifests 파일은 아래와 같은 여러 방법으로 정의 가능 - kustomize application - helm charts - jsonnet files - Plain directory of YAML/json manifests - 그 외 플러그인을 통한 설정 ![ArgoCD 아키텍처 구성도](https://www.kimsehwan96.com/content/images/2024/03/f00484bc-7f38-4d19-bf08-d73baf58847f.png) ## 아키텍쳐 ArgoCD 는 실행중인 애플리케이션을 모니터링하고, 현재 상태를 우리가 원하는 Desired 상태(Git repo 의 manifests 에 정의된 상턔)와 비교하는 쿠버네티스 컨트롤러로 구현되었습니다. 현재 상태가 목표 상태와 다른 경우 애플리케이션은 `OutOfSync` 상태로 간주합니다. 이러한 상태의 차이점을 시각화하고, 알림을 줄 수 있고, 원하는 상태로 자동 , 또는 수동으로 동기화 할 수 있는 기능을 제공합니다. ## 컴포넌트 아래는 쿠버네티스 클러스터에 ArgoCD 를 설치 했을 때 설치되는 ArgoCD 의 컴포넌트들입니다. ### API Server API Server 는 gRPC/REST 서버로, 웹 UI, CLI, CI/CD 시스템에서 사용하기 위한 API를 노출합니다. 이 서버는 아래와 같은 역할을 합니다. - 애플리케이션 매니지먼트와 상태 리포트 - invoking of application operations (sync, rollback, user-defined actions) - repository and cluster credential management (stored ad K8s secrets) - authn(인증) 그리고 외부 인증 공급자 (idP)에 대한 authn 위임 - RBAC 강제 - listener/forwarder for git webhook events ### Repository Server 레포지토리 서버는 애플리케이션 매니페스트를 저장하는 로컬 Git 레포지토리의 로컬 캐시를 관리하는 내부 서비스입니다. (Redis 사용 구현). 아래와 같은 inputs 가 제공되었을 때 쿠버네티스 매니페스트를 생성하고 반환하는 역할을 합니다. - Repository URL - Revision (Commit, tag , branch) - application path (Git 레포 안에서의 경로) - template specific settings: parameters, helm values.yaml ### Application Controller 애플리케이션 컨트롤러는 현재 상태와 원하는 상태를 지속적으로 비교하는 쿠버네티스 컨트롤러입니다. ([컨트롤러](https://kubernetes.io/ko/docs/concepts/architecture/controller/?ref=kimsehwan96.com) ) 쿠버네티스 컨트롤러는 일종의 패턴으로, 하나 이상의 쿠버네티스 리소스 유형을 추적합니다. 이 컨트롤러 **오브젝트**는 현재 리소스의 상태를 의도한 상태에 가깝게 만드는 역할을 합니다. 아무튼, 이 ArgoCD컨트롤러는 현재 실행중인 애플리케이션의 상태를 목표한 상태, 즉 ArgoCD 프로젝트에 기술한 Git Repo, Revision, Path 등을 종합해서, Git 에 저장된 Desired State(Manifest)와 비교하고, 만약 실행중인 애플리케이션과 목표한 상태가 다른 경우 `OutOfSync` 를 감지합니다. 사용자의 설정에 따라 자동으로 Sync 를 하거나, 메뉴얼하게 하거나 하는 등의 동작을 할 수 있고. 유저가 정의한 lifecycle에 대한 훅을 호출 할 수 있습니다. (PreSync, Sync, PostSync) ## Core Concepts ### Application Manifests 로 정의된 쿠버네티스 리소스의 집합입니다. Application 은 ArgoCD CRD 입니다. (argocdproj.io/v1alpha1) e.g ``` apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: test-httpbin namespace: test-argocd finalizers: - resources-finalizer.argocd.argoproj.io spec: destination: namespace: test-httpbin server: https://kubernetes.default.svc project: default source: path: dev/httpbin repoURL: https://github.com/foo/bar.git targetRevision: master syncPolicy: automated: prune: false selfHeal: false syncOptions: - CreateNamespace=true - ServerSideApply=true ``` ### Application source type Application 에서 사용할 manifests 의 형태. - plain manifests (yaml, json) - kustomize - helm - etc.. ### Target state (Desired state) Git 에 작성되어있는 우리가 원하는 애플리케이션의 상태. 각종 쿠버네티스 오브젝트들의 manifests 들을 의미하고, ArgoCD 가 Sync (동기화) 하며 도달하고자 하는 상태를 의미함 ### Live State 현재 애플리케이션(쿠버네티스 오브젝트들)의 상태. Live State 와 Target State 가 동일하면 Sync, 동일하지 않다면 OutOfSync 를 의미함 ### Sync status 우리가 원하는 상태와, 현재 상태의 차이를 의미함. ### Sync 동기화. Target State 와 Live State 를 일치시키는 작업 기본적으로 3분마다 git repo 에 manifests 변경점이 있는지 polling 하는데. 이 딜레이를 제거하고싶은 경우에는 github 등으로부터 webhook을 받아 처리 가능. (단 argocd 접근 URL/도메인이 외부에 노출되어있어야 함) ### Sync operation state 동기화 작업이 성공했는지 아닌지 ### Refresh 현재 상태와 원하는 상태(git)을 비교하는 작업. ### Health 애플리케이션의 상태(Helath). 애플리케이션이 정상적으로 동작중인지 등 ### Tool Manifest 를 디렉터리 내 파일로부터 만들어내기 위한 툴. Kustomize 같은것들을 의미 --- ## 정리하자면 ArgoCD 는 GitOps 툴이고, Git repo url, revision, application path, values.yaml(helm) 등과 같은 정보를 토대로 Git 으로부터 Manifests 를 생성/얻어오고. 그 Manifests 파일을 통해 쿠버네티스에서 애플리케이션을 구동합니다. 그리고 현재 상태와 원하는 상태(Git)를 지속적으로 비교하고, OutOfSync 발생시 자동, 혹은 메뉴얼하게 다시 원하는 상태의 Manifests 파일을 쿠버네티스에 전달합니다. 중요한 점은. ArgoCD 라는 명칭 답게 CI (쿠버네티스에서는 Container Image 생성, 그 외에는 뭐 앱 빌드 등)를 하는것은 아니기 때문에. Github Action 등으로 Git 에 PR merge 등이 발생하는 경우 → Container Image Build 와 같은 CI 과정은 별도로 있어야 한다는걸 유의해야합니다. (argocd best practice 에 별도로 명시되어있는 내용. 아래에 공유 예정) 또한, ArgoCD 는 애플리케이션의 소스코드 (.java, .py 등등)를 전혀 알필요도, 볼필요도 없이 오직 Kubernetes Manifests (yaml/json)에만 관심이 있다는 점도 유의해야합니다. 코드 개발 → Github merge → CI(이미지 빌드) Github 에 Manifests 파일 변경 → ArgoCD OutOfSync → Sync → Manifests 반영 위 두 파이프라인은 전혀 다르지만, 어느정도 같이 가야한다는 점이 또 유의해야 할 점이라고 볼 수 있습니다. 또한 Kubernetes 를 위한 GitOps 툴이기 때문에, CD 를 하기 위해서는 해당 애플리케이션을 Containerizing 해야 한다는 것도 유의해야 합니다. ArgoCD 의 Desired State 가 git에 있어야 하는데. 이 Desired State 는 Kubernetes Manifests 를 의미하고, [지원되는](https://argo-cd.readthedocs.io/en/stable/?ref=kimsehwan96.com#how-it-works) 것들에는 Kustomize, Helm, Plain Manifests, [jsonnet](https://jsonnet.org/articles/kubernetes.html?ref=kimsehwan96.com), Any custom config management tool configured as a config management plugin(?)가 있다. Kustomize 는 `kustomization.yaml`로 불리는 최종 manifests 파일에 base, resources , patches 를 구분해서 넣을 수 있는데, 이것을 통해 형상별 base 기반으로 여러 값들을 바꿔가면서 overlay 형태로 여러 manifests를 만들 수 있습니다. ([Kustomize로 K8S 리소스 관리하기](https://velog.io/@pullee/Kustomize%EB%A1%9C-K8S-%EB%A6%AC%EC%86%8C%EC%8A%A4-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0?ref=kimsehwan96.com) , [https://bcho.tistory.com/1392](https://bcho.tistory.com/1392?ref=kimsehwan96.com)) (Kustoization 예제 : [https://github.com/kimsehwan96/k8s-kustomize-example](https://github.com/kimsehwan96/k8s-kustomize-example?ref=kimsehwan96.com)) Helm 은 쿠버네티스 패키지 매니저이며, Helm Chart 는 헬름 패키지를 의미한다. 이 패키지에는 쿠버네티스 클러스터 내에서 애플리케이션, 도구, 서비스를 구동하는데 필요한 모든 리소스 정의가 포함되어있다. (Manifests) --- ## 번외 ## Best practice ### Manifests(Config) 레포와 소스코드(애플리케이션) 레포의 분리 [Best Practices - Argo CD - Declarative GitOps CD for Kubernetes](https://argo-cd.readthedocs.io/en/stable/user-guide/best%5Fpractices/?ref=kimsehwan96.com#separating-config-vs-source-code-repositories) 쿠버네티스 Manifests를 관리하는 레포와, 애플리케이션의 소스코드 레포를 분리하는것은 아래와 같은 이유로 강력하게 추천됩니다. 1. 애플리케이션 코드와 애플리케이션 설정(k8s manifests)를 확실하게 분리합니다. 만약 두개를 같은 레포에서 관리하고있다면, 자동회된 CI 단계의 수준에 따라서 다르겠지만(얼마나 잘 구축했는지). 단순히 replicas 개수를 바꾸는 커밋/푸시를 하는것임에도 애플리케이션 빌드를 하는CI 동작을 트리거하게 될 수도 있습니다. (단순 예시) 2. audit log 를 더 깔끔하게 만듭니다. audit(감사)를 위해서 설정파일(manifests)을 들고있는 git 레포는 여러 변경점들에 대한 더 깔끔한 git 히스토리 관리가 필요합니다. 단순한 개발 목적의 커밋은 감사 목적의 히스토리 추적에 노이즈가 될 뿐입니다. 3. 애플리케이션은 여러 Git 리포지토리에서 빌드된 서비스로 구성될 수 있지만 단일 단위로 배포됩니다. 마이크로서비스 애플리케이션은 서로 다른 버전 관리 체계와 릴리스 주기를 가진 서비스들로 구성되는 경우가 많습니다(예: ELK, Kafka + ZooKeeper). 매니페스트를 단일 구성 요소의 소스 코드 리포지토리 중 하나에 저장하는 것은 적절하지 않을 수 있습니다. 4. 액세스 분리. 애플리케이션을 개발하는 개발자와 프로덕션 환경으로 푸시할 수 있거나 푸시해야 하는 개발자가 의도적이든 의도적이지 않든 반드시 동일하지 않을 수 있습니다. 리포지토리를 분리하면 애플리케이션 구성 리포지토리가 아닌 소스 코드 리포지토리에 커밋 액세스 권한을 부여할 수 있습니다. 5. CI 파이프라인을 자동화하는 경우 매니페스트 변경 사항을 동일한 Git 리포지토리에 푸시하면 빌드 작업과 Git 커밋 트리거의 무한 루프가 트리거될 수 있습니다. 구성 변경 사항을 푸시할 별도의 리포지토리가 있으면 이러한 일이 발생하지 않습니다. ### 긴급 대응을 위한 여지를 만들기 일부 긴급대응/자동화를 위한 여지를 남겨두고 모든 것을 Git manifests 로 작성하지 않는것이 나을 수도 있습니다. 만약 Deployment 오브젝트의 replica 개수를 K8s Horizontal Pod Autoscaler(HPA)가 통제하도록 두고 싶은 경우, replicas를 git 에 넣지 않는것이 좋습니다. ``` apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment spec: # do not include replicas in the manifests if you want replicas to be controlled by HPA # replicas: 1 template: spec: containers: - image: nginx:1.7.9 name: nginx ports: - containerPort: 80 ... ``` replica 는 Deployment 오브젝트에 넣지 않는것이 ArgoCD 와의 궁합이 더 좋다. HPA 가 관리하도록 하자. ### Kustomize/Helm 을 사용시 업스트림 버전을 고정시키기 helm / kustomize 를 사용하는 경우, 이것들은 템플릿 도구이기때문에 base (kustomize 의 base 및 helm 의 업스트림 레포 등)의 변경에 따라서 manifests 가 변경 될 수 있습니다. (보통 외부 helm chart 등을 사용할 때 발생) 따라서 kustomize 의 외부 base나, 외부 helm chart 의 버전을 고정해서 사용하는것이 추천됨. ``` bases: - github.com/argoproj/argo-cd//manifests/cluster-install?ref=v0.11.1 ``` kustomization 예시 ## Kubernetes object 선언적 접근법을 통해 쿠버네티스 오브젝트를 만들 수 있다. 쿠버네티스 오브젝트는 파드, 디플로이먼트, 레플리카 셋, 서비스, 컨피그맵, 시크릿 등 쿠버네티스 내에서 영속성을 가지는 오브젝트. 이것에 대한 명세를 쿠버네티스에 제공하면, 쿠버네티스는 이러한 오브젝트에 대한 명세들을 etcd에 넣어둔다. ## Kustomize [https://github.com/kubernetes-sigs/kustomize/blob/master/examples/chart.md](https://github.com/kubernetes-sigs/kustomize/blob/master/examples/chart.md?ref=kimsehwan96.com) kustomize 는 쿠버네티스 리소스 설정(manifests)를 템플릿과 DSL로부터 자유롭게 커스텀 할 수 있는 솔루션. 원본 yaml 을 건드리지 않고도 다양한 목적으로 커스텀 할 수 있고. make 나 sed 와 비슷한 동작을 합니다. deployment, servic, configmap과 같은 yaml 파일이 있는곳에 kustomization.yaml 파일을 생성합니다. 그리고 이것들을 커스텀해서 새로운 manifests 를 생성 할 수 있습니다. (예를 들면 deployment, service 매니페스트에 공통 label 을 붙여준다던지) example : [GitHub - kimsehwan96/k8s-kustomize-example: for example](https://github.com/kimsehwan96/k8s-kustomize-example?ref=kimsehwan96.com) ## Helm [Helm | Helm](https://helm.sh/?ref=kimsehwan96.com) ## Plain Manifests 쿠버네티스에서 선언적 접근 방식으로 오브젝트를 생성/업데이트 할 수 있는 설정파일이다. 대부분 yaml 로 작성한다. ``` apiVersion: v1 kind: Service metadata: name: my-nginx-svc labels: app: nginx spec: type: LoadBalancer ports: - port: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: my-nginx labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.14.2 ports: - containerPort: 80 ``` 위는 그 예시. 한 파일안에 `---` 를 통해 오브젝트(리소스)타입을 구분하여 선언 가능하나. 웬만하면 파일을 쪼개서 관리하는게 낫다. (안헷갈리게) 순수한 manifests 파일 ## Helm with kustomize ``` namespace: test-argocd resources: - ./argocd-namespace.yaml helmCharts: - name: argo-cd includeCRDs: true repo: https://argoproj.github.io/argo-helm releaseName: argo-cd version: 5.42.1 # 2023.08.01 https://artifacthub.io/packages/helm/argo/argo-cd namespace: test-argocd valuesFile: values.yaml ``` kustomize 와 helm 을 같이 사용 kustomize 와 helm 을 같이 쓰는 방법이다. helm 을 CLI 기반으로 쓰기엔 히스토리추적이 어렵고 (궁극적인 gitops 하기엔 조금..), 순수 manifests 와 같이 쓸 수 있는(?) 방법인 helm with kustomize 방법이 여러모로 관리하기 편하다고 느껴진다. 장점이라고하면 모든 manifest 를 kustomize 라는 단일 툴 기반으로 관리가 가능 하다. (helm 을 쓰든 , 안쓰든). 그리고 kubectl 에 네이티브하게 이식되어있다. (`$ kubectl kustomize . | kubectl apply -f -` , `$ kubectl kustomize --enable-helm . | kubectl apply -f -`) ## Manual Deploy 우선 ArgoCD 에서의 Deploy 는 Sync 와 동일하다. 이 의미는 git 에 있는 manifests 의 상태로 argocd 가 destination namespace/cluster 에 해당 쿠버네티스 오브젝트들을 Desired state 로 만들어 내겠다는 의미이다. 이러한 특징 덕분에 2가지의 접근 방법이 발생하게 되는데. 우선 Containerizing 된 애플리케이션(자바, 파이썬, 웹 등)을 배포하는 케이스를 가정해보자. 이러한 애플리케이션은 보통 Deployment 오브젝트를 이용해 배포를 하게 될 것이다. (Deployment 오브젝트를 생성하면 내부적으로 Replicaset, pod 들도 생길거고, 그것과 별도로 Serivce 오브젝트등도 필요하겠지요. 그 외에 다른 오브젝트들도 필요할수도.) 이 때 컨테이너를 만들기 위해서. 파드에 올라갈 컨테이너들을 명시하는데. 컨테이너를 만들 이미지를 `{imageName}:{tag}` 형식으로 지정 할 수 있다. 예를들어 `foo/backend:latest` 라는 이미지를 기반으로 배포를 한다고했을 때. manifest 를 제외하고 Application 이 새로 빌드되고, latest 태그를 달아서 이미지가 푸시되었을 때. 실제로 기존에 latest 로 배포된 이미지와, 방금 latest 로 새로 배포된 이미지는 다른 이미지이다. (소스코드 변경점이 있는 경우) 하지만 ArgoCD 입장에서는 해당 태그에 새로운 이미지가 푸시되었든 어떻게 되었든 manifest 가 변경되지 않으면 변경점이 없기 때문에 새로 컨테이너들을 띄우지 않는다. 이런 상황에서 새로운 latest 태그를 갖는 이미지로 컨테이너를 띄우고 싶다면 ArgoCD 레벨에서 할 건 없고. `$ kubectl rollout restart deployment {deployment name}` 을 통해 재시작해야하는데. 심지어 여기서 imagePullPolicy 가 `IfNotPresent` 로 되어있으면 새로 이미지를 받아오지 않기 때문에 소용없고. imagePullPolicy 를 `Always` 로 하는 경우에만 반영이 될 것이다. 따라서 이미지 태그를 그대로 두면서 ArgoCD 를 통해 메뉴얼하든, 자동이든 배포하는것은 불가능에 가깝고. 만약 이미지를 `foo/backend:v1.0.0` 을 통해서 배포했다고 해보자. 이 경우에 애플리케이션 레포지토리에서 새로 `v1.1.0` 이미지를 빌드했고, 그것을 반영하고싶다면 애플리케이션 소스코드 레포지토리와 별도로. manifest 를 관리하는 레포에서 해당 이미지 태그를 `v1.0.0` 에서 `v1.1.0` 으로 변경하고 git 에 반영하면. ArgoCD 가 AutoSync가 활성화 되어있는 경우에 Webhook 을 연결했으면 즉시, 그게 아니라면 3분안에 변경점을 파악하고 앱들을 새로운 이미지로 띄우게 될 것이다. 만약 AutoSync 를 활성화 하지 않았다면 ArgoCD가 `OutOfSync` 를 보여줄것이고. (이것을 slack 에 noti 하는 것도 가능) 이 때 운영 담당자가 manifest 변경점을 확인하고, git 과 sync를 맞춰도 된다고 판단하면 그 때 Manual 하게 Sync를 맞추면 될 것이다. 어찌되었든, 애플리케이션 소스 레포지토리와 Manifest 레포지토리는 별개로 가져가고. 애플리케이션 소스 레포지토리의 CI 동작과 동시에 ArgoCD 와의 어떤 자동화된 작업을 하는것은 다양한 방법을 찾아봐야한다. 예를들어 애플리케이션 소스코드 레포지토리에서 새로운 버전으로 빌드를 했고. 그것과 동시에 어떤식으로든 manifest 파일내 이미지 정보가 새로운 버전으로 바뀐다고 했을 때. 그리고 그것이 manifest 깃에 바로 반영된다고 했을 때. 그렇게되면 또 CI 툴에서 컨테이너 이미지가 빌드되기 전에 ArgoCD 의 Sync 동작을 수행하면 ImagePullBackOff 에러가 발생할 것.. → ArgoCD Image Updater 라는 소프트웨어가 있는데. 여러 조건이 있겠지만 Docker Image 가 업데이트되는것을 감지해서 새로운 이미지를 사용하도록 자동으로 git 에 버전과 관련된 변경점을 커밋해버리는것 같다. (더 알아볼 필요가 있음) [GitHub - argoproj-labs/argocd-image-updater: Automatic container image update for Argo CD](https://github.com/argoproj-labs/argocd-image-updater?ref=kimsehwan96.com) [Argo CD Image Updater](https://argocd-image-updater.readthedocs.io/en/stable/?ref=kimsehwan96.com) ## Self Managed ArgoCD ArgoCD의 manifest 파일의 변경점을 감지하고 Sync를 맞출 수 있도록. ArgoCD 자신의 manifest를 바라보는 ArgoCD Application 을 운영하는 것. ![ArgoCD 가 자기 자신의 매니페스트를 관리하는 Self Managed ArgoCD 구조도](https://www.kimsehwan96.com/content/images/2024/03/85279951-33d2-4666-8cab-faa8fe1471ea.png) argocd 가 argocd 애플리케이션을 관리한다. ## Cluster bootstrapping (App of apps or whatever) ArgoCD 의 애플리케이션을 통해, 다른 ArgoCD 애플리케이션 여러개를 생성하도록 하는 패턴. 클러스터 부트스트래핑 용으로 많이 사용 함. 우리가 필수적으로 세팅해야하는 일부 쿠버네티스 오브젝트(ConfigMap)에 대한 것이나, 필수적으로 사용해야하는 서비스/소프트웨어를 세팅하는데 사용하면 됩니다. ![ArgoCD 의 App of Apps 패턴을 나타낸 다이어그램](https://www.kimsehwan96.com/content/images/2024/03/f8f3f46d-92b9-46d7-8ebf-8b08fe384d4b-1.png) App of Apps 패턴 코드 예제 : [https://github.com/kimsehwan96/istio-example/tree/master/apps](https://github.com/kimsehwan96/istio-example/tree/master/apps?ref=kimsehwan96.com) ### kube-apiserver 와 etcd 는 어떻게 통신하는가? URL: https://www.kimsehwan96.com/how-to-communicate-between-kube-apiserver-etcd-each-other/ Last updated: 2026-07-20T12:37:00.000Z 💡 이 문서는 과연 kube-apiserver 가 클러스터 내 여러 etcd 머신과 어떻게 통신하는지 알고자 deep-dive 하면서 파악하는 내용을 정리한 문서입니다. kube-apiserver 가 오직 리더 etcd 노드에만 요청을 보내는지, 아니면 모든 etcd 노드에 요청을 보내는지 등과 같은 부분, etcd 는 과연 로드밸런서가 필요한것인가? 같은 것들을 다뤄보려 합니다. ## kube-apiserver kube-apiserver 는 컨트롤플레인의 중추적인 역할을 담당하는 컴포넌트로, 모든 kubernetes client 요청(e.g kubectl) 을 처리하고, 그것의 결과를 etcd 로 부터 받아와서 전달하거나 etcd 에 데이터를 쓰는 역할을 담당합니다. 또한 `kubelet` , `kube-controller-manager`, `kube-scheduler` 또한 `kube-apiserver` 와 통신하며 클러스터의 상태를 감시하고 필요에 따라 리소스를 생성하거나, 업데이트하는 등의 작업을 수행합니다. 각 컴포넌트는 별도의 `kubeconfig` 파일을 통해 `kube-apiserver` 의 주소, 인증 정보를 들고있으며 보통 컨트롤 플레인에 설치된 `kubelet` , `kube-controller-manager` , `kube-scheduler` 등은 `kube-apiserver` 와 같이 설치되어있는 경우가 대부분이기 때문에 `127.0.0.1:6443` 등으로 로컬호스트를 바라보는 경우가 대부분입니다. (보통 `kube-apiserver` 도 떠있으니까.) `kube-apiserver` 가 로드밸런서와 연결되어있다면 로드밸런서 주소를 바라보게 설정 되어야 합니다. ![kube-apiserver 가 클러스터의 중앙 관리 포인트임을 나타낸 구성요소 다이어그램](https://www.kimsehwan96.com/content/images/2024/02/b9e6a349-90ae-4ab7-9360-f1d3ef6fdeb9.png) 쿠버네티스 클러스터 구성요소 kube-apiserver 가 쿠버네티스 클러스터의 중앙 집중식 관리 포인트 역할을 한다 ## etcd `kube-apiserver` 는 결국 `etcd` 에 데이터를 조회하고, 데이터를 넣는 작업을 하게 됩니다. `etcd`가 쿠버네티스 클러스터의 백엔드 스토리지인 이유가 이것입니다. `etcd` 는 분산 저장 `key:value` 스토리지/스토어로 클러스터링하여 사용하며 Raft 알고리즘에 의해서 클러스터 내에 어느 시점이든 오직 1개의 리더만이 존재하는 형태로 구성됩니다. 또한 `etcd` 에 어떤 요청이 들어왔을 때, 합의가 필요한 요청이 아닌 경우 (예를들어 읽기 조회) 는 요청이 들어온 `etcd` 노드가 리더든, 아니든 요청을 처리 할 수 있지만, 합의가 필요한 요청(쓰기)의 경우, 리더가 아닌경우 리더에게 전달하며, 리더는 합의 된 내용을 다시 팔로워들에게 전파하는 과정을 거치게 됩니다. (참고 : [FAQ](https://etcd.io/docs/v3.5/faq/?ref=kimsehwan96.com#do-clients-have-to-send-requests-to-the-etcd-leader) ) ## kube-apiserver ↔︎ etcd `kube-apiserver` 와 `etcd` 간 통신은 간단하게 설명해서 `etcd`가 공식 제공하는 `go` 언어로 짜여진 etcd v3 API 호환 클라이언트를 한단계 추상화한 것을 `kube-apiserver` 가 사용하고 있다. 라고 간단하게 정리 할 수 있습니다. 즉 `etcd` 클라이언트를 직접 호출하는 것이 아니라, 한단계 `wrapping` 되어서 추상화된 레이어를 사용해서 `etcd` 와 통신하고 있습니다. 따라서 우리가 정말 `kube-apiserver` 와 `etcd` 가 어떻게 통신하는지. 예를들어 1. kube-apiserver 에 등록된 `etcd-servers` 에 있는 모든 서버에게 읽기 / 쓰기 요청을 호출 할까? 2. 아니면 `etcd-ervers` 중 리더를 찾아서 리더에게만 쓰기 요청을 호출하고, 읽기 요청은 일종의 라운드로빈 같은 로드밸런싱을 통해서 호출 할까? 3. 아니면 그런거 없이 그냥 아예 랜덤으로 `etcd-servers` 중 하나에 요청하고 읽기 요청이면 리더가 아니더라도 잘 처리 되고 쓰기 요청이면 `etcd` 내에서 리더에게 알아서 다시 전달할까? **같은 의구심을 처리하기 위해서** `kube-apiserver` **와** `etcd` **클라이언트를 동시에 분석 할 필요가 있습니다.** 더 나아가서 일반적인 경우 `etcd` 앞단에 로드밸런서를 두지 않는 이유가 무엇일지도 정리해나가면서 파악하는것이 이 글의 목표입니다. ### 그래서 무엇 무엇을 알아 봐야 할까? 위에서도 설명했지만 1. `kube-apiserver` 1. [https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go](https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go?ref=kimsehwan96.com) 2. [https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver](https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver?ref=kimsehwan96.com) 2. `kube-apiserver` 가 `etcd` 를 호출할 때 사용하는 추상화된 레이어 1. [https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/storage/etcd3](https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/storage/etcd3?ref=kimsehwan96.com) 3. 추상화된 레이어 안에서 사용되는 `etcd` 클라이언트 1. [https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go](https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go?ref=kimsehwan96.com) 4. `etcd`클라이언트 자체 1. [https://github.com/etcd-io/etcd/blob/main/client/v3](https://github.com/etcd-io/etcd/blob/main/client/v3?ref=kimsehwan96.com) 이렇게 네 가지 부분정도를 확인하면 실제로 `kube-apisever` 가 어떻게 `etcd` 와 상호작용 하는지 알 수 있을 것으로 예상됩니다. ## 동작 우선 근본적으로, `etcd client` 가 어떻게 동작하는지 알면 `kube-apiserver` 가 `etcd` 를 어떻게 추상화했든지는 알 필요 없이 동작을 이해 할 수 있습니다. 현재 etcd 3.4 버전 이상부터는 `clientv3-grpc1.23` 이라는 버전의 밸런서(클라이언트 사이드 로드밸런서라고 생각하면 됩니다)를 사용합니다. etcd 는 gRPC 서버를 제공하고, 그것에 통신하기위한 gRPC 클라이언트는 클라이언트 사이드 LB(Balancer)를 사용 가능합니다. ([etcd client design](https://etcd.io/docs/v3.5/learning/design-client/?ref=kimsehwan96.com#clientv3-grpc123-balancer-overview) ) **동작은 아래와 같습니다.** 1. etcd 클라이언트는 etcd 클러스터 노드들에 대한 TCP 커넥션을 모두 들고 있습니다. 1. 만약 etcd 멤버가 3이면 3개, 5이면 5개겠죠 2. 그리고 모든 요청에 대해서 읽기/쓰기 상관 없이(정확히는 리더에게 전달해서 합의를 해야하는 요청이든, 아니든 상관 없이) 라운드 로빈 형태로 순차적으로 각 etcd 멤버들에 요청을 보냅니다. 3. 이 때 만약 읽기 요청이라면 요청받은 etcd 노드가 그대로 응답을 던집니다. 4. 이 때 쓰기 요청이라면, 그리고 요청받은 etcd 노드가 리더라면 그대로 리더가 합의하에 처리합니다. 5. 이 때 쓰기 요청이고, 그리고 요청받은 etcd 노드가 팔로워라면 리더에게 전달합니다. ![etcd 노드가 읽기·쓰기 요청을 리더·팔로워에 따라 처리하는 흐름 다이어그램](https://www.kimsehwan96.com/content/images/2024/02/3a72d393-d4cf-4c87-a1f4-3369499b6caa.png) etcd 공식 문서에서 발췌한 `clientv3-grpc1.23` 의 `Balancer` 구조 [https://github.com/etcd-io/etcd/blob/main/client/v3/internal/resolver/resolver.go](https://github.com/etcd-io/etcd/blob/main/client/v3/internal/resolver/resolver.go?ref=kimsehwan96.com) `etcd` 클라이언트의 `resolver` 라는 코드가 이러한 로드밸런싱에 관련된 부분을 처리하는데 기본적인 로드밸런싱 정책은 `round_robin` 입니다. 그리고 이 로드밸런싱은 etcd 가 별도 구현하는것이 아닌 gRPC 리졸버 라고 부르는 코드쪽에서 구현된 것이고. 그것과 관련한 문서는([gRPC Load Balancing](https://grpc.io/blog/grpc-load-balancing/?ref=kimsehwan96.com#load-balancing-options) ) 여기서 볼 수 있습니다. `clientv3-grpc1.23` 밸런서부터는 이제 모든 etcd 노드에 대해서 TCP 커넥션을 유지하면서 처리한다고 하고, 그러한 구조를 통해서 앞으로 `round_robin` 뿐만 아니라 `power of two, pick leader` 같은 로드밸런싱 정책 구현도 쉽게 가능할것이라고 문서에서 설명합니다. (이건 주의할것이, 아직 구현되었다는것은 아닙니다. 현재는 여전히 `round_robin` 만 사용 가능) ## 실제 동작 검증 192.168.203.2 : node1 / etcd1 / control plane 192.168.203.3 : node2 / etcd2 / control plane 192.168.203.4 : node3 / etcd3 192.168.203.5 : node4 / etcd4 192.168.203.6 : node5 / etcd5 위와 같이 Kubernetes 클러스터를 생성하고(kubespray). 테스트해봅니다. (컨트롤플레인 노드만 두고 워커노드는 그냥 두지 않았습니다.) 또한 etcd 는 모두 `systemd` 서비스로 (static pod 가 아닌) 프로비저닝 하였습니다. 여기서 보고자 하는 부분은 `kube-apiserver` 가 각 `etcd` 노드에 별다른 가중치 없이 돌아가며 요청을 보내는지 확인하는것이 목적입니다. 가능하면 `etcd put` 요청시 리더가 아닌 etcd 노드에 요청이 들어갔을 때 리더에 전달하는 부분까지 확인하고싶었지만 테스트의 어려움이 있어(저보다 잘 하시는 분이 테스트 해보시고 공유좀!) 이정도 선에서만 테스트 해보겠습니다. ``` #!/bin/bash # kube-apiserver 포트 찾기 PORTS=$(netstat -ntep | grep kube-api | awk '{print $4}' | cut -d':' -f2 | sort | uniq | paste -sd "," -) if [ -z "$PORTS" ]; then echo "kube-apiserver 포트를 찾을 수 없습니다." exit 1 fi # tcpdump 필터 생성 FILTER="src host 192.168.203.2 and dst port 2379 and (" for PORT in $(echo $PORTS | tr "," "\n"); do FILTER+="src port $PORT or " done FILTER=${FILTER%or } FILTER+=")" # tcpdump 실행 echo "실행하는 tcpdump 명령어: sudo tcpdump -i any -nn '$FILTER'" sudo tcpdump -i any -nn "$FILTER" ``` 위와 같은 형태의 쉘 스크립트를 작성하고 수행해봅니다. 위 쉘 스크립트는 `kube-apiserver` 가 `etcd` 와 연결되면서(2379번포트) 동적으로 할당받은 `TCP` 포트들을 찾아내서, `node1` 에서 출발해서 다른 모든 호스트의 `2379` 번으로 넘어가는 트래픽을 캡쳐하는 스크립트입니다. ![kube-apiserver 와 etcd(2379) 사이 트래픽을 캡처하는 스크립트 화면](https://www.kimsehwan96.com/content/images/2024/02/7c4665eb-100a-4554-b308-1df7f9e25a70.png) `kube-apiserver` 에서 `etcd` 로의 요청은 라운드로빈 형태로 돌아가면서 요청이 들어가는 것을 볼 수 있다. 위 캡쳐를 잘 확인해보면 `192.168.203.2` 에서 실행중인 `kube-apiserver` 에서 `etcd` 모든 노드 (192.168.203.\[2:6\]) 으로 호출을 돌아가면서 하는 것을 볼 수 있습니다. 만약 모든 요청이 `etcd` 의 리더에 들어가는게 아니였어? 생각 할 수 도 있는데. 리더는 아래와 같았습니다. ``` export ETCDCTL_API=3 HOST_1=192.168.203.2 HOST_2=192.168.203.3 HOST_3=192.168.203.4 HOST_4=192.168.203.5 HOST_5=192.168.203.6 ENDPOINTS=$HOST_1:2379,$HOST_2:2379,$HOST_3:2379,$HOST_4:2379,$HOST_5:2379 /usr/local/bin/etcdctl --endpoints=$ENDPOINTS \ --cacert=/etc/ssl/etcd/ssl/ca.pem \ --cert=/etc/ssl/etcd/ssl/admin-node1.pem \ --key=/etc/ssl/etcd/ssl/admin-node1-key.pem \ --write-out=table \ endpoint status ``` ![etcd endpoint status 로 리더가 192.168.203.6 임을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/02/eb37cd51-c676-4db3-bd2c-adac650d7a09.png) 현재 etcd 리더는 192.168.203.6 에 설치된 etcd 이다. 이를 통해서 `kube-apiserver` → `etcd` 통신은 리더에게만 전달되거나, 모든 `etcd` 에 동시에 전달되는것이 아니라 `round_robin` 형태로 로드밸런싱 되어서 골고루 분산되어서 요청이 전달된다는것을 확인 할 수 있었습니다. ## 번외 위에서는 kube-apiserver 가 wrapping 해서 사용중인 etcd v3 client 의 기본적인 클라이언트 사이드 로드밸런싱 정책이 round\_robin 이기 때문에 kube-apsierver 가 바라보는 etcd 서버들에게 돌아가면서 요청을 전달합니다. ([https://github.com/etcd-io/etcd/blob/e7b3bb6ccac840770f108ef9a0f013fa51b83256/client/v3/internal/resolver/resolver.go#L43](https://github.com/etcd-io/etcd/blob/e7b3bb6ccac840770f108ef9a0f013fa51b83256/client/v3/internal/resolver/resolver.go?ref=kimsehwan96.com#L43) ) 이러한 로드밸런싱 정책은 etcd 또한 grpc-go 에서 구현된 것을 사용하고 있고. 관련한 문서는 ([https://github.com/grpc/grpc-go/blob/master/examples/features/load\_balancing/README.md](https://github.com/grpc/grpc-go/blob/master/examples/features/load%5Fbalancing/README.md?ref=kimsehwan96.com) ) 여기서 찾아 볼 수 있습니다. Default 옵션은 pick\_first 인데, etcd v3 client 는 기본적으로 round\_robin 으로 하도록 위쪽 permalink 처럼 확인이 가능합니다. 여기서 pick\_first 정책을 etcd v3 client 에서 정말 사용이 가능할지 테스트를 해보고자 아래와 같이 테스트를 해보았습니다. [GitHub - kimsehwan96/etcd-v3-client-with-pick-firstContribute to kimsehwan96/etcd-v3-client-with-pick-first development by creating an account on GitHub.![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkimsehwan96![](https://opengraph.githubassets.com/5c2417d7711c2a87ec984ff93bfa71cf9b8c138d05fdede6bddfbf9a92ab1648/kimsehwan96/etcd-v3-client-with-pick-first)](https://github.com/kimsehwan96/etcd-v3-client-with-pick-first?ref=kimsehwan96.com) 자세한 내용은 위 git repo 에 README.md 로 작성해두었습니다. ## Wrapping up `kube-apiserver` 는 각 `etcd` 멤버들에게 골고루 부하를 분산하면서 `GET` , `PUT` , `WATCH` 등의 요청을 하게 됩니다. 만약 `etcd` 에서 합의가 필요한 요청인 `PUT` 요청을 리더가 받았다면 그대로 처리 후 전파, 팔로워가 받았다면 우선 리더에게 전달후 처리 등의 동작을 하게 되고, 그 외 합의가 필요하지 않은 요청은 받은 `etcd` 노드가 바로 처리해서 전달하게 됩니다. 따라서 특정 `etcd` 멤버에 부하가 과도하게 걸리거나 하는 걱정은 이미 `etcd` 클라이언트의 클라이언트 사이드 LB 가 처리해주고 있기 때문에 크게 걱정할 부분은 아닙니다. 재미있는점은 `etcd` 공식 문서중 클라이언트 디자인 부분에서 향후 `Balencer` 쪽에서 `power of two` , `pick leader` 등의 LB 정책 (현재는 `round_robin` 만 제공)을 확장할 생각이 있는 것 같기도 합니다. (power of two 는 랜덤하게 2개의 백엔드 인스턴스를 골라서 그 중 한개에 트래픽을 보내는 LB 전략. [https://www.haproxy.com/blog/power-of-two-load-balancing](https://www.haproxy.com/blog/power-of-two-load-balancing?ref=kimsehwan96.com)) (pick leader 는 아마도 ETCD 리더에게만 트래픽을 보내는 전략으로 구현 예정인 듯) 결론적으로 `kube-apiserver` 앞단에 로드밸런서(HAProxy / Nginx 같은 소프트웨어 LB, 혹은 L4 Switch 같은 물리 LB 등)를 두는것과 다르게 `etcd` 는 클라이언트 사이드 LB를 통해 추가적인 네트워크 홉을 줄이면서 `gRPC` 의 성능 향상(`HTTP2`) 부분을 그대로 가져갈 수 있는 디자인을 채택한것 같습니다. 이 부분을 deep dive 하면서 자연스럽게 `gRPC` 에서의 LB 에 대한 의구심이나 정리할점도 많다고 느꼈는데 ([https://grpc.io/blog/grpc-load-balancing/#load-balancing-options](https://grpc.io/blog/grpc-load-balancing/?ref=kimsehwan96.com#load-balancing-options)) 이 부분은 추후에 정리해서 자세히 포스팅 하도록 하겠습니다. ### ARM64 Mac UTM 에서 x86_64 가상머신 실행 URL: https://www.kimsehwan96.com/how-to-run-x86-64-virtualmachine-in-arm64-mac-with-utm/ Last updated: 2026-07-20T12:37:00.000Z 💡 ARM64 Mac(M1, M2,M3 ..) 환경에서 UTM 을 사용해 x86\_64 가상머신을 구동하는 방법을 설명합니다. ## 유의 할 점 ARM64 가 아닌 CPU 아키텍처를 에뮬레이션 하는 경우, VM 자체 성능이 매우 느림을 감안하고 테스트 해야 합니다. 기본적인 설정들로만 x86\_64 가상머신을 UTM을 통해 실행하려고 하면 각종 에러가 발생합니다. 아래 가이드에 맞게 실행하면 오류 없이 실행됩니다. ![UTM 새 VM 생성에서 Emulate 를 선택하는 화면](https://www.kimsehwan96.com/content/images/2024/02/a20e1aa9-dd6f-49fe-af83-2ec93e9fbf15.png) Emulate 선택 ![UTM 에서 운영체제로 Linux 를 선택하는 화면](https://www.kimsehwan96.com/content/images/2024/02/693b7b6c-bb79-4788-923c-30b16c14f016.png) Linux 선택 ![UTM 에서 부팅 ISO 를 x86_64(amd64) 이미지로 선택하는 화면](https://www.kimsehwan96.com/content/images/2024/02/6452ec20-bad9-4458-b0c2-c716a124ea90.png) Boot ISO 이미지를 x86\_64(amd64) 이미지로 선택 ![UTM VM 의 메모리와 CPU 코어를 설정하는 화면](https://www.kimsehwan96.com/content/images/2024/02/f3a0d11a-d48f-461e-91dc-9c46e5af67e9.png) 따로 튜닝할 부분 없음. 기호에 맞게 메모리/CPU 코어 수정 ![UTM 에서 Open VM Settings 로 VM 설정에 진입하는 화면](https://www.kimsehwan96.com/content/images/2024/02/feae3b2f-a6e9-49d2-9c31-10dbc763ebb9.png) Open VM Settings 후 저장하여 VM 설정으로 이동 ![UTM 에서 CPU 를 IvyBridge 로 변경하는 설정 화면](https://www.kimsehwan96.com/content/images/2024/02/a94a8d04-03c1-49b0-887e-973d40941530.png) CPU 를 기본 값에서 ****IvyBridge** 로 변경 (변경하지 않으면 설치 단계에서 OS Installer 실행 시 glibc fatal error 발생) (참고 : [How To Resolve Fatal glibc error: CPU does not support x86-64-v2](https://medium.com/@Derilee/how-to-resolve-fatal-glibc-error-cpu-does-not-support-x86-64-v2-e502712c1abe?ref=kimsehwan96.com) ) (참고 : [Error booting RHEL9 install iso · Issue #4286 · utmapp/UTM](https://github.com/utmapp/UTM/issues/4286?ref=kimsehwan96.com) ) (참고 : [How to run x86 Linux on M1 MacBook?](https://zenn.dev/tetra2000/articles/x86%5Flinux%5Fon%5Fm1?ref=kimsehwan96.com) ) ![UTM 에서 디스플레이를 VGA 로 변경하는 설정 화면](https://www.kimsehwan96.com/content/images/2024/02/dad65312-9eaa-4563-a019-7ed8e6979f41.png) 디스플레이를 기본값에서 VGA로 변경 → 그래야 인스톨러 GUI 가 제대로 보임 ![VGA 로 변경한 뒤 정상 표시되는 x86_64 리눅스 설치 관리자 화면](https://www.kimsehwan96.com/content/images/2024/02/d35fb78d-9323-4146-922e-ab6bf4a2c514.png) VM 설정 → 시스템 → CPU 를 클릭해보면 다양한 CPU 를 선택할 수 있게 나옵니다. 기본적으로 최신 운영체제는 대부분 `xxxx-v2` 와 같이 v2 이상의 CPU를 사용해야 glibc 에러가 안나는 것 같습니다. (정확한 원인이나 CPU 와 관련된 부분은 추후 파악 필요) 여기서 다른 CPU를 선택해서 테스트해도 무방할 것 같습니다만. 현재 테스트해서 설치에 성공한 CPU 타입은 IvyBridge 였습니다. ## x86\_64 가성머신 구동 및 OS 설치 이후 결과화면 ![x86_64 가상머신을 구동해 OS 설치를 마친 결과 화면](https://www.kimsehwan96.com/content/images/2024/02/-----------2024-02-28-------3.11.45.png) 왼쪽 위가 x86\_64 가상머신으로 `arch` 명령어 수행시 x86\_64 확인 가능, 오른쪽 아래는 맥의 터미널로 `arch` 명령어 수행시 arm64 확인 가능 ## Wrapping up 이렇게 ARM(M1, M2, M3)맥에서 UTM 을 사용해 x86\_64 아키텍처 가상머신을 사용하는 방법을 알아보았습니다. 실제로 OS 설치시 매우 긴 시간이 필요한 만큼 정말 간단한 테스트 용도 외에는 사용하지 않는것이 더 나을 것 이라고 판단 됩니다. 추가적으로 잘 안되는 점이 있었던 경우에는 댓글로 달아주시면 감사하겠습니다. 또한 그 외 다양한 피드백은 댓글로 달아주세요. ### Keel 을 이용한 컨테이너 이미지 태그 업데이트 자동 감지 및 쿠버네티스 파드 재배포 자동화 URL: https://www.kimsehwan96.com/keel-operator/ Last updated: 2026-07-20T12:37:00.000Z ## Keel ? Keel 은 Helm, DaemonSet, StatefulSet, Deployment 를 통해 생성된 파드의 컨테이너 이미지 업데이트를 자동화하는 쿠버네티스 오퍼레이터입니다. [GitHub - keel-hq/keel: Kubernetes Operator to automate Helm, DaemonSet, StatefulSet & Deployment updatesKubernetes Operator to automate Helm, DaemonSet, StatefulSet & Deployment updates - keel-hq/keel![](https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg)GitHubkeel-hq![](https://opengraph.githubassets.com/b6a0b1880a93266aa62e739608349c7bc75a54c1c37c585b14b50c9b19fd51d5/keel-hq/keel)](https://github.com/keel-hq/keel?ref=kimsehwan96.com) [KeelKubernetes Operator to automate Helm, DaemonSet, StatefulSet & Deployment updates![](https://keel.sh/img/logo_small.png)](https://keel.sh/?ref=kimsehwan96.com) ## 오퍼레이터(Operator)? Opeartor 는 사용자 정의 리소스를 사용하여 애플리케이션 및 해당 컴포넌트를 관리하는 쿠버네티스의 소프트웨어 익스텐션입니다. 오퍼레이터는 쿠버네티스 원칙 중 “컨트롤 루프”를 따릅니다. ## 설치 by Helm (argocd application) ```yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: keel namespace: test-argocd spec: project: default source: repoURL: https://charts.keel.sh chart: keel targetRevision: 1.0.2 # 2023.4.21 helm: releaseName: keel # valueFiles: # values file 참조 가능 # - values-production.yaml values: | # inline value 추가 가능 helmProvider: enabled: false service: enabled: false type: ClusterIP ingress: enabled: false basicauth: enabled: true user: "admin" password: "password" destination: server: https://kubernetes.default.svc namespace: test-keel syncPolicy: automated: prune: false selfHeal: false syncOptions: - CreateNamespace=true ``` ArgoCD Application 을 통해 HelmChart 를 직접보고 설치하였습니다. inline value 로 vaules 를 넣어주었고. helmProvider 는 사용하지 않을 계획이라(Helm 차트를 자동으로 업데이트하지 않을거고, 컨테이너 이미지에 대한 업데이트만 감지해서 처리할거라서) Disable 하였습니다. basicauth 와 같은 인증 옵션을 키면 keel admin dasboard 를 사용 할 수 있습니다. [Guide | KeelKeel installation instructionsKeel![](https://keel.sh/img/logo_small.png)](https://keel.sh/docs/?ref=kimsehwan96.com#deploying-with-kubectl) ## 이미지 업데이트를 감지하여서 업데이트하도록 하는 방법 ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: test-keel-app namespace: test-keel-app labels: app: test-keel-app app.kubernetes.io/name: test-keel-app annotations: keel.sh/pollSchedule: "@every 1m" keel.sh/policy: force keel.sh/match-tag: "true" keel.sh/trigger: poll spec: # replicas: 1 selector: matchLabels: app: test-keel-app template: metadata: labels: app: test-keel-app spec: containers: - name: test-keel-app image: kimsehwan96/my-playground:dev imagePullPolicy: "Always" ``` keel 오퍼레이터 설치 이후 이미지 업데이트를 감지해서 업데이트 하고자 하는 Deployment, StatefulSet, DaemonSet 등의 오브젝트 manifest 에 label or annotation 을 추가해주면 됩니다. `keel.sh/policy` 의 `force` 옵션은 태그가 시멘틱버저닝을(ie v1.0.0) 따르지 않더라도 강제업데이트 하는 정책입니다. `keel.sh/match-tag=true` 로 지정하는 경우 현재 지정된 태그와 동일한 태그에 대해서 digest 가 달라지는 경우에 업데이트 하도록 하는 옵션입니다. (그래서 dev 같은 태그만 두고, 이걸 이미지 업데이트하면 감지해서 업데이트하게 됩니다) `keel.sh/trigger` 의 `poll` 은 웹훅등을 쓰지 않고 container image registry 를 폴링해서 업데이트를 감지하겠다는 옵션입니다. ([Guide | Keel](https://keel.sh/docs/?ref=kimsehwan96.com#triggers) ) 이미지 업데이트를 감지해서 Deployment 와 같은 오브젝트에 대해 restart 를 해주는 오퍼레이터라서. `imagePullPolicy` 는 우리 케이스처럼 같은 태그의 업데이트를 감지하고, 정말 업데이트 하고싶으면 `Always` 로 지정해야 합니다! ## 실제 동작 ![이미지 업데이트 감지를 위해 imagePullPolicy 를 Always 로 지정한 매니페스트](https://www.kimsehwan96.com/content/images/2024/02/7c930ab0-3d83-478c-9c2a-c97a0a310c1c.png) 동일한 `kimsehwan96/my-playground:dev` 에 새로운 이미지를 푸시해본다. 기존 `test.txt` 의 값이 2 였던것을 기억하자. ![테스트 애플리케이션의 test.txt 값이 2 임을 확인하는 화면](https://www.kimsehwan96.com/content/images/2024/02/a9f1d245-f21e-4914-b866-85b01d7cfe13.png) `test.txt` 의 숫자를 3으로 변경하고 이미지 빌드 및 푸시 (동일한 태그인 dev) ![Keel 오퍼레이터가 이미지 업데이트를 감지해 파드를 재시작한 화면](https://www.kimsehwan96.com/content/images/2024/02/ff9eb163-8653-4aff-a937-c875ac207b02.png) keel 오퍼레이터가 감지하고 restart 해버린다 ! (그래서 파드가 새로 생김) ![재배포된 새 파드에 갱신된 test.txt 값이 반영된 결과 화면](https://www.kimsehwan96.com/content/images/2024/02/b44b22da-f50c-499c-89e6-51aa94ca5483.png) ## 대시보드 ![Keel 대시보드에서 이미지 업데이트 횟수와 정책 여부를 보여주는 화면](https://www.kimsehwan96.com/content/images/2024/02/9cebb530-5844-40d1-a306-4a85e545c186.png) 몇번의 업데이트가 있었는지. 정책이 있는지 없는지(이미지 업데이트 관련) 확인 가능하고. 위에 일주일에 몇번 업데이트가 있었는지도 보임 ![Keel 대시보드에서 현재 감시 중인 이미지 목록을 보여주는 화면](https://www.kimsehwan96.com/content/images/2024/02/a0e962a9-45ec-4830-a91f-ba9974c18cf9.png) 현재 감시중인 이미지가 뭐있는지 확인 가능 ![Keel 대시보드의 이미지 업데이트 감사 로그 화면](https://www.kimsehwan96.com/content/images/2024/02/79572df3-abf3-4470-ac86-ae60b98781d8.png) 업데이트에 대한 audit log 남음 ! ### kube-apiserver 를 컨트롤 플레인 외부에 설치해서 사용하기 URL: https://www.kimsehwan96.com/use-kube-apiserver-in-outside-of-control-plane-for-kube-apiserver-dedicated-node/ Last updated: 2026-07-20T12:37:00.000Z [Scaling Kubernetes to 7,500 nodesWe’ve scaled Kubernetes clusters to 7,500 nodes, producing a scalable infrastructure for large models like GPT-3, CLIP, and DALL·E, but also for rapid small-scale iterative research such as Scaling Laws for Neural Language Models.![](https://openai.com/favicon.ico)![](https://images.openai.com/blob/84745f0a-d786-4066-9907-4ce230afd73c/scaling-kubernetes-to-7-500-nodes.png?trim=0%2C5%2C696%2C5&width=1000&quality=80)](https://openai.com/research/scaling-kubernetes-to-7500-nodes?ref=kimsehwan96.com) 💡 이 포스팅은 OpenAI 의 7,500 노드 규모의 클러스터를 운영하면서 겪은 기술적 이슈를 다룬 글을 통해 아이디어를 얻고 작성한 글이다. ## 개요 원래 kube-apiserver 는 보통 컨트롤 플레인 노드 안에서 `static pod` 형태로 운영된다. `static pod` 는 `kubernetes` 가 관리하진 않지만(생성/추가/삭제 등), `kubernetes` 에서 관측 가능한 파드이고, 이것은 `/etc/kubernetes/manifests` 에 정의된 `yaml` 들을 파드로 `kubelet` 이 바라보고 생성하는 컨테이너들이다. 엄밀히 말하자면 쿠버네티스의 자원은 아니지만, 쿠버네티스가 써야 할 컴포넌트들을 주로 `static pod` 로 생성해서 사용한다. 대표적인 예가 `kube-apiserver` , `kube-scheduler` , `kube-controller-manager` 등이다. 만약 `kubeadm` 으로 별도의 설정 없이 클러스터를 프로비저닝하면 `etcd` 또한 `static pod` 로 프로비저닝 하게 된다. ## Dedicated Node `dedicated node` 란 보통 특정 서비스/애플리케이션/워크로드의 성능 극대화를 위해, 단일 서비스/애플리케이션/워크로드만을 하나의 노드(머신)에서 운영하는 케이스를 의미한다. 보통 I/O 작업이 많거나, 아니면 다른 워크로드에 의해서 영향을 받으면 안되는, 혹은 크리티컬한 워크로드를 이런형태로 사용 가능하고 그 대표적인 예가 `etcd` 이다. `etcd` 의 각종 사용사례를 보면 궁극적으로 성능 최대화, 클러스터의 안정성을 위해서 별도의 머신에서 `etcd` 만을 두고 운영하는 형태를 권장한다. 특히 클러스터 내 노드의 개수가 많은경우가 그렇다. `openai` 의 경우는 `kube-apiserver` 또한 `dedicated node` 에서 static pod 가 아닌 별도의 `systemd service` 로 사용하는것으로 추측된다. 그러한 사용사례가 크게 문제가 될 것이 없는것이 첫째, `kube-apiserver` 는 `stateless` 하다. 단지 뒷단의 백엔드에 `etcd` 가 있긴 하지만 `kube-apiserver` 자체가 상태를 들고있고, 상태에 의해서 문제가 발생하는 서비스가 아니다. 두번째, `kube-apiserver` 는 `etcd` 와만 잘 연결되면 외부에서 `kube-apiserver` 에 request 를 했을 때, 그것을 처리하는데 문제가 없다. **위와 같은 특성으로** `kube-apiserver` **는 꼭 컨트롤플레인 노드에서 실행되어야 하는것이 아니다!** ## 실제 테스트 여기서 `kube-apiserver` 는 `systemd` 서비스와 같이 컨테이너가 아닌 네이티브한 `프로세스` 로 실행하는것으로 테스트해보았다. 이 테스트에서의 전제조건은 `etcd` 는 stacked 가 아닌 `external` 형태로 배포되어있을 때의 환경에서 테스트 하였다. ### 환경 `node1` : 컨트롤플레인, `192.168.201.12` / etcd 가 `systemd` 로 실행중 `node2` : 컨트롤플레인, `192.168.201.13` / etcd 가 `systemd` 로 실행중 `node3` : etcd 가 `systemd` 로 실행중 `node4` : 아무것도 없는 노드 `192.168.201.15` ### 목표 구성 `node1` 에서 `static pod` 로 실행중인 `kube-apiserver` 를 `node4` 에서 프로세스로 띄워보고, `node4` 의 `kube-apiserver` 에 요청을 했을 때 잘 처리되는지 확인한다. ### 컨트롤 플레인에서 `kube-apiserver` `static pod` 제거 우선 kubelet은 `/etc/kubernetes/manifests` 에 있는 `yaml` 파일을 `static pod` 로 띄우게 된다. ``` [root@node1 manifests]# pwd /etc/kubernetes/manifests [root@node1 manifests]# tree . . ├── kube-apiserver.yaml ├── kube-controller-manager.yaml └── kube-scheduler.yaml ``` 위와 같이 총 3개의 파드가 `static pod` 로 띄워져있는데, `static pod` 를 제거하는 방법은, 단순하게 해당 디렉터리에서 `yaml` 을 제거하는것이다. 따라서 `mv kube-apiserver.yaml ~/` 을 통해 파일을 삭제하진 않고 제거해보았다. 이후 `ps -ef | grep kube` 를 통해 `kube-apiserver` 프로세스(컨테이너) 가 있는지 확인해본다. ![kube-apiserver 를 제거해 kube-controller-manager 와 kube-scheduler 만 남은 프로세스 목록](https://www.kimsehwan96.com/content/images/2024/02/00e6a2b8-9782-472c-b537-d73f8ab2707b.png) kube-controller-manager 및 kube-scheduelr 만 존재하는 상태 이후, kubeadm 으로 생성되었던 `apiserver` 인증서에 `node4` 의 `ip` 주소를 SAN (subject alternative name) 으로 넣어줘야 하기 때문에 `/etc/kubernetes/kubeadm-config.yaml` 파일을 수정한다. ![kubeadm-config.yaml 의 certSANs 에 192.168.201.15 를 추가한 설정 화면](https://www.kimsehwan96.com/content/images/2024/02/c1e5d1d0-f1dd-48ec-ba93-4847ee9e72c8.png) 192.168.201.15 를 certSANs 에 추가 이후에 `/etc/kuberentes/pki` , `/etc/kubernetes/ssl` (이건 kubespray 썼을 경우 인증서 경로) 내의 `apiserver.crt` , `apiserver.key` 를 삭제한다. 이후 `$ kubeadm init phase certs apiserver --config=/etc/kubernetes/kubeadm-config.yaml` 을 통해 `apiserver` 인증서를 새로 생성한다. `apiserver.crt` , `apiserver.key` 인증서를 `node4` 머신에 옮겨주면 되는데, 테스트 차원에서 sa.pub / sa.key 같은 인증서들도 필요하기때문에 통째로 node4 머신에 옮겨준다. `$ scp /etc/kubernetes/ssl/* hayden@192.168.201.15:/home/hayden` 이후 `etcd` 인증서 또한 옮겨주어야 한다. `kubespray` 를 사용해 `etcd` 를 프로비저닝 한 케이스에서 `etcd` 인증서는 `/etc/ssl/etcd/ssl` 경로에 들어가있다. 여기서 원칙적으로는 `node4` 가 사용할 별도 인증서를 넘겨주는게 맞겠지만, 테스트 차원에서 `/etc/ssl/etcd/ssl` 경로에 있는 `ca.pem` , `node-node2.pem` , `node-node2-key.pem` 인증서를 `node4` 머신에 옮겨준다. `$ scp /etc/ssl/etcd/ssl/{ca.pem,node-node2.pem,node-node2-key.pem} hayden@192.168.201.15:/home/hayden` 이후 `node4` 머신에서 `kube-apiserver` 바이너리를 다운로드 받는다. ![kube-apiserver 바이너리 다운로드 안내 링크 카드의 파비콘](https://kubernetes.io/images/favicon.png) [Download Kubernetes](https://kubernetes.io/releases/download/?ref=kimsehwan96.com) 해당 링크에서 찾을 수 있다. `$ wget https://dl.k8s.io/v1.29.1/bin/linux/arm64/kube-apiserver` (테스트중인 환경의 아키텍처에 맞게 주의해서 다운로드) 이후 `node4` 머신에서 `kube-apiserver` 를 인자와 함께 아래와 같이 실행한다. (각자 상황에 따라 ip 주소 등이 다를 수 있음 주의) ``` $ ./kube-apiserver --advertise-address=192.168.201.15 --allow-privileged=true \ --anonymous-auth=True --apiserver-count=2 --authorization-mode=Node,RBAC --bind-address=0.0.0.0 \ --client-ca-file=./ca.crt --default-not-ready-toleration-seconds=300 --default-unreachable-toleration-seconds=300 \ --enable-admission-plugins=NodeRestriction --enable-aggregator-routing=False --enable-bootstrap-token-auth=true \ --endpoint-reconciler-type=lease --etcd-cafile=./ca.pem --etcd-certfile=./node-node2.pem \ --etcd-compaction-interval=5m0s --etcd-keyfile=./node-node2-key.pem \ --etcd-servers=https://192.168.201.12:2379,https://192.168.201.13:2379,https://192.168.201.14:2379 \ --kubelet-client-certificate=./apiserver-kubelet-client.crt --kubelet-client-key=./apiserver-kubelet-client.key \ --kubelet-preferred-address-types=InternalDNS,InternalIP,Hostname,ExternalDNS,ExternalIP --profiling=False \ --proxy-client-cert-file=./front-proxy-client.crt --proxy-client-key-file=./front-proxy-client.key --secure-port=6443 \ --requestheader-username-headers=X-Remote-User --requestheader-group-headers=X-Remote-Group \ --requestheader-extra-headers-prefix=X-Remote-Extra- --service-account-issuer=https://kubernetes.default.svc.cluster.local \ --service-account-key-file=./sa.pub --service-account-lookup=True --service-account-signing-key-file=./sa.key \ --storage-backend=etcd3 --tls-cert-file=./apiserver.crt --tls-private-key-file=./apiserver.key ``` `kube-apiserver` 는 정말 많은 인자/옵션들이 있는데 이것을 직접 알아내서 확인한것이 아니라, 아까 `node1` 에서 본 `kube-apiserver.yml` `static pod` 명세에서 찾아서 넣으면 된다. 여기서 수정해야 할 값들은 `--client-ca-file` , `--etcd-certifle` 과 같은 인증서 옵션들이다. (나중에 더 자세히 작성) 이렇게하고 `node4` 에서 `kube-apsierver` 를 실행하면 `kube-apiserver` 가 동작하기 시작한다. 이것을 테스트하기 위해 다시 `node1` 으로 넘어가서 `kubectl` 이 사용할 `config` 파일을 수정해본다. `$ vim /root/.kube/config` ![외부 노드(node4)에서 kube-apiserver 가 기동된 것을 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/02/e4fdc9cd-1194-404b-8451-21420ed7fb2b.png) 원래는 `node1` 이 자기자신 `static pod` 가 당연하게도 localhost로 뜨기때문에, localhost:6443 으로 호출하도록 되어있었다. 이것을 node4 주소로 변경 이렇게 하게 되면 앞으로 `node1` 머신에서의 `kubectl` 호출은 `node4` (192.168.201.15) 에 있는 `kube-apiserver` 에 호출을 하게 된다. `$ curl -k "https://192.168.201.15:6443/readyz?verbose"` 위와 같이 `node4` 에 떠있는 `kube-apiserver` 의 상태를 조회해볼 수 있고. ![curl 로 외부 kube-apiserver 의 readyz 상태를 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/02/49bf46b2-3d55-489c-8a5b-a2046dcba7cb.png) curl 로 kube-apiserver 호출해서 상태 확인 `$ kubectl get --raw='/readyz?verbose'` 을 통해 `kubectl` 로 동일한 결과를 확인 할 수 있다. ![kubectl get --raw 로 kube-apiserver 상태를 확인하는 터미널 출력](https://www.kimsehwan96.com/content/images/2024/02/04b9aefe-d9f8-4604-bbd2-5c6d77507404.png) kubectl 로 kube-apiserver 상태 확인 `$ kubectl get po -A` 와 같은 일반적인 요청도 잘 처리 된다. ![kubectl get po -A 요청이 정상 처리되는 결과 터미널 출력](https://www.kimsehwan96.com/content/images/2024/02/415dd4ba-067f-4a9a-917e-44c02e6a31de.png) kubectl 테스트 결과 0:00 /0:36 1× 위는 `node1` , 아래는 `node4` 이다. 이렇게 컨트롤 플레인 외부에 `kube-apiserver` 를 둘 수 있다 ! (`node4` 는 쿠버네티스와 전혀 관계 없는 머신이다) ## Wrapping up 이렇게 K8s 의 Control Plane 외부에서 `kube-apiserver` 를 실행하고 정상 동작하는것을 확인해봤는데 당연히 프로덕션에서 이러한 구조를 사용하기 위해서는 `kube-apiserver` 를 `systemd` 서비스로 래핑해서 사용하거나 혹은 `kubelet` \+ `static pods` 조합으로 사용해야 할 것이다. 만약 `kube-apiserver` 를 이러한 `dedicated node` 에서 `systemd` 서비스등으로 설치해서 사용하는 경우 쿠버네티스 클러스터의 자체적인 로깅/모니터링/관리는 포기하게 되므로 트레이드 오프를 잘 판단해서 사용해야 한다. OpenAI의 경우 거대한 규모의 K8s 클러스터를 운영하고있고, 그에 따른 `kube-apiserver` 의 부하도 엄청난 상황에서 `kube-apiserver`를 위한 전용(dedicated)노드에서 실행하여 조금 더 안정적으로 쿠버네티스 클러스터를 운영하려고 했던 케이스로 보인다. 일반적인 유즈케이스에서는 이렇게 사용할 필요는 없겠지만 K8s Control Plane 의 일부 컴포넌트를 이런식으로 인증서 설정만 잘 하면 클러스터 외부에 설치해서 사용 할 수 있다는 아이디어를 얻을 수 있는 계기가 되었다. ### Istio 란 무엇이고, 무엇을 할 수 있을까? URL: https://www.kimsehwan96.com/what-is-istio-and-service-mesh/ Last updated: 2026-07-20T12:37:00.000Z > 코드 예제는 [https://github.com/kimsehwan96/istio-example](https://github.com/kimsehwan96/istio-example?ref=kimsehwan96.com) 에서 볼 수 있습니다. > 이글에서는 엠비언트 메시에 대해서는 다루고 있지 않습니다. > [https://lnkd.in/g8VTRrY6 ](https://lnkd.in/g8VTRrY6?ref=kimsehwan96.com)글을 많이 참고하였습니다. ### TL;DR 애플리케이션 코드의 변경 없이 마이크로 서비스의 가시성 확보, 트래픽 매니징(L7 라우팅), 보안등의 기능을 추가, 확장 할 수 있는 솔루션입니다. 이것은 Sidecar 패턴에 의해 구현되고 최근에는 이것으로 인한 overhead 를 줄이기위한 istio ambient mesh 등이 소개되고있지만, 여기서는 Envoy Proxy를 사이드카형태로 파드에 배치하여 사용하는 방식에 대해 설명합니다. 즉, 애플리케이션 레이어 에서 구현되었던 기능들을 인프라 레이어 로 디커플링하는 방법이라고 생각하시면 편합니다. ### Service Mesh 란? 서비스메시는 애플리케이션 코드의 변경 없이 observability(가시성 → log / metrics / trace)와 traffic management(트래픽 관리) 그리고 보안을 담당할 인프라 계층으로 정의됩니다. 그렇다면 MSA 구조에서는 DNS(+ 서비스 디스커버리)와 로드밸런서로 관리가 충분 할탠데 왜 서비스메시를 워크로드에 적용하고 있을까요? 여기에 대한 답은 가시성, 트래픽관리, 보안 그리고 계층이라는 4가지 키워드에 담겨있습니다. ### 가시성(Observability) 가시성을 구성하는 3대 요소에는 로그, 메트릭, 트레이스가 있습니다. 이 3가지는 각각 다른 방법으로 수집되고 다른 용도로 사용되지만, 각각 또는 함께 보았을 때 인프라 및 애플리케이션에 대한 중요한 인사이트를 제공하고 디버깅 포인트를 제공해줍니다. ![관측 가능성의 세 요소인 로그·메트릭·트레이스를 나타낸 그림](https://cdn-images-1.medium.com/max/800/0*Ofo8ozoqcaeIGUbd.png) 매우 중요한 기능이지만 기존에는 직접 로깅, 메트릭수집 그리고 트레이스 관련 코드를 개발자가 코드로 작성해야 사용 가능한 기능이였습니다. 애플리케이션 로그의 경우는 Elasticsearch 를 사용하는 ELK 스택이라든지, AWS Cloudwatch , 데이터독 과 같은 솔루션을 도입해서 활용하면 되지만 마이크로서비스 환경에서 마이크로서비스들끼리 어떻게 트래픽을 주고받는지에 대한 로그는 따로 확보하기가 쉽지 않았습니다. 메트릭과 트레이싱도 마찬가지구요. 서비스메시는 애플리케이션 “바깥” 에서, 즉 애플리케이션의 코드를 수정하지 않고도 “인프라”레이어에서 가시성을 확보 할 수 있습니다. Istio는 내부적으로 Envoy의 강력한 Observability 기능 덕분에 매우 쉽게 로그, 메트릭, 트레이싱을 구현 할 수 있었고 이와 같은 이유로 AWS App Mesh, Hashicorp Consul 과 같은 다른 서비스메시 솔루션들 또한 내부적으로 Enovy를 사용하고 있습니다. ### 트래픽 관리 서비스메시는 서비스간의 통신을 컨트롤 합니다. 여러팀에서 독립적으로 마이크로서비스를 개발, 배포하는 동적인 환경에서 트래픽을 어떤 서비스로 보내야 하는지를 애플리케이션 내부에서 관리한다면 경로가 바뀔 때 마다 애플리케이션을 매번 새로 배포해줘야 할 수도 있습니다. Istio는 Sidecar Pattern 을 통해서 애플리케이션 코드의 변경 없이 작업자가 VirtualService 와 DestionationRule 이라는 Istio 의 Custom Resource를 통해서 원하는 서비스로 트래픽을 보낼 수 있게 만들어줍니다. 또한 VirtualSerice 의 weight, subset 항목을 통해 Canary 배포등의 작업을 수행할 수 있습니다. (유의 : 여기서의 카나리배포는, 카나리배포를 적용하고자하는 기존 파드, 신규파드등이 이미 존재하고 있을때 트래픽 레벨에서 유입량을 조절해주는 것이므로, 파드 자체를 카나리배포하는것은 작업자, 혹은 ArgoCD 같은 별도 Operator 등을 사용해야 할 필요가 있습니다.) 물론 순수 K8s 만을 이용해 Canary 구현은 replica 수를 조절하면서 가능하겠지만, 이 과정이 매우 간단해지는것이고 Envoy의 기능을 활용해서 Header / Path 기반의 L7 라우팅 또한 매우 쉽게 설정 할 수 있습니다. ### 보안 서비스메시라는 개념은 마이크로서비스로의 전환이라는 업계 트렌드 속에서 등장했습니다. 하나의 모놀로식 구조로 구현되고, 내부 컴포넌트간의 Function Call 이였던 것들이 독립적인 마이크로서비스 컴포넌트로 분리되고, 각 컴포넌트(서비스)간 통신으로 동작하게 되자 Man in the middle 등의 공격의 가능성에 대해서도 대비할 필요가 생겼습니다. 따라서 많은 서비스 메시들은 서비스를 인증(authn), 인가(authz)하는 기능들을 제공하고있습니다. 대표적인 기능이 istio 에서 매우 쉽게 설정 할 수 있는 mTLS(Mutual TLS) 설정입니다. 이를 통해 Isitio Control Plane이 알지 못하는 서비스와는 아예 통신을 할 수 없게 막을 수 있으며, Policy를 통해 어떤 서비스가 어떤 서비스와 통신 할수 있는지 없는지 여부 또한 코드화 해서 관리 할 수 있습니다. 이 모든것이 “애플리케이션 코드의 변경 없이” 클러스터 보안을 강화 할 수 있습니다. ### 계층(layer) “애플리케이션 코드의 변경 없이”가 서비스메시가 주는 특별한 가치라고 볼 수 있습니다. 서비스 메시 없이도 트래픽관리, 보안, 가시성 모두 애플리케이션 코드, 혹은 쿠버네티스 설정으로 구현 해 낼 수 있습니다. 하지만 애플리케이션 레이어와 인프라(istio)레이어를 분리하는 “디커플링” 과정을 통해 관심사의 분리를 정확하게 해낼 수 있습니다. 이에 따라 각 개발자가 집중해야 할 대상이 명확해지고, 생산성을 높일 수 있습니다. 또한 애플리케이션 코드와 서비스 메시 영역은 “애플리케이션 계층”, “인프라 계층” 으로 디커플링 되어있기 때문에 동일한 워크로드를 Istio 가 아닌 App Mesh, Consul, Linkerd 와 연동한다고 해도 애플리케이션 코드는 변경이 필요 없습니다. ### Istio 는 어떻게 동작하는가? 서비스 메시를 이야기 할 때에는 Envoy 이야기를 하지 않을 수 없습니다. Linkerd를 제외한 Istio, Consul, App Mesh, Kong Mesh 등 대부분의 서비스메시는 모두 Envoy를 기반으로 만들어졌습니다. Envoy는 대사, 또는 사절 즉 메시지를 전달해주는 사람이라는 영단에서 나왔는데, 대리인이라는 뜻의 Proxy와 의미상 유사해서 채택하였다고 합니다. 구글에서는 Istio 프로젝트를 준비중일 때 Envoy의 존재를 몰랐고, Nginx 를 sidecar proxy로 쓰는 방향으로 진행하다가 Envoy의 존재를 알게되고 메인테이너/개발자의 동의를 얻고 Istio 에 Envoy를 사용하기로 했습니다. Envoy의 가장 큰 특징은 가시성이 뛰어나고 성능이 우수하며, API를 통해 실시간 설정 업데이트가 가능하다는 점입니다. 이 점이 Nginx 와 가장 큰 차이점입니다. Nginx의 경우 설정이 변경되었을 때 conf 파일을 수정하고 reload를 수행하거나, 유료 서비스인 Nginx Instance Manager 를 사용해서 동일한 작업을 해줘야 하지만. Envoy는 API를 통해 동적으로 신규 설정을 적용 할 수 있습니다. Istio 의 경우 VirtualService , DestinationRule 과 같은 리소스가 생성되면 이를 Envoy 설정으로 변경해서 Envoy Proxy 에 전파해주는 방식으로 동작합니다. ![오토바이에 장착된 사이드카를 비유로 보여주는 사진](https://cdn-images-1.medium.com/max/800/0*uPdiN3WxmhqpVSC4.png) 오토바이 옆에 붙는 사이드카 우리는 앞서 Istio가 Sidecar Pattern 형태로 Istio(Enboy) Proxy를 사용해서 서비스메시를 구현했다고 했습니다. 파드는 쿠버네티스의 애플리케이션/워크로드의 기본 구성 요소입니다. 쿠버네티스는 컨테이너 대신 파드를 관리하고, 파드는 컨테이너를 캡슐화 합니다. 파드에는 하나 이상의 컨테이너, 스토리지, IP주소 및 컨테이너가 실행되는 방식을 제어하는 옵션이 포함 될 수 있습니다. 일반적으로 하나의 컨테이너를 포함하는 파드는 가장 일반적인 쿠버네티스 사용 사례이며, 여러 컨테이너를 포함하는 파드또한 사용됩니다. 다중 컨테이너 파드는 몇가지 패턴이 있는데 이 중 하나가 사이드카 컨테이너 패턴입니다. 사이드카 컨테이너는 파드의 기본 컨테이너와 함께 실행되는 컨테이너입니다. 사이드카 패턴은 현재 컨테이너의 기능을 변경하지 않고 확장합니다. 즉 애플리케이션의 코드 수정 없이 애플리케이션 컨테이너의 로깅을 붙이거나, 프록시를 붙이는 등 기능을 확장하기 위해서 사용됩니다. ![OpenLens 에서 istio 사이드카가 주입된 애플리케이션 파드를 확인하는 화면](https://cdn-images-1.medium.com/max/800/0*41ZdSAefWjFc4btQ.png) OpenLens 를 통해 istio sidecar 가 붙은 애플리케이션 파드를 확인하는 모습 위는 실제 애플리케이션 파드에 애플리케이션 컨테이너와, isito-proxy 컨테이너가 사이드카 패턴으로 동시에 떠있는 모습입니다. 그럼 여기서 파드에 어떻게 여러 컨테이너가 붙고, 같이 상호작용이 가능한지 궁금 할 수 있습니다. 무언가가 파드라는 추상화된 그룹으로 컨테이너들을 묶어주고있다고 추측이 가실겁니다. ![파드 안의 pause 컨테이너를 확인하는 화면](https://cdn-images-1.medium.com/max/800/0*6xY_WFjYA_PwqQH5.png) pause 컨테이너 쿠버네티스에서 파드를 생성하게되면, 우리가 생성하라고 이야기 하지도 않았지만 pause 컨테이너라는 것도 같이 생성됩니다. 이건 여러개의 컨테이너에 대해서 동일한 자원 환경, 다시말하면 동일한 network namespace, volume mount 등을 공유해서 사용하기 위해서 필요합니다. pause 컨테이너는 Pod 내부의 컨테이너들을 위한 일종의 부모 컨테이너 역할을 수행합니다. 이 pause 컨테이너는 크게 두가지 역할을 하는데. 첫 번째, pod의 컨테이너들이 리눅스 namespace를 공유할 수 있도록 합니다. (리눅스 네임스페이스에는 PID, 네트워크, UTS, Mount, IPC, cgroup 등이 있습니다.). 두 번째로는 PID 1 (init process)로서의 역할을 하고, 좀비 프로세스를 거둬들이기도 합니다. 그냥 이 Pod라는 그룹의 부모 프로세스/컨테이너 역할을 하는거라고 간단하게 생각하면 되겠습니다. ![pause 컨테이너가 파드에서 PID 1 부모 프로세스 역할을 하는 개념 그림](https://cdn-images-1.medium.com/max/800/0*e5NTTetPNsHJY0Il.png) 이렇게 pause 컨테이너가 파드 내의 여러 컨테이너들의 network, PID, IPC, UTS 등 네임스페이스를 공유해서 사용 할 수 있게 해주기 때문에, 해당 파드 내의 컨테이너는 해당 파드 안에서 동일한 네트워크 설정을 공유하게되고, localhost 로 컨테이너간 통신도 가능하게 됩니다. 우리가 특정 애플리케이션 파드에 sidecar 형태로 istio-proxy를 붙이도록 설정하면 istio의 webhook을 통해 istiod 의 API를 호출하게 되고, 우리 애플리케이션 파드에 init container 와 istio-proxy 컨테이너를 주입하게 됩니다. init container 는 다른 컨테이너들이 생기기 전에 먼저 수행되며, 트래픽을 제어하기 위한 iptable 작업을 수행합니다. ![Istio 가 파드에 init 컨테이너와 istio-proxy 사이드카를 주입하는 과정 그림](https://cdn-images-1.medium.com/max/800/0*LHkgvwdQGa3yJQXu.png) > [https://github.com/istio/cni/blob/master/tools/packaging/common/istio-iptables.sh](https://github.com/istio/cni/blob/master/tools/packaging/common/istio-iptables.sh?ref=kimsehwan96.com) 여기서 실제 istio-iptables 명령어를 확인 할 수 있다. 여기서 istio-iptables 명령어를 통해 해당 파드로 들어는 트래픽을 모두 istio-proxy 로 프록싱하도록 설정합니다. 이렇게 istio-proxy 가 설정되어있는 파드로 들어오는 트래픽은 애플리케이션 컨테이너로 인입되기 전 무조건 istio-proxy(envoy)를 거치게 되고, envoy proxy 의 설정에 따라서 애플리케이션의 특정 경로로 라우팅 한다든지, 인증처리를 한다든지, mTLS 검증을 한다든지 등의 기능 확장을 애플리케이션 코드 없이 할 수 있게 됩니다. ### Istio 의 구성요소 ![Istio 구성요소를 나타낸 공식 문서 다이어그램](https://cdn-images-1.medium.com/max/800/0*T7vPIqq8E9PfvwG1.png) Istio 공식 문서 발췌 Istio 서비스 메시는 논리적으로 데이터 플레인과 컨트롤 플레인으로 나뉩니다. 데이터 플레인은 사이드카로 배포된 프록시(Envoy)집합으로 구성됩니다. 이 프록시들은 마이크로 서비스간의 모든 네트워크 통신을 중개하고 제어하며, 트래픽에 대한 telemetry를 수집합니다. (가시성) 컨트롤 플레인은 트래픽을 라우팅하도록 프록시를 구성하고 관리합니다. 실제 트래픽을 처리하는 부분이 아닌 프록시들을 관리하기위한 것 이라고 보면 됩니다. ### Envoy Istio는 확장된 버전의 Envoy 프록시를 사용하며, Envoy는 서비스메시의 모든 서비스에 대한 인바운드, 아웃바운드 트래픽을 중개 합니다. Envoy 프록시는 데이터 플레인 트래픽과 상호작용하는 유일한 Istio 의 컴포넌트입니다. Sidecar 형태로 배포되고 서비스 디스커버리, 로드밸런싱, TLS Termination, 서킷브레이커, Health Check, 가시성 확보 등 다양한 기능들을 애플리케이션 코드 변경 없이 확장 할 수 있습니다. ### Istiod Istiod는 서비스 디스커버리, 프록시 구성 및 관리, 인증서 관리등을 수행합니다. Istiod는 트래픽을 제어하는 라우팅 규칙을 Envoy 전용 설정으로 변환하고, 런타임 사이드카(Envoy들) 에게 전파합니다. 또한 CA 역할을 해서 데이터플레인에서 mTLS 통신을 하기 위한 인증서를 생성합니다. Istio 에서 mTLS 로 마이크로서비스간 서버/클라이언트 상호 TLS 인증을 할 때 모두 Istiod CA 가 서명한 인증서를 갖고 인증합니다. ### Istio 가 할 수 있는 것 ### Traffic Management Blue-Green 배포, 카나리 배포, A/B 테스트, 서킷 브레이커, Fault injection 등 다양한 제어 기능을 사용 할 수 있습니다. 또한 보통 API Gateway 라고 부르는, 혹은 쿠버네티스 Ingress 와 비슷한 기능또한 제공 할 수 있습니다. ![Istio 트래픽 관리 구조를 나타낸 다이어그램 (jimmysong.io 발췌)](https://cdn-images-1.medium.com/max/800/0*9uK-hRiw7qj-Nhg8.png) [https://jimmysong.io/en/blog/istio-servicemesh-api-gateway/](https://jimmysong.io/en/blog/istio-servicemesh-api-gateway/?ref=kimsehwan96.com) 글에서 발췌 기존 쿠버네티스 클러스터에서 서비스를 노출하기위해서는 NodePort, LoadBalancer 와 같은 쿠버네티스 서비스 오브젝트를 생성하여 노출하거나, Kubernetes Ingress 를 사용하여 노출하였습니다. 우선 쿠버네티스의 서비스 오브젝트로 생성하는 NodePort 의 경우는 워커노드의 머신의 특정 포트를 노출하여서 외부에서 서비스 접근을 할 수 있게 해주지만, 서비스 오브젝트 뒷단의 파드가 어떻게 있건 단순한 로드밸런싱 정도밖에 해줄 수 없습니다. LoadBalancer 오브젝트의 경우 생성 할 때 마다(클라우드 기준) 클라우드의 로드밸런서 리소스와 1:1 매칭이 되는데, 만약 노출해야하는 서비스가 100개라면 어떻게 해야 할까요? 클라우드에 존재하는 로드밸런서를 100개를 만드는것은 비용면에서도, 관리 측면에서도 맞지 않아 보입니다. 위와 같이 로드밸런서 를 여러개 만들지 않고서 단일 로드밸런서 를 통해 트래픽을 라우팅하고자 도입된 개념이 Kubernetes Ingress 입니다. Ingress Controller 에는 대표적으로 Nginx 가 있으며, Istio 도 이렇게 사용이 가능합니다. ![Kubernetes Ingress 와 Istio 게이트웨이를 비교한 다이어그램 (jimmysong.io 발췌)](https://cdn-images-1.medium.com/max/800/0*ia_wW7mgij68HYF9.png) [https://jimmysong.io/en/blog/istio-servicemesh-api-gateway/](https://jimmysong.io/en/blog/istio-servicemesh-api-gateway/?ref=kimsehwan96.com) 글에서 발췌 위와 같이 ingress controller 를 통해 생성된 단일 로드밸런서를 통해 서비스를 노출 할 수 있습니다. ```yaml apiVersion: networking.k8s.io/v1beta1 kind: Ingress metadata: annotations: kubernetes.io/ingress.class: nginx name: ingress spec: rules: - host: httpbin.example.com http: paths: - path: /status/* backend: serviceName: httpbin servicePort: 8000 - host: app.example.com http: paths: - path: / backend: serviceName: app servicePort: 8080 - host: grafana.example.com http: paths: - path: / backend: serviceName: grafana servicePort: 20001 ... ... ``` 위와 같은 ingress 를 통해 단일 로드밸런서에서 host 및 path 기반의 트래픽 라우팅(L7)이 가능합니다. istio 또한 위와 같은 방식으로 사용 가능합니다. Istio 는 위와 같은 방식, 즉 Ingress Controller 로 사용 가능하지만 더 나아가 Istio Gateway 라는 개념으로 확장시켰습니다. Istio Gateway 리소스는 클러스터와의 North-South 트래픽을 담당한다는 점에서 Kubernbetes Ingress 와 비슷하게 동작하지만, 기타 Istio 에서 지원하는 더 다양한 기능 (Canary / Blue-Green 배포등을 지원하기 위한 subset, weight 개념등)을 지원 할 수 있게 됩니다. Istio Gateway 리소스는 L4 \~ L6 에서 동작하며, TLS Termination, 포트 노출등의 작업을 하고 VirtualSerivce 라는 리소스를 통해 L7 에서 구성 할 수 있는 버전 기반의 트래픽 라우팅, 오류 주입, 리다이렉션, rewrite 기타 다양한 라우팅 규칙을 사용 할 수 있습니다. ```yaml apiVersion: v1 kind: Namespace metadata: name: httpbin --- apiVersion: v1 kind: Pod metadata: namespace: httpbin name: httpbin labels: app: httpbin sidecar.istio.io/inject: 'true' spec: terminationGracePeriodSeconds: 3 containers: - name: httpbin image: kennethreitz/httpbin --- apiVersion: v1 kind: Service metadata: namespace: httpbin name: httpbin-service spec: type: ClusterIP selector: app: httpbin ports: - name: http protocol: TCP port: 80 targetPort: 80 --- apiVersion: networking.istio.io/v1alpha3 kind: Gateway metadata: name: httpbin-gateway spec: selector: istio: ingressgateway servers: - hosts: - 'app.hayden.com' port: name: http number: 80 protocol: HTTP tls: httpsRedirect: false - hosts: - httpbin.local.dev port: name: https number: 443 protocol: HTTPS tls: mode: SIMPLE credentialName: httpbin-cert --- apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: httpbin-virtualservice spec: gateways: - httpbin-gateway hosts: - 'app.hayden.com' http: - match: - uri: prefix: / route: - destination: host: httpbin-service.httpbin.svc.cluster.local port: number: 80 ``` 위와 같이 gateway 리소스는 istio : ingressgateway 를 select하게 되는데, 해당 레이블이 있는 파드가 ingress controller 역할을 하게 됩니다. ![istio-ingressgateway 파드를 확인하는 화면](https://cdn-images-1.medium.com/max/800/0*N5efvvIIVo-2JoHY.png) istio-ingressgateway 파드 ### mTLS mTLS 는 기본적으로 활성화 되어있습니다. 따라서 istio-proxy 가 사이드카로 배포된 마이크로서비스 끼리는 추가적인 인증/인가 정책을 붙이지 않는 한 보호된 상태로 서비스간 트래픽을 주고 받게 됩니다. ![Kiali 대시보드에서 서비스 간 mTLS 트래픽을 보여주는 화면](https://cdn-images-1.medium.com/max/1200/0*MAdOFkeMdswTMxoG.png) Kaili 대시보드 위는 제가 테스트중인 환경에서, kiali 라는 istio용 대시보드를 통해 서비스간 연결을 보는 부분입니다. 각 마이크로 서비스간 통신에 자물쇠가 채워져 있는 것이 보이는데, 디폴트로 mTLS 가 enabled 된 상태이기 때문입니다. 다만, mTLS 가 enabled 되어있지만 기본옵션으로 mTLS 트래픽 및 plain text 트래픽 모두 받도록 설정이 되어있기 때문에, 이 상태에서 제가 istio-proxy 사이드카가 배포되지 않은 서비스에서 node-test-server 서비스를 한번 호출해보겠습니다. ```yaml apiVersion: v1 kind: Namespace metadata: name: test --- apiVersion: v1 kind: Pod metadata: namespace: test name: httpbin-test labels: app: httpbin-test sidecar.istio.io/inject: 'false' spec: terminationGracePeriodSeconds: 3 containers: - name: httpbin image: kennethreitz/httpbin ``` 위는 트래픽을 보내볼 테스트용 파드(httpbin-test)를 서술하였습니다. 사이드카 인젝션을 명시적으로 거부하도록 하고 위 파드를 배포해보겠습니다. ![사이드카 주입을 거부한 httpbin-test 테스트 파드 매니페스트](https://cdn-images-1.medium.com/max/1200/0*477_qUAj0-AKEv_7.png) 위에서 보는 것 처럼 이 파드에는 istio-proxy 사이드카 컨테이너가 붙어있지 않습니다. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: node-test-server-v1 spec: replicas: 1 selector: matchLabels: app: node-test-server template: metadata: labels: app: node-test-server version: v1 sidecar.istio.io/inject: 'true' spec: containers: - name: node-test-server image: kimsehwan96/node-server imagePullPolicy: Always ports: - containerPort: 3000 name: http ``` 위는 트래픽의 대상이 될 node-test-server 라는 이름의 Deployment 오브젝트를 구성한 내용입니다. `sidecar.istio.io/inject: 'true'` 을 통해 istio-proxy 를 사이드카로 주입합니다. 그러면 이 애플리케이션 파드는 서비스메시의 데이터플레인에 포함되게 됩니다. 위 상태에서 별다른 설정 없이 한번 httpbin-test 파드에서 위 애플리케이션을 호출해보겠습니다. ![사이드카가 주입된 애플리케이션을 httpbin-test 파드에서 호출하는 터미널 출력](https://cdn-images-1.medium.com/max/1200/0*TSFpVpEzRckSEFIi.png) 위와 같이 내부 서비스 오브젝트의 도메인으로 호출 하였을 때 mTLS 가 활성화 되어있음에도 불구하고 plain text 형태로 들어올 이 요청에도 응답을 처리하게 됩니다. ```yaml apiVersion: security.istio.io/v1beta1 kind: PeerAuthentication metadata: name: node-test-server-mtls-strict spec: mtls: mode: STRICT ``` 위와같이 `PeerAuthentication` 정책을 `STRICT` 로 하여 node-server-test 네임스페이스에 반영해보면 ```plaintext # curl -XGET http://node-test-server-service.node-test-server.svc.cluster.local curl: (56) Recv failure: Connection reset by peer ``` 위와 같이 요청이 거부됩니다. ![mTLS 로 인해 curl 요청이 거부되어 Connection reset 이 발생한 터미널 출력](https://cdn-images-1.medium.com/max/800/0*5nQ52d0vKFy-psXa.png) 위는 istio-proxy 가 없는 파드에서 요청한 것이고, 아래는 istio-proxy 가 있는 파드에서 요청한 것입니다. 이렇게 mTLS 를 강제하여서 서비스를 보호 할 수 있습니다. [https://istio.io/latest/docs/reference/config/security/peer\_authentication/#PeerAuthentication-MutualTLS-Mode](https://istio.io/latest/docs/reference/config/security/peer%5Fauthentication/?ref=kimsehwan96.com#PeerAuthentication-MutualTLS-Mode) 이 문서에서 각각 어떤 옵션이 있는지 확인 할 수 있는데 `PeerAuthentication.MutualTLS.Mode` 의 값이 `PREMISSIVE` 면 plain text 및 mTLS 터널 트래픽을 모두 허용하는데, 디폴트 옵션이 이것입니다. 이 옵션은 서비스메시 전체 / 네임스페이스 별 분리 적용이 가능하기 때문에 필요에 의해 적절히 설정하면 되겠습니다. ### Istio 예제/예시 ### 설치 방법 설치 방법에는 크게 `istioctl` 을 통해 설치하는 방법과 `helm` 을 이용하는 방법이 있습니다. > Operator 를 이용해 설치하는 방법도 존재하지만, 공식적으로 권장하지 않는 방식입니다. [https://istio.io/latest/docs/setup/install/operator/](https://istio.io/latest/docs/setup/install/operator/?ref=kimsehwan96.com) istioctl을 통해 설치하기 위해서는 먼저 로컬 머신에 istioctl을 설치합니다. ```bash $ curl -L https://istio.io/downloadIstio | sh - ``` 이후 `$ istioctl install --set profile=demo -y` 와 같은 명령어로 istio 를 현재 k8s context 클러스터에 생성하게 됩니다. 다만 위 방법은 gitops 에 맞지 않는 방식이기 때문에 아래와 같은 방법으로 하는것이 더 나을것으로 판단됩니다. istioctl manifest 라는 커맨드를 통해 istioctl 및 profile에 맞는 k8s manifests 를 생성 할 수 있습니다. (참고 : [https://istio.io/latest/docs/reference/commands/istioctl/#istioctl-manifest](https://istio.io/latest/docs/reference/commands/istioctl/?ref=kimsehwan96.com#istioctl-manifest)) 두번째로는 helm 을 이용해서 설치하는 방법입니다. 이건 profile 과 다르게 우리가 설치해야하는 요소를 직접 하나하나 설치해야하는 단점이 있지만, 조금 더 관리가 편하고 manifests 코드 자체를 git 에 올리지 않아도 되는 장점(?)이 있습니다. istioctl 을 통해 생성하는 manifests 파일은 크기가 어마어마합니다. 다만, profile 은 프로파일에 아래와 같이 설치되는 컴포넌트들이 지정되는데, helm 으로 할 경우는 각 요소를 직접 설치해줘야하는 단점은 있습니다만. 어려운건 아닙니다. ![Istio 설치 프로파일에 지정된 컴포넌트 목록 화면](https://cdn-images-1.medium.com/max/800/0*BDTjkgWqyZeAarPY.png) helm 으로 istiod, ingress-gateway 를 설치한 방법은 [https://github.com/kimsehwan96/istio-example/tree/master/istiod](https://github.com/kimsehwan96/istio-example/tree/master/istiod?ref=kimsehwan96.com) [https://github.com/kimsehwan96/istio-example/tree/master/istio-ingress-gateway](https://github.com/kimsehwan96/istio-example/tree/master/istio-ingress-gateway?ref=kimsehwan96.com) 위에서 코드로 확인 가능합니다. ### Ingress Gateway Ingress Gateway 는 기존 K8s Ingress 와 유사하게, 단일 로드밸런서의 엔드포인트를 통해 클러스터 외부의 요청을 받아서, 내부 서비스들로 로드밸런싱을 해주는 역할을 하는 게이트웨이라고 볼 수 있습니다. 이 오브젝트를 생성하면, 클라우드환경의 경우 클라우드 공급자의 로드밸런서를 통해서, On-premise 의 경우 케이스에 따라 다르겠지만, Metallb 등을 사용해서 IP Address Pool 안에서의 IP 등으로 type: LoadBalancer 인 서비스 오브젝트가 생성되고, External IP 와 매핑됩니다. 예를들어, AWS 기준으로 이렇게 생성하였을 때 Application Load Balancer 가 생성되고, 그것에 해당하는 도메인 `foo.aws-alb.com` 이 생성되었다고 해봅시다. 이 경우에 Route53 에서 `*.hayden.com` 이라는 도메인에 대한 A 레코드(with alias) `foo.aws-alb.com` 으로 지정하면, 우리는 `*.hayden.com` 으로 들어오는 트래픽을 모두 `Istio Ingress Gateway`로 전달 해줄 수 있습니다. 이후 Gateway 오브젝트 및 VirtualService 오브젝트를 사용하여 호스트헤더, HTTP 헤더, 경로 기반의 라우팅을 할 수 있게 됩니다. 즉 North-South 트래픽을 컨트롤 하기위해 생성하는 Istio 의 오브젝트라고 생각하면 됩니다. 다만 순수한 K8s Ingress 오브젝트와는 다르기 때문에 Ingress 로 검색 할수는 없고, Ingress Gateway는 디플로이먼트(파드) 및 서비스(로드밸런서)로 구성되게 됩니다. ![Istio Ingress Gateway 가 디플로이먼트와 서비스로 구성됨을 보여주는 그림](https://cdn-images-1.medium.com/max/1200/0*H5Uhy4QD62WutP1u.png) 위에서 보는것과 같이 내부 서비스메시의 트래픽 시작점이 istio-ingressgateway 이고 (단, 외부에서 클러스터로 호출 한 경우에 한정) 이후에는 각 마이크로서비스간 사전 정의된 방식대로 트래픽이 흘러가게 됩니다. ![istio-ingressgateway 를 시작점으로 하는 서비스 메시 트래픽 흐름 다이어그램](https://cdn-images-1.medium.com/max/800/0*S_bzxkgFogcRbKac.png) 위는 다양한 방식의 K8s 에서의 North-South 트래픽을 처리하는 방법을 도식화 한 것입니다. 방금 소개한 방식은 Istio Gateway 에 해당합니다. 자세한 내용은 : [https://jimmysong.io/en/blog/istio-servicemesh-api-gateway/#using-istio-gateway-to-expose-services](https://jimmysong.io/en/blog/istio-servicemesh-api-gateway/?ref=kimsehwan96.com#using-istio-gateway-to-expose-services) 블로그에 잘 정리되어있습니다. ```yaml namespace: istio-system resources: - ./istio-ingress-namespace.yaml helmCharts: - name: gateway includeCRDs: true repo: https://istio-release.storage.googleapis.com/charts releaseName: istio-ingressgateway version: 1.19.0 namespace: istio-system ``` 위는 istio ingress gateway 를 설치하는 helm 예시입니다. 위를 통해 생성하면 deployment + pod , service 오브젝트가 생기고, 해당 서비스 오브젝트는 LoadBalancer 타입이므로 External IPs 와 매핑되게 됩니다. 저는 로컬에서 테스트하고 있어 localhost 로 연결되었습니다. ![k9s 에서 istio-ingressgateway 서비스를 확인하는 화면](https://cdn-images-1.medium.com/max/1200/0*VdNP3iz2fiSg87ZD.png) k9s 로 istio-ingressgateway 서비스 확인하는 모습 ![Istio Gateway 오브젝트와 VirtualService 오브젝트를 확인하는 화면](https://cdn-images-1.medium.com/max/800/0*JIYiTEKCqAsSQRjN.png) Gateway 오브젝트와 VirtaulService 오브젝트 위는 실제 `ingress` 로 부터 들어온 트래픽을 실제 서비스로 라우팅 해주기위한 `Gateway` , `VirtualService` 오브젝트입니다. `Gateway` 오브젝트에서 어떤 `ingress gateway` 에서 들어온 트래픽인지 지정하고, 어떤 호스트로 들어온것을 처리할것인지 명시합니다. 이후에 `VirtualService` 에서 서비스별로 만들어놓은 게이트웨이를 지정하여서 트래픽을 받아 처리하게 됩니다. ### Virtual Service + Gateway Istio 에서 Gateway (커스텀 리소스)는 들어오거나 나가는 HTTP/TCP 연결을 수신하는 서비스메시의 가장자리에서 동작하는 로드밸런서를 의미합니다. 여기서는 노출 되어야하는 포트, 사용할 프로토콜, 로드밸런서에 대한 SNI 구성, TLS Termination 등을 설정합니다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: Gateway metadata: name: my-gateway namespace: some-config-namespace spec: selector: app: my-gateway-controller servers: - port: number: 80 name: http protocol: HTTP hosts: - uk.bookinfo.com - eu.bookinfo.com tls: httpsRedirect: true # sends 301 redirect for http requests - port: number: 443 name: https-443 protocol: HTTPS hosts: - uk.bookinfo.com - eu.bookinfo.com tls: mode: SIMPLE # enables HTTPS on this port serverCertificate: /etc/certs/servercert.pem privateKey: /etc/certs/privatekey.pem - port: number: 9443 name: https-9443 protocol: HTTPS hosts: - "bookinfo-namespace/*.bookinfo.com" tls: mode: SIMPLE # enables HTTPS on this port credentialName: bookinfo-secret # fetches certs from Kubernetes secret - port: number: 9080 name: http-wildcard protocol: HTTP hosts: - "*" - port: number: 2379 # to expose internal service via external port 2379 name: mongo protocol: MONGO hosts: - "*" ``` 위는 L4-L6 속성을 작성하고있습니다. 이후에 이 Gateway 를 VirtualService 에 바인딩하여서 게이트웨이로 들어온 트래픽을 제어 할 수 있습니다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: bookinfo-rule namespace: bookinfo-namespace spec: hosts: - reviews.prod.svc.cluster.local - uk.bookinfo.com - eu.bookinfo.com gateways: - some-config-namespace/my-gateway - mesh # applies to all the sidecars in the mesh http: - match: - headers: cookie: exact: "user=dev-123" route: - destination: port: number: 7777 host: reviews.qa.svc.cluster.local - match: - uri: prefix: /reviews/ route: - destination: port: number: 9080 # can be omitted if it's the only port for reviews host: reviews.prod.svc.cluster.local weight: 80 - destination: host: reviews.qa.svc.cluster.local weight: 20 ``` 위와 같이 VirtualService 는 게이트웨이와 바인딩하여서, 해당 게이트웨이로 도착하는 트래픽에 대한 라우팅을 적용하게 됩니다. 여기에서는 호스트 헤더, HTTP 헤더 기반의 라우팅, 경로 기반 라우팅, rewrite, 가중치 기반 트래픽 전달 등을 반영 할 수 있습니다. ### mTLS mTLS 는 mutual TLS 의 약자입니다. 기존의 TLS 핸드쉐이크의 경우 서버의 인증서만을 검증하였었는데, mTLS 의 경우 클라이언트의 인증서를 서버에서 검증하는 과정까지 추가된 것이라고 볼 수 있습니다. 별도로 반영하지 않아도 현재 Istio는 디폴트로 적용되어있고, 다만 기본 옵션이 Istio Proxy 에서 mTLS 로 보호된 터널과, plain text 로 들어오는 트래픽 둘다 허용하는 모드로 되어있기 때문에, 정말 검증되고 인증된, 즉 Service Mesh 내 트래픽만 받고 싶다면 mTLS 옵션을 STRICT 로 반영하면 됩니다. 기본은 Permissive mode 이기 때문입니다. ```yaml apiVersion: security.istio.io/v1beta1 kind: PeerAuthentication metadata: name: python-test-server-mtls-strict namespace: foo spec: mtls: mode: STRICT ``` 위와 같이 특정 네임스페이스에 대해서 mTLS 트래픽만 받겠다고 잠글 수 있고, 서비스메시 전체에 대해서도 잠글 수 있습니다. ![네임스페이스에 mTLS STRICT 모드를 적용하는 PeerAuthentication 설정](https://cdn-images-1.medium.com/max/1200/0*xNJY_hS6xXt5MK8M.png) Kiali 라는 Istio 용 대시보드를 사용해보면, 이렇게 서비스간 mTLS 가 적용되어있는 모습을 확인 할 수 있습니다. 실제로 서비스메시 외 서비스(즉 Istio Proxy 가 주입되지 않은 서비스)에서 호출하는 경우 요청은 실패합니다. ### Canary Istio 의 가중치 기반 라우팅 기능을 통해 특정 버전의 서비스에 대한 트래픽 비율을 조절 할 수 있습니다. `DestionationRule` 및 `VirtualService` 리소스를 통해 구현 가능합니다. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: node-test-server-v1 spec: selector: matchLabels: app: node-test-server version: v1 template: metadata: labels: app: node-test-server version: v1 sidecar.istio.io/inject: 'true' spec: containers: - name: node-test-server image: kimsehwan96/node-server imagePullPolicy: Always ports: - containerPort: 3000 name: http resources: requests: cpu: 100m limits: cpu: 100m ``` 위는 우리가 테스트로 사용할 서버를 version: v1 레이블을 붙인 배포입니다. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: node-test-server-v2 spec: selector: matchLabels: app: node-test-server version: v2 template: metadata: labels: app: node-test-server version: v2 sidecar.istio.io/inject: 'true' spec: containers: - name: node-test-server image: kimsehwan96/node-server imagePullPolicy: Always ports: - containerPort: 3000 name: http resources: requests: cpu: 100m limits: cpu: 100m ``` 위는 방금 봤던 구성과 동일하지만, 버전만 다른 구성입니다. ```yaml apiVersion: v1 kind: Service metadata: name: node-test-server-service labels: app: node-test-server spec: type: ClusterIP selector: app: node-test-server ports: - name: http protocol: TCP port: 80 targetPort: 3000 ``` 서비스 오브젝트 또한 생성합니다. selector 가 app: node-test-server 이므로 이 서비스 오브젝트를 통해서 마이크로서비스에 대한 트래픽이 직접 들어가면, 파드개수에 맞게 라운드로빈 형태로 로드밸런싱 되었을 것입니다. 우리는 VirtualService 및 DestionationRule 을통해 카나리 배포를 테스트해볼겁니다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: node-test-server-virtualservice-internal spec: hosts: - node-test-server-service.node-test-server.svc.cluster.local http: - route: - destination: host: node-test-server-service.node-test-server.svc.cluster.local port: number: 80 subset: v1 weight: 90 - destination: host: node-test-server-service.node-test-server.svc.cluster.local port: number: 80 subset: v2 weight: 10 ``` 위와 같이 VirtualService 를 지정하고, destination 은 아까 만든 서비스 오브젝트를 향하게 합니다. 이렇게하면 내부 서비스가 node-test-server-service.node-test-server.svc.cluster.local 를 호출하더라도 이 VirtualService 에 등록된 정보를 토대로 istio(envoy) proxy 가 트래픽을 한번 처리하게 됩니다. ```yaml apiVersion: networking.istio.io/v1alpha3 kind: DestinationRule metadata: name: node-test-server-destination-internal spec: host: node-test-server-service.node-test-server.svc.cluster.local subsets: - name: v1 labels: version: v1 - name: v2 labels: version: v2 ``` DestinationRule 을통해 VirtualService 에 지정한 subset과 서비스 오브젝트에 등록된 엔드포인트(파드)를 매칭 시킬 수 있습니다. 이 조건을 통해 실제 파드별 버전에 따라 트래픽을 라우팅하게 됩니다. 이렇게만 해도 충분히 트래픽의 90%는 버전1로, 10%는 버전2로 가는 설정이 되었지만 실제 환경과 유사하게, HPA(Horizontal Pod Auto scaling 을 활성화해서 트래픽이 많이 들어가는 버전의 파드에 대해 유연하게 확장하도록 설정도 해봅니다. ```bash $ kubectl autoscale deployments node-test-server-v2 -n node-test-server --cpu-percent=10 --min=1 --max=10 horizontalpodautoscaler.autoscaling/node-test-server-v2 autoscaled $ kubectl autoscale deployments node-test-server-v1 -n node-test-server --cpu-percent=10 --min=1 --max=10 horizontalpodautoscaler.autoscaling/node-test-server-v1 autoscaled ``` 이후 테스트용으로 트래픽을 흘려보고 Kiali 및 HPA를 지켜봅니다. ![카나리 배포 테스트를 위해 HPA 를 설정하는 터미널 출력](https://cdn-images-1.medium.com/max/1200/0*sYcuIsue6dWOolhv.png) 우리가 설정한대로 버전1에 90% 트래픽이, 버전2에 10% 트래픽이 흘러갑니다. ![카나리 배포로 버전1·버전2에 트래픽이 90:10 으로 나뉘고 HPA 가 적용되는 Kiali 화면](https://cdn-images-1.medium.com/max/800/0*zo1uK3TC_KeKrkQ3.png) 카나리 배포와 더불어 HPA 가 적용되는 모습 버전1 파드에 대해서 Replica 가 9개, 버전2 파드에 대해 Replica 가 2개가 생성되었습니다. 버전1 앱에 트래픽이 많이 흘러들어가고 있기 때문이라고 납득 할 수 있습니다. 실제 파드 자체를 Canary 배포, 예를들어 기존 버전 앱, 신규 버전 앱을 1:9 비율로 배포를 하고 조절한다, 이것 자체는 Deployment 로도 충분히 가능하고, ArgoCD를 통해서도 충분히 가능한 작업입니다. 하지만 파드 자체의 개수외에 트래픽 자체에 대한 컨트롤은 Istio 로 할 수 있습니다. 이것이 더 유연한 작업들을 할 수 있게 도와주고, 여기서는 모든 트래픽에 대해서 랜덤하게 1:9 비율로 틀어줬지만, 헤더기반의 라우팅 또한 적용 가능합니다. 즉 특정 유저나 내부 개발자들만 신규 버전 마이크로서비스로 트래픽이 흐르고, 그 외에는 과거 버전 마이크로서비스로 트래픽이 흐르게 하는등 조건에 맞게 유연하게 트래픽 관리가 가능합니다. ### Authz/Authn JWT 토큰에 대해서 검증하고, 검증된 트래픽에 대해서만 뒷단의 특정 경로, 특정 HTTP Method 만 허용하게 하는등 인증을 애플리케이션 레이어가 아닌 인프라 레이어에서 할 수 있도록 작업 할 수 있습니다. ```yaml apiVersion: security.istio.io/v1beta1 kind: RequestAuthentication metadata: name: kibana-request-authn spec: selector: matchLabels: app: kibana jwtRules: - issuer: 'https://keycloak.foo.bar/realms/mater' jwksUri: 'https://keycloak.foo.bar/realms/master/protocol/openid-connect/certs' forwardOriginalToken: true fromHeaders: - name: Authorization prefix: 'Bearer ' ``` 위와 같이 요청으로 들어온 트래픽에서 `Authorization: Bearer {JWT Token}` 부분을 떼어내고, 유효한 토큰인지 `ReqeustAuthentication` 리소스로 검증 하도록 할 수 있습니다. ```yaml apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: kibana-authz-policy-oauth2 spec: selector: matchLabels: app: kibana action: CUSTOM provider: name: oauth2-proxy rules: - to: - operation: paths: - '/*' --- apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: kibana-authz-policy-allow spec: selector: matchLabels: app: kibana action: ALLOW rules: - from: - source: requestPrincipals: ['https://keycloak.foo.bar/realms/k8s/*'] to: - operation: methods: ['*'] paths: ['/*'] when: - key: request.auth.claims[oauth2-proxy_roles] values: ['kibana'] ``` 또한 위와 같이 Authn (인증) 이후에 해당 JWT 토큰의 role / claim 등을 까보고, 그것을 기반으로 어떤 경로, 어떤 HTTP method 를 허용할지 지정해줄 수 있습니다. 이것을 통해 서비스간 JWT 토큰 인증을 애플리케이션이 아닌, Envoy Proxy 에서 처리하도록 할 수 있습니다. 예를들어 외부에서 요청하는 1개의 API 요청이, 내부적으로는 20개의 마이크로 서비스를 거친다고 가정할 때, 각 20개의 마이크로 서비스 애플리케이션 내부에서 JWT 토큰 검증 로직이 일일이 들어가있다면 추후에 기능을 변경/확장 할 때 20개의 애플리케이션에 대해서 작업해야하지만 Istio 레이어로 분리하면 애플리케이션 코드를 일일이 수정하지 않고 yaml 기반의 istio 설정을 조금만 변경해주면 구현이 가능하게 됩니다. ### 결론 작성하다보니 글이 길어졌는데, 이 글에서 소개하지 않은 Istio 의 기능은 더 많습니다. Fault Injection , Circuit Breaker , SideCar CRD 를 이용한 egress policy 제어등등, 정말 다양한 기능을 애플리케이션 코드의 수정 없이 인프라 레이어에서 추가할 수 있습니다. 반면, SideCar 기반의 서비스 메시 (Ambient Mesh 모드가 아닌)임으로 인해 발생하는 오버헤드, 단점, 장애포인트가 늘어나는 등의 단점도 있고, 또 Istio 자체에 대한 러닝커브도 단점으로 작용 할 수 있습니다. 여러분의 환경/상황에맞게 Istio를 도입하는것이 가장 중요하다고 생각합니다. 어떤 오픈소스, 솔루션이든 트레이드오프는 있기 마련입니다! ### eBPF 에 대해 알아보자 (1) URL: https://www.kimsehwan96.com/what-is-ebpf-1/ Last updated: 2026-07-20T12:37:00.000Z > 최근 Kubernetes / Cloud Native 와 관련해서 가장 핫(?)한 기술 중 하나는 아마도 eBPF 일 것이다. 특히 Kubernetes 의 CNI 중 하나인 cilium 은 기술의 근간이 eBPF 이다. 최근 cilium 을 도입하기 위해서 개인적으로 알아보고, 공부하는 차원에서 작성하는 글이며 틀린 점이나 피드백을 줄 것이 있다면 댓글 환영합니다. ## eBPF 란? eBPF 는 `extended Berkeley Packet Filter` 의 약자로, 확장된 버클리 패킷 필터라고 번역 할 수 있다. eBPF 는 개발자가 커널에 동적으로 자신이 개발한 코드를 로드하여 커널의 동작을 변경 할 수 있는 기술인데, 자신이 개발한 코드가 네트워크와 관련된것일수도, 가시성(Observability)를 위한 것일 수도 있습니다. eBPF 를 통해 고성능 네트워킹, 통합된 가시성, 그리고 보안 도구까지도 사용 가능합니다. eBPF는 Linux 커널에 통합되어있으므로 최신 Linux 배포판에서 동작합니다. 또한 eBPF는 안전과 효율성을 위해서 Linux 커널 내의 자체 가상머신에서 실행됩니다. BPF 자체는 버클리 패킷 필터의 약자로 1997년 커널 2.1.75 버전에서 Liunx에 처음 도입되었고, `tcpdump` 툴에서 패킷을 효율적으로 캡쳐하기 위해 도입되었습니다. 이후 커널 버전 3.18 부터 `eBPF` 라고 불리는 것으로 발전하였는데, `eBPF maps` 라는 것이 소개되었고, 이것은 `user space` 애플리케이션과 `BPF` 프로그램이 액세스 할 수 있는, 그리고 공유 할 수 있는 데이터 구조입니다. 또한 `user space` 애플리케이션과 상호작용 할 수 있도록 `bpf()` 시스템 콜이 추가되었고, `BPF Helper function` 들이 추가되었습니다. 여기서는 깊게 다루지 않고 eBPF 가 처음에는 `BPF`로 시작했고, 뭔가 좀 더 확장되어 갔구나 정도만 이해하면 될 것 같습니다. Liunx 는 기본적으로 `User Space` 와 `Kernel` 로 분리되어있고, 우리가 만든 Application 은 기본적으로 `User Space`에서 실행되며, `Kernel`과 상호작용 하기 위해서는 `system call` 을 사용해서 처리하게 됩니다. 이것을 단적으로 쉽게 보는 방법은 `strace` 명령어를 사용하면 알 수 있습니다. ![strace 로 특정 명령어가 사용하는 system call 을 확인하는 터미널 출력](https://velog.velcdn.com/images/kimsehwan96/post/b7929323-4ecc-42ec-91ea-2ba4b2ffcff0/image.png) strace 명령어로 특정 명령어를 사용 할 때 사용하는 system call 들을 확인 가능 `$ strace -c ls` 와 같이 `strace -c {command}` 형태로 명령을 수행하면, 해당 명령/애플리케이션이 호출하는 시스템 콜이 무엇인지 알 수 있습니다. 이렇게 시스템콜을 호출해서 애플리케이션을 구현하는 것이 아니라, 정말 커널 영역 내에서 뭔가 내가 만든 코드가 돌아가게 하고싶다면 아마 `커널 모듈`을 직접 만들어서 컴파일하고 ,빌드하고, 운영체제에 로드하면 되겠지만. 그게 단순하거나 쉬운 작업이 아니기도 하고, 커널 전체의 안정성을 떨어트릴 확률이 큽니다. **`eBPF`는 커널 모듈을 작성하고 빌드하고 로드하지 않고도, 동적으로, 그리고 안전하게 커널 영역에서의 작업을 할 수 있습니다.** `eBPF` 는 네트워킹 영역에서도 매우 큰 성능 향상을 이끌어 내는데, `eBPF` 프로그램을 통해 패킷을 필터링(`drop` , `redirect` , `pass`) 하고 그 패킷들이 커널의 네트워크 스택을 타지 않고 바로 다른 `NIC`에 넘겨주거나, `drop` 할 수 있어서 오버헤드가 줄고 성능향상에 도움이 된다고 합니다. 실제로 클라우드플레어나 Meta 의 경우 `ddos` 공격에 대한 방어책으로 `eBPF` 프로그램을 쓴다고 알려져있고, 초당 천만개 이상의 패킷을 단일 CPU가 `drop` 하는 수준의 성능이라고 합니다. 결론적으로 매우 쉽게 이야기하자면, `eBPF`는 **커널을 수정하거나, 커널 모듈을 만들어서 로드하거나 할 필요 없이 커널 영역에서 우리가 만든 프로그램을 동작**시켜서 네트워크 패킷을 조작하거나, 가시성을 얻거나, 보안을 강화 하는 등 다양한 작업을 할 수 있게 하는 프로그램, 기능이라고 보면 됩니다. ## Kubernetes 와는 무슨 관계가 있을까요 쿠버네티스에서 CNI(Container Network Interface) 는 [https://github.com/containernetworking/cni/blob/main/SPEC.md](https://github.com/containernetworking/cni/blob/main/SPEC.md?ref=kimsehwan96.com) 에 지정된 스펙에 따라서 구현하면 되는데, 기존 CNI, 그리고 `kube-proxy`는 `iptables` 기반으로 구현되어있는 경우가 대부분이였습니다. `iptables`는 정말 훌륭하게 각종 라우팅처리, 패킷 드롭, 패스 잘 해주고 있지만, `iptables` 에서 관리하는 테이블(규칙들)이 커지면 커질수록 새로운 규칙을 반영하는데도 오래걸리고, iptables 에서 들어온 패킷에 맞는 규칙을 찾는데 걸리는 시간이 늘어나서 전반적인 네트워크 성능이 저하되는 경우가 있다고 합니다. ![cilium 공식 문서 발췌: BPF 프로그램이 NIC 에도 로드되는 XDP 구조 그림](https://velog.velcdn.com/images/kimsehwan96/post/a329302a-c31e-4a27-a841-774481f4f6c5/image.png) cilum 공식문서 발췌. BPF 프로그램은 NIC 에도 로드된다. (xDP) `eBPF`는 근본적으로 `iptables`와 달리 커널의 네트워크 스택을 타지 않고 `drop` / `redirect` 등이 가능하기 때문에, 그리고 `iptables` 처럼 규칙을 찾는데 시간이 걸리는 것이 아닌 이미 로드된 `eBPF` 프로그램이 패킷을 처리하기때문에 `iptables`에 비해 성능이 비약적으로 향상된다고 알려져있습니다. 이와 관련해 자세한 내용은 다음 포스팅에서 작성해보도록 하겠습니다. ## 그래서 eBPF 코드 어떻게 짜는데? > 아래 예제는 macOS 에서는 별도의 작업을 해야 동작할 수 있습니다. Linux (가상)머신에서 수행하는게 좋습니다. 테스트 코드는 [https://github.com/lizrice/learning-ebpf](https://github.com/lizrice/learning-ebpf?ref=kimsehwan96.com) 레포지토리를 참고하였습니다. `eBPF` 애플리케이션을 작성하기 위한 여러 라이브러리와 프레임워크가 있지만 우선 `BCC python` 을 통해 간단히 테스트 해보도록 하겠습니다. 우선 여러분의 환경에 맞게 `bcc` 툴을 설치하면 되는데. [https://github.com/iovisor/bcc/blob/master/INSTALL.md](https://github.com/iovisor/bcc/blob/master/INSTALL.md?ref=kimsehwan96.com) 위 문서를 참조하고, `ubuntu` 기준으로는 `$ sudo apt-get install bpfcc-tools linux-headers-$(uname -r)` 로 설치 가능합니다. 그렇게 한 이후 `hello.py` 라는 이름의 파일을 아래와 같이 만들어 봅니다. ```python #!/usr/bin/python3 from bcc import BPF program = r""" int hello(void *ctx) { bpf_trace_printk("Hello World!"); return 0; } """ b = BPF(text=program) syscall = b.get_syscall_fnname("execve") b.attach_kprobe(event=syscall, fn_name="hello") b.trace_print() ``` 아직 각 부분이 무엇을 하는지 알지 못하더라도, 동작시켜보면서 하나하나 확인해봅시다. 위 코드를 실행시켜보면 (`$ python3 hello.py`) ![예제 BPF 프로그램(hello.py)을 실행한 터미널 출력](https://velog.velcdn.com/images/kimsehwan96/post/f4d1ff61-5d25-4d1b-805e-a1fb112bb9da/image.png) 위 BPF 프로그램을 실행한 모습 위와 같이 무언가 콘솔에 출력으로 찍히는것을 볼 수 있습니다. 우리는 `Hello World!` 를 출력해내는 우리의 첫번째 `eBPF` 프로그램을 작성하고 실행시켰습니다. 위 코드는 `eBPF` 프로그램과, `eBPF` 프로그램을 로드하고, 출력을 읽어오는 유저 스페이스의 코드 두 부분으로 구성되는데. `program = ...` 으로 시작하는 부분이 `eBPF` 프로그램이고 그 외 나머지가 유저스페이스의 우리의 애플리케이션입니다. ```c int hello(void *ctx) { bpf_trace_printk("Hello World!"); return 0; } ``` 위에 해당하는 부분은 실제 커널에서 실행될 `eBPF` 프로그램입니다. `eBPF` 프로그램은 Helper Function 들이 존재하고 `bpf_trace_printk()` 는 그 중 하나입니다. 이러한 `Helper Function` 들에 대해서는 나중 포스팅에서 다루도록 하겠습니다. 이 `eBPF` 프로그램은 실행하기전에 컴파일 해야 하지만 `BCC`가 이번 예제에서는 대신 처리하고 있습니다. `eBPF` 프로그램은 `event` 에 `attach` 되어야 하는데 이 예시에서는 프로그램을 실행할때 호출되는 시스템 콜인 `execve` 에 `attach` 되었습니다. 따라서 이 코드를 실행하는 머신에서 새로운 프로그램이 실행(시스템 콜 호출)될 때 마다 이 `eBPF` 프로그램이 호출되는 것 이라고 생각 할 수 있습니다. 더 자세한 `eBPF` 프로그램에 대한 내용은 나중 포스트에서 다루도록 하겠습니다. ## cilium 도 eBPF 프로그램이 있는건가요? `cilium` 을 Kubernetes 클러스터에 설치하게 되면 `cilium agent` 라는 데몬셋이 노드마다 설치되게 됩니다. 이 `cilium agent`가 `eBPF` 프로그램을 로드하고 `eBPF` 프로그램에 따라 네트워크 패킷이 처리되어서 `CNI` 규격에 맞는 동작을 하게 됩니다. 실제로 `cilium` 이 사용하는 `eBPF` 프로그램의 코드는 [https://github.com/cilium/cilium/tree/main/bpf](https://github.com/cilium/cilium/tree/main/bpf?ref=kimsehwan96.com) 위에서 볼 수 있습니다. ## cilium 을 제 로컬 k8s 클러스터에서 테스트 할 수 있을까요? > minikube 환경에서 cilium 을 설치하는 방법을 소개합니다. > minikube 버전 `v1.31.1` 을 기준으로 합니다. > macOS 의 경우 도커 버전 `4.25.2` 이하 버전에서만 cilium 이 정상 동작합니다. ([https://github.com/kubernetes/minikube/issues/17780](https://github.com/kubernetes/minikube/issues/17780?ref=kimsehwan96.com)) ```console $ minikube start \ --driver=docker \ --insecure-registry="quay.io" \ --insecure-registry="registry.k8s.io" \ --network=minikube \ --network-plugin=cni \ --cni=false ``` 위와 같이 `minikube` 를 실행하면 `cni` 를 설치하지 않은 형태의 쿠버네티스 클러스터를 생성 할 수 있습니다. 이후 `$ helm repo add cilium https://helm.cilium.io/ $ helm install cilium cilium/cilium --version 1.14.5 --namespace kube-system` 형태로 `cilium` 을 헬름을 이용해서 설치 하실 수 있고. [https://github.com/kimsehwan96/istio-example/tree/velog-example/cilium](https://github.com/kimsehwan96/istio-example/tree/velog-example/cilium?ref=kimsehwan96.com) 에서와 같은 구조로 `kustomization.yaml` 을 생성 후 `$ kubectl kustomize --enable-helm . | kubectl apply -f - --server-side --force-conflicts` 형태로 설치도 가능합니다. ![cilium 을 minikube 의 CNI 로 설치한 화면](https://velog.velcdn.com/images/kimsehwan96/post/7e23a789-0e0d-4a39-a865-c16fb4fadc58/image.png) cilium 을 minikube의 CNI로 설치한 모습 정상적으로 설치되었다면 위에 보는 것 처럼 `cilium` 데몬셋이 정상적으로 떠있어야 하고. 단일 워커노드 클러스터의 경우 `cilium operator` 하나가 `Pending` 상태로 떠있는것은 정상입니다. ([https://guide.ncloud-docs.com/docs/k8s-k8strouble](https://guide.ncloud-docs.com/docs/k8s-k8strouble?ref=kimsehwan96.com)) 같은 사례로 nks 이용시 단일 워커노드 사용중일 경우 `cilium operator` 가 `Pending` 으로 유지되는 현상이 있습니다. 참고 : - `Learning eBPF by Liz Rice (O’Reilly). Copyright 2023 Vertical Shift Ltd., 978-1-098-13512-6.` - [https://github.com/lizrice/learning-ebpf](https://github.com/lizrice/learning-ebpf?ref=kimsehwan96.com) ### 가상머신과 컨테이너의 비교 URL: https://www.kimsehwan96.com/virtual-machine-vs-container/ Last updated: 2026-07-20T12:37:00.000Z ## 가상머신 가상머신(Virtual Machine, VM)은 물리적 하드웨어 시스템에 구축되어 자체 CPU , 메모리, 네트워크 인터페이스 및 스토리지를 갖추고 가상 컴퓨터 시스템으로 작동하는 가상 환경입니다. 하이퍼바이저(혹은 가상머신 모니터, VMM)라 불리는 소프트웨어는 하드웨어세서 가상 머신의 리소스를 분리하고, 적절히 프로비저닝해서 가상머신에서 사용 할 수 있도록 합니다. KVM(커널기반 가상 머신), VirutalBox , VMWare 같은 하이퍼바이저가 탑재된 물리적 머신을 호스트 머신, 호스트 OS, 호스트 등으로 부릅니다. 리소스를 사용하는 여러 VM 을 게스트 머신, 게스트 OS, 게스트 등으로 부릅니다. ### 하이퍼바이저 유형 가상머신을 사용하기 위해서는 하이퍼바이저가 필요합니다. 하이퍼바이저는 호스트 머신의 리소스를 각 게스트 머신에 할당하고 분리하는 소프트웨어입니다. 하이퍼바이저에는 크게 2가지 유형이 있습니다. #### 유형1 (Type 1) 물리적 컴퓨터에 직접 실행되고, 물리적 컴퓨터의 모든 리소스에 액세스 할 수 있기 때문에 베어메탈 하이퍼바이저라고도 부릅니다. 시스템 하드웨어와 직접 상호작용합니다. 즉 베어메탈 하이퍼바이저는 운영체제를 통하지 않고 호스트 시스템의 물리적 하드웨어에 직접 설치됩니다. 베어메탈 하이퍼바이저는 매우 효율적이고, 서버 가상화, 데스크톱 가상화 및 앱 가상 환경 생성등에 자주 사용됩니다. KVM(커널 기반 하이퍼바이저)의 경우 자체 커널이 없는 유형2 하이퍼바이저와 다르게 자체 커널을 포함한다는 의미입니다. ![유형 1 베어메탈 하이퍼바이저의 구조를 나타낸 다이어그램](https://velog.velcdn.com/images/kimsehwan96/post/5b964a97-d158-44de-a1ad-87ea822bc31d/image.png) - Type 1 하이퍼바이저에는 - KVM (Kernal-Based-Virtual Machine) - Red Hat Enterprise Virtualization (RHEV) - Hyper-V (windows) - VMware vSphere 등이 있습니다. #### 유형2 (Type 2) 유형2 하이퍼바이저 또는 호스트형 하이퍼바이저는 이미 자체 운영체제(OS)가 실행 중인 호스트 머신에 설치됩니다. 이 기존 OS가 리소스 할당을 담당하며, 호스팅된 하이퍼바이저는 특정 기능이나 작업을 위해 생성된 환경에만 집중합니다. 호스팅 하이퍼바이저는 개발자가 애플리케이션을 빌드하고 테스트 할 수 있는 가상 환경을 만드는 데 자주 사용됩니다. ![유형 2 호스팅형 하이퍼바이저의 구조를 나타낸 다이어그램](https://velog.velcdn.com/images/kimsehwan96/post/6ff2a765-ea7c-4ef6-9548-4592bee1da9c/image.png) Type 2 하이퍼바이저에는 - Oracle Virtual Box - VMWare Workstation / Fusion - QEMU - Microsoft Virtual PC 등이 있습니다. ### 각 하이퍼바이저 유형 사용 시기 유형 1 하이퍼바이저는 일반적으로 데이터 센터, 엔터프라이즈 컴퓨팅 워크로드, 웹 서버, 기타 고정된 애플리케이션에 사용됩니다. 클라우드 컴퓨팅 환경은 베어메탈 하이퍼바이저(유형 1)를 사용하여 가장 성능이 뛰어난 가상 머신을 제공합니다. 즉 우리가 쓰는 EC2 인스턴스와 같은 것들은 유형 1 하이퍼바이저를 통해 제공되는 가상 머신을 사용하는 것입니다. 유형 2 하이퍼바이저는 주로 워크로드가 리소스 집약적이지 않거나, 운영에 중요하지 않은 데스크탑 및 개발 환경에서 가장 자주 사용됩니다. 또한 사용자가 두 개 이상의 운영 체제를 동시에 사용하고 싶지만, 하나의 머신에 액세스 할 수 있는 경우에도 선호됩니다. ### 가상머신 유형 가상 머신에는 두 가지 기본 유형이 있습니다. 시스템 가상 머신과 프로세스 가상 머신입니다. #### 시스템 가상 머신 시스템 가상 머신은 완전히 설치된 OS를 수용합니다. 이를 통해 여러 가상 머신이 호스트 머신과 다른 운영체제를 실행 할 수 있습니다. #### 프로세스 가상 머신 프로세스 가상 머신은 애플리케이션 기능에 중점을 두며, OS 의 전체 설치를 허용하지 않습니다. 대신 특정 앱이나 프로그램을 실행하는 동안 OS 의 가상 환경을 생성한 다음 앱이나 프로그램이 종료되면 OS 환경을 파괴합니다. ![프로세스 가상 머신의 개념을 나타낸 다이어그램](https://velog.velcdn.com/images/kimsehwan96/post/cf47598a-43b1-4ac0-a86f-f7c657a774a1/image.png) 시스템 가상 머신은 CPU/GPU/램 등의 물리적인 리소스를 할당받아서 호스트 머신에 설치되지않은 운영체제 환경을 에뮬레이션 합니다. 유형 1 또는 유형 2 하이퍼바이저(VMM)이 설치되어서 사용되는 형태로 vShere, Hyper-v, KVM, Virtual Box, UTM 등이 여기에 포함됩니다. ![시스템 가상 머신의 개념을 나타낸 다이어그램](https://velog.velcdn.com/images/kimsehwan96/post/c7db694c-1042-4102-9b2a-16fece554308/image.png) 프로세스 가상 머신은 시스템 가상 머신과 다르게 가상 운영체제를 완전히 설치 할 수 있는 기능을 제공하지 않습니다. 대신 특정 애플리케이션, 프로그램을 사용하는동안 호스트 머신의 OS 내에 가상 환경을 생성하고, 해당 애플리케이션/프로그램이 종료되는 즉시 가상 환경도 제거됩니다. Linux Wine , JVM(Java Virtual Machine), .NET Framework 등이 여기에 포함됩니다. ### 가상머신의 장점 #### 완벽한 격리 보안 가상 머신은 완전 독립 실행형 시스템으로 격리되어 실행됩니다. 따라서 가상 컴퓨터는 호스트 머신에 있는 다른 가상 컴퓨터에 영향을 주기 어렵습니다. 각각의 개별 가상 머신은 악용되거나 공격당할 수 있지만, 같은 호스트머신에 있는 인접한 가상 머신에 영향을 줄 수 없습니다. #### 대화형 개발 컨테이너는 일반적으로 컨테이너를 실행하는데 필요한 예상되는 종속성 및 구성을 정적으로 정의한 것인 반면, 가상 머신은 좀 더 동적이기 때문에 대화형으로 개발 할 수 있습니다. 가상 머신에 소프트웨어를 수동으로 설치 할 수 있으며, 가상 머신의 스냅샷을 통해 현재 구성 상태를 캡쳐하고, 해당 스냅샷을 통해 추가적인 머신을 구동 할 수 있습니다. → Hashicorp packer 의 등장으로 가상머신의 이미지 또한 컨테이너처럼 정적으로 구성이 가능하긴 합니다. - 비용 절감 (하나의 머신에서 여러 가상 머신 구동함으로서 얻는) - 다운타임 감소 - 확장성 ### 가상머신의 단점 - 반복 속도 - 스토리지 크기 비용 (이미지의 크기가 일반적으로 수십GB 수준임) - 실제 머신보다 떨어지는 효율성 (하이퍼바이저) - 제대로 보안을 구축하지 않는다면, 같은 호스트 머신의 다른 가상머신에도 영향을 줄 수 있음. ## 컨테이너 컨테이너는 애플리케이션과 해당 종속성(라이브러리, 바이너리 및 추가 구성 파일)을 포함한 전체 런타임 환경을 캡슐화하여 애플리케이션의 이식성, 확장성, 보안 및 민첩성을 향상시키는 경량 독립형 패키지입니다. 리눅스의 chroot, cgroup, namespace 등의 기술을 사용해 구현합니다. ![리눅스 chroot·cgroup·namespace 로 구현되는 컨테이너 구조 다이어그램](https://velog.velcdn.com/images/kimsehwan96/post/5dae0593-a777-4cb8-a29f-49aa5e1055fb/image.png) 컨테이너는 호스트머신의 커널을 공유하며, 컨테이너 엔진을 통해 컨테이너를 띄우게 됩니다. 컨테이너 엔진에는 - Docker - Containerd - CRI-O - Podman - Linux Containers(LXC) - RKT 등이 있습니다. 가상 머신과 컨테이너의 가장 큰 차이점은 가상 머신은 컴퓨터 전체를 하드웨어 계층까지 가상화 하는 반면에, 컨테이너는 운영체제 수준 위의 소프트웨어 계층만 가상화 한다는 것입니다. 따라서 컨테이너는 OS를 부팅하거나, 라이브러리를 로드 할 필요가 없기 때문에 훨씬 더 효율적이고 가벼워집니다. 또한 컨테이너화된 에플리케이션은 몇 초 만에 시작 할 수 있으며, 가상 머신에 비해 더 많은 인스턴스를 머신에 수용 할 수 있습니다. 컨테이너는 동일한 운영 체제 커널을 공유하고, 시스템의 나머지 부분으로 부터 애플리케이션 프로세스를 격리합니다. 예를들어 ARM Linux 시스템은 ARM Linux 컨테이너를 실행하고, x86 Linux 시스템은 x86 Linux 컨테이너를 실행합니다. 즉, 컨테이너는 이식성이 있지만, 정의된 운영 체제에 제약을 받습니다. Linux용 컨테이너는 Windows 에서 실행 할 수 없습니다. ### 컨테이너 종류 컨테이너는 크게 시스템 컨테이너와 애플리케이션 컨테이너로 나뉩니다. #### 시스템 컨테이너 시스템 컨테이너는 컨테이너 기술을 사용해 운영체제 위에 하드웨어 가상화 없이 운영체제를 실행합니다. 시스템 컨테이너를 지향하는 컨테이너 런타임으로는 LXC, LXD가 있습니다. 쉽게 설명하자면, 컨테이너를 이용해 운영체제를 실행하고(e.g CentOS, Ubuntu, Debian, Alpine Linux 등) 해당 컨테이너 내에 여러 애플리케이션과 라이브러리, 종속성을 추가로 설치해서 사용하는 컨셉이라고 볼 수 있습니다. #### 애플리케이션 컨테이너 우리가 주로 많이 쓰게 될 유형입니다. 컨테이너 기술을 활용해 하나의 애플리케이션(프로세스)를 실행하는 것을 목표로 합니다. 시스템 컨테이너와 애플리케이션 컨테이너로 유형을 나누긴 했지만, 실제로 현재 시점에서는 둘을 나누는게 사실상 무의미합니다. Docker / Containerd / CRI-O 등을 사용해도, LXD / LXC 가 지향하고자 했던 시스템 컨테이너를 충분히 구축하고 구현 할 수 있기 때문입니다. 사실상 나누는 의미는 무의미합니다. ### 컨테이너의 장점 #### 민첩성 및 경량화 개발자의 생산성이 향상되고, 앱 개발 속도가 빨라집니다. 컨테이너는 CI/CD 파이프라인을 간소화하며, DevOps 팀과 마이크로 서비스 배포에 이상적입니다. 컨테이너 이미지를 빌드하는데 드는 시간은, 가상머신의 이미지를 생성(Packer 같은 툴로 이미지를 Bake 하거나, 그것이 아니더라도)하는데 드는 시간보다 적으며, 이미지의 크기 자체도 컨테이너는 수십MB \~ 수백MB 수준이지만, 가상머신의 이미지는 수GB \~ 수십GB에 이릅니다. 또한 부팅되는 시간또한 컨테이너는 수초\~수십초에 불과합니다. #### 확장성 쿠버네티스와 같은 컨테이너 오케스트레이션 툴을 사용하거나, 그것이 아니더라도 워크로드 요구 사항의 변화에 따라 컨테이너 배포를 확장하거나 축소하여 앱의 가용성을 높이기 수월합니다. #### 강력한 에코시스템 대부분의 컨테이너 런타임 시스템은 사전 제작된 컨테이너의 호스팅된 공개 레포지토리를 제공합니다. 이것을 Base Layer 로 하여 이미지를 빌드하거나, 개발하는경우 많은 시간을 아낄 수 있습니다. #### 책임 분리 컨테이너화를 통해 단일 애플리케이션을 단일 컨테이너에 패키징하면서, 책임을 분리 할 수 있습니다. 개발자는 애플리케이션의 로직과 종속 항목에 집중하고, 운영팀/DevOps 팀은 배포 및 관리에 집중 할 수 있습니다. ### 컨테이너의 단점 #### 공유 호스트 악용 컨테이너는 모두 운영 체제 계층 아래의 동일한 기본 하드웨어 시스템을 공유하므로, 한 컨테이너가 악용되면 컨테이너에서 벗어나 공유 하드웨어애 영향을 미칠 수 있습니다. 따라서 컨테이너에 대한 각종 보안대책을 잘 적용해야 합니다. #### 스토리지 컨테이너는 데이터나 상태를 저장하지 않는 비저장형으로 설계되었습니다. 따라서 컨테이너 환경에서 데이터 및 상태를 저장하기 위해서는 별도의 볼륨을 마운트해서 사용해야 합니다. ## 컨테이너 오케스트레이션의 필요성 ![컨테이너 오케스트레이션의 필요성을 설명하는 다이어그램](https://velog.velcdn.com/images/kimsehwan96/post/392901be-541b-4afe-acd9-441bbcab7d0c/image.png) 애플리케이션이 성장함에 따라 컨테이너를 수동으로 생성, 관리, 삭제하는 것이 어려워 질 수 있습니다. 컨테이너 오케스트레이션은 컨테이너 가용성, 프로비저닝, 스케줄링, 배포를 자동화 하고 컨테이너 전체 수명 주기를 관리합니다. - 컨테이너 배포 - 컨테이너 관리 - 리소스 할당 - 스케일링 - 로드 밸런싱 - 네트워킹 (서비스 디스커버리) - 스케줄링 - 모니터링 위와 같은 컨테이너에 대한 전반적인 관리를 위해 필요한것이 컨테이너 오케스트레이션 툴이며, 대표적으로 그리고 사실상의 표준인 Kubernetes 가 있습니다. (Docker-compose , Docker-Swarm, Apache Mesos 도 여기에 포함) ## Wrap up 가상머신과 컨테이너는 동일한 개념이 아닙니다. 과거 컨테이너의 초기 등장 시절에는 경량화된 가상머신 등의 이름으로 업계에서 사용되거나, 비즈니스를 하는 경우가 있었지만 2023년 말 현재 지금 시점에서는 명확하게 분리되어서 사용됩니다. 가상머신이 낫냐, 컨테이너가 낫냐는 각 사용사례, 환경에 따라서 달라지고 서로 상호 보완적인 존재입니다. 단적으로 예를들자면 ec2 인스턴스를 활용해 사용되는 AWS의 EKS 서비스를 볼 수 있습니다. ec2 인스턴스는 Type-1 하이퍼바이저를 통해 생성되어서 유저에게 제공되는 가상머신입니다. 이 가상머신에 쿠버네티스가 설치되고 거기에는 여러 컨테이너를 사용자가 띄우게 됩니다. ### 안녕하세요 URL: https://www.kimsehwan96.com/annyeonghaseyo/ Last updated: 2024-02-26T03:06:41.000Z 0219